Compare commits
45
Commits
@@ -99,3 +99,41 @@ jobs:
|
|||||||
mkdir -p ~/unom-flatpak/site/repo
|
mkdir -p ~/unom-flatpak/site/repo
|
||||||
cd ~/unom-flatpak
|
cd ~/unom-flatpak
|
||||||
docker compose -f compose.production.yml up -d
|
docker compose -f compose.production.yml up -d
|
||||||
|
|
||||||
|
winget:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Sync winget source compose + server
|
||||||
|
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||||
|
with:
|
||||||
|
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||||
|
username: ${{ secrets.DEPLOY_USER }}
|
||||||
|
port: ${{ secrets.DEPLOY_PORT }}
|
||||||
|
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
# Land all three flat in ~/unom-winget/ (drop the packaging/winget/server/ prefix).
|
||||||
|
source: "packaging/winget/server/compose.production.yml,packaging/winget/server/server.mjs,packaging/winget/server/handler.mjs"
|
||||||
|
target: "~/unom-winget"
|
||||||
|
strip_components: 3
|
||||||
|
overwrite: true
|
||||||
|
|
||||||
|
- name: Start winget REST source
|
||||||
|
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||||
|
with:
|
||||||
|
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||||
|
username: ${{ secrets.DEPLOY_USER }}
|
||||||
|
port: ${{ secrets.DEPLOY_PORT }}
|
||||||
|
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
set -euo pipefail
|
||||||
|
# ./data/data.json is NOT shipped by this workflow — windows-host.yml rsyncs it on each
|
||||||
|
# stable tag (same content/config split as the flatpak repo). Ensure the bind-mount
|
||||||
|
# source exists so the container starts; it serves 503 until the first catalogue lands.
|
||||||
|
mkdir -p ~/unom-winget/data
|
||||||
|
cd ~/unom-winget
|
||||||
|
docker compose -f compose.production.yml up -d
|
||||||
|
# Surface a missing catalogue here rather than letting winget report "no package found".
|
||||||
|
sleep 3
|
||||||
|
curl -fsS http://127.0.0.1:3240/healthz || echo "NOTE: no catalogue yet - publish a stable tag to populate it"
|
||||||
|
|||||||
@@ -39,6 +39,25 @@ jobs:
|
|||||||
working-directory: plugin-kit
|
working-directory: plugin-kit
|
||||||
run: bun install --frozen-lockfile --ignore-scripts
|
run: bun install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
# bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking each
|
||||||
|
# top-level FILE to itself — `node_modules/@punktfunk/host/package.json -> package.json`, a
|
||||||
|
# dangling self-reference. `dist/` therefore arrives intact while the manifest that points at
|
||||||
|
# it does not, so module resolution dies at the first step and every `@punktfunk/host` import
|
||||||
|
# reads as "cannot find module". Replacing the tree with a real copy is the whole fix; drop
|
||||||
|
# this step once bun links `file:` deps correctly again.
|
||||||
|
- name: "Repair the file: dependency (bun 1.3 self-symlink)"
|
||||||
|
working-directory: plugin-kit
|
||||||
|
run: |
|
||||||
|
# -f follows the link, so this is true only when the manifest actually resolves.
|
||||||
|
if test -f node_modules/@punktfunk/host/package.json; then
|
||||||
|
echo "bun linked it correctly — this step can go"
|
||||||
|
else
|
||||||
|
rm -rf node_modules/@punktfunk/host
|
||||||
|
cp -R ../sdk node_modules/@punktfunk/host
|
||||||
|
fi
|
||||||
|
test -f node_modules/@punktfunk/host/package.json
|
||||||
|
test -f node_modules/@punktfunk/host/dist/index.d.ts
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
working-directory: plugin-kit
|
working-directory: plugin-kit
|
||||||
run: bun run typecheck
|
run: bun run typecheck
|
||||||
|
|||||||
@@ -172,24 +172,57 @@ jobs:
|
|||||||
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
|
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
|
||||||
# pf-vkhdr-layer's clippy below runs --release.
|
# pf-vkhdr-layer's clippy below runs --release.
|
||||||
#
|
#
|
||||||
# pf-encode is linted SEPARATELY with --all-targets so its Windows `#[cfg(test)]` modules
|
# pf-encode and pf-capture are linted SEPARATELY with --all-targets so their Windows
|
||||||
# are type-checked — the AMF C-ABI layout assertions (`variant_layout_matches_c` and
|
# `#[cfg(test)]` modules are type-checked — pf-encode's AMF C-ABI layout assertions
|
||||||
# friends, which are the only guard on a hand-mirrored vtable ABI), the QSV tests, and the
|
# (`variant_layout_matches_c` and friends, which are the only guard on a hand-mirrored
|
||||||
# PyroWave-Windows smoke test. The host lint above cannot cover them: `-p punktfunk-host`
|
# vtable ABI), the QSV tests, the PyroWave-Windows smoke test; pf-capture's `StallWatch`
|
||||||
# only builds pf-encode as a dependency, so its test targets are never compiled, and that
|
# tests, the DXGI HDR self-tests and the cursor-conversion tables. The host lint above
|
||||||
# blind spot is what let the Linux twin's tests rot to the wrong arity unnoticed.
|
# cannot cover them: `-p punktfunk-host` only builds those crates as dependencies, so their
|
||||||
# NOTE: clippy (a check, no link step) is deliberately the vehicle here — `cargo test`
|
# test targets are never compiled anywhere, and that blind spot is what let the Linux twin's
|
||||||
# with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk link-imports
|
# tests rot to the wrong arity unnoticed. pf-capture has no cargo features, so it needs no
|
||||||
# NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve only against
|
# feature juggling and pulls in no extra dep tree.
|
||||||
# the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can and does
|
# NOTE: for the HOST and pf-encode, clippy (a check, no link step) is deliberately the
|
||||||
# run the tests there.) Running them here would need an `--features amf-qsv,qsv` build
|
# vehicle — `cargo test` with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk
|
||||||
# without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 — not
|
# link-imports NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve
|
||||||
# worth it while ci.yml executes the same tests.
|
# only against the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can
|
||||||
|
# and does run the tests there.) Running them here would need an `--features amf-qsv,qsv`
|
||||||
|
# build without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 —
|
||||||
|
# not worth it while ci.yml executes the same tests.
|
||||||
|
#
|
||||||
|
# That reasoning does NOT extend to pf-capture: it has no encoder dependency at all
|
||||||
|
# (`cargo tree -p pf-capture` lists no nvidia/ffmpeg/libvpl/pyrowave), so its test binary
|
||||||
|
# links against nothing this runner lacks, and it reuses the release artifacts the steps
|
||||||
|
# above already built. Its Windows `#[test]`s — StallWatch, the f16 conversions, the cursor
|
||||||
|
# truth table, the IDD generation masking — are Windows-only code that NO other job can
|
||||||
|
# execute, so linting them was leaving real coverage on the table. See the run step below.
|
||||||
run: |
|
run: |
|
||||||
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||||
cargo clippy --release -p pf-encode --all-targets --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "pf-encode clippy" }
|
cargo clippy --release -p pf-encode --all-targets --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "pf-encode clippy" }
|
||||||
|
cargo clippy --release -p pf-capture --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-capture clippy" }
|
||||||
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||||
|
|
||||||
|
- name: Test (pf-capture, Windows)
|
||||||
|
shell: pwsh
|
||||||
|
# The only Rust tests that RUN on Windows CI. pf-capture's `#[cfg(target_os = "windows")]`
|
||||||
|
# test modules cover code no Linux job compiles, let alone executes: 19 declared, of which
|
||||||
|
# 18 execute here — the IDD-push StallWatch state machine and ring-generation masking
|
||||||
|
# (idd_push.rs), the cursor shape→wire truth table (idd_push/cursor_poll.rs), and
|
||||||
|
# `f32_to_f16` including the rounding-carry / saturation edges the HDR P010 path depends on
|
||||||
|
# (dxgi/selftest.rs). All 18 are pure — no Win32, no device, no desktop. The 19th,
|
||||||
|
# `hdr_p010_selftest_intel_1080_live`, is `#[ignore]`d because it needs a real Intel
|
||||||
|
# adapter; it stays a manual `-- --ignored` run on the validation boxes. Until this step
|
||||||
|
# the whole set was type-checked by the clippy line above and nothing more.
|
||||||
|
#
|
||||||
|
# --release for the same reason as the clippy step: it reuses C:\t\release instead of
|
||||||
|
# spawning a second debug dep tree (the C1069 disk-exhaustion trigger). If this step ever
|
||||||
|
# starts tripping C1069 anyway, record THAT here rather than quietly dropping the step.
|
||||||
|
#
|
||||||
|
# The link question this step turns on was settled empirically before it was added: the same
|
||||||
|
# command was run on a Windows dev box against a workspace checkout and linked + executed
|
||||||
|
# cleanly, building in ~51 s off an existing release target dir.
|
||||||
|
run: |
|
||||||
|
cargo test --release -p pf-capture; if ($LASTEXITCODE) { throw "pf-capture tests" }
|
||||||
|
|
||||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
# Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games
|
# Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games
|
||||||
@@ -326,3 +359,82 @@ jobs:
|
|||||||
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
|
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
|
||||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# winget manifests for the release just attached above. Runs AFTER the attach step so the
|
||||||
|
# InstallerUrl the manifest pins is already live — winget validates the URL + hash, and a
|
||||||
|
# manifest published ahead of its artifact is a hard 404 for every client that picks it up.
|
||||||
|
# Stable tags only: winget pins one immutable artifact per version, so the rolling `canary/`
|
||||||
|
# alias has nothing it could point at.
|
||||||
|
- name: Emit + attach winget manifests (stable tags only)
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
& scripts/ci/winget-manifest.ps1 `
|
||||||
|
-Version $env:HOST_VERSION -InstallerPath $env:HOST_SETUP_PATH -OutDir C:\t\out\winget
|
||||||
|
. scripts/ci/gitea-release.ps1
|
||||||
|
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||||
|
foreach ($f in (Get-ChildItem C:\t\out\winget -Filter *.yaml)) {
|
||||||
|
Upsert-GiteaAsset -ReleaseId $rid -File $f.FullName
|
||||||
|
}
|
||||||
|
|
||||||
|
# Republish the winget REST source on unom-1 once the release above carries its manifests.
|
||||||
|
#
|
||||||
|
# A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
|
||||||
|
# not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
|
||||||
|
# reads the manifests from the release, so it must not run before they are attached.
|
||||||
|
winget-source:
|
||||||
|
needs: package
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# build-data re-derives the WHOLE catalogue from the releases rather than appending this one,
|
||||||
|
# so the result cannot drift and re-running any tag reproduces it byte for byte.
|
||||||
|
- name: Build + test the source catalogue
|
||||||
|
working-directory: packaging/winget/server
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
npm install --no-audit --no-fund
|
||||||
|
node build-data.mjs --out data/data.json
|
||||||
|
# A wrong response SHAPE does not fail loudly — winget just reports "no package found".
|
||||||
|
# Gate on the suite before anything reaches the box.
|
||||||
|
node test.mjs
|
||||||
|
|
||||||
|
# Content only. server.mjs/handler.mjs/compose land via deploy-services.yml, matching how the
|
||||||
|
# flatpak repo's content and config deploy on separate paths.
|
||||||
|
- name: Ship the catalogue to unom-1
|
||||||
|
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
username: ${{ secrets.DEPLOY_USER }}
|
||||||
|
port: ${{ secrets.DEPLOY_PORT }}
|
||||||
|
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
source: "packaging/winget/server/data/data.json"
|
||||||
|
target: "~/unom-winget/data"
|
||||||
|
strip_components: 4
|
||||||
|
overwrite: true
|
||||||
|
|
||||||
|
# No restart: server.mjs reloads on mtime change. This only proves the new catalogue is the
|
||||||
|
# one actually being served, and fails the release if it is not.
|
||||||
|
- name: Verify the served catalogue
|
||||||
|
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
username: ${{ secrets.DEPLOY_USER }}
|
||||||
|
port: ${{ secrets.DEPLOY_PORT }}
|
||||||
|
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
set -euo pipefail
|
||||||
|
curl -fsS http://127.0.0.1:3240/healthz
|
||||||
|
echo
|
||||||
|
curl -fsS -X POST http://127.0.0.1:3240/manifestSearch \
|
||||||
|
-H 'content-type: application/json' -d '{"FetchAllManifests":true}' \
|
||||||
|
| grep -q "${GITHUB_REF_NAME#v}" \
|
||||||
|
|| { echo "served catalogue does not contain ${GITHUB_REF_NAME#v}"; exit 1; }
|
||||||
|
echo "winget source serving ${GITHUB_REF_NAME#v}"
|
||||||
|
env:
|
||||||
|
GITHUB_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
|
|||||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||||
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
|
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
|
||||||
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||||
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
|
||||||
|
|
||||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||||
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
||||||
|
|||||||
+561
-8
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.18.0"
|
"version": "0.19.2"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
@@ -665,6 +665,58 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/game/end": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"session"
|
||||||
|
],
|
||||||
|
"summary": "End a launched game",
|
||||||
|
"description": "Ends a game whose session has already gone and which is waiting out its reconnect window — the\nconsole's \"End now\" for a game the host is about to close anyway. `app_id` picks one title; omit it\nto end every waiting game.\n\nThis does **not** touch a game whose session is still live: ending that is session management\n(`DELETE /session`), and how the game is treated then follows the operator's policy.",
|
||||||
|
"operationId": "endGame",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/EndGameRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "How many waiting games were ended",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/EndGameResult"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "No game is waiting to be ended",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/gpus": {
|
"/api/v1/gpus": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -913,7 +965,7 @@
|
|||||||
"library"
|
"library"
|
||||||
],
|
],
|
||||||
"summary": "List the game library",
|
"summary": "List the game library",
|
||||||
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns.",
|
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).",
|
||||||
"operationId": "getLibrary",
|
"operationId": "getLibrary",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -924,6 +976,15 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "platform",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Only entries on this platform (case-insensitive, e.g. `PS2`)",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -2226,7 +2287,7 @@
|
|||||||
"session"
|
"session"
|
||||||
],
|
],
|
||||||
"summary": "Stop the active session",
|
"summary": "Stop the active session",
|
||||||
"description": "Kicks the connected client: stops the video/audio stream threads and clears the launch\nstate. Idempotent — succeeds even when nothing is streaming.",
|
"description": "Kicks the connected client: stops the video/audio stream threads and clears the launch\nstate. Idempotent — succeeds even when nothing is streaming.\n\nCounts as a **deliberate** stop, exactly like a client pressing Stop: the display skips its\nkeep-alive linger, and the end-game-on-session-end policy (if the operator enabled one) applies.",
|
||||||
"operationId": "stopSession",
|
"operationId": "stopSession",
|
||||||
"responses": {
|
"responses": {
|
||||||
"204": {
|
"204": {
|
||||||
@@ -2280,6 +2341,98 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/session/settings": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"session"
|
||||||
|
],
|
||||||
|
"summary": "Session⇄game lifetime settings",
|
||||||
|
"description": "Whether a launched game's exit ends the streaming session, and whether a session ending ends the\ngame (with the reconnect window that protects a dropped client's unsaved progress). See\n`design/session-game-lifetime.md`.",
|
||||||
|
"operationId": "getSessionSettings",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Stored settings + which axes this build enforces",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SessionSettingsState"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"session"
|
||||||
|
],
|
||||||
|
"summary": "Set the session⇄game lifetime settings",
|
||||||
|
"description": "Persists the settings (clamped) and applies them from the next decision — including to a session\nthat is already streaming, since the policy is read when a session ends rather than when it starts.",
|
||||||
|
"operationId": "setSessionSettings",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SessionSettings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Settings stored; the new state",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SessionSettingsState"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Malformed settings body",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Settings could not be persisted",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/stats/capture/live": {
|
"/api/v1/stats/capture/live": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -3251,6 +3404,67 @@
|
|||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"ActiveGame": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One launched game, for the console's running-game card.",
|
||||||
|
"required": [
|
||||||
|
"client",
|
||||||
|
"title",
|
||||||
|
"plane",
|
||||||
|
"state"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"app_id": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Store-qualified library id (`steam:570`) — the key the console matches against `GET /library`\nto show box art. Absent for an operator-typed GameStream command."
|
||||||
|
},
|
||||||
|
"client": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Client-supplied device name of the session that launched it; may be empty."
|
||||||
|
},
|
||||||
|
"grace_remaining_s": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Seconds until this game is ended — only present on a `grace` row.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"plane": {
|
||||||
|
"$ref": "#/components/schemas/Plane",
|
||||||
|
"description": "`native` or `gamestream`."
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int64",
|
||||||
|
"description": "The session streaming it; `null` for a game waiting out its reconnect window.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`launching` (launched, not seen running yet), `running`, `exited`, or `grace` (its session is\ngone and it will be ended when the reconnect window closes).",
|
||||||
|
"example": "running"
|
||||||
|
},
|
||||||
|
"store": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Which store surfaced it (`steam`, `heroic`, `custom`, …), when known."
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Display title."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ApiActiveGpu": {
|
"ApiActiveGpu": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The GPU live sessions are encoding on right now.",
|
"description": "The GPU live sessions are encoding on right now.",
|
||||||
@@ -3807,8 +4021,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"CustomEntry": {
|
"CustomEntry": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/GameMeta",
|
||||||
|
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]."
|
||||||
|
},
|
||||||
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits.",
|
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"title"
|
"title"
|
||||||
@@ -3817,6 +4036,10 @@
|
|||||||
"art": {
|
"art": {
|
||||||
"$ref": "#/components/schemas/Artwork"
|
"$ref": "#/components/schemas/Artwork"
|
||||||
},
|
},
|
||||||
|
"detect": {
|
||||||
|
"$ref": "#/components/schemas/DetectHint",
|
||||||
|
"description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns."
|
||||||
|
},
|
||||||
"external_id": {
|
"external_id": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
@@ -3856,10 +4079,18 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits."
|
||||||
},
|
},
|
||||||
"CustomInput": {
|
"CustomInput": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/GameMeta",
|
||||||
|
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept."
|
||||||
|
},
|
||||||
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Request body to create or replace a custom entry (no `id` — the host owns it).",
|
|
||||||
"required": [
|
"required": [
|
||||||
"title"
|
"title"
|
||||||
],
|
],
|
||||||
@@ -3867,6 +4098,10 @@
|
|||||||
"art": {
|
"art": {
|
||||||
"$ref": "#/components/schemas/Artwork"
|
"$ref": "#/components/schemas/Artwork"
|
||||||
},
|
},
|
||||||
|
"detect": {
|
||||||
|
"$ref": "#/components/schemas/DetectHint",
|
||||||
|
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
|
||||||
|
},
|
||||||
"launch": {
|
"launch": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
@@ -3888,6 +4123,9 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Request body to create or replace a custom entry (no `id` — the host owns it)."
|
||||||
},
|
},
|
||||||
"CustomPreset": {
|
"CustomPreset": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -3935,6 +4173,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"DetectHint": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.",
|
||||||
|
"properties": {
|
||||||
|
"exe": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "The game's own executable, as an absolute path."
|
||||||
|
},
|
||||||
|
"install_dir": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one."
|
||||||
|
},
|
||||||
|
"process_name": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"DeviceRef": {
|
"DeviceRef": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "A device in the pairing flow.",
|
"description": "A device in the pairing flow.",
|
||||||
@@ -4126,6 +4391,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"EndGameRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Request body for `endGame`.",
|
||||||
|
"properties": {
|
||||||
|
"app_id": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Store-qualified library id (`steam:570`) to end; omit to end every waiting game."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"EndGameResult": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Result of an `endGame`.",
|
||||||
|
"required": [
|
||||||
|
"ended"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"ended": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "How many waiting games were ended.",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"EventKind": {
|
"EventKind": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
@@ -4240,6 +4532,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"description": "A launched game was confirmed running — fires once per launch, after the host has actually\nseen the game's process (not merely spawned its launcher).",
|
||||||
|
"required": [
|
||||||
|
"game",
|
||||||
|
"kind"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"game": {
|
||||||
|
"$ref": "#/components/schemas/GameRefPayload"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"game.running"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"description": "A launched game is gone. `reason` distinguishes the player quitting from the host ending it\nper the lifetime policy.",
|
||||||
|
"required": [
|
||||||
|
"game",
|
||||||
|
"reason",
|
||||||
|
"kind"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"game": {
|
||||||
|
"$ref": "#/components/schemas/GameRefPayload"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"game.exited"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"$ref": "#/components/schemas/GameEndReason"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@@ -4432,9 +4766,22 @@
|
|||||||
],
|
],
|
||||||
"description": "The event catalog (RFC §4). Serialized internally tagged as `\"kind\": \"<domain>.<verb>\"`,\nflattened into [`HostEvent`]. **Additive-only** within [`SCHEMA_VERSION`]."
|
"description": "The event catalog (RFC §4). Serialized internally tagged as `\"kind\": \"<domain>.<verb>\"`,\nflattened into [`HostEvent`]. **Additive-only** within [`SCHEMA_VERSION`]."
|
||||||
},
|
},
|
||||||
|
"GameEndReason": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Why a launched game is no longer running.",
|
||||||
|
"enum": [
|
||||||
|
"exited",
|
||||||
|
"terminated"
|
||||||
|
]
|
||||||
|
},
|
||||||
"GameEntry": {
|
"GameEntry": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/GameMeta",
|
||||||
|
"description": "Descriptive metadata, flattened — see [`GameMeta`]."
|
||||||
|
},
|
||||||
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "One title in the unified library, regardless of which store it came from.",
|
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"store",
|
"store",
|
||||||
@@ -4477,6 +4824,127 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "One title in the unified library, regardless of which store it came from."
|
||||||
|
},
|
||||||
|
"GameMeta": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Descriptive metadata for a title — everything a richer library UI (details pane, platform\nfilter, couch-pick badges) renders beyond the poster. Every field is optional and defaults to\nabsent, so pre-metadata catalogs, providers, and clients keep working unchanged. The struct is\n`#[serde(flatten)]`-ed into [`GameEntry`] / the custom-store shapes: one definition, a flat\nwire shape everywhere.\n\nValues are free-form display strings, not enums — emulation sources (RomM, EmuDeck, Playnite)\neach have their own vocabulary and the host has no business normalizing it.",
|
||||||
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Short blurb for a details pane."
|
||||||
|
},
|
||||||
|
"developer": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"genres": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)."
|
||||||
|
},
|
||||||
|
"platform": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive).",
|
||||||
|
"example": "PS2"
|
||||||
|
},
|
||||||
|
"players": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Maximum simultaneous (local) players.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"publisher": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"region": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)."
|
||||||
|
},
|
||||||
|
"release_year": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Year of first release — the granularity metadata sources reliably agree on.",
|
||||||
|
"example": 2001,
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GameOnSessionEnd": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What to do with the launched game when its session ends.",
|
||||||
|
"enum": [
|
||||||
|
"keep",
|
||||||
|
"on_quit",
|
||||||
|
"always"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GameRefPayload": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "A launched game, as the `game.*` events see it.",
|
||||||
|
"required": [
|
||||||
|
"title",
|
||||||
|
"client",
|
||||||
|
"plane"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"app": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Store-qualified library id (`steam:570`). Absent for an operator-typed GameStream\n`apps.json` command, which has no library entry behind it."
|
||||||
|
},
|
||||||
|
"client": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Client-supplied device name of the session that launched it; may be empty."
|
||||||
|
},
|
||||||
|
"plane": {
|
||||||
|
"$ref": "#/components/schemas/Plane"
|
||||||
|
},
|
||||||
|
"store": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Which store surfaced it (`steam`, `heroic`, `custom`, …), when known."
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Display title."
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"GameSession": {
|
"GameSession": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -4506,6 +4974,13 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"encoder_pin": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "`PUNKTFUNK_ENCODER` (the host.env encoder pin), when set to something other than `auto`\n(e.g. `qsv`, `nvenc`, `amf`, `software`). A pin whose vendor contradicts the selected\nGPU is overridden at session open — the adapter wins — so the console can warn that the\npin is stale rather than letting the selection look broken."
|
||||||
|
},
|
||||||
"env_override": {
|
"env_override": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
@@ -5115,6 +5590,13 @@
|
|||||||
},
|
},
|
||||||
"description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails."
|
"description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails."
|
||||||
},
|
},
|
||||||
|
"games": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Launched games the host is tracking, as compact labels (`Hades`, `Hades (closing in 4:12)`).\n\nThe countdown form is the one that matters: it means the game's client is gone and the host\nwill end the game when the window closes — something a user at the machine should be able to\nsee (and stop) without opening the console. Empty when nothing was launched."
|
||||||
|
},
|
||||||
"kept_displays": {
|
"kept_displays": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32",
|
"format": "int32",
|
||||||
@@ -5627,8 +6109,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ProviderEntryInput": {
|
"ProviderEntryInput": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/GameMeta",
|
||||||
|
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]."
|
||||||
|
},
|
||||||
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key.",
|
|
||||||
"required": [
|
"required": [
|
||||||
"external_id",
|
"external_id",
|
||||||
"title"
|
"title"
|
||||||
@@ -5637,6 +6124,10 @@
|
|||||||
"art": {
|
"art": {
|
||||||
"$ref": "#/components/schemas/Artwork"
|
"$ref": "#/components/schemas/Artwork"
|
||||||
},
|
},
|
||||||
|
"detect": {
|
||||||
|
"$ref": "#/components/schemas/DetectHint",
|
||||||
|
"description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits."
|
||||||
|
},
|
||||||
"external_id": {
|
"external_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The provider's stable id for this title (the reconcile diff key)."
|
"description": "The provider's stable id for this title (the reconcile diff key)."
|
||||||
@@ -5662,6 +6153,9 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key."
|
||||||
},
|
},
|
||||||
"ProviderRemoved": {
|
"ProviderRemoved": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -5725,7 +6219,8 @@
|
|||||||
"audio_streaming",
|
"audio_streaming",
|
||||||
"pin_pending",
|
"pin_pending",
|
||||||
"paired_clients",
|
"paired_clients",
|
||||||
"active_sessions"
|
"active_sessions",
|
||||||
|
"games"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"active_sessions": {
|
"active_sessions": {
|
||||||
@@ -5738,6 +6233,13 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "True while the audio stream thread is running."
|
"description": "True while the audio stream thread is running."
|
||||||
},
|
},
|
||||||
|
"games": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ActiveGame"
|
||||||
|
},
|
||||||
|
"description": "Every launched game the host is tracking: one row per live session that launched a title, plus\nany game whose session has ended and which is waiting out its reconnect window before being\nended (`state: \"grace\"`). Empty when nothing was launched — a plain desktop stream has no game."
|
||||||
|
},
|
||||||
"paired_clients": {
|
"paired_clients": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32",
|
"format": "int32",
|
||||||
@@ -5907,6 +6409,57 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"SessionSettings": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The persisted settings.",
|
||||||
|
"properties": {
|
||||||
|
"disconnect_grace_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "How long a vanished client has to reconnect before `Always` ends its game. Ignored by the\nother two policies.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"game_on_session_end": {
|
||||||
|
"$ref": "#/components/schemas/GameOnSessionEnd",
|
||||||
|
"description": "End the launched game when the session ends. See [`GameOnSessionEnd`]."
|
||||||
|
},
|
||||||
|
"session_on_game_exit": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "End the streaming session when the launched game exits."
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SessionSettingsState": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The session⇄game lifetime settings, plus which axes this build acts on.",
|
||||||
|
"required": [
|
||||||
|
"settings",
|
||||||
|
"configured",
|
||||||
|
"enforced"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"configured": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether an operator has ever saved these settings (`false` ⇒ `settings` are the defaults)."
|
||||||
|
},
|
||||||
|
"enforced": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Which fields this build actually enforces. Empty on a platform with no launch path (macOS),\nso the console can say so instead of offering a switch that does nothing."
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"$ref": "#/components/schemas/SessionSettings",
|
||||||
|
"description": "The stored settings (or the built-in defaults when this host has never been configured)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"SetGpuPreference": {
|
"SetGpuPreference": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Request body for `setGpuPreference`.",
|
"description": "Request body for `setGpuPreference`.",
|
||||||
|
|||||||
@@ -990,7 +990,9 @@ struct ContentView: View {
|
|||||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto)
|
rawValue: UInt32(clamping: gamepadType)) ?? .auto)
|
||||||
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"],
|
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"],
|
||||||
let g = PunktfunkConnection.GamepadType(name: name) {
|
let g = PunktfunkConnection.GamepadType(name: name) {
|
||||||
pad = g
|
// Back through resolveType so the lever is adopted as the session's setting: the
|
||||||
|
// per-pad arrivals declare it too, which is what the host actually builds from.
|
||||||
|
pad = GamepadManager.shared.resolveType(setting: g)
|
||||||
}
|
}
|
||||||
var bitrate = UInt32(clamping: bitrateKbps)
|
var bitrate = UInt32(clamping: bitrateKbps)
|
||||||
if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"],
|
if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"],
|
||||||
|
|||||||
@@ -55,7 +55,13 @@ public final class GamepadCapture {
|
|||||||
/// Wire pad index (GamepadManager's stable lowest-free assignment), threaded onto every
|
/// Wire pad index (GamepadManager's stable lowest-free assignment), threaded onto every
|
||||||
/// event this controller sends — the low byte of `flags`.
|
/// event this controller sends — the low byte of `flags`.
|
||||||
let pad: UInt32
|
let pad: UInt32
|
||||||
/// The controller KIND declared to the host (GamepadArrival) when the slot opened.
|
/// The controller KIND declared to the host (GamepadArrival) when the slot opened — the
|
||||||
|
/// user's explicit "Controller type" setting when they picked one, else the detected
|
||||||
|
/// kind (`GamepadManager.declaredKind(for:)`). NOT the physical pad's kind: local feedback
|
||||||
|
/// keys off the live `GCController` subclass instead, so whatever the host DOES send is
|
||||||
|
/// applied natively to the pad in the user's hands. What the host sends is bounded by the
|
||||||
|
/// emulated type, though — a virtual DualShock 4 has no adaptive-trigger reports in its
|
||||||
|
/// protocol, so emulating one gives those up by construction (rumble + lightbar remain).
|
||||||
let pref: PunktfunkConnection.GamepadType
|
let pref: PunktfunkConnection.GamepadType
|
||||||
var buttons: UInt32 = 0
|
var buttons: UInt32 = 0
|
||||||
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
||||||
@@ -166,7 +172,7 @@ public final class GamepadCapture {
|
|||||||
private func openSlot(_ dc: GamepadManager.DiscoveredController) {
|
private func openSlot(_ dc: GamepadManager.DiscoveredController) {
|
||||||
guard let pad = manager.padIndex(for: dc), let ext = dc.controller.extendedGamepad else { return }
|
guard let pad = manager.padIndex(for: dc), let ext = dc.controller.extendedGamepad else { return }
|
||||||
let c = dc.controller
|
let c = dc.controller
|
||||||
let slot = Slot(controller: c, pad: UInt32(pad), pref: dc.kind)
|
let slot = Slot(controller: c, pad: UInt32(pad), pref: manager.declaredKind(for: dc))
|
||||||
slots.append(slot)
|
slots.append(slot)
|
||||||
|
|
||||||
ext.valueChangedHandler = { [weak self, weak slot] g, _ in
|
ext.valueChangedHandler = { [weak self, weak slot] g, _ in
|
||||||
@@ -192,9 +198,12 @@ public final class GamepadCapture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Declare this pad's controller KIND before any of its input, so the host builds a
|
// Declare this pad's controller KIND before any of its input, so the host builds a
|
||||||
// matching virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
|
// matching virtual device — the user's chosen type when they picked one, else per-pad
|
||||||
// re-sends it a few times against datagram loss; an older host ignores it and uses the
|
// detection (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). This declaration is
|
||||||
// session-default kind. Then wake the host pad (pads are created lazily from the first
|
// what the host actually builds from, so it MUST carry an explicit setting; the
|
||||||
|
// handshake's session default is only the fallback for a pad that never declares. The
|
||||||
|
// core re-sends it a few times against datagram loss; an older host ignores it and uses
|
||||||
|
// the session-default kind. Then wake the host pad (pads are created lazily from the first
|
||||||
// event; a DualSense's UHID handshake + initial lightbar write only start then).
|
// event; a DualSense's UHID handshake + initial lightbar write only start then).
|
||||||
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
|
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
|
||||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
|
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
|
||||||
|
|||||||
@@ -131,13 +131,40 @@ public final class GamepadManager: ObservableObject {
|
|||||||
GCController.stopWirelessControllerDiscovery()
|
GCController.stopWirelessControllerDiscovery()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user's controller-type choice AS CHOSEN (not resolved) for the session being dialed —
|
||||||
|
/// adopted by `resolveType` and read back by `declaredKind(for:)`. `.auto` = detect per pad.
|
||||||
|
public private(set) var typeSetting: PunktfunkConnection.GamepadType = .auto
|
||||||
|
|
||||||
|
/// The kind to DECLARE to the host for one forwarded controller (its `GamepadArrival`).
|
||||||
|
/// An explicit setting wins for every pad — the handshake's session default alone does NOT
|
||||||
|
/// stick, because a current host honors the per-pad arrival over it (punktfunk-host's
|
||||||
|
/// `Pads::set_kind`), so a client that declared only the detected kind here would silently
|
||||||
|
/// undo the user's choice. `.auto` keeps per-pad detection, which is what makes a mixed
|
||||||
|
/// session (pad 0 a DualSense, pad 1 an Xbox pad) honest.
|
||||||
|
public func declaredKind(
|
||||||
|
for controller: DiscoveredController
|
||||||
|
) -> PunktfunkConnection.GamepadType {
|
||||||
|
Self.declaredKind(setting: typeSetting, detected: controller.kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pure fold behind `declaredKind(for:)` (pf-client-core's `declared_kind`).
|
||||||
|
nonisolated static func declaredKind(
|
||||||
|
setting: PunktfunkConnection.GamepadType,
|
||||||
|
detected: PunktfunkConnection.GamepadType
|
||||||
|
) -> PunktfunkConnection.GamepadType {
|
||||||
|
setting == .auto ? detected : setting
|
||||||
|
}
|
||||||
|
|
||||||
/// Connect-time resolution of the user's controller-type setting: an explicit choice
|
/// Connect-time resolution of the user's controller-type setting: an explicit choice
|
||||||
/// wins; `.auto` matches the virtual pad to the active physical controller (DualSense →
|
/// wins; `.auto` matches the virtual pad to the active physical controller (DualSense →
|
||||||
/// DualSense, DualShock 4 → DualShock 4, an Xbox pad → Xbox One, anything else → Xbox
|
/// DualSense, DualShock 4 → DualShock 4, an Xbox pad → Xbox One, anything else → Xbox
|
||||||
/// 360); no controller at all defers to the host.
|
/// 360); no controller at all defers to the host. Called once per dial with the RAW setting,
|
||||||
|
/// which it also adopts for `declaredKind(for:)` so the handshake default and every pad's
|
||||||
|
/// arrival can never disagree about an explicit choice.
|
||||||
public func resolveType(
|
public func resolveType(
|
||||||
setting: PunktfunkConnection.GamepadType
|
setting: PunktfunkConnection.GamepadType
|
||||||
) -> PunktfunkConnection.GamepadType {
|
) -> PunktfunkConnection.GamepadType {
|
||||||
|
typeSetting = setting
|
||||||
guard setting == .auto else { return setting }
|
guard setting == .auto else { return setting }
|
||||||
// Refresh from the LIVE controller list first. `active` is otherwise only populated by the
|
// Refresh from the LIVE controller list first. `active` is otherwise only populated by the
|
||||||
// async `.GCControllerDidConnect` notification, so at connect time it can still be nil even
|
// async `.GCControllerDidConnect` notification, so at connect time it can still be nil even
|
||||||
|
|||||||
@@ -236,6 +236,13 @@ public final class StreamLayerView: NSView {
|
|||||||
let hotY: Int
|
let hotY: Int
|
||||||
}
|
}
|
||||||
private var hostCursors: [UInt32: HostCursorShape] = [:]
|
private var hostCursors: [UInt32: HostCursorShape] = [:]
|
||||||
|
/// The last shape actually worn. State (`0xD0`, a per-frame datagram) announces a new serial the
|
||||||
|
/// moment the host QUEUES its bitmap on the reliable control stream, so the client routinely
|
||||||
|
/// knows a serial before it holds the pixels — and the shape ring drops the NEWEST under burst
|
||||||
|
/// (`CURSOR_SHAPE_QUEUE`), which the host never re-sends because it only sends on a serial
|
||||||
|
/// CHANGE. Both leave `hostCursors[serial]` empty; wearing the previous pointer through that
|
||||||
|
/// gap degrades it to a briefly-stale shape instead of blinking the pointer out of existence.
|
||||||
|
private var lastWornShape: HostCursorShape?
|
||||||
private var cursorState: PunktfunkConnection.CursorStateEvent?
|
private var cursorState: PunktfunkConnection.CursorStateEvent?
|
||||||
/// Last `CursorRenderMode.clientDraws` told to the host (the §8 mid-stream render flip);
|
/// Last `CursorRenderMode.clientDraws` told to the host (the §8 mid-stream render flip);
|
||||||
/// nil = nothing sent yet. Edge-detected by [`reconcileCursorRender`] from the live mouse
|
/// nil = nothing sent yet. Edge-detected by [`reconcileCursorRender`] from the live mouse
|
||||||
@@ -509,10 +516,19 @@ public final class StreamLayerView: NSView {
|
|||||||
override public func resetCursorRects() {
|
override public func resetCursorRects() {
|
||||||
if captured && desktopMouse {
|
if captured && desktopMouse {
|
||||||
// Cursor channel active: wear the HOST's pointer shape (it is no longer in the
|
// Cursor channel active: wear the HOST's pointer shape (it is no longer in the
|
||||||
// video); hidden host pointer (or no shape yet) = invisible. Without the channel,
|
// video); a HIDDEN host pointer (or nothing seen yet at all) = invisible. Without the
|
||||||
// M1 behavior: invisible local cursor, the composited host cursor is the visible one.
|
// channel, M1 behavior: invisible local cursor, the composited host cursor is the
|
||||||
|
// visible one.
|
||||||
|
//
|
||||||
|
// A visible pointer whose announced serial has no bitmap yet falls back to the last
|
||||||
|
// worn shape (see `lastWornShape`) rather than to `invisibleCursor`. That case is
|
||||||
|
// routine, not degenerate — state outruns its bitmap on every single shape change —
|
||||||
|
// and treating it as "hide the pointer" made the pointer VANISH over anything whose
|
||||||
|
// shape arrived late or got dropped, with no recovery until the next change. Only
|
||||||
|
// `st.visible == false` may hide the pointer; a missing bitmap may not.
|
||||||
if cursorChannelActive, let st = cursorState, st.visible,
|
if cursorChannelActive, let st = cursorState, st.visible,
|
||||||
let shape = hostCursors[st.serial] {
|
let shape = hostCursors[st.serial] ?? lastWornShape {
|
||||||
|
lastWornShape = shape
|
||||||
addCursorRect(bounds, cursor: scaledCursor(shape))
|
addCursorRect(bounds, cursor: scaledCursor(shape))
|
||||||
} else {
|
} else {
|
||||||
addCursorRect(bounds, cursor: Self.invisibleCursor)
|
addCursorRect(bounds, cursor: Self.invisibleCursor)
|
||||||
@@ -583,6 +599,8 @@ public final class StreamLayerView: NSView {
|
|||||||
|
|
||||||
private func applyCursorShape(_ ev: PunktfunkConnection.CursorShapeEvent) {
|
private func applyCursorShape(_ ev: PunktfunkConnection.CursorShapeEvent) {
|
||||||
guard let shape = Self.makeShape(ev) else {
|
guard let shape = Self.makeShape(ev) else {
|
||||||
|
// Truthful only because `resetCursorRects` falls back to `lastWornShape`: before that,
|
||||||
|
// a rejection here left the announced serial with no bitmap and HID the pointer.
|
||||||
streamInputLog.warning("cursor shape rejected (\(ev.width)x\(ev.height)) — keeping the previous cursor")
|
streamInputLog.warning("cursor shape rejected (\(ev.width)x\(ev.height)) — keeping the previous cursor")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,25 @@ final class GamepadWireTests: XCTestCase {
|
|||||||
XCTAssertEqual(GamepadWire.maxPads, 16)
|
XCTAssertEqual(GamepadWire.maxPads, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAnExplicitControllerTypeIsWhatEveryPadDeclares() {
|
||||||
|
// The regression this pins: the "Controller type" setting reached the Hello only, and each
|
||||||
|
// pad's arrival then re-declared the DETECTED kind — which the host honors over the
|
||||||
|
// session default, so "emulate my DualSense as a DualShock 4" produced a DualSense.
|
||||||
|
// (pf-client-core's `declared_kind` test is the same table.)
|
||||||
|
XCTAssertEqual(
|
||||||
|
GamepadManager.declaredKind(setting: .dualShock4, detected: .dualSense), .dualShock4)
|
||||||
|
// Every physical pad in a mixed session follows the one explicit choice.
|
||||||
|
for detected: PunktfunkConnection.GamepadType in [
|
||||||
|
.dualSense, .xbox360, .switchPro, .dualSenseEdge,
|
||||||
|
] {
|
||||||
|
XCTAssertEqual(
|
||||||
|
GamepadManager.declaredKind(setting: .xbox360, detected: detected), .xbox360)
|
||||||
|
}
|
||||||
|
// Automatic keeps per-pad detection — otherwise a mixed session collapses to one type.
|
||||||
|
XCTAssertEqual(GamepadManager.declaredKind(setting: .auto, detected: .dualSense), .dualSense)
|
||||||
|
XCTAssertEqual(GamepadManager.declaredKind(setting: .auto, detected: .switchPro), .switchPro)
|
||||||
|
}
|
||||||
|
|
||||||
func testTouchpadConversionCorners() {
|
func testTouchpadConversionCorners() {
|
||||||
// GC ±1 with +y up → wire 0...65535 with origin top-left, +y down.
|
// GC ±1 with +y up → wire 0...65535 with origin top-left, +y down.
|
||||||
let topLeft = GamepadWire.touchpad(x: -1, y: 1)
|
let topLeft = GamepadWire.touchpad(x: -1, y: 1)
|
||||||
|
|||||||
@@ -620,6 +620,7 @@ fn mock_library() -> (
|
|||||||
store: store.to_string(),
|
store: store.to_string(),
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
art: crate::library::Artwork::default(),
|
art: crate::library::Artwork::default(),
|
||||||
|
platform: None,
|
||||||
};
|
};
|
||||||
let games = vec![
|
let games = vec![
|
||||||
game("steam:570", "steam", "Dota 2"),
|
game("steam:570", "steam", "Dota 2"),
|
||||||
|
|||||||
@@ -204,9 +204,17 @@ mod session_main {
|
|||||||
mode,
|
mode,
|
||||||
compositor: CompositorPref::from_name(&settings.compositor)
|
compositor: CompositorPref::from_name(&settings.compositor)
|
||||||
.unwrap_or(CompositorPref::Auto),
|
.unwrap_or(CompositorPref::Auto),
|
||||||
gamepad: match GamepadPref::from_name(&settings.gamepad) {
|
gamepad: {
|
||||||
Some(GamepadPref::Auto) | None => gamepad.auto_pref(),
|
// The setting AS CHOSEN goes to the pad service too, not just the Hello: the host
|
||||||
Some(explicit) => explicit,
|
// builds each virtual pad from that pad's arrival and only falls back to this
|
||||||
|
// session default for a pad that never declares one, so an explicit choice that
|
||||||
|
// stopped here would be undone the moment a controller connected.
|
||||||
|
let chosen = GamepadPref::from_name(&settings.gamepad).unwrap_or(GamepadPref::Auto);
|
||||||
|
gamepad.set_kind_override(chosen);
|
||||||
|
match chosen {
|
||||||
|
GamepadPref::Auto => gamepad.auto_pref(),
|
||||||
|
explicit => explicit,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
bitrate_kbps: settings.bitrate_kbps,
|
bitrate_kbps: settings.bitrate_kbps,
|
||||||
audio_channels: settings.audio_channels,
|
audio_channels: settings.audio_channels,
|
||||||
|
|||||||
+117
-25
@@ -7,10 +7,12 @@
|
|||||||
//! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host
|
//! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host
|
||||||
//! orchestrator).
|
//! orchestrator).
|
||||||
|
|
||||||
// Scaffold: trait defaults + synthetic sources are defined ahead of the backends that use them.
|
|
||||||
#![allow(dead_code)]
|
|
||||||
// Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
// Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
// …and that program only covers a whole `unsafe fn` body once the body needs its own block: in
|
||||||
|
// edition 2021 `unsafe_op_in_unsafe_fn` is allow-by-default, which exempted the crate's hardest FFI
|
||||||
|
// (the ring/slot construction, the channel broker, every D3D converter ctor) from the deny above.
|
||||||
|
#![deny(unsafe_op_in_unsafe_fn)]
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||||
@@ -19,9 +21,16 @@ use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
use pf_frame::DmabufFrame;
|
use pf_frame::DmabufFrame;
|
||||||
|
|
||||||
/// Produces frames from a captured output. Lives on its own thread, feeding the encoder
|
/// Produces frames from a captured output. Lives on its own thread, handing frames over without
|
||||||
/// over a bounded drop-oldest channel (never block the compositor).
|
/// ever blocking the compositor — the Linux portal publishes into a one-deep OVERWRITING slot
|
||||||
|
/// (drop-oldest), so a stalled consumer costs the intermediate frames and is still handed the
|
||||||
|
/// freshest one.
|
||||||
pub trait Capturer: Send {
|
pub trait Capturer: Send {
|
||||||
|
// ---- Frames -----------------------------------------------------------------------------
|
||||||
|
// `next_frame` blocks for one; `try_latest` is the steady-state non-blocking read;
|
||||||
|
// `wait_arrival` + `supports_arrival_wait` are the frame-driven trigger that replaces a
|
||||||
|
// free-running tick.
|
||||||
|
|
||||||
fn next_frame(&mut self) -> Result<CapturedFrame>;
|
fn next_frame(&mut self) -> Result<CapturedFrame>;
|
||||||
|
|
||||||
/// [`next_frame`](Self::next_frame) with a caller-chosen first-frame budget instead of the
|
/// [`next_frame`](Self::next_frame) with a caller-chosen first-frame budget instead of the
|
||||||
@@ -52,23 +61,47 @@ pub trait Capturer: Send {
|
|||||||
/// Block until a FRESH frame is available via [`try_latest`](Self::try_latest) or
|
/// Block until a FRESH frame is available via [`try_latest`](Self::try_latest) or
|
||||||
/// `deadline` passes — the encode loop's frame-driven wait (latency plan T1.1): waking on
|
/// `deadline` passes — the encode loop's frame-driven wait (latency plan T1.1): waking on
|
||||||
/// the compositor's publish instead of sampling at a free-running tick deletes the
|
/// the compositor's publish instead of sampling at a free-running tick deletes the
|
||||||
/// sample-and-hold (~half a frame interval on average). Must NOT consume the frame (the
|
/// sample-and-hold (~half a frame interval on average). Must NOT consume the frame — the
|
||||||
/// loop's `try_latest` call does); backends buffer internally where the arrival channel
|
/// loop's `try_latest` call does that — so a backend implements this by waiting on a wakeup
|
||||||
/// can't be peeked. Only called when [`supports_arrival_wait`](Self::supports_arrival_wait)
|
/// and then PEEKING its hand-off slot. Only called when
|
||||||
/// is `true`; errors surface at the following `try_latest`.
|
/// [`supports_arrival_wait`](Self::supports_arrival_wait) is `true`; errors surface at the
|
||||||
|
/// following `try_latest`.
|
||||||
fn wait_arrival(&mut self, _deadline: std::time::Instant) {}
|
fn wait_arrival(&mut self, _deadline: std::time::Instant) {}
|
||||||
|
|
||||||
|
// ---- Lifecycle --------------------------------------------------------------------------
|
||||||
|
// Whether the capturer is being used right now, and whether it can still be used at all.
|
||||||
|
|
||||||
/// Gate expensive per-frame work so the capturer can be kept alive (reused) between
|
/// Gate expensive per-frame work so the capturer can be kept alive (reused) between
|
||||||
/// streams without burning CPU. The portal capturer skips the de-pad copy while inactive;
|
/// streams without burning CPU. The portal capturer skips the de-pad copy while inactive and
|
||||||
/// the default is a no-op (synthetic sources are produced on demand). Set `true` for the
|
/// flushes its frame mailbox on `false`; the default is a no-op (synthetic sources are produced
|
||||||
/// duration of a stream, `false` when it ends.
|
/// on demand). Set `true` for the duration of a stream, `false` when it ends.
|
||||||
fn set_active(&self, _active: bool) {}
|
///
|
||||||
|
/// `&mut self`: it mutates capturer state, and every caller owns the capturer. It took `&self`
|
||||||
|
/// only because the flag happened to be an `Arc<AtomicBool>` — an implementation detail leaking
|
||||||
|
/// into the contract, and one the mailbox flush this now also does would not have shared.
|
||||||
|
fn set_active(&mut self, _active: bool) {}
|
||||||
|
|
||||||
|
/// Whether this capturer can still produce frames — the gate a caller that POOLS capturers
|
||||||
|
/// across streams must consult before reusing one.
|
||||||
|
///
|
||||||
|
/// Some backends have TERMINAL states that are only observable by trying to consume a frame:
|
||||||
|
/// the Linux portal capturer's zero-copy poison flag, a dead PipeWire thread, and a source that
|
||||||
|
/// never returns to `Streaming` are all sticky, and each makes every subsequent
|
||||||
|
/// [`next_frame`](Self::next_frame) / [`try_latest`](Self::try_latest) fail — for that backend
|
||||||
|
/// an `Err` from either is terminal, never transient. A pool that re-admits such a capturer
|
||||||
|
/// wedges the next session permanently (it re-fails at the same point, every reconnect), which
|
||||||
|
/// is why this predicate exists rather than leaving callers to infer liveness from an error they
|
||||||
|
/// have often already discarded.
|
||||||
|
///
|
||||||
|
/// `true` (the default) for backends with no such state: the synthetic sources, and the Windows
|
||||||
|
/// IDD-push capturer, whose failures already end the session through its own rebuild path.
|
||||||
|
fn is_alive(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Cursor -----------------------------------------------------------------------------
|
||||||
|
// The out-of-band pointer: where it is, who draws it, and (Linux/gamescope) where to read it.
|
||||||
|
|
||||||
/// The source's static HDR mastering metadata (SMPTE ST.2086 + content light level), when the
|
|
||||||
/// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`). `None` = unknown /
|
|
||||||
/// SDR / a backend that doesn't expose it (the default — Linux capture has no HDR path yet).
|
|
||||||
/// The stream loop forwards this to the encoder (in-band SEI) and the client (`0xCE` datagram),
|
|
||||||
/// so the two stay a single source of truth. May change mid-session if the source is regraded.
|
|
||||||
/// The capture source's LIVE cursor state, when it arrives out-of-band from the frames
|
/// The capture source's LIVE cursor state, when it arrives out-of-band from the frames
|
||||||
/// (the Windows IddCx hardware-cursor channel). Polled by the encode loop every tick and
|
/// (the Windows IddCx hardware-cursor channel). Polled by the encode loop every tick and
|
||||||
/// preferred over `CapturedFrame::cursor` — with a hardware cursor, pointer-only moves
|
/// preferred over `CapturedFrame::cursor` — with a hardware cursor, pointer-only moves
|
||||||
@@ -90,13 +123,21 @@ pub trait Capturer: Send {
|
|||||||
|
|
||||||
/// Attach a gamescope cursor source (remote-desktop-sweep Phase C). gamescope paints no
|
/// Attach a gamescope cursor source (remote-desktop-sweep Phase C). gamescope paints no
|
||||||
/// `SPA_META_Cursor`, so [`cursor`](Self::cursor)'s slot stays empty — this hands the Linux
|
/// `SPA_META_Cursor`, so [`cursor`](Self::cursor)'s slot stays empty — this hands the Linux
|
||||||
/// portal capturer gamescope's nested Xwayland `(DISPLAY, XAUTHORITY)` targets (it may run
|
/// portal capturer a way to reach gamescope's nested Xwaylands (it may run several — one per
|
||||||
/// several — one per `--xwayland-count`) so it reads the pointer shape/position over X11
|
/// `--xwayland-count`) so it reads the pointer shape/position over X11 (XFixes +
|
||||||
/// (XFixes + QueryPointer), following whichever display is focused, and publishes it into that
|
/// QueryPointer), following whichever display is focused, and publishes it into that same slot.
|
||||||
/// same slot. Called once, after the capturer is built, only for gamescope sessions. Default
|
/// Called once, after the capturer is built, only for gamescope sessions. Default no-op: every
|
||||||
/// no-op: every non-gamescope capturer already has a cursor source.
|
/// non-gamescope capturer already has a cursor source.
|
||||||
fn attach_gamescope_cursor(&mut self, _targets: Vec<(String, Option<String>)>) {}
|
#[cfg(target_os = "linux")]
|
||||||
|
fn attach_gamescope_cursor(&mut self, _targets: GamescopeCursorTargets) {}
|
||||||
|
|
||||||
|
// ---- Stream properties ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The source's static HDR mastering metadata (SMPTE ST.2086 + content light level), when the
|
||||||
|
/// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`). `None` = unknown /
|
||||||
|
/// SDR / a backend that doesn't expose it (the default — Linux capture has no HDR path yet).
|
||||||
|
/// The stream loop forwards this to the encoder (in-band SEI) and the client (`0xCE` datagram),
|
||||||
|
/// so the two stay a single source of truth. May change mid-session if the source is regraded.
|
||||||
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -111,10 +152,18 @@ pub trait Capturer: Send {
|
|||||||
1
|
1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Host-initiated resize --------------------------------------------------------------
|
||||||
|
// These two are ONE operation split in half and must be implemented together: a backend that
|
||||||
|
// returns `Some` from `capture_target_id` is promising `resize_output` works, and one that
|
||||||
|
// implements `resize_output` without the identity leaves the caller no way to check that the
|
||||||
|
// display it just reconfigured is still this capturer's. Both defaults decline.
|
||||||
|
|
||||||
/// The OS display-target id this capturer is bound to (Windows IDD-push), so the resize path
|
/// The OS display-target id this capturer is bound to (Windows IDD-push), so the resize path
|
||||||
/// can verify the display it just reconfigured is STILL the one this capturer serves (an
|
/// can verify the display it just reconfigured is STILL the one this capturer serves (an
|
||||||
/// in-place resize keeps the target; a re-arrival fallback mints a new one, which needs a
|
/// in-place resize keeps the target; a re-arrival fallback mints a new one, which needs a
|
||||||
/// fresh capturer). `None` = the backend has no such identity (every non-IDD backend).
|
/// fresh capturer). `None` = the backend has no such identity (every non-IDD backend).
|
||||||
|
///
|
||||||
|
/// PAIRED with [`resize_output`](Self::resize_output) — see the cluster note above.
|
||||||
fn capture_target_id(&self) -> Option<u32> {
|
fn capture_target_id(&self) -> Option<u32> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -125,9 +174,25 @@ pub trait Capturer: Send {
|
|||||||
/// EXTERNAL changes only) and no teardown: the capture pipeline and its send thread survive;
|
/// EXTERNAL changes only) and no teardown: the capture pipeline and its send thread survive;
|
||||||
/// only the encoder is swapped by the caller once the first new-size frame arrives. Returns
|
/// only the encoder is swapped by the caller once the first new-size frame arrives. Returns
|
||||||
/// `true` when handled; `false` (the default) routes the caller to the full-rebuild path.
|
/// `true` when handled; `false` (the default) routes the caller to the full-rebuild path.
|
||||||
|
///
|
||||||
|
/// PAIRED with [`capture_target_id`](Self::capture_target_id) — see the cluster note above.
|
||||||
fn resize_output(&mut self, _width: u32, _height: u32) -> bool {
|
fn resize_output(&mut self, _width: u32, _height: u32) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recreate the delivery ring at the CURRENT mode and re-run the driver attach handshake —
|
||||||
|
/// the recovery half of a swap-chain bounce the descriptor poller cannot see: an
|
||||||
|
/// exclusive-topology eviction (the vdisplay re-assert watchdog) is a real topology change,
|
||||||
|
/// so the OS drives COMMIT_MODES on the live virtual display too and the driver's swap-chain
|
||||||
|
/// is recreated while this capturer keeps waiting on the old ring attachment — frames stop
|
||||||
|
/// with an unchanged descriptor (same mode, same HDR), so the two-strike debounce never
|
||||||
|
/// trips. Arms the same recover-or-drop window as a real resize, so a driver that cannot
|
||||||
|
/// re-attach still fails the session cleanly. Returns `true` when handled; `false` (the
|
||||||
|
/// default) means the backend has no in-place ring recovery and the caller should treat the
|
||||||
|
/// pipeline as unrecoverable in place.
|
||||||
|
fn recreate_ring_in_place(&mut self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
|
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
|
||||||
@@ -299,6 +364,23 @@ pub struct ZeroCopyPolicy {
|
|||||||
pub pyrowave_modifiers: Vec<u64>,
|
pub pyrowave_modifiers: Vec<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Discovers gamescope's nested Xwayland cursor targets — `(DISPLAY, XAUTHORITY)`, one per
|
||||||
|
/// `--xwayland-count` — for [`Capturer::attach_gamescope_cursor`].
|
||||||
|
///
|
||||||
|
/// A CLOSURE, not the `Vec` it used to be, and re-run on a slow cadence by the cursor worker. The
|
||||||
|
/// snapshot was taken once, before the game launched: gamescope creates a second Xwayland for the
|
||||||
|
/// game but only advertises the FIRST in any child's environ, so the game's display was invisible to
|
||||||
|
/// discovery — and when the connected (Big Picture) display then reported "gamescope is not drawing
|
||||||
|
/// the pointer here", the source blanked the cursor for the whole game session, which is the exact
|
||||||
|
/// regression the module doc says it fixed. A provider also lets the worker retry a display that
|
||||||
|
/// died, and lets a stream that starts BEFORE the game converge instead of staying cursorless.
|
||||||
|
///
|
||||||
|
/// Built by the host facade (it wraps `pf_vdisplay::gamescope_xwayland_cursor_targets`), exactly
|
||||||
|
/// like [`FrameChannelSender`] — so the capture→host edge stays one-way.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub type GamescopeCursorTargets =
|
||||||
|
std::sync::Arc<dyn Fn() -> Vec<(String, Option<String>)> + Send + Sync>;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
|
pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
|
||||||
true
|
true
|
||||||
@@ -447,14 +529,24 @@ pub mod synthetic_nv12;
|
|||||||
/// `want_hdr` offers the GNOME 50+ HDR formats (10-bit PQ/BT.2020 dmabufs) instead of the SDR
|
/// `want_hdr` offers the GNOME 50+ HDR formats (10-bit PQ/BT.2020 dmabufs) instead of the SDR
|
||||||
/// set — pass it only when the mirrored monitor is actually in HDR mode (the host probes
|
/// set — pass it only when the mirrored monitor is actually in HDR mode (the host probes
|
||||||
/// DisplayConfig) or the negotiation runs into its 10 s timeout and latches the SDR downgrade.
|
/// DisplayConfig) or the negotiation runs into its 10 s timeout and latches the SDR downgrade.
|
||||||
/// The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts (the one-way edge).
|
/// `want_metadata_cursor` asks for cursor-as-metadata (`SPA_META_Cursor`) — pass it only when
|
||||||
|
/// the session's encode path composites `CapturedFrame::cursor` (the host consults
|
||||||
|
/// `pf-encode`'s `cursor_blend_capable`); otherwise the portal EMBEDS the pointer so it is
|
||||||
|
/// never silently lost. The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts
|
||||||
|
/// (the one-way edge).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn open_portal_monitor(
|
pub fn open_portal_monitor(
|
||||||
anchored: bool,
|
anchored: bool,
|
||||||
want_hdr: bool,
|
want_hdr: bool,
|
||||||
|
want_metadata_cursor: bool,
|
||||||
policy: ZeroCopyPolicy,
|
policy: ZeroCopyPolicy,
|
||||||
) -> Result<Box<dyn Capturer>> {
|
) -> Result<Box<dyn Capturer>> {
|
||||||
linux::PortalCapturer::open(anchored, want_hdr && !hdr_capture_failed(), policy)
|
linux::PortalCapturer::open(
|
||||||
|
anchored,
|
||||||
|
want_hdr && !hdr_capture_failed(),
|
||||||
|
want_metadata_cursor,
|
||||||
|
policy,
|
||||||
|
)
|
||||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+389
-2375
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
|||||||
|
//! The portal CONTROL PLANE: the xdg ScreenCast / RemoteDesktop handshake (async, `ashpd` over
|
||||||
|
//! zbus, on its own tokio runtime), the cursor-mode choice, and GNOME's BT.2100 colour-mode probe.
|
||||||
|
//!
|
||||||
|
//! Split out of `linux/mod.rs` (sweep Phase 5.3) to separate the async control plane from the
|
||||||
|
//! realtime half: nothing here runs per frame — the handshake happens once, then the thread parks
|
||||||
|
//! until `PortalSession`'s `Drop` (in the parent) releases it, and DROPPING the zbus
|
||||||
|
//! connection is what ends the compositor's cast. The probe is likewise a one-shot D-Bus round-trip
|
||||||
|
//! for a control-plane caller.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use std::os::fd::OwnedFd;
|
||||||
|
|
||||||
|
/// Whether any monitor of the live GNOME session is currently in BT.2100 (HDR) colour mode — the
|
||||||
|
/// precondition for Mutter's monitor screencast advertising the 10-bit PQ formats (GNOME 50+;
|
||||||
|
/// Mutter only appends the HDR formats while the mirrored monitor's colour state is BT.2020+PQ).
|
||||||
|
/// Queried over the session bus: `DisplayConfig.GetCurrentState`, monitor property
|
||||||
|
/// `"color-mode" == 1` (`META_COLOR_MODE_BT2100`). `false` on any error — not GNOME, a pre-48
|
||||||
|
/// Mutter without colour modes, no monitors — so callers fall back to the honest SDR offer.
|
||||||
|
/// Blocking (one D-Bus round-trip on a fresh connection); call from control-plane threads only.
|
||||||
|
pub fn gnome_hdr_monitor_active() -> bool {
|
||||||
|
use ashpd::zbus;
|
||||||
|
// GetCurrentState reply: (serial, monitors, logical_monitors, properties); each monitor is
|
||||||
|
// (spec(ssss), modes a(siiddada{sv}), properties a{sv}) — "color-mode" lives in the monitor
|
||||||
|
// properties.
|
||||||
|
type Mode = (
|
||||||
|
String,
|
||||||
|
i32,
|
||||||
|
i32,
|
||||||
|
f64,
|
||||||
|
f64,
|
||||||
|
Vec<f64>,
|
||||||
|
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||||
|
);
|
||||||
|
type Monitor = (
|
||||||
|
(String, String, String, String),
|
||||||
|
Vec<Mode>,
|
||||||
|
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||||
|
);
|
||||||
|
type LogicalMonitor = (
|
||||||
|
i32,
|
||||||
|
i32,
|
||||||
|
f64,
|
||||||
|
u32,
|
||||||
|
bool,
|
||||||
|
Vec<(String, String, String, String)>,
|
||||||
|
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||||
|
);
|
||||||
|
type State = (
|
||||||
|
u32,
|
||||||
|
Vec<Monitor>,
|
||||||
|
Vec<LogicalMonitor>,
|
||||||
|
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||||
|
);
|
||||||
|
let probe = || -> Result<bool> {
|
||||||
|
// zbus is built async-only here (ashpd's tokio integration) — run the one round-trip on
|
||||||
|
// a throwaway current-thread runtime; this is a control-plane call, never per-frame.
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.context("build tokio runtime")?;
|
||||||
|
rt.block_on(async {
|
||||||
|
let conn = zbus::Connection::session().await.context("session bus")?;
|
||||||
|
let reply = conn
|
||||||
|
.call_method(
|
||||||
|
Some("org.gnome.Mutter.DisplayConfig"),
|
||||||
|
"/org/gnome/Mutter/DisplayConfig",
|
||||||
|
Some("org.gnome.Mutter.DisplayConfig"),
|
||||||
|
"GetCurrentState",
|
||||||
|
&(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("DisplayConfig.GetCurrentState")?;
|
||||||
|
let (_serial, monitors, _logical, _props): State = reply
|
||||||
|
.body()
|
||||||
|
.deserialize()
|
||||||
|
.context("parse GetCurrentState")?;
|
||||||
|
Ok(monitors.iter().any(|(_spec, _modes, props)| {
|
||||||
|
props
|
||||||
|
.get("color-mode")
|
||||||
|
.and_then(|v| u32::try_from(v).ok())
|
||||||
|
.is_some_and(|mode| mode == 1) // META_COLOR_MODE_BT2100
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
match probe() {
|
||||||
|
Ok(hdr) => hdr,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(error = %format!("{e:#}"), "GNOME HDR colour-mode probe failed — SDR");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`).
|
||||||
|
/// With `want_metadata` the ladder prefers **cursor-as-metadata**: the compositor keeps its cheap
|
||||||
|
/// hardware cursor plane and ships the pointer as PipeWire `SPA_META_Cursor` metadata (position +
|
||||||
|
/// an occasional bitmap), which the consumer composites itself — avoiding the producer burning the
|
||||||
|
/// cursor into every frame (`Embedded`), which on gamescope would defeat its HW cursor plane.
|
||||||
|
/// Without it — the session's encode path has no compositing stage for a metadata cursor
|
||||||
|
/// (`pf-encode`'s `cursor_blend_capable` said the resolved backend can't blend) — the ladder
|
||||||
|
/// prefers `Embedded`, so the pointer is in the pixels instead of in metadata nothing would draw.
|
||||||
|
/// Both ladders fall through to the other mode, then `Hidden`; a failed property query (an older
|
||||||
|
/// portal) keeps the prior `Embedded` behavior so the cursor is never silently lost.
|
||||||
|
async fn choose_cursor_mode(
|
||||||
|
proxy: &ashpd::desktop::screencast::Screencast,
|
||||||
|
want_metadata: bool,
|
||||||
|
) -> ashpd::desktop::screencast::CursorMode {
|
||||||
|
use ashpd::desktop::screencast::CursorMode;
|
||||||
|
match proxy.available_cursor_modes().await {
|
||||||
|
Ok(avail) if want_metadata && avail.contains(CursorMode::Metadata) => {
|
||||||
|
tracing::info!(
|
||||||
|
?avail,
|
||||||
|
"ScreenCast: requesting cursor-as-metadata (SPA_META_Cursor)"
|
||||||
|
);
|
||||||
|
CursorMode::Metadata
|
||||||
|
}
|
||||||
|
Ok(avail) if avail.contains(CursorMode::Embedded) => {
|
||||||
|
if want_metadata {
|
||||||
|
tracing::info!(
|
||||||
|
?avail,
|
||||||
|
"ScreenCast: cursor metadata unavailable — requesting Embedded cursor"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
?avail,
|
||||||
|
"ScreenCast: requesting Embedded cursor (this session's encoder does not \
|
||||||
|
composite a metadata cursor)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
CursorMode::Embedded
|
||||||
|
}
|
||||||
|
Ok(avail) if avail.contains(CursorMode::Metadata) => {
|
||||||
|
// Embedded wanted but not offered. Metadata still beats Hidden: the CPU capture
|
||||||
|
// path composites `SPA_META_Cursor` inline, so part of the matrix keeps a pointer.
|
||||||
|
tracing::warn!(
|
||||||
|
?avail,
|
||||||
|
"ScreenCast: Embedded cursor not advertised — requesting cursor-as-metadata \
|
||||||
|
(only CPU-path frames will composite it)"
|
||||||
|
);
|
||||||
|
CursorMode::Metadata
|
||||||
|
}
|
||||||
|
Ok(avail) => {
|
||||||
|
tracing::warn!(
|
||||||
|
?avail,
|
||||||
|
"ScreenCast: neither Metadata nor Embedded cursor advertised — cursor will be hidden"
|
||||||
|
);
|
||||||
|
CursorMode::Hidden
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
"ScreenCast: AvailableCursorModes query failed — defaulting to Embedded cursor"
|
||||||
|
);
|
||||||
|
CursorMode::Embedded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The portal handshake: connect ScreenCast, select a single monitor, start, open the
|
||||||
|
/// PipeWire remote, hand the fd + node id back, then keep the session alive until `quit_rx`
|
||||||
|
/// resolves (the capturer's `Drop` — see [`PortalSession`]).
|
||||||
|
pub(super) fn portal_thread(
|
||||||
|
setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>,
|
||||||
|
quit_rx: tokio::sync::oneshot::Receiver<()>,
|
||||||
|
want_metadata_cursor: bool,
|
||||||
|
) {
|
||||||
|
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||||
|
use ashpd::desktop::PersistMode;
|
||||||
|
use ashpd::enumflags2::BitFlags;
|
||||||
|
|
||||||
|
// Multi-thread runtime: the zbus connection's background reader must be pumped
|
||||||
|
// continuously across the create_session → select_sources → start handshake, or the
|
||||||
|
// portal reports "Invalid session". (A current-thread runtime starves it.)
|
||||||
|
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let err_tx = setup_tx.clone();
|
||||||
|
|
||||||
|
rt.block_on(async move {
|
||||||
|
let result: Result<()> = async {
|
||||||
|
let proxy = Screencast::new()
|
||||||
|
.await
|
||||||
|
.context("connect ScreenCast portal")?;
|
||||||
|
let session = proxy
|
||||||
|
.create_session(Default::default())
|
||||||
|
.await
|
||||||
|
.context("create_session")?;
|
||||||
|
let cursor_mode = choose_cursor_mode(&proxy, want_metadata_cursor).await;
|
||||||
|
proxy
|
||||||
|
.select_sources(
|
||||||
|
&session,
|
||||||
|
SelectSourcesOptions::default()
|
||||||
|
.set_cursor_mode(cursor_mode)
|
||||||
|
// Only MONITOR is offered by the wlroots backend
|
||||||
|
// (AvailableSourceTypes=1); requesting unsupported types
|
||||||
|
// invalidates the session.
|
||||||
|
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||||
|
.set_multiple(false)
|
||||||
|
.set_persist_mode(PersistMode::DoNot),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("select_sources")?
|
||||||
|
.response()
|
||||||
|
.context("select_sources rejected (unsupported source type / cursor mode?)")?;
|
||||||
|
let streams = proxy
|
||||||
|
.start(&session, None, Default::default())
|
||||||
|
.await
|
||||||
|
.context("start cast")?
|
||||||
|
.response()
|
||||||
|
.context("start response (chooser cancelled? portal misconfigured?)")?;
|
||||||
|
let stream = streams
|
||||||
|
.streams()
|
||||||
|
.first()
|
||||||
|
.context("portal returned no streams")?
|
||||||
|
.clone();
|
||||||
|
let node_id = stream.pipe_wire_node_id();
|
||||||
|
let fd = proxy
|
||||||
|
.open_pipe_wire_remote(&session, Default::default())
|
||||||
|
.await
|
||||||
|
.context("open_pipe_wire_remote")?;
|
||||||
|
|
||||||
|
setup_tx
|
||||||
|
.send(Ok((fd, node_id)))
|
||||||
|
.map_err(|_| anyhow!("capturer dropped before setup completed"))?;
|
||||||
|
|
||||||
|
// Keep `proxy` + `session` (and the underlying zbus connection) alive for the
|
||||||
|
// capture; the cast is torn down when the connection drops (ashpd's `Session`
|
||||||
|
// has no `Drop`) — which now happens when this park returns, not at process exit.
|
||||||
|
let _keep_alive = (&proxy, &session);
|
||||||
|
let _ = quit_rx.await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
let _ = err_tx.send(Err(format!("{e:#}")));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Drop the runtime HERE, before the caller signals completion: shutting the 2 workers down is
|
||||||
|
// what finishes releasing the zbus connection, so a `done` signal sent after this means the
|
||||||
|
// compositor-side session is really gone (see `PortalSession::drop`).
|
||||||
|
drop(rt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combined RemoteDesktop+ScreenCast portal setup (KWin/GNOME). ScreenCast sources are selected
|
||||||
|
/// on a session created via RemoteDesktop, so a single RemoteDesktop `start` grant —
|
||||||
|
/// pre-authorized headlessly via the `kde-authorized` permission, exactly like the libei input
|
||||||
|
/// path — also covers screen capture, with no separate ScreenCast dialog (which has no such
|
||||||
|
/// bypass). Yields the same PipeWire fd + node id as the standalone path; the consumer is
|
||||||
|
/// identical, as is the `quit_rx` teardown park (see [`PortalSession`]).
|
||||||
|
pub(super) fn portal_thread_remote_desktop(
|
||||||
|
setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>,
|
||||||
|
quit_rx: tokio::sync::oneshot::Receiver<()>,
|
||||||
|
want_metadata_cursor: bool,
|
||||||
|
) {
|
||||||
|
use ashpd::desktop::remote_desktop::{DeviceType, RemoteDesktop, SelectDevicesOptions};
|
||||||
|
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||||
|
use ashpd::desktop::PersistMode;
|
||||||
|
use ashpd::enumflags2::BitFlags;
|
||||||
|
|
||||||
|
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let err_tx = setup_tx.clone();
|
||||||
|
|
||||||
|
rt.block_on(async move {
|
||||||
|
let result: Result<()> = async {
|
||||||
|
let remote = RemoteDesktop::new()
|
||||||
|
.await
|
||||||
|
.context("connect RemoteDesktop portal")?;
|
||||||
|
let screencast = Screencast::new()
|
||||||
|
.await
|
||||||
|
.context("connect ScreenCast portal")?;
|
||||||
|
let session = remote
|
||||||
|
.create_session(Default::default())
|
||||||
|
.await
|
||||||
|
.context("create RemoteDesktop session")?;
|
||||||
|
// RemoteDesktop requires a device selection; we never connect_to_eis on this session
|
||||||
|
// (input injection runs its own), but selecting devices is what makes `start` the
|
||||||
|
// RemoteDesktop grant the kde-authorized bypass covers.
|
||||||
|
remote
|
||||||
|
.select_devices(
|
||||||
|
&session,
|
||||||
|
SelectDevicesOptions::default()
|
||||||
|
.set_devices(DeviceType::Keyboard | DeviceType::Pointer)
|
||||||
|
.set_persist_mode(PersistMode::DoNot),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("select_devices")?
|
||||||
|
.response()
|
||||||
|
.context("select_devices rejected")?;
|
||||||
|
let cursor_mode = choose_cursor_mode(&screencast, want_metadata_cursor).await;
|
||||||
|
screencast
|
||||||
|
.select_sources(
|
||||||
|
&session,
|
||||||
|
SelectSourcesOptions::default()
|
||||||
|
.set_cursor_mode(cursor_mode)
|
||||||
|
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||||
|
.set_multiple(false)
|
||||||
|
.set_persist_mode(PersistMode::DoNot),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("select_sources")?
|
||||||
|
.response()
|
||||||
|
.context("select_sources rejected (unsupported source type?)")?;
|
||||||
|
let streams = remote
|
||||||
|
.start(&session, None, Default::default())
|
||||||
|
.await
|
||||||
|
.context("start RemoteDesktop+ScreenCast")?
|
||||||
|
.response()
|
||||||
|
.context("start response (grant not pre-authorized / headless dialog?)")?;
|
||||||
|
let stream = streams
|
||||||
|
.streams()
|
||||||
|
.first()
|
||||||
|
.context("portal returned no screencast streams")?
|
||||||
|
.clone();
|
||||||
|
let node_id = stream.pipe_wire_node_id();
|
||||||
|
let fd = screencast
|
||||||
|
.open_pipe_wire_remote(&session, Default::default())
|
||||||
|
.await
|
||||||
|
.context("open_pipe_wire_remote")?;
|
||||||
|
|
||||||
|
setup_tx
|
||||||
|
.send(Ok((fd, node_id)))
|
||||||
|
.map_err(|_| anyhow!("capturer dropped before setup completed"))?;
|
||||||
|
|
||||||
|
// Keep the proxies + session (and their zbus connection) alive for the capture, until
|
||||||
|
// the capturer's `Drop` fires the quit channel.
|
||||||
|
let _keep_alive = (&remote, &screencast, &session);
|
||||||
|
let _ = quit_rx.await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
let _ = err_tx.send(Err(format!("{e:#}")));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// See `portal_thread`: drop the runtime before the caller's completion signal.
|
||||||
|
drop(rt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,634 @@
|
|||||||
|
//! Cursor-as-metadata: the `SPA_META_Cursor` parser and the CPU-path composite blits.
|
||||||
|
//!
|
||||||
|
//! Split out of `linux/pipewire.rs` (sweep Phase 5.2). Both halves are producer-driven and
|
||||||
|
//! bounds-critical: [`update_cursor_meta`] reads a bitmap at offsets the COMPOSITOR chose (its own
|
||||||
|
//! SAFETY proof notes that a missing bound SIGSEGVs inside the PipeWire `.process` callback, where
|
||||||
|
//! `catch_unwind` cannot help), and the `composite_cursor*` blits clip a caller-positioned bitmap
|
||||||
|
//! into a frame buffer. Separating them from the stream machinery is what makes them testable
|
||||||
|
//! without a compositor.
|
||||||
|
|
||||||
|
use super::PixelFormat;
|
||||||
|
use pipewire as pw;
|
||||||
|
use pw::spa;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Latest cursor state parsed from `SPA_META_Cursor` (cursor-as-metadata mode). Position is
|
||||||
|
/// refreshed every buffer that carries the meta (including Mutter's cursor-only "corrupted"
|
||||||
|
/// buffers we otherwise skip for their stale frame); the RGBA bitmap is cached and only
|
||||||
|
/// replaced when the compositor sends a fresh one (`bitmap_offset != 0`).
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct CursorState {
|
||||||
|
/// True when the compositor reports a visible pointer (`spa_meta_cursor.id != 0`).
|
||||||
|
visible: bool,
|
||||||
|
/// Top-left where the bitmap is drawn = reported position − hotspot.
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
/// Cached straight-alpha RGBA pixels (`bw*bh*4`, bytes R,G,B,A). `Arc` so the overlay handed
|
||||||
|
/// to each GPU frame is a refcount bump, not a copy. Empty until the first bitmap arrives.
|
||||||
|
rgba: Arc<Vec<u8>>,
|
||||||
|
bw: u32,
|
||||||
|
bh: u32,
|
||||||
|
/// Bumps whenever the bitmap (`rgba`/`bw`/`bh`) changes — stable across position-only moves,
|
||||||
|
/// so the GPU encoder re-uploads its cursor texture only on change.
|
||||||
|
serial: u64,
|
||||||
|
/// The compositor-reported hotspot — carried on the overlay for the cursor-forward
|
||||||
|
/// channel (the blend path uses the pre-adjusted `x`/`y` and never reads it).
|
||||||
|
hot_x: i32,
|
||||||
|
hot_y: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CursorState {
|
||||||
|
/// A shareable overlay for the encode/forward paths, or `None` before the first bitmap
|
||||||
|
/// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the
|
||||||
|
/// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the
|
||||||
|
/// client's relative-mode hint (M3) — which is a different fact from "no cursor yet".
|
||||||
|
/// The encode loop strips invisible overlays before any blend path sees the frame.
|
||||||
|
/// Cheap: clones an `Arc` + a few scalars.
|
||||||
|
pub(super) fn overlay(&self) -> Option<pf_frame::CursorOverlay> {
|
||||||
|
if self.rgba.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(pf_frame::CursorOverlay {
|
||||||
|
x: self.x,
|
||||||
|
y: self.y,
|
||||||
|
w: self.bw,
|
||||||
|
h: self.bh,
|
||||||
|
rgba: self.rgba.clone(),
|
||||||
|
serial: self.serial,
|
||||||
|
hot_x: self.hot_x.max(0) as u32,
|
||||||
|
hot_y: self.hot_y.max(0) as u32,
|
||||||
|
visible: self.visible,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract straight (R,G,B,A) from one 4-byte cursor-bitmap pixel, honoring the bitmap's SPA
|
||||||
|
/// video format (portals emit RGBA or BGRA; ARGB/ABGR handled for completeness). Unknown
|
||||||
|
/// 4-byte formats are read as RGBA.
|
||||||
|
pub(super) fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) {
|
||||||
|
match vfmt {
|
||||||
|
x if x == spa::sys::SPA_VIDEO_FORMAT_RGBA => (s[0], s[1], s[2], s[3]),
|
||||||
|
x if x == spa::sys::SPA_VIDEO_FORMAT_BGRA => (s[2], s[1], s[0], s[3]),
|
||||||
|
x if x == spa::sys::SPA_VIDEO_FORMAT_ARGB => (s[1], s[2], s[3], s[0]),
|
||||||
|
x if x == spa::sys::SPA_VIDEO_FORMAT_ABGR => (s[3], s[2], s[1], s[0]),
|
||||||
|
_ => (s[0], s[1], s[2], s[3]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update `cursor` from the newest buffer's `SPA_META_Cursor` (no-op when the buffer carries no
|
||||||
|
/// cursor meta — producer doesn't support it, or the portal isn't in Metadata cursor mode).
|
||||||
|
/// Called for EVERY dequeued buffer, before the stale-frame skip, so pointer-only movements
|
||||||
|
/// (which Mutter delivers as metadata-only "corrupted" buffers) still refresh the position.
|
||||||
|
pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sys::spa_buffer) {
|
||||||
|
// SAFETY: `spa_buf` is the live buffer we still hold (dequeued, not yet requeued).
|
||||||
|
// `spa_buffer_find_meta` returns the `spa_meta` (type + byte `size` + `data` pointer) for
|
||||||
|
// `SPA_META_Cursor`, or null. We take `find_meta` rather than `find_meta_data` specifically
|
||||||
|
// to obtain the region's real `size`: the bitmap offset, pixel offset and stride read below
|
||||||
|
// are ALL producer-written, and without a bound against the actual region they drive
|
||||||
|
// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read
|
||||||
|
// that SIGSEGVs inside the PipeWire `.process` callback (a segfault `catch_unwind` cannot
|
||||||
|
// catch). Every offset below is validated against `region_size` with checked arithmetic,
|
||||||
|
// mirroring the fd-length guard the main frame path already applies to xdg-desktop-portal-wlr.
|
||||||
|
let meta = unsafe { spa::sys::spa_buffer_find_meta(spa_buf, spa::sys::SPA_META_Cursor) };
|
||||||
|
if meta.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: `meta` is non-null and points into the held buffer's metadata array.
|
||||||
|
let (region_size, data) = unsafe { ((*meta).size as usize, (*meta).data as *const u8) };
|
||||||
|
if data.is_null() || region_size < std::mem::size_of::<spa::sys::spa_meta_cursor>() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cur = data as *const spa::sys::spa_meta_cursor;
|
||||||
|
// SAFETY: `region_size >= size_of::<spa_meta_cursor>()` checked above, so every field is in bounds.
|
||||||
|
let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe {
|
||||||
|
(
|
||||||
|
(*cur).id,
|
||||||
|
(*cur).position.x,
|
||||||
|
(*cur).position.y,
|
||||||
|
(*cur).hotspot.x,
|
||||||
|
(*cur).hotspot.y,
|
||||||
|
(*cur).bitmap_offset,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if id == 0 {
|
||||||
|
// SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only
|
||||||
|
// REWRITES a buffer's meta region when the cursor changed, so recycled buffers
|
||||||
|
// between damage frames carry a stale id-0 meta — treating that as hidden flickered
|
||||||
|
// the cursor off between hovers (on-glass round 5). Keep the last-known state; a
|
||||||
|
// pointer that really left/hid simply stops producing updates. (The M3 hidden hint
|
||||||
|
// loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cursor.visible = true;
|
||||||
|
cursor.x = pos_x - hot_x;
|
||||||
|
cursor.y = pos_y - hot_y;
|
||||||
|
cursor.hot_x = hot_x;
|
||||||
|
cursor.hot_y = hot_y;
|
||||||
|
if bmp_off == 0 {
|
||||||
|
// Position-only update — keep the cached bitmap.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let bmp_off = bmp_off as usize;
|
||||||
|
// The `spa_meta_bitmap` header must fit entirely inside the region before we read it —
|
||||||
|
// `bitmap_offset` is producer-controlled and otherwise reads past the metadata.
|
||||||
|
match bmp_off.checked_add(std::mem::size_of::<spa::sys::spa_meta_bitmap>()) {
|
||||||
|
Some(end) if end <= region_size => {}
|
||||||
|
_ => return,
|
||||||
|
}
|
||||||
|
// SAFETY: `bmp_off + size_of::<spa_meta_bitmap>() <= region_size` (checked directly above),
|
||||||
|
// so the header is fully in bounds for a read of that many bytes. `read_unaligned` is
|
||||||
|
// REQUIRED, not defensive: `bmp_off` is producer-written and nothing in the SPA contract or
|
||||||
|
// in this function establishes that `data + bmp_off` meets `spa_meta_bitmap`'s alignment —
|
||||||
|
// the previous field reads through an aligned `*const` asserted an invariant the code never
|
||||||
|
// proved. The struct is `Copy` POD, so one unaligned read yields an owned, aligned local.
|
||||||
|
let bmp = unsafe { (data.add(bmp_off) as *const spa::sys::spa_meta_bitmap).read_unaligned() };
|
||||||
|
let (vfmt, bw, bh, stride, pix_off) = (
|
||||||
|
bmp.format,
|
||||||
|
bmp.size.width,
|
||||||
|
bmp.size.height,
|
||||||
|
bmp.stride.max(0) as usize,
|
||||||
|
bmp.offset as usize,
|
||||||
|
);
|
||||||
|
// Ignore empty or implausibly large bitmaps (the meta-size request covers <= 1024×1024;
|
||||||
|
// real cursors are ≤96px — the cursor channel downscales >120px for the wire anyway).
|
||||||
|
if bw == 0 || bh == 0 || bw > 1024 || bh > 1024 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let row = bw as usize * 4;
|
||||||
|
let stride = if stride < row { row } else { stride };
|
||||||
|
let Some(extent) = bitmap_extent(bmp_off, pix_off, stride, row, bh as usize, region_size)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// SAFETY: `bitmap_extent` returned `Some`, which means (see its contract) the whole range
|
||||||
|
// `[bmp_off + pix_off, +len)` lies inside `region_size` and `len` is EXACTLY the extent the
|
||||||
|
// strided loop below reads. `data` is the producer's meta-region base, live for this callback.
|
||||||
|
let src = unsafe { std::slice::from_raw_parts(data.add(extent.start), extent.len()) };
|
||||||
|
let mut rgba = vec![0u8; bw as usize * bh as usize * 4];
|
||||||
|
for y in 0..bh as usize {
|
||||||
|
for x in 0..bw as usize {
|
||||||
|
let so = y * stride + x * 4;
|
||||||
|
let (r, g, b, a) = decode_bitmap_pixel(vfmt, &src[so..so + 4]);
|
||||||
|
let d = (y * bw as usize + x) * 4;
|
||||||
|
rgba[d] = r;
|
||||||
|
rgba[d + 1] = g;
|
||||||
|
rgba[d + 2] = b;
|
||||||
|
rgba[d + 3] = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor.rgba = Arc::new(rgba);
|
||||||
|
cursor.bw = bw;
|
||||||
|
cursor.bh = bh;
|
||||||
|
cursor.serial = cursor.serial.wrapping_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The byte range inside the producer's cursor-meta region that a `bh`-row, `row`-wide,
|
||||||
|
/// `stride`-strided bitmap at `bmp_off + pix_off` occupies — or `None` when it does not fit, or when
|
||||||
|
/// any of the arithmetic overflows.
|
||||||
|
///
|
||||||
|
/// THE bound on `update_cursor_meta`. Every input except `region_size` is producer-written, and
|
||||||
|
/// `region_size` is the real byte size of the meta region libspa handed us (which is why the caller
|
||||||
|
/// takes `spa_buffer_find_meta` rather than `find_meta_data`). Without this check the offsets drive
|
||||||
|
/// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read that
|
||||||
|
/// SIGSEGVs inside the PipeWire `.process` callback, where `catch_unwind` cannot help. A stride near
|
||||||
|
/// `i32::MAX` is enough to overflow the multiply on its own, so every step is checked.
|
||||||
|
///
|
||||||
|
/// `len()` of the returned range is EXACTLY `stride·(bh−1) + row`: the last row contributes only its
|
||||||
|
/// `row` visible bytes, not a full stride, so a bitmap that ends flush against the region's end is
|
||||||
|
/// accepted rather than rejected by a padding byte that is never read.
|
||||||
|
///
|
||||||
|
/// Extracted (sweep Phase 6.1) purely so it can be tested — the caller is unreachable without a live
|
||||||
|
/// compositor.
|
||||||
|
fn bitmap_extent(
|
||||||
|
bmp_off: usize,
|
||||||
|
pix_off: usize,
|
||||||
|
stride: usize,
|
||||||
|
row: usize,
|
||||||
|
bh: usize,
|
||||||
|
region_size: usize,
|
||||||
|
) -> Option<std::ops::Range<usize>> {
|
||||||
|
if bh == 0 || row == 0 || stride < row {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let span = stride.checked_mul(bh - 1)?.checked_add(row)?;
|
||||||
|
let start = bmp_off.checked_add(pix_off)?;
|
||||||
|
let end = start.checked_add(span)?;
|
||||||
|
(end <= region_size).then_some(start..end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Destination channel byte offsets (R,G,B) and bytes-per-pixel for a packed-RGB `PixelFormat`,
|
||||||
|
/// or `None` for a layout the CPU cursor blit doesn't handle (YUV/10-bit — those never reach
|
||||||
|
/// the CPU de-pad path anyway).
|
||||||
|
pub(super) fn dst_offsets(fmt: PixelFormat) -> Option<(usize, usize, usize, usize)> {
|
||||||
|
Some(match fmt {
|
||||||
|
PixelFormat::Bgrx | PixelFormat::Bgra => (2, 1, 0, 4),
|
||||||
|
PixelFormat::Rgbx | PixelFormat::Rgba => (0, 1, 2, 4),
|
||||||
|
PixelFormat::Rgb => (0, 1, 2, 3),
|
||||||
|
PixelFormat::Bgr => (2, 1, 0, 3),
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alpha-blend the cached cursor bitmap into a packed 10-bit (`X2Rgb10`/`X2Bgr10`) CPU frame:
|
||||||
|
/// unpack each u32, blend the 8-bit cursor channels scaled to 10 bits (`v<<2 | v>>6`), repack.
|
||||||
|
/// The frame samples are PQ-encoded, so like the 8-bit gamma-space blend this is a display-
|
||||||
|
/// referred approximation — fine for a cursor. `r_shift` is the R channel's bit offset (20 for
|
||||||
|
/// x:R:G:B, 0 for x:B:G:R); G is always at 10 and B mirrors R.
|
||||||
|
pub(super) fn composite_cursor_rgb10(
|
||||||
|
tight: &mut [u8],
|
||||||
|
w: usize,
|
||||||
|
h: usize,
|
||||||
|
r_shift: u32,
|
||||||
|
cursor: &CursorState,
|
||||||
|
) {
|
||||||
|
let b_shift = 20 - r_shift; // 0 or 20 — the opposite end from R
|
||||||
|
let (bw, bh) = (cursor.bw as i32, cursor.bh as i32);
|
||||||
|
for cy in 0..bh {
|
||||||
|
let dy = cursor.y + cy;
|
||||||
|
if dy < 0 || dy as usize >= h {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for cx in 0..bw {
|
||||||
|
let dx = cursor.x + cx;
|
||||||
|
if dx < 0 || dx as usize >= w {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let s = ((cy * bw + cx) as usize) * 4;
|
||||||
|
let a = cursor.rgba[s + 3] as u32;
|
||||||
|
if a == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 8-bit cursor channel → 10-bit (replicate the top bits into the bottom).
|
||||||
|
let up10 = |v: u8| ((v as u32) << 2) | ((v as u32) >> 6);
|
||||||
|
let (sr, sg, sb) = (
|
||||||
|
up10(cursor.rgba[s]),
|
||||||
|
up10(cursor.rgba[s + 1]),
|
||||||
|
up10(cursor.rgba[s + 2]),
|
||||||
|
);
|
||||||
|
let di = (dy as usize * w + dx as usize) * 4;
|
||||||
|
let px = u32::from_le_bytes(tight[di..di + 4].try_into().unwrap());
|
||||||
|
let blend = |dst: u32, src: u32| (src * a + dst * (255 - a)) / 255;
|
||||||
|
let dr = blend((px >> r_shift) & 0x3ff, sr);
|
||||||
|
let dg = blend((px >> 10) & 0x3ff, sg);
|
||||||
|
let db = blend((px >> b_shift) & 0x3ff, sb);
|
||||||
|
let out = (px & 0xc000_0000) | (dr << r_shift) | (dg << 10) | (db << b_shift);
|
||||||
|
tight[di..di + 4].copy_from_slice(&out.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alpha-blend the cached cursor bitmap into the tightly-packed CPU frame at its latched
|
||||||
|
/// position. Cheap: a straight-alpha blit over at most ~256×256 pixels, clipped to the frame —
|
||||||
|
/// the whole point of cursor-as-metadata (no forced full-frame composite on the producer).
|
||||||
|
pub(super) fn composite_cursor(
|
||||||
|
tight: &mut [u8],
|
||||||
|
w: usize,
|
||||||
|
h: usize,
|
||||||
|
fmt: PixelFormat,
|
||||||
|
cursor: &CursorState,
|
||||||
|
) {
|
||||||
|
if !cursor.visible || cursor.rgba.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The packed 10-bit HDR layouts blend via bit unpack/repack, not byte offsets.
|
||||||
|
match fmt {
|
||||||
|
PixelFormat::X2Rgb10 => return composite_cursor_rgb10(tight, w, h, 20, cursor),
|
||||||
|
PixelFormat::X2Bgr10 => return composite_cursor_rgb10(tight, w, h, 0, cursor),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let Some((ri, gi, bi, bpp)) = dst_offsets(fmt) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (bw, bh) = (cursor.bw as i32, cursor.bh as i32);
|
||||||
|
for cy in 0..bh {
|
||||||
|
let dy = cursor.y + cy;
|
||||||
|
if dy < 0 || dy as usize >= h {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for cx in 0..bw {
|
||||||
|
let dx = cursor.x + cx;
|
||||||
|
if dx < 0 || dx as usize >= w {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let s = ((cy * bw + cx) as usize) * 4;
|
||||||
|
let a = cursor.rgba[s + 3] as u32;
|
||||||
|
if a == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (sr, sg, sb) = (
|
||||||
|
cursor.rgba[s] as u32,
|
||||||
|
cursor.rgba[s + 1] as u32,
|
||||||
|
cursor.rgba[s + 2] as u32,
|
||||||
|
);
|
||||||
|
let di = (dy as usize * w + dx as usize) * bpp;
|
||||||
|
let blend = |dst: u8, src: u32| ((src * a + dst as u32 * (255 - a)) / 255) as u8;
|
||||||
|
tight[di + ri] = blend(tight[di + ri], sr);
|
||||||
|
tight[di + gi] = blend(tight[di + gi], sg);
|
||||||
|
tight[di + bi] = blend(tight[di + bi], sb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A solid-colour cursor state at `(x, y)`, `w`×`h`, alpha `a`.
|
||||||
|
fn cursor(x: i32, y: i32, w: u32, h: u32, rgb: (u8, u8, u8), a: u8) -> CursorState {
|
||||||
|
let mut px = Vec::with_capacity((w * h * 4) as usize);
|
||||||
|
for _ in 0..w * h {
|
||||||
|
px.extend_from_slice(&[rgb.0, rgb.1, rgb.2, a]);
|
||||||
|
}
|
||||||
|
CursorState {
|
||||||
|
visible: true,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
rgba: Arc::new(px),
|
||||||
|
bw: w,
|
||||||
|
bh: h,
|
||||||
|
serial: 1,
|
||||||
|
hot_x: 0,
|
||||||
|
hot_y: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably -------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bitmap_extent_accepts_a_bitmap_that_fits() {
|
||||||
|
// 4×2 RGBA, tightly packed: 32 bytes at offset 0.
|
||||||
|
assert_eq!(bitmap_extent(0, 0, 16, 16, 2, 32), Some(0..32));
|
||||||
|
// …and the same bitmap behind a header + pixel offset.
|
||||||
|
assert_eq!(bitmap_extent(24, 8, 16, 16, 2, 64), Some(32..64));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bitmap_extent_charges_the_last_row_only_its_visible_bytes() {
|
||||||
|
// stride 32, row 16, 3 rows ⇒ 32*2 + 16 = 80, NOT 96. A bitmap ending flush against the
|
||||||
|
// region must be accepted: the trailing stride padding is never read.
|
||||||
|
assert_eq!(bitmap_extent(0, 0, 32, 16, 3, 80), Some(0..80));
|
||||||
|
assert_eq!(bitmap_extent(0, 0, 32, 16, 3, 79), None, "one byte short");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bitmap_extent_rejects_anything_past_the_region() {
|
||||||
|
assert_eq!(bitmap_extent(0, 0, 16, 16, 2, 31), None);
|
||||||
|
// An offset alone can push it out.
|
||||||
|
assert_eq!(bitmap_extent(1, 0, 16, 16, 2, 32), None);
|
||||||
|
assert_eq!(bitmap_extent(0, 1, 16, 16, 2, 32), None);
|
||||||
|
// A region of zero accepts nothing.
|
||||||
|
assert_eq!(bitmap_extent(0, 0, 16, 16, 1, 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The producer picks `stride` and both offsets, so each is an overflow vector on its own.
|
||||||
|
#[test]
|
||||||
|
fn bitmap_extent_survives_hostile_arithmetic() {
|
||||||
|
// stride × (bh-1) overflows.
|
||||||
|
assert_eq!(bitmap_extent(0, 0, usize::MAX, 16, 3, usize::MAX), None);
|
||||||
|
// span + row overflows. Needs ≥2 rows so `stride·(bh−1)` is already at the ceiling: with a
|
||||||
|
// SINGLE row `stride·0 == 0`, and even a `usize::MAX`-wide row is then arithmetically in
|
||||||
|
// range — which is correct rather than a miss, since the caller has already capped `bw` at
|
||||||
|
// 1024 and `row` is therefore ≤ 4096.
|
||||||
|
assert_eq!(
|
||||||
|
bitmap_extent(0, 0, usize::MAX, usize::MAX, 2, usize::MAX),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// bmp_off + pix_off overflows.
|
||||||
|
assert_eq!(bitmap_extent(usize::MAX, 1, 16, 16, 1, usize::MAX), None);
|
||||||
|
// start + span overflows.
|
||||||
|
assert_eq!(
|
||||||
|
bitmap_extent(usize::MAX - 8, 0, 16, 16, 1, usize::MAX),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// A near-i32::MAX stride — the case the SAFETY comment calls out — must not wrap.
|
||||||
|
assert_eq!(bitmap_extent(0, 0, i32::MAX as usize, 16, 1024, 4096), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bitmap_extent_rejects_degenerate_geometry() {
|
||||||
|
assert_eq!(bitmap_extent(0, 0, 16, 16, 0, 4096), None, "zero rows");
|
||||||
|
assert_eq!(bitmap_extent(0, 0, 16, 0, 2, 4096), None, "zero-width row");
|
||||||
|
assert_eq!(bitmap_extent(0, 0, 8, 16, 2, 4096), None, "stride < row");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- composite_cursor: clipping, alpha, and every layout --------------------------------
|
||||||
|
|
||||||
|
/// Read pixel `(x, y)`'s (R, G, B) out of a packed frame, honouring the layout's byte order.
|
||||||
|
fn px_rgb(buf: &[u8], w: usize, x: usize, y: usize, fmt: PixelFormat) -> (u8, u8, u8) {
|
||||||
|
let (ri, gi, bi, bpp) = dst_offsets(fmt).expect("packed layout");
|
||||||
|
let i = (y * w + x) * bpp;
|
||||||
|
(buf[i + ri], buf[i + gi], buf[i + bi])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_packed_layout_lands_the_colour_in_its_own_channels() {
|
||||||
|
for fmt in [
|
||||||
|
PixelFormat::Bgrx,
|
||||||
|
PixelFormat::Bgra,
|
||||||
|
PixelFormat::Rgbx,
|
||||||
|
PixelFormat::Rgba,
|
||||||
|
PixelFormat::Rgb,
|
||||||
|
PixelFormat::Bgr,
|
||||||
|
] {
|
||||||
|
let bpp = dst_offsets(fmt).unwrap().3;
|
||||||
|
let (w, h) = (4usize, 4usize);
|
||||||
|
let mut buf = vec![0u8; w * h * bpp];
|
||||||
|
// Opaque pure red at (1, 1).
|
||||||
|
composite_cursor(&mut buf, w, h, fmt, &cursor(1, 1, 1, 1, (255, 0, 0), 255));
|
||||||
|
assert_eq!(px_rgb(&buf, w, 1, 1, fmt), (255, 0, 0), "{fmt:?}");
|
||||||
|
// Nothing else moved.
|
||||||
|
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (0, 0, 0), "{fmt:?}");
|
||||||
|
assert_eq!(px_rgb(&buf, w, 2, 1, fmt), (0, 0, 0), "{fmt:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cursor_hanging_off_every_edge_is_clipped_not_wrapped() {
|
||||||
|
let (w, h, fmt) = (4usize, 4usize, PixelFormat::Bgrx);
|
||||||
|
// Top-left: only the bottom-right quarter of a 2×2 lands, at (0, 0).
|
||||||
|
let mut buf = vec![0u8; w * h * 4];
|
||||||
|
composite_cursor(
|
||||||
|
&mut buf,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
fmt,
|
||||||
|
&cursor(-1, -1, 2, 2, (10, 20, 30), 255),
|
||||||
|
);
|
||||||
|
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (10, 20, 30));
|
||||||
|
assert_eq!(px_rgb(&buf, w, 1, 0, fmt), (0, 0, 0));
|
||||||
|
assert_eq!(px_rgb(&buf, w, 0, 1, fmt), (0, 0, 0));
|
||||||
|
// Bottom-right: only the top-left quarter lands, at (3, 3).
|
||||||
|
let mut buf = vec![0u8; w * h * 4];
|
||||||
|
composite_cursor(&mut buf, w, h, fmt, &cursor(3, 3, 2, 2, (10, 20, 30), 255));
|
||||||
|
assert_eq!(px_rgb(&buf, w, 3, 3, fmt), (10, 20, 30));
|
||||||
|
assert_eq!(px_rgb(&buf, w, 2, 3, fmt), (0, 0, 0));
|
||||||
|
// Fully outside in each direction: the frame is untouched.
|
||||||
|
for pos in [(-2, 0), (0, -2), (4, 0), (0, 4), (-9, -9), (99, 99)] {
|
||||||
|
let mut buf = vec![0u8; w * h * 4];
|
||||||
|
composite_cursor(
|
||||||
|
&mut buf,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
fmt,
|
||||||
|
&cursor(pos.0, pos.1, 2, 2, (255, 255, 255), 255),
|
||||||
|
);
|
||||||
|
assert!(buf.iter().all(|&b| b == 0), "drew something at {pos:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transparent_and_hidden_cursors_draw_nothing() {
|
||||||
|
let (w, h, fmt) = (2usize, 2usize, PixelFormat::Bgrx);
|
||||||
|
// Alpha 0 — every pixel skipped.
|
||||||
|
let mut buf = vec![0u8; w * h * 4];
|
||||||
|
composite_cursor(&mut buf, w, h, fmt, &cursor(0, 0, 2, 2, (255, 255, 255), 0));
|
||||||
|
assert!(buf.iter().all(|&b| b == 0));
|
||||||
|
// `visible: false` — the whole blit is skipped.
|
||||||
|
let mut c = cursor(0, 0, 2, 2, (255, 255, 255), 255);
|
||||||
|
c.visible = false;
|
||||||
|
let mut buf = vec![0u8; w * h * 4];
|
||||||
|
composite_cursor(&mut buf, w, h, fmt, &c);
|
||||||
|
assert!(buf.iter().all(|&b| b == 0));
|
||||||
|
// No bitmap yet — likewise.
|
||||||
|
let mut c = cursor(0, 0, 2, 2, (255, 255, 255), 255);
|
||||||
|
c.rgba = Arc::new(Vec::new());
|
||||||
|
let mut buf = vec![0u8; w * h * 4];
|
||||||
|
composite_cursor(&mut buf, w, h, fmt, &c);
|
||||||
|
assert!(buf.iter().all(|&b| b == 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn half_alpha_blends_toward_the_destination() {
|
||||||
|
let (w, h, fmt) = (1usize, 1usize, PixelFormat::Bgrx);
|
||||||
|
// dst = white, src = black at 50% ⇒ mid grey (integer blend: (0*128 + 255*127)/255 = 127).
|
||||||
|
let mut buf = vec![255u8; w * h * 4];
|
||||||
|
composite_cursor(&mut buf, w, h, fmt, &cursor(0, 0, 1, 1, (0, 0, 0), 128));
|
||||||
|
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (127, 127, 127));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A layout the CPU blit cannot address must be declined, not mis-blitted.
|
||||||
|
#[test]
|
||||||
|
fn unsupported_layouts_are_declined() {
|
||||||
|
assert!(dst_offsets(PixelFormat::Nv12).is_none());
|
||||||
|
assert!(dst_offsets(PixelFormat::Yuv444).is_none());
|
||||||
|
let (w, h) = (2usize, 2usize);
|
||||||
|
let mut buf = vec![0u8; w * h * 4];
|
||||||
|
composite_cursor(
|
||||||
|
&mut buf,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
PixelFormat::Nv12,
|
||||||
|
&cursor(0, 0, 2, 2, (255, 255, 255), 255),
|
||||||
|
);
|
||||||
|
assert!(buf.iter().all(|&b| b == 0), "NV12 must not be blitted");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- composite_cursor_rgb10: the 10-bit unpack/repack round trip -------------------------
|
||||||
|
|
||||||
|
/// Pack an `x:R:G:B` (`X2Rgb10`) pixel — R at bit 20, G at 10, B at 0 — the way a producer does.
|
||||||
|
fn pack_x2rgb10(r: u32, g: u32, b: u32) -> [u8; 4] {
|
||||||
|
(0xC000_0000 | (r << 20) | (g << 10) | b).to_le_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_10bit_path_round_trips_an_untouched_pixel() {
|
||||||
|
// Alpha 0 ⇒ the blend is skipped entirely, so the packed pixel must come back bit-identical
|
||||||
|
// (including the top two bits, which are alpha and must survive the repack).
|
||||||
|
for (r, g, b) in [(0, 0, 0), (1023, 1023, 1023), (940, 64, 512), (1, 2, 3)] {
|
||||||
|
let src = pack_x2rgb10(r, g, b);
|
||||||
|
let mut buf = src.to_vec();
|
||||||
|
composite_cursor(
|
||||||
|
&mut buf,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
PixelFormat::X2Rgb10,
|
||||||
|
&cursor(0, 0, 1, 1, (255, 255, 255), 0),
|
||||||
|
);
|
||||||
|
assert_eq!(buf, src, "({r},{g},{b}) was modified by a zero-alpha blend");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_10bit_path_writes_the_right_channel_at_the_right_shift() {
|
||||||
|
// Opaque pure red, 8-bit 255 → 10-bit 1023 (the `v<<2 | v>>6` expansion).
|
||||||
|
let mut buf = pack_x2rgb10(0, 0, 0).to_vec();
|
||||||
|
composite_cursor(
|
||||||
|
&mut buf,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
PixelFormat::X2Rgb10,
|
||||||
|
&cursor(0, 0, 1, 1, (255, 0, 0), 255),
|
||||||
|
);
|
||||||
|
let v = u32::from_le_bytes(buf[..4].try_into().unwrap());
|
||||||
|
assert_eq!((v >> 20) & 0x3ff, 1023, "R");
|
||||||
|
assert_eq!((v >> 10) & 0x3ff, 0, "G");
|
||||||
|
assert_eq!(v & 0x3ff, 0, "B");
|
||||||
|
assert_eq!(v & 0xc000_0000, 0xc000_0000, "alpha bits preserved");
|
||||||
|
|
||||||
|
// X2Bgr10 puts R at bit 0 and B at 20 — the SAME cursor must land in the other end.
|
||||||
|
let mut buf = pack_x2rgb10(0, 0, 0).to_vec();
|
||||||
|
composite_cursor(
|
||||||
|
&mut buf,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
PixelFormat::X2Bgr10,
|
||||||
|
&cursor(0, 0, 1, 1, (255, 0, 0), 255),
|
||||||
|
);
|
||||||
|
let v = u32::from_le_bytes(buf[..4].try_into().unwrap());
|
||||||
|
assert_eq!(v & 0x3ff, 1023, "R at bit 0 for x:B:G:R");
|
||||||
|
assert_eq!((v >> 20) & 0x3ff, 0, "B untouched");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_10bit_path_clips_like_the_8bit_one() {
|
||||||
|
let (w, h) = (2usize, 2usize);
|
||||||
|
let mut buf: Vec<u8> = (0..w * h).flat_map(|_| pack_x2rgb10(0, 0, 0)).collect();
|
||||||
|
let before = buf.clone();
|
||||||
|
// Entirely off-frame.
|
||||||
|
composite_cursor(
|
||||||
|
&mut buf,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
PixelFormat::X2Rgb10,
|
||||||
|
&cursor(-5, -5, 2, 2, (255, 255, 255), 255),
|
||||||
|
);
|
||||||
|
assert_eq!(buf, before);
|
||||||
|
// Straddling the top-left corner: only (0, 0) is written.
|
||||||
|
composite_cursor(
|
||||||
|
&mut buf,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
PixelFormat::X2Rgb10,
|
||||||
|
&cursor(-1, -1, 2, 2, (255, 255, 255), 255),
|
||||||
|
);
|
||||||
|
let p0 = u32::from_le_bytes(buf[0..4].try_into().unwrap());
|
||||||
|
let p1 = u32::from_le_bytes(buf[4..8].try_into().unwrap());
|
||||||
|
assert_eq!((p0 >> 20) & 0x3ff, 1023);
|
||||||
|
assert_eq!((p1 >> 20) & 0x3ff, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- decode_bitmap_pixel: the producer's byte order ------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_bitmap_format_is_decoded_to_straight_rgba() {
|
||||||
|
let s = [1u8, 2, 3, 4];
|
||||||
|
assert_eq!(
|
||||||
|
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_RGBA, &s),
|
||||||
|
(1, 2, 3, 4)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_BGRA, &s),
|
||||||
|
(3, 2, 1, 4)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_ARGB, &s),
|
||||||
|
(2, 3, 4, 1)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_ABGR, &s),
|
||||||
|
(4, 3, 2, 1)
|
||||||
|
);
|
||||||
|
// An unknown 4-byte format reads as RGBA rather than being rejected — documented behaviour.
|
||||||
|
assert_eq!(decode_bitmap_pixel(0xdead_beef, &s), (1, 2, 3, 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
//! The PipeWire `EnumFormat` / `Buffers` / `Meta` param pods the capture stream offers, and the
|
||||||
|
//! `Pod` serializer they all end in.
|
||||||
|
//!
|
||||||
|
//! Split out of `linux/pipewire.rs` (sweep Phase 5.2) because it is the crate's WIRE surface: what
|
||||||
|
//! these builders put in a pod is what the compositor intersects against, and a missing property is
|
||||||
|
//! not a compile error but a link that silently stalls in `negotiating`. Nothing here touches the
|
||||||
|
//! stream, the buffers or the frames — every function is a pure `facts -> Vec<u8>`.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use pipewire as pw;
|
||||||
|
use pw::spa;
|
||||||
|
use spa::param::video::VideoFormat;
|
||||||
|
|
||||||
|
pub(super) fn serialize_pod(obj: pw::spa::pod::Object) -> Result<Vec<u8>> {
|
||||||
|
Ok(pw::spa::pod::serialize::PodSerializer::serialize(
|
||||||
|
std::io::Cursor::new(Vec::new()),
|
||||||
|
&pw::spa::pod::Value::Object(obj),
|
||||||
|
)
|
||||||
|
.context("serialize pod")?
|
||||||
|
.0
|
||||||
|
.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a LINEAR/modifier DMA-BUF `EnumFormat` pod. Packed BGRx is the existing import path;
|
||||||
|
/// NV12 is gamescope's producer-side RGB→YUV path (opt-in during bring-up).
|
||||||
|
pub(super) fn build_dmabuf_format(
|
||||||
|
format: VideoFormat,
|
||||||
|
modifiers: &[u64],
|
||||||
|
preferred: Option<(u32, u32, u32)>,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
|
||||||
|
use pw::spa::param::format::{FormatProperties, MediaSubtype, MediaType};
|
||||||
|
let mut obj = pw::spa::pod::object!(
|
||||||
|
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||||
|
pw::spa::param::ParamType::EnumFormat,
|
||||||
|
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
|
||||||
|
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
||||||
|
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
|
||||||
|
pw::spa::pod::property!(
|
||||||
|
FormatProperties::VideoSize,
|
||||||
|
Choice,
|
||||||
|
Range,
|
||||||
|
Rectangle,
|
||||||
|
pw::spa::utils::Rectangle {
|
||||||
|
width: dw,
|
||||||
|
height: dh
|
||||||
|
},
|
||||||
|
pw::spa::utils::Rectangle {
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
},
|
||||||
|
pw::spa::utils::Rectangle {
|
||||||
|
width: 8192,
|
||||||
|
height: 8192
|
||||||
|
}
|
||||||
|
),
|
||||||
|
pw::spa::pod::property!(
|
||||||
|
FormatProperties::VideoFramerate,
|
||||||
|
Choice,
|
||||||
|
Range,
|
||||||
|
Fraction,
|
||||||
|
pw::spa::utils::Fraction { num: dhz, denom: 1 },
|
||||||
|
pw::spa::utils::Fraction { num: 0, denom: 1 },
|
||||||
|
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if format == VideoFormat::NV12 {
|
||||||
|
obj.properties.push(pw::spa::pod::Property {
|
||||||
|
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorMatrix,
|
||||||
|
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||||
|
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||||
|
pw::spa::sys::SPA_VIDEO_COLOR_MATRIX_BT709,
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
obj.properties.push(pw::spa::pod::Property {
|
||||||
|
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorRange,
|
||||||
|
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||||
|
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||||
|
pw::spa::sys::SPA_VIDEO_COLOR_RANGE_16_235,
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
obj.properties.push(pw::spa::pod::Property {
|
||||||
|
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
|
||||||
|
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||||
|
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Long(
|
||||||
|
pw::spa::utils::Choice(
|
||||||
|
pw::spa::utils::ChoiceFlags::empty(),
|
||||||
|
pw::spa::utils::ChoiceEnum::Enum {
|
||||||
|
default: modifiers[0] as i64,
|
||||||
|
alternatives: modifiers.iter().map(|&m| m as i64).collect(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
serialize_pod(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build one GNOME 50+ HDR format pod: `format` (xRGB_210LE / xBGR_210LE) as a LINEAR-only
|
||||||
|
/// dmabuf with **MANDATORY** BT.2020 primaries + SMPTE ST.2084 (PQ) transfer-function props —
|
||||||
|
/// the exact colorimetry Mutter's monitor stream advertises while the mirrored monitor is in
|
||||||
|
/// HDR mode (its HDR pods carry the same props MANDATORY, so both sides must speak them for
|
||||||
|
/// the intersection to exist; an SDR or pre-50 producer can never match this pod).
|
||||||
|
///
|
||||||
|
/// LINEAR-only because every 10-bit consumer we have reads the buffer without a de-tile pass:
|
||||||
|
/// the CPU path mmaps it, and the VAAPI passthrough imports it into a VA surface. The tiled
|
||||||
|
/// EGL de-tile blit renders into an 8-bit `GL_RGBA8` texture — it would silently crush the
|
||||||
|
/// depth — so tiled modifiers are deliberately NOT advertised (a zero-copy 10-bit de-tile is
|
||||||
|
/// the follow-up). SHM is excluded entirely: Mutter's SHM record path paints 8-bit ARGB32
|
||||||
|
/// regardless of the negotiated format.
|
||||||
|
/// `SPA_VIDEO_TRANSFER_SMPTE2084` (PQ) — spelled out rather than taken from `pw::spa::sys`
|
||||||
|
/// because libspa only grew the constant with the BT2020_10/SMPTE2084/ARIB_STD_B67 block, and
|
||||||
|
/// the distro builders (Ubuntu 24.04 noble for the .deb) ship headers predating it — bindgen
|
||||||
|
/// then emits no such constant and the host fails to compile there, even though the code never
|
||||||
|
/// runs on those systems (the HDR path needs GNOME 50+).
|
||||||
|
///
|
||||||
|
/// 14 is the enum's position in `spa/param/video/color.h` and is wire ABI, not a private
|
||||||
|
/// detail: SPA mirrors GStreamer's `GstVideoTransferFunction`, where that block was added
|
||||||
|
/// together, so the value is identical on every libspa that has the symbol at all. On one that
|
||||||
|
/// doesn't, PipeWire simply fails to intersect this format offer and the session negotiates
|
||||||
|
/// SDR — the same outcome as not offering HDR.
|
||||||
|
const SPA_VIDEO_TRANSFER_SMPTE2084: u32 = 14;
|
||||||
|
|
||||||
|
pub(super) fn build_hdr_dmabuf_format(
|
||||||
|
format: VideoFormat,
|
||||||
|
preferred: Option<(u32, u32, u32)>,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
|
||||||
|
use pw::spa::param::format::{FormatProperties, MediaSubtype, MediaType};
|
||||||
|
let mut obj = pw::spa::pod::object!(
|
||||||
|
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||||
|
pw::spa::param::ParamType::EnumFormat,
|
||||||
|
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
|
||||||
|
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
||||||
|
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
|
||||||
|
pw::spa::pod::property!(
|
||||||
|
FormatProperties::VideoSize,
|
||||||
|
Choice,
|
||||||
|
Range,
|
||||||
|
Rectangle,
|
||||||
|
pw::spa::utils::Rectangle {
|
||||||
|
width: dw,
|
||||||
|
height: dh
|
||||||
|
},
|
||||||
|
pw::spa::utils::Rectangle {
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
},
|
||||||
|
pw::spa::utils::Rectangle {
|
||||||
|
width: 8192,
|
||||||
|
height: 8192
|
||||||
|
}
|
||||||
|
),
|
||||||
|
pw::spa::pod::property!(
|
||||||
|
FormatProperties::VideoFramerate,
|
||||||
|
Choice,
|
||||||
|
Range,
|
||||||
|
Fraction,
|
||||||
|
pw::spa::utils::Fraction { num: dhz, denom: 1 },
|
||||||
|
pw::spa::utils::Fraction { num: 0, denom: 1 },
|
||||||
|
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||||
|
),
|
||||||
|
);
|
||||||
|
obj.properties.push(pw::spa::pod::Property {
|
||||||
|
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
|
||||||
|
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||||
|
value: pw::spa::pod::Value::Long(0), // DRM_FORMAT_MOD_LINEAR
|
||||||
|
});
|
||||||
|
obj.properties.push(pw::spa::pod::Property {
|
||||||
|
key: pw::spa::sys::SPA_FORMAT_VIDEO_transferFunction,
|
||||||
|
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||||
|
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(SPA_VIDEO_TRANSFER_SMPTE2084)),
|
||||||
|
});
|
||||||
|
obj.properties.push(pw::spa::pod::Property {
|
||||||
|
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorPrimaries,
|
||||||
|
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||||
|
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||||
|
pw::spa::sys::SPA_VIDEO_COLOR_PRIMARIES_BT2020,
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
serialize_pod(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default (shm/CPU-path) format offer: raw video in any encoder-mappable layout, any
|
||||||
|
/// size, any framerate (0/1 = variable allowed — gamescope fixates exactly that).
|
||||||
|
pub(super) fn build_default_format_obj(preferred: Option<(u32, u32, u32)>) -> pw::spa::pod::Object {
|
||||||
|
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
|
||||||
|
pw::spa::pod::object!(
|
||||||
|
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||||
|
pw::spa::param::ParamType::EnumFormat,
|
||||||
|
pw::spa::pod::property!(
|
||||||
|
pw::spa::param::format::FormatProperties::MediaType,
|
||||||
|
Id,
|
||||||
|
pw::spa::param::format::MediaType::Video
|
||||||
|
),
|
||||||
|
pw::spa::pod::property!(
|
||||||
|
pw::spa::param::format::FormatProperties::MediaSubtype,
|
||||||
|
Id,
|
||||||
|
pw::spa::param::format::MediaSubtype::Raw
|
||||||
|
),
|
||||||
|
// Offer the layouts the encoder can map to an NVENC input format. wlroots
|
||||||
|
// commonly fixates packed RGB (3 bpp); other compositors offer 4 bpp. Only
|
||||||
|
// these are requested, so negotiation fails loudly rather than handing us a
|
||||||
|
// format we'd misinterpret.
|
||||||
|
pw::spa::pod::property!(
|
||||||
|
pw::spa::param::format::FormatProperties::VideoFormat,
|
||||||
|
Choice,
|
||||||
|
Enum,
|
||||||
|
Id,
|
||||||
|
VideoFormat::RGB,
|
||||||
|
VideoFormat::RGB,
|
||||||
|
VideoFormat::BGR,
|
||||||
|
VideoFormat::RGBx,
|
||||||
|
VideoFormat::BGRx,
|
||||||
|
VideoFormat::RGBA,
|
||||||
|
VideoFormat::BGRA,
|
||||||
|
),
|
||||||
|
pw::spa::pod::property!(
|
||||||
|
pw::spa::param::format::FormatProperties::VideoSize,
|
||||||
|
Choice,
|
||||||
|
Range,
|
||||||
|
Rectangle,
|
||||||
|
pw::spa::utils::Rectangle {
|
||||||
|
width: dw,
|
||||||
|
height: dh
|
||||||
|
},
|
||||||
|
pw::spa::utils::Rectangle {
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
},
|
||||||
|
pw::spa::utils::Rectangle {
|
||||||
|
width: 8192,
|
||||||
|
height: 8192
|
||||||
|
}
|
||||||
|
),
|
||||||
|
pw::spa::pod::property!(
|
||||||
|
pw::spa::param::format::FormatProperties::VideoFramerate,
|
||||||
|
Choice,
|
||||||
|
Range,
|
||||||
|
Fraction,
|
||||||
|
pw::spa::utils::Fraction { num: dhz, denom: 1 },
|
||||||
|
pw::spa::utils::Fraction { num: 0, denom: 1 },
|
||||||
|
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a Buffers param for the CPU path accepting anything mappable: MemPtr, MemFd, and
|
||||||
|
/// DmaBuf. The DmaBuf bit matters for producers like gamescope whose format intersection
|
||||||
|
/// lands on their modifier-bearing (LINEAR) pod: they then offer *only* DmaBuf buffers, and
|
||||||
|
/// without this bit the buffer-type intersection is empty and the link silently stalls in
|
||||||
|
/// "negotiating". A LINEAR dmabuf is mmap-able by MAP_BUFFERS, so the CPU de-pad copy works.
|
||||||
|
pub(super) fn build_mappable_buffers() -> Result<Vec<u8>> {
|
||||||
|
serialize_pod(pw::spa::pod::Object {
|
||||||
|
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||||
|
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||||
|
properties: vec![pw::spa::pod::Property {
|
||||||
|
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
|
||||||
|
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||||
|
value: pw::spa::pod::Value::Int(
|
||||||
|
(1i32 << pw::spa::sys::SPA_DATA_MemPtr)
|
||||||
|
| (1i32 << pw::spa::sys::SPA_DATA_MemFd)
|
||||||
|
| (1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
|
||||||
|
),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a Buffers param for a TRUE SHM path: MemPtr + MemFd only, NO DmaBuf. Forces the
|
||||||
|
/// producer to download into mappable memory (Mutter's `glReadPixels`), which orders against its
|
||||||
|
/// render — so the frame is complete and current by construction. This is the only race-free
|
||||||
|
/// capture of Mutter's virtual monitor on NVIDIA: the compositor renders straight into the buffer
|
||||||
|
/// pool, NVIDIA attaches no implicit dmabuf fence (verified: `EXPORT_SYNC_FILE` waited=false) and
|
||||||
|
/// can't produce an explicit sync_fd, so any dmabuf read (zero-copy OR mmap) races the render and
|
||||||
|
/// flashes the buffer's previous frame. Excluding DmaBuf is what makes the difference vs.
|
||||||
|
/// `build_mappable_buffers` (which still let Mutter hand dmabufs).
|
||||||
|
pub(super) fn build_shm_only_buffers() -> Result<Vec<u8>> {
|
||||||
|
serialize_pod(pw::spa::pod::Object {
|
||||||
|
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||||
|
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||||
|
properties: vec![pw::spa::pod::Property {
|
||||||
|
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
|
||||||
|
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||||
|
value: pw::spa::pod::Value::Int(
|
||||||
|
(1i32 << pw::spa::sys::SPA_DATA_MemPtr) | (1i32 << pw::spa::sys::SPA_DATA_MemFd),
|
||||||
|
),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a Buffers param requesting dmabuf-only buffers.
|
||||||
|
pub(super) fn build_dmabuf_buffers() -> Result<Vec<u8>> {
|
||||||
|
serialize_pod(pw::spa::pod::Object {
|
||||||
|
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||||
|
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||||
|
properties: vec![pw::spa::pod::Property {
|
||||||
|
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
|
||||||
|
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||||
|
value: pw::spa::pod::Value::Int(1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request the compositor attach `SPA_META_Cursor` to each buffer, so the pointer travels as
|
||||||
|
/// metadata (position + an occasional bitmap) instead of being burned into the frame. Paired
|
||||||
|
/// with the portal's `CursorMode::Metadata`; producers that don't support it simply don't
|
||||||
|
/// attach it (harmless). Size is a range up to a 256×256 bitmap — bigger than any real cursor.
|
||||||
|
pub(super) fn build_cursor_meta_param() -> Result<Vec<u8>> {
|
||||||
|
fn meta_size(w: u32, h: u32) -> i32 {
|
||||||
|
(std::mem::size_of::<spa::sys::spa_meta_cursor>()
|
||||||
|
+ std::mem::size_of::<spa::sys::spa_meta_bitmap>()
|
||||||
|
+ (w as usize * h as usize * 4)) as i32
|
||||||
|
}
|
||||||
|
serialize_pod(pw::spa::pod::Object {
|
||||||
|
type_: pw::spa::utils::SpaTypes::ObjectParamMeta.as_raw(),
|
||||||
|
id: pw::spa::param::ParamType::Meta.as_raw(),
|
||||||
|
properties: vec![
|
||||||
|
pw::spa::pod::Property {
|
||||||
|
key: pw::spa::sys::SPA_PARAM_META_type,
|
||||||
|
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||||
|
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(spa::sys::SPA_META_Cursor)),
|
||||||
|
},
|
||||||
|
pw::spa::pod::Property {
|
||||||
|
key: pw::spa::sys::SPA_PARAM_META_size,
|
||||||
|
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||||
|
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
|
||||||
|
pw::spa::utils::Choice(
|
||||||
|
pw::spa::utils::ChoiceFlags::empty(),
|
||||||
|
// The max must cover the producer's offer or the Meta param silently
|
||||||
|
// fails to negotiate and NO buffer ever carries the meta region:
|
||||||
|
// Mutter offers a FIXED `SPA_POD_Int(CURSOR_META_SIZE(384, 384))`
|
||||||
|
// (meta-screen-cast-stream-src.c, GNOME 50) — a 256² max made the
|
||||||
|
// intersection empty, which cost the whole Linux cursor channel
|
||||||
|
// on-glass. 1024² is headroom, not an allocation: the negotiated
|
||||||
|
// region follows the producer's value.
|
||||||
|
pw::spa::utils::ChoiceEnum::Range {
|
||||||
|
default: meta_size(64, 64),
|
||||||
|
min: meta_size(1, 1),
|
||||||
|
max: meta_size(1024, 1024),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The `SPA_PARAM_BUFFERS_dataType` bitmask a serialized Buffers pod carries.
|
||||||
|
///
|
||||||
|
/// A deliberately literal SPA reader rather than a heuristic scan: an object property is
|
||||||
|
/// `{ key: u32, flags: u32, value: spa_pod }` and a `spa_pod` is `{ size: u32, type: u32, body }`,
|
||||||
|
/// so the `i32` sits exactly 16 bytes past the key — and the intervening `size` word is itself
|
||||||
|
/// `4`, which is why "find the first plausible-looking int" reads the wrong field.
|
||||||
|
fn buffers_data_type(pod: &[u8]) -> i32 {
|
||||||
|
let key = spa::sys::SPA_PARAM_BUFFERS_dataType.to_ne_bytes();
|
||||||
|
let at = pod
|
||||||
|
.windows(4)
|
||||||
|
.position(|w| w == key)
|
||||||
|
.expect("dataType key present in the Buffers pod");
|
||||||
|
let word = |off: usize| u32::from_ne_bytes(pod[off..off + 4].try_into().unwrap());
|
||||||
|
assert_eq!(word(at + 8), 4, "dataType's value pod should be 4 bytes");
|
||||||
|
assert_eq!(
|
||||||
|
word(at + 12),
|
||||||
|
spa::sys::SPA_TYPE_Int,
|
||||||
|
"dataType's value pod should be an Int"
|
||||||
|
);
|
||||||
|
i32::from_ne_bytes(pod[at + 16..at + 20].try_into().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEM_PTR: i32 = 1 << spa::sys::SPA_DATA_MemPtr;
|
||||||
|
const MEM_FD: i32 = 1 << spa::sys::SPA_DATA_MemFd;
|
||||||
|
const DMABUF: i32 = 1 << spa::sys::SPA_DATA_DmaBuf;
|
||||||
|
|
||||||
|
/// The three Buffers pods differ ONLY in this bitmask, and each bit is load-bearing:
|
||||||
|
/// `build_mappable_buffers` must include DmaBuf or gamescope's modifier-bearing pod wins the
|
||||||
|
/// format intersection and the BUFFER intersection is then empty (a link stuck in
|
||||||
|
/// "negotiating"); `build_shm_only_buffers` must EXCLUDE it or Mutter hands dmabufs and the
|
||||||
|
/// race-free download path is not race-free; `build_dmabuf_buffers` must exclude the mappable
|
||||||
|
/// types or an HDR session can be handed a MemFd buffer, which Mutter paints 8-bit ARGB32
|
||||||
|
/// regardless of the negotiated 10-bit format.
|
||||||
|
#[test]
|
||||||
|
fn each_buffers_pod_requests_exactly_its_own_data_types() {
|
||||||
|
assert_eq!(
|
||||||
|
buffers_data_type(&build_mappable_buffers().unwrap()),
|
||||||
|
MEM_PTR | MEM_FD | DMABUF,
|
||||||
|
"the CPU path must accept mappable dmabufs too"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
buffers_data_type(&build_shm_only_buffers().unwrap()),
|
||||||
|
MEM_PTR | MEM_FD,
|
||||||
|
"PUNKTFUNK_FORCE_SHM must exclude DmaBuf"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
buffers_data_type(&build_dmabuf_buffers().unwrap()),
|
||||||
|
DMABUF,
|
||||||
|
"the zero-copy/HDR path must exclude SHM"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every pod builder must produce a pod libspa will accept back — a serializer that silently
|
||||||
|
/// emitted a malformed object would fail only at negotiation, on a live compositor.
|
||||||
|
#[test]
|
||||||
|
fn every_pod_round_trips_through_pod_from_bytes() {
|
||||||
|
let mut pods: Vec<(&str, Vec<u8>)> = vec![
|
||||||
|
("mappable buffers", build_mappable_buffers().unwrap()),
|
||||||
|
("shm-only buffers", build_shm_only_buffers().unwrap()),
|
||||||
|
("dmabuf buffers", build_dmabuf_buffers().unwrap()),
|
||||||
|
("cursor meta", build_cursor_meta_param().unwrap()),
|
||||||
|
(
|
||||||
|
"default format",
|
||||||
|
serialize_pod(build_default_format_obj(None)).unwrap(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"dmabuf BGRx",
|
||||||
|
build_dmabuf_format(VideoFormat::BGRx, &[0, 1, 2], Some((1920, 1080, 60))).unwrap(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"dmabuf NV12",
|
||||||
|
build_dmabuf_format(VideoFormat::NV12, &[0], Some((1280, 720, 60))).unwrap(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"hdr xRGB",
|
||||||
|
build_hdr_dmabuf_format(VideoFormat::xRGB_210LE, None).unwrap(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"hdr xBGR",
|
||||||
|
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, Some((3840, 2160, 120))).unwrap(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (name, bytes) in &mut pods {
|
||||||
|
assert!(!bytes.is_empty(), "{name} serialized to nothing");
|
||||||
|
assert_eq!(bytes.len() % 8, 0, "{name} is not 8-byte aligned/padded");
|
||||||
|
assert!(
|
||||||
|
spa::pod::Pod::from_bytes(bytes).is_some(),
|
||||||
|
"{name} did not parse back as a pod"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The HDR pods carry BOTH colorimetry properties MANDATORY — Mutter's HDR pods do the same, so
|
||||||
|
/// the intersection only exists if we speak them. Dropping either would negotiate an SDR-labelled
|
||||||
|
/// 10-bit stream (or nothing at all).
|
||||||
|
#[test]
|
||||||
|
fn the_hdr_pods_carry_mandatory_pq_and_bt2020() {
|
||||||
|
for fmt in [VideoFormat::xRGB_210LE, VideoFormat::xBGR_210LE] {
|
||||||
|
let pod = build_hdr_dmabuf_format(fmt, None).unwrap();
|
||||||
|
for (name, key) in [
|
||||||
|
(
|
||||||
|
"transferFunction",
|
||||||
|
spa::sys::SPA_FORMAT_VIDEO_transferFunction,
|
||||||
|
),
|
||||||
|
("colorPrimaries", spa::sys::SPA_FORMAT_VIDEO_colorPrimaries),
|
||||||
|
("modifier", spa::sys::SPA_FORMAT_VIDEO_modifier),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
pod.windows(4).any(|w| w == key.to_ne_bytes()),
|
||||||
|
"{fmt:?} pod is missing {name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The PQ id and BT.2020 id must both appear as values.
|
||||||
|
assert!(
|
||||||
|
pod.windows(4)
|
||||||
|
.any(|w| w == SPA_VIDEO_TRANSFER_SMPTE2084.to_ne_bytes()),
|
||||||
|
"{fmt:?} pod does not carry the PQ transfer id"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
pod.windows(4)
|
||||||
|
.any(|w| w == spa::sys::SPA_VIDEO_COLOR_PRIMARIES_BT2020.to_ne_bytes()),
|
||||||
|
"{fmt:?} pod does not carry BT.2020 primaries"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An NV12 offer pins BT.709 limited so gamescope's producer-side RGB→YUV shader matches OUR
|
||||||
|
/// bitstream colorimetry; the packed-RGB offer must NOT carry those (it is not YUV).
|
||||||
|
#[test]
|
||||||
|
fn only_the_nv12_offer_pins_the_colour_matrix() {
|
||||||
|
let nv12 = build_dmabuf_format(VideoFormat::NV12, &[0], None).unwrap();
|
||||||
|
let bgrx = build_dmabuf_format(VideoFormat::BGRx, &[0], None).unwrap();
|
||||||
|
for (name, key) in [
|
||||||
|
("colorMatrix", spa::sys::SPA_FORMAT_VIDEO_colorMatrix),
|
||||||
|
("colorRange", spa::sys::SPA_FORMAT_VIDEO_colorRange),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
nv12.windows(4).any(|w| w == key.to_ne_bytes()),
|
||||||
|
"NV12 offer is missing {name}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!bgrx.windows(4).any(|w| w == key.to_ne_bytes()),
|
||||||
|
"packed-RGB offer should not pin {name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pin our hand-written PQ transfer id against the real libspa binding. We can't take the
|
||||||
|
/// constant from `pw::spa::sys` directly (older distro headers don't export it — see
|
||||||
|
/// [`super::SPA_VIDEO_TRANSFER_SMPTE2084`]), so assert the two agree wherever the symbol
|
||||||
|
/// DOES exist. Any libspa that renumbers the enum fails this instead of silently tagging
|
||||||
|
/// the HDR offer with the wrong transfer function.
|
||||||
|
///
|
||||||
|
/// Only builds where tests are compiled — the .deb/.rpm builders run plain `cargo build`,
|
||||||
|
/// so this never reintroduces the compile failure it exists to prevent.
|
||||||
|
#[test]
|
||||||
|
fn pq_transfer_id_matches_libspa() {
|
||||||
|
assert_eq!(
|
||||||
|
super::SPA_VIDEO_TRANSFER_SMPTE2084,
|
||||||
|
super::pw::spa::sys::SPA_VIDEO_TRANSFER_SMPTE2084,
|
||||||
|
"libspa renumbered spa_video_transfer_function — update the hardcoded PQ id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
//! honouring it also gives the stream gamescope's own idle auto-hide, which this source never had.
|
//! honouring it also gives the stream gamescope's own idle auto-hide, which this source never had.
|
||||||
|
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
Arc, Mutex,
|
Arc, Mutex,
|
||||||
};
|
};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -51,12 +51,29 @@ use x11rb::protocol::xproto::{
|
|||||||
Window,
|
Window,
|
||||||
};
|
};
|
||||||
use x11rb::protocol::Event;
|
use x11rb::protocol::Event;
|
||||||
use x11rb::rust_connection::RustConnection;
|
use x11rb::rust_connection::{DefaultStream, RustConnection};
|
||||||
|
|
||||||
/// Serializes the brief `XAUTHORITY` env swap around a connect (the var is process-global). Only
|
use crate::GamescopeCursorTargets;
|
||||||
/// ever contended if two gamescope sessions start at once — rare, and the swap is microseconds.
|
|
||||||
|
/// Serializes the `XAUTHORITY` env swap of the LEGACY connect fallback (the var is process-global).
|
||||||
|
///
|
||||||
|
/// The fallback is a last resort now — see [`connect_conn`]. It serialises this source against
|
||||||
|
/// itself and nothing else: `getenv` needs no lock to be racy, so every OTHER thread's read (libspa
|
||||||
|
/// plugin load, EGL/CUDA init — concurrent by construction, since `attach_gamescope_cursor` runs
|
||||||
|
/// while the PipeWire thread is starting) could still observe the swapped value or a torn
|
||||||
|
/// environ. That is why the primary path parses the cookie itself and never touches the
|
||||||
|
/// environment.
|
||||||
static XAUTH_LOCK: Mutex<()> = Mutex::new(());
|
static XAUTH_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
/// The `MIT-MAGIC-COOKIE-1` auth-protocol name, as it appears in an `.Xauthority` entry.
|
||||||
|
const MIT_MAGIC_COOKIE_1: &[u8] = b"MIT-MAGIC-COOKIE-1";
|
||||||
|
|
||||||
|
/// Re-run the targets provider this often: adopt Xwaylands that appeared after the stream started
|
||||||
|
/// (gamescope spawns the game's on launch and advertises only the first in any child's environ) and
|
||||||
|
/// retry ones whose connection died. Two seconds is far below a human's tolerance for a missing
|
||||||
|
/// pointer and costs one cheap socket probe per known display.
|
||||||
|
const REDISCOVER: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
/// Position out-paces the stream fps (`POLL`); shape rides `CursorNotify` events drained each tick.
|
/// Position out-paces the stream fps (`POLL`); shape rides `CursorNotify` events drained each tick.
|
||||||
/// 4 ms ≈ 250 Hz matches the Windows GDI poller — the polled position IS the composited position
|
/// 4 ms ≈ 250 Hz matches the Windows GDI poller — the polled position IS the composited position
|
||||||
/// and must out-run a 240 fps session or the pointer stutters.
|
/// and must out-run a 240 fps session or the pointer stutters.
|
||||||
@@ -73,46 +90,42 @@ const GS_CURSOR_FEEDBACK: &str = "GAMESCOPE_CURSOR_VISIBLE_FEEDBACK";
|
|||||||
/// `GetProperty` per display per interval is nothing next to the 250 Hz pointer poll.
|
/// `GetProperty` per display per interval is nothing next to the 250 Hz pointer poll.
|
||||||
const FEEDBACK_RESYNC: Duration = Duration::from_millis(250);
|
const FEEDBACK_RESYNC: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
/// A running XFixes cursor reader. Dropping it stops the worker thread and joins it, releasing the
|
/// A running XFixes cursor reader. Dropping it stops the worker thread and waits — bounded — for it
|
||||||
/// X connections — so it lives exactly as long as the capturer that owns it.
|
/// to release the X connections, so it lives (at most) as long as the capturer that owns it.
|
||||||
pub(super) struct XFixesCursorSource {
|
pub(super) struct XFixesCursorSource {
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
|
/// Signalled by the worker just before it returns — lets `Drop` bound its wait (see there).
|
||||||
|
done: std::sync::mpsc::Receiver<()>,
|
||||||
join: Option<std::thread::JoinHandle<()>>,
|
join: Option<std::thread::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl XFixesCursorSource {
|
impl XFixesCursorSource {
|
||||||
/// Connect to every gamescope nested Xwayland in `targets` (`(DISPLAY, XAUTHORITY)`) and start
|
/// Start publishing cursor overlays into `slot` from whichever of gamescope's nested Xwaylands
|
||||||
/// publishing cursor overlays into `slot`, following the focused display's pointer. Returns
|
/// it is drawing the pointer on. `targets` is re-run every [`REDISCOVER`] to adopt Xwaylands
|
||||||
/// `None` — and logs — if NONE can be used (no X connection / no XFixes), so the caller
|
/// that appear later and retry dead ones; `frame_size` is the negotiated capture size, packed
|
||||||
/// degrades to no gamescope cursor (today's behaviour) instead of failing the session.
|
/// `(w << 32) | h`, `0` until the first negotiation (see [`scale_to_frame`]).
|
||||||
|
///
|
||||||
|
/// Returns `None` only if the thread cannot be spawned. An empty or entirely-unusable target
|
||||||
|
/// list is NOT a failure: the worker idles and keeps re-running the provider, so a stream that
|
||||||
|
/// starts before the game converges instead of being cursorless for the session.
|
||||||
pub(super) fn spawn(
|
pub(super) fn spawn(
|
||||||
targets: Vec<(String, Option<String>)>,
|
targets: GamescopeCursorTargets,
|
||||||
slot: Arc<Mutex<Option<CursorOverlay>>>,
|
slot: Arc<Mutex<Option<CursorOverlay>>>,
|
||||||
|
frame_size: Arc<AtomicU64>,
|
||||||
) -> Option<Self> {
|
) -> Option<Self> {
|
||||||
// Connect on the caller's thread so failures degrade cleanly and the displays are validated
|
// First pass on the caller's thread: the common case connects here, so the log line below
|
||||||
// before we commit a thread.
|
// reports the real state instead of "starting…".
|
||||||
let mut displays = Vec::new();
|
let mut displays = Vec::new();
|
||||||
for (dpy, xauth) in targets {
|
rediscover(&mut displays, &targets, true);
|
||||||
match connect(&dpy, xauth.as_deref()) {
|
|
||||||
Ok((conn, root, feedback)) => {
|
|
||||||
displays.push(XDisplay::new(dpy, conn, root, feedback))
|
|
||||||
}
|
|
||||||
Err(e) => tracing::warn!(
|
|
||||||
dpy = %dpy,
|
|
||||||
error = %e,
|
|
||||||
"gamescope cursor: skipping a nested Xwayland we can't use"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if displays.is_empty() {
|
|
||||||
tracing::warn!(
|
|
||||||
"gamescope cursor: no usable nested Xwayland — no in-video pointer this session \
|
|
||||||
(falls back to today's cursorless gamescope stream)"
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect();
|
let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect();
|
||||||
let feedback = displays.iter().any(|d| d.gs_visible.is_some());
|
let feedback = displays.iter().any(|d| d.gs_visible.is_some());
|
||||||
|
if displays.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
"gamescope cursor: no usable nested Xwayland yet — retrying every {}s (a game's \
|
||||||
|
Xwayland appears when it launches)",
|
||||||
|
REDISCOVER.as_secs()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
displays = ?names,
|
displays = ?names,
|
||||||
cursor_feedback = feedback,
|
cursor_feedback = feedback,
|
||||||
@@ -120,15 +133,21 @@ impl XFixesCursorSource {
|
|||||||
pointer on (cursor_feedback=false ⇒ this gamescope publishes no \
|
pointer on (cursor_feedback=false ⇒ this gamescope publishes no \
|
||||||
GAMESCOPE_CURSOR_VISIBLE_FEEDBACK, degrading to the pointer-motion heuristic)"
|
GAMESCOPE_CURSOR_VISIBLE_FEEDBACK, degrading to the pointer-motion heuristic)"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
let stop_worker = Arc::clone(&stop);
|
let stop_worker = Arc::clone(&stop);
|
||||||
|
let (done_tx, done_rx) = std::sync::mpsc::sync_channel::<()>(1);
|
||||||
let join = std::thread::Builder::new()
|
let join = std::thread::Builder::new()
|
||||||
.name("pf-gs-cursor".into())
|
.name("pf-gs-cursor".into())
|
||||||
.spawn(move || run(displays, slot, stop_worker))
|
.spawn(move || {
|
||||||
|
run(displays, slot, stop_worker, targets, frame_size);
|
||||||
|
let _ = done_tx.send(());
|
||||||
|
})
|
||||||
.ok()?;
|
.ok()?;
|
||||||
Some(XFixesCursorSource {
|
Some(XFixesCursorSource {
|
||||||
stop,
|
stop,
|
||||||
|
done: done_rx,
|
||||||
join: Some(join),
|
join: Some(join),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -137,35 +156,69 @@ impl XFixesCursorSource {
|
|||||||
impl Drop for XFixesCursorSource {
|
impl Drop for XFixesCursorSource {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.stop.store(true, Ordering::Relaxed);
|
self.stop.store(true, Ordering::Relaxed);
|
||||||
|
// BOUNDED join. The worker blocks in `RustConnection` replies with no read timeout, so a
|
||||||
|
// peer that stops answering while keeping its socket open (a hung Xwayland) would hang
|
||||||
|
// capturer teardown — and teardown runs on the session path. On timeout we detach: the
|
||||||
|
// thread only ever touches its own X connections and an `Arc`'d slot, so leaving it to
|
||||||
|
// finish on its own is safe (it observes `stop` and exits the moment its reply lands).
|
||||||
|
let joinable = self.done.recv_timeout(Duration::from_millis(250)).is_ok();
|
||||||
if let Some(j) = self.join.take() {
|
if let Some(j) = self.join.take() {
|
||||||
let _ = j.join();
|
if joinable {
|
||||||
|
let _ = j.join(); // returns at once: `done` already fired
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"gamescope cursor: worker did not stop within 250ms (blocked on an X reply?) — \
|
||||||
|
detaching it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-run the targets provider and reconcile `displays` with it: connect to any target we do not
|
||||||
|
/// track yet, and re-connect one whose display died. Existing healthy displays are left alone, so
|
||||||
|
/// their shape cache and last position survive.
|
||||||
|
fn rediscover(displays: &mut Vec<XDisplay>, targets: &GamescopeCursorTargets, first: bool) {
|
||||||
|
for (dpy, xauth) in targets() {
|
||||||
|
let existing = displays.iter().position(|d| d.name == dpy);
|
||||||
|
if existing.is_some_and(|i| !displays[i].dead) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match connect(&dpy, xauth.as_deref()) {
|
||||||
|
Ok((conn, root, root_size, feedback)) => {
|
||||||
|
let d = XDisplay::new(dpy.clone(), conn, root, root_size, feedback);
|
||||||
|
match existing {
|
||||||
|
Some(i) => {
|
||||||
|
tracing::info!(dpy = %dpy, "gamescope cursor: reconnected a nested Xwayland");
|
||||||
|
displays[i] = d;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if !first {
|
||||||
|
tracing::info!(dpy = %dpy, "gamescope cursor: adopted a new nested Xwayland");
|
||||||
|
}
|
||||||
|
displays.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Debug, not warn: with a 2 s retry a warn would flood for every Xwayland a
|
||||||
|
// `--xwayland-count` reports but that never comes up.
|
||||||
|
Err(e) if first => tracing::warn!(
|
||||||
|
dpy = %dpy, error = %e,
|
||||||
|
"gamescope cursor: skipping a nested Xwayland we can't use (will retry)"
|
||||||
|
),
|
||||||
|
Err(e) => tracing::debug!(dpy = %dpy, error = %e, "gamescope cursor: retry failed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the X connection, negotiate XFixes, and select cursor-change + root-property events —
|
/// Open the X connection, negotiate XFixes, and select cursor-change + root-property events —
|
||||||
/// returning the connection, root window and this display's initial
|
/// returning the connection, root window, the root's pixel size (for [`scale_to_frame`]) and this
|
||||||
/// [`GAMESCOPE_CURSOR_FEEDBACK`](GS_CURSOR_FEEDBACK) reading (`(atom, value)`; the value is `None`
|
/// display's initial [`GAMESCOPE_CURSOR_FEEDBACK`](GS_CURSOR_FEEDBACK) reading (`(atom, value)`;
|
||||||
/// when gamescope publishes no such property here). `RustConnection` reads `XAUTHORITY` from the
|
/// the value is `None` when gamescope publishes no such property here).
|
||||||
/// env at connect time only, so set it under the lock (the host isn't a gamescope child), connect,
|
type Connected = (RustConnection, Window, (u16, u16), (Atom, Option<bool>));
|
||||||
/// then restore.
|
|
||||||
type Connected = (RustConnection, Window, (Atom, Option<bool>));
|
|
||||||
|
|
||||||
fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
|
fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
|
||||||
let (conn, screen_num) = {
|
let (conn, screen_num) = connect_conn(dpy, xauthority)?;
|
||||||
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
let prev = std::env::var_os("XAUTHORITY");
|
|
||||||
if let Some(x) = xauthority {
|
|
||||||
std::env::set_var("XAUTHORITY", x);
|
|
||||||
}
|
|
||||||
let out = RustConnection::connect(Some(dpy));
|
|
||||||
match (&prev, xauthority) {
|
|
||||||
(Some(p), _) => std::env::set_var("XAUTHORITY", p),
|
|
||||||
(None, Some(_)) => std::env::remove_var("XAUTHORITY"),
|
|
||||||
(None, None) => {}
|
|
||||||
}
|
|
||||||
out.map_err(|e| format!("connect: {e}"))?
|
|
||||||
};
|
|
||||||
|
|
||||||
// XFixes ≥ 1 gives GetCursorImage / SelectCursorInput; ask for a modern minor, take what we get.
|
// XFixes ≥ 1 gives GetCursorImage / SelectCursorInput; ask for a modern minor, take what we get.
|
||||||
conn.xfixes_query_version(5, 0)
|
conn.xfixes_query_version(5, 0)
|
||||||
@@ -173,12 +226,16 @@ fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
|
|||||||
.and_then(|c| c.reply())
|
.and_then(|c| c.reply())
|
||||||
.map_err(|e| format!("XFixes unavailable: {e}"))?;
|
.map_err(|e| format!("XFixes unavailable: {e}"))?;
|
||||||
|
|
||||||
let root = conn
|
let screen = conn
|
||||||
.setup()
|
.setup()
|
||||||
.roots
|
.roots
|
||||||
.get(screen_num)
|
.get(screen_num)
|
||||||
.ok_or_else(|| format!("no X screen {screen_num}"))?
|
.ok_or_else(|| format!("no X screen {screen_num}"))?;
|
||||||
.root;
|
let root = screen.root;
|
||||||
|
// gamescope's nested root can be a DIFFERENT size from the output/PipeWire node it publishes
|
||||||
|
// (`-w/-h` vs `-W/-H` are independent knobs), and this is the space `QueryPointer` answers in.
|
||||||
|
// Free here — the setup reply is already parsed.
|
||||||
|
let root_size = (screen.width_in_pixels, screen.height_in_pixels);
|
||||||
|
|
||||||
// Wake the worker's event drain whenever the cursor shape changes (incl. hide/show).
|
// Wake the worker's event drain whenever the cursor shape changes (incl. hide/show).
|
||||||
conn.xfixes_select_cursor_input(root, xfixes::CursorNotifyMask::DISPLAY_CURSOR)
|
conn.xfixes_select_cursor_input(root, xfixes::CursorNotifyMask::DISPLAY_CURSOR)
|
||||||
@@ -203,7 +260,143 @@ fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
|
|||||||
}
|
}
|
||||||
let _ = conn.flush();
|
let _ = conn.flush();
|
||||||
let feedback = read_cursor_feedback(&conn, root, feedback_atom);
|
let feedback = read_cursor_feedback(&conn, root, feedback_atom);
|
||||||
Ok((conn, root, (feedback_atom, feedback)))
|
Ok((conn, root, root_size, (feedback_atom, feedback)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Establish the X connection for `dpy` using `xauthority`'s cookie, WITHOUT touching this process's
|
||||||
|
/// environment.
|
||||||
|
///
|
||||||
|
/// `RustConnection::connect` reads `XAUTHORITY` from the env, so the original implementation
|
||||||
|
/// `set_var`'d it around each connect under [`XAUTH_LOCK`]. That is unsound from a live
|
||||||
|
/// multithreaded host: the lock serialises this source against itself, but `getenv` takes no lock,
|
||||||
|
/// so any concurrent reader (libspa's plugin load, EGL/CUDA init — running at exactly this moment,
|
||||||
|
/// since the PipeWire thread is starting up) could read the swapped value or race the environ
|
||||||
|
/// rewrite outright. The project already has a process-wide env-lock discipline elsewhere, but
|
||||||
|
/// sharing it would be the wrong layer AND would still not fix `getenv`.
|
||||||
|
///
|
||||||
|
/// So: parse the MIT-MAGIC-COOKIE-1 entry out of the file ourselves and hand it to
|
||||||
|
/// `connect_to_stream_with_auth_info`, which is what `RustConnection::connect` does internally with
|
||||||
|
/// the cookie IT found. The env swap survives only as a fallback for a file we cannot parse (an
|
||||||
|
/// unexpected layout, or an auth family whose entry we decline to guess at).
|
||||||
|
fn connect_conn(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, usize), String> {
|
||||||
|
let Some(path) = xauthority else {
|
||||||
|
// No per-display cookie file to inject: the ambient environment is already what this
|
||||||
|
// connect should use, so there is nothing to swap and nothing to parse.
|
||||||
|
return RustConnection::connect(Some(dpy)).map_err(|e| format!("connect: {e}"));
|
||||||
|
};
|
||||||
|
match mit_magic_cookie(path, dpy) {
|
||||||
|
Some((name, data)) => match connect_with_cookie(dpy, name, data) {
|
||||||
|
Ok(v) => return Ok(v),
|
||||||
|
Err(e) => tracing::debug!(
|
||||||
|
dpy = %dpy, xauthority = %path, error = %e,
|
||||||
|
"gamescope cursor: cookie connect failed — falling back to the XAUTHORITY env swap"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
None => tracing::debug!(
|
||||||
|
dpy = %dpy, xauthority = %path,
|
||||||
|
"gamescope cursor: no MIT-MAGIC-COOKIE-1 entry for this display — falling back to the \
|
||||||
|
XAUTHORITY env swap"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
connect_via_env_swap(dpy, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to `dpy` and complete the setup handshake with an explicit cookie — the same two steps
|
||||||
|
/// `RustConnection::connect` performs, minus its env-derived auth lookup.
|
||||||
|
fn connect_with_cookie(
|
||||||
|
dpy: &str,
|
||||||
|
auth_name: Vec<u8>,
|
||||||
|
auth_data: Vec<u8>,
|
||||||
|
) -> Result<(RustConnection, usize), String> {
|
||||||
|
let parsed = x11rb::reexports::x11rb_protocol::parse_display::parse_display(Some(dpy))
|
||||||
|
.map_err(|e| format!("parse display {dpy}: {e}"))?;
|
||||||
|
let screen = usize::from(parsed.screen);
|
||||||
|
let mut stream = None;
|
||||||
|
let mut last_err = None;
|
||||||
|
for addr in parsed.connect_instruction() {
|
||||||
|
match DefaultStream::connect(&addr) {
|
||||||
|
Ok((s, _peer)) => {
|
||||||
|
stream = Some(s);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => last_err = Some(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let stream = stream.ok_or_else(|| match last_err {
|
||||||
|
Some(e) => format!("connect: {e}"),
|
||||||
|
None => "connect: no usable address".to_string(),
|
||||||
|
})?;
|
||||||
|
RustConnection::connect_to_stream_with_auth_info(stream, screen, auth_name, auth_data)
|
||||||
|
.map(|c| (c, screen))
|
||||||
|
.map_err(|e| format!("setup: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LEGACY fallback (see [`connect_conn`]): swap `XAUTHORITY`, connect, restore. Serialised against
|
||||||
|
/// this source's own concurrent connects, but NOT against other threads' `getenv` — which is why it
|
||||||
|
/// is a fallback and not the path taken.
|
||||||
|
fn connect_via_env_swap(dpy: &str, xauthority: &str) -> Result<(RustConnection, usize), String> {
|
||||||
|
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let prev = std::env::var_os("XAUTHORITY");
|
||||||
|
std::env::set_var("XAUTHORITY", xauthority);
|
||||||
|
let out = RustConnection::connect(Some(dpy));
|
||||||
|
match prev {
|
||||||
|
Some(p) => std::env::set_var("XAUTHORITY", p),
|
||||||
|
None => std::env::remove_var("XAUTHORITY"),
|
||||||
|
}
|
||||||
|
out.map_err(|e| format!("connect: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `MIT-MAGIC-COOKIE-1` `(name, data)` for `dpy` from the `.Xauthority`-format file at `path`.
|
||||||
|
///
|
||||||
|
/// Entry layout, all integers big-endian: `u16 family`, then four length-prefixed byte strings —
|
||||||
|
/// `address`, `number` (the display number in ASCII), `name`, `data`. An entry with an empty
|
||||||
|
/// `number` matches any display.
|
||||||
|
///
|
||||||
|
/// Deliberately does NOT match on family/address, unlike libxcb's own lookup. A gamescope Xwayland
|
||||||
|
/// gets its own single-entry cookie file, so there is nothing to disambiguate; and a wrong pick
|
||||||
|
/// cannot do damage — the server rejects the cookie, and [`connect_conn`] falls back to the env
|
||||||
|
/// path, which does the full match. Guessing the peer address for a `LOCAL`-family unix socket, on
|
||||||
|
/// the other hand, is exactly the sort of detail that would rot.
|
||||||
|
fn mit_magic_cookie(path: &str, dpy: &str) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||||
|
let bytes = std::fs::read(path).ok()?;
|
||||||
|
let want = display_number(dpy);
|
||||||
|
// An entry whose `number` is empty matches any display — only used if no exact match is found.
|
||||||
|
let mut wildcard = None;
|
||||||
|
let mut p = 0usize;
|
||||||
|
'entries: while p + 2 <= bytes.len() {
|
||||||
|
p += 2; // family
|
||||||
|
let mut fields: [&[u8]; 4] = [&[]; 4];
|
||||||
|
for f in fields.iter_mut() {
|
||||||
|
let Some(lb) = bytes.get(p..p + 2) else {
|
||||||
|
break 'entries; // truncated — keep whatever we already found
|
||||||
|
};
|
||||||
|
let len = usize::from(u16::from_be_bytes([lb[0], lb[1]]));
|
||||||
|
p += 2;
|
||||||
|
let Some(v) = bytes.get(p..p + len) else {
|
||||||
|
break 'entries;
|
||||||
|
};
|
||||||
|
*f = v;
|
||||||
|
p += len;
|
||||||
|
}
|
||||||
|
let [_address, number, name, data] = fields;
|
||||||
|
if name != MIT_MAGIC_COOKIE_1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if want.as_deref().is_some_and(|w| number == w) {
|
||||||
|
return Some((name.to_vec(), data.to_vec()));
|
||||||
|
}
|
||||||
|
if number.is_empty() && wildcard.is_none() {
|
||||||
|
wildcard = Some((name.to_vec(), data.to_vec()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wildcard
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The display NUMBER of an X display string as ASCII bytes: `":2"`, `"host:2"` and `":2.0"` all
|
||||||
|
/// yield `b"2"`. `None` when there is no numeric display component to match on.
|
||||||
|
fn display_number(dpy: &str) -> Option<Vec<u8>> {
|
||||||
|
let num = dpy.rsplit_once(':')?.1.split('.').next()?;
|
||||||
|
(!num.is_empty() && num.bytes().all(|b| b.is_ascii_digit())).then(|| num.as_bytes().to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` off `root`. `None` = the property is absent or
|
/// Read `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` off `root`. `None` = the property is absent or
|
||||||
@@ -227,6 +420,9 @@ struct XDisplay {
|
|||||||
name: String,
|
name: String,
|
||||||
conn: RustConnection,
|
conn: RustConnection,
|
||||||
root: Window,
|
root: Window,
|
||||||
|
/// The nested root's size in pixels — the space `QueryPointer` reports in, which is NOT
|
||||||
|
/// necessarily the captured frame's (see [`scale_to_frame`]).
|
||||||
|
root_size: (u16, u16),
|
||||||
/// Last polled pointer position — a change since the previous tick marks this display FOCUSED.
|
/// Last polled pointer position — a change since the previous tick marks this display FOCUSED.
|
||||||
last_pos: Option<(i32, i32)>,
|
last_pos: Option<(i32, i32)>,
|
||||||
/// Cached cursor shape, refreshed only after this display's XFixes `CursorNotify`.
|
/// Cached cursor shape, refreshed only after this display's XFixes `CursorNotify`.
|
||||||
@@ -247,12 +443,14 @@ impl XDisplay {
|
|||||||
name: String,
|
name: String,
|
||||||
conn: RustConnection,
|
conn: RustConnection,
|
||||||
root: Window,
|
root: Window,
|
||||||
|
root_size: (u16, u16),
|
||||||
(feedback_atom, gs_visible): (Atom, Option<bool>),
|
(feedback_atom, gs_visible): (Atom, Option<bool>),
|
||||||
) -> Self {
|
) -> Self {
|
||||||
XDisplay {
|
XDisplay {
|
||||||
name,
|
name,
|
||||||
conn,
|
conn,
|
||||||
root,
|
root,
|
||||||
|
root_size,
|
||||||
last_pos: None,
|
last_pos: None,
|
||||||
shape: Shape::default(),
|
shape: Shape::default(),
|
||||||
need_shape: true,
|
need_shape: true,
|
||||||
@@ -288,11 +486,39 @@ struct Shape {
|
|||||||
visible: bool,
|
visible: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map a root-space pointer position into FRAME space.
|
||||||
|
///
|
||||||
|
/// `QueryPointer` answers in the nested root's coordinates, but `CursorOverlay::x/y`'s contract is
|
||||||
|
/// FRAME pixels — and gamescope's `-w/-h` (nested root) and `-W/-H` (output + PipeWire node) are
|
||||||
|
/// independent knobs. Measured on this box with gamescope 3.16.23 at `-W 1280 -H 720 -w 640 -h 360`
|
||||||
|
/// the two spaces differ by 2×, so publishing root coordinates verbatim drew the pointer at a
|
||||||
|
/// fraction of its real position. `frame` is `(0, 0)` until the PipeWire format negotiates, in which
|
||||||
|
/// case the position passes through unscaled — the same as before, and correct for the common case
|
||||||
|
/// where the two spaces agree.
|
||||||
|
///
|
||||||
|
/// The cursor BITMAP is not scaled: it stays at the shape's native size, so on a mismatched session
|
||||||
|
/// the pointer lands in the right place but is drawn at root scale.
|
||||||
|
fn scale_to_frame((x, y): (i32, i32), root: (u16, u16), frame: (u32, u32)) -> (i32, i32) {
|
||||||
|
let (rw, rh) = (u32::from(root.0), u32::from(root.1));
|
||||||
|
let (fw, fh) = frame;
|
||||||
|
if rw == 0 || rh == 0 || fw == 0 || fh == 0 || (rw, rh) == (fw, fh) {
|
||||||
|
return (x, y);
|
||||||
|
}
|
||||||
|
(
|
||||||
|
((i64::from(x) * i64::from(fw)) / i64::from(rw)) as i32,
|
||||||
|
((i64::from(y) * i64::from(fh)) / i64::from(rh)) as i32,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn run(
|
fn run(
|
||||||
mut displays: Vec<XDisplay>,
|
mut displays: Vec<XDisplay>,
|
||||||
slot: Arc<Mutex<Option<CursorOverlay>>>,
|
slot: Arc<Mutex<Option<CursorOverlay>>>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
|
targets: GamescopeCursorTargets,
|
||||||
|
frame_size: Arc<AtomicU64>,
|
||||||
) {
|
) {
|
||||||
|
let mut last_discover = std::time::Instant::now();
|
||||||
|
let mut warned_scale = false;
|
||||||
let mut active = 0usize;
|
let mut active = 0usize;
|
||||||
// The overlay serial must bump whenever the DRAWN cursor changes — either the active display's
|
// The overlay serial must bump whenever the DRAWN cursor changes — either the active display's
|
||||||
// shape OR which display is active (per-display XFixes serials aren't comparable across
|
// shape OR which display is active (per-display XFixes serials aren't comparable across
|
||||||
@@ -309,6 +535,11 @@ fn run(
|
|||||||
if resync {
|
if resync {
|
||||||
last_resync = std::time::Instant::now();
|
last_resync = std::time::Instant::now();
|
||||||
}
|
}
|
||||||
|
// Adopt Xwaylands that appeared since we started (a game's, above all) and retry dead ones.
|
||||||
|
if last_discover.elapsed() >= REDISCOVER {
|
||||||
|
last_discover = std::time::Instant::now();
|
||||||
|
rediscover(&mut displays, &targets, false);
|
||||||
|
}
|
||||||
|
|
||||||
// 1) Poll every display's pointer; note which moved since last tick (the fallback focus
|
// 1) Poll every display's pointer; note which moved since last tick (the fallback focus
|
||||||
// signal, used only when this gamescope publishes no cursor verdict).
|
// signal, used only when this gamescope publishes no cursor verdict).
|
||||||
@@ -395,8 +626,27 @@ fn run(
|
|||||||
// a `None` here would leave the last visible overlay standing on repeat frames.
|
// a `None` here would leave the last visible overlay standing on repeat frames.
|
||||||
let d = &displays[active];
|
let d = &displays[active];
|
||||||
let drawn = d.shape.visible && !hidden_by_gamescope;
|
let drawn = d.shape.visible && !hidden_by_gamescope;
|
||||||
|
// Root space → frame space (see `scale_to_frame`). The negotiated size arrives from the
|
||||||
|
// PipeWire thread's `param_changed`, packed `(w << 32) | h`; `0` = not negotiated yet.
|
||||||
|
let packed = frame_size.load(Ordering::Relaxed);
|
||||||
|
let frame = ((packed >> 32) as u32, packed as u32);
|
||||||
|
if !warned_scale
|
||||||
|
&& frame.0 != 0
|
||||||
|
&& (u32::from(d.root_size.0), u32::from(d.root_size.1)) != frame
|
||||||
|
{
|
||||||
|
warned_scale = true;
|
||||||
|
tracing::warn!(
|
||||||
|
dpy = %d.name,
|
||||||
|
root = %format!("{}x{}", d.root_size.0, d.root_size.1),
|
||||||
|
negotiated = %format!("{}x{}", frame.0, frame.1),
|
||||||
|
"gamescope cursor: the nested root and the captured frame are different sizes \
|
||||||
|
(gamescope -w/-h vs -W/-H) — scaling the pointer POSITION into frame space; the \
|
||||||
|
cursor bitmap stays at root scale"
|
||||||
|
);
|
||||||
|
}
|
||||||
let overlay = match (d.last_pos, d.shape.rgba.is_empty()) {
|
let overlay = match (d.last_pos, d.shape.rgba.is_empty()) {
|
||||||
(Some((px, py)), false) => {
|
(Some(pos), false) => {
|
||||||
|
let (px, py) = scale_to_frame(pos, d.root_size, frame);
|
||||||
let key = (active, d.shape.serial);
|
let key = (active, d.shape.serial);
|
||||||
if key != last_key {
|
if key != last_key {
|
||||||
out_serial += 1;
|
out_serial += 1;
|
||||||
@@ -511,7 +761,147 @@ fn argb_premul_to_straight_rgba(argb: &[u32]) -> Vec<u8> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::pick_active;
|
use super::{
|
||||||
|
display_number, mit_magic_cookie, pick_active, scale_to_frame, MIT_MAGIC_COOKIE_1,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One `.Xauthority` entry in wire form: `u16 family` + four length-prefixed byte strings.
|
||||||
|
fn entry(family: u16, address: &[u8], number: &[u8], name: &[u8], data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut v = family.to_be_bytes().to_vec();
|
||||||
|
for f in [address, number, name, data] {
|
||||||
|
v.extend_from_slice(&(f.len() as u16).to_be_bytes());
|
||||||
|
v.extend_from_slice(f);
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_xauth(bytes: &[u8]) -> std::path::PathBuf {
|
||||||
|
// The scratch file lives beside the test binary's temp dir; unique per call via the address
|
||||||
|
// of a local (no rand dependency, and the tests do not run concurrently on one path).
|
||||||
|
let mut p = std::env::temp_dir();
|
||||||
|
let uniq = format!(
|
||||||
|
"pf-xauth-test-{}-{:p}",
|
||||||
|
std::process::id(),
|
||||||
|
bytes as *const [u8]
|
||||||
|
);
|
||||||
|
p.push(uniq);
|
||||||
|
std::fs::write(&p, bytes).expect("write scratch xauth");
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_number_handles_every_display_spelling() {
|
||||||
|
assert_eq!(display_number(":2").as_deref(), Some(&b"2"[..]));
|
||||||
|
assert_eq!(display_number(":2.0").as_deref(), Some(&b"2"[..]));
|
||||||
|
assert_eq!(display_number("host:13.1").as_deref(), Some(&b"13"[..]));
|
||||||
|
// Nothing to match on — the caller then accepts only a wildcard entry.
|
||||||
|
assert_eq!(display_number("bogus"), None);
|
||||||
|
assert_eq!(display_number(":"), None);
|
||||||
|
assert_eq!(display_number(":abc"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cookie reader is the one PARSER in this file fed a file we did not write, so it gets the
|
||||||
|
/// hostile-input treatment: exact-match, wildcard fallback, skipping other auth protocols, and
|
||||||
|
/// truncation.
|
||||||
|
#[test]
|
||||||
|
fn mit_magic_cookie_picks_the_matching_entry() {
|
||||||
|
let mut file = Vec::new();
|
||||||
|
// A different protocol on the display we want — must be skipped, not returned.
|
||||||
|
file.extend(entry(256, b"host", b"2", b"XDM-AUTHORIZATION-1", b"nope"));
|
||||||
|
// Another display's cookie.
|
||||||
|
file.extend(entry(
|
||||||
|
256,
|
||||||
|
b"host",
|
||||||
|
b"7",
|
||||||
|
MIT_MAGIC_COOKIE_1,
|
||||||
|
b"other-display",
|
||||||
|
));
|
||||||
|
// Ours.
|
||||||
|
file.extend(entry(256, b"host", b"2", MIT_MAGIC_COOKIE_1, b"the-cookie"));
|
||||||
|
let p = write_xauth(&file);
|
||||||
|
let got = mit_magic_cookie(p.to_str().unwrap(), ":2");
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
Some((MIT_MAGIC_COOKIE_1.to_vec(), b"the-cookie".to_vec()))
|
||||||
|
);
|
||||||
|
// A display with no entry at all yields nothing (⇒ the caller falls back to the env path).
|
||||||
|
assert_eq!(mit_magic_cookie(p.to_str().unwrap(), ":9"), None);
|
||||||
|
let _ = std::fs::remove_file(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mit_magic_cookie_falls_back_to_a_wildcard_entry() {
|
||||||
|
// An empty `number` matches any display — but an exact match still wins.
|
||||||
|
let mut file = entry(256, b"host", b"", MIT_MAGIC_COOKIE_1, b"wildcard");
|
||||||
|
file.extend(entry(256, b"host", b"3", MIT_MAGIC_COOKIE_1, b"exact"));
|
||||||
|
let p = write_xauth(&file);
|
||||||
|
let path = p.to_str().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
mit_magic_cookie(path, ":3").map(|(_, d)| d),
|
||||||
|
Some(b"exact".to_vec())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
mit_magic_cookie(path, ":4").map(|(_, d)| d),
|
||||||
|
Some(b"wildcard".to_vec())
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_truncated_xauthority_keeps_what_it_already_parsed() {
|
||||||
|
let mut file = entry(256, b"host", b"", MIT_MAGIC_COOKIE_1, b"wildcard");
|
||||||
|
// A second entry cut off mid-length-prefix.
|
||||||
|
file.extend(entry(256, b"host", b"5", MIT_MAGIC_COOKIE_1, b"truncated"));
|
||||||
|
file.truncate(file.len() - 4);
|
||||||
|
let p = write_xauth(&file);
|
||||||
|
// No panic, no over-read: the wildcard found before the truncation still serves.
|
||||||
|
assert_eq!(
|
||||||
|
mit_magic_cookie(p.to_str().unwrap(), ":5").map(|(_, d)| d),
|
||||||
|
Some(b"wildcard".to_vec())
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_garbage_xauthority_is_declined_not_fatal() {
|
||||||
|
// A length prefix that claims far more than the file holds.
|
||||||
|
let p = write_xauth(&[0, 0, 0xff, 0xff, 1, 2, 3]);
|
||||||
|
assert_eq!(mit_magic_cookie(p.to_str().unwrap(), ":0"), None);
|
||||||
|
let _ = std::fs::remove_file(p);
|
||||||
|
// A missing file is simply "no cookie".
|
||||||
|
assert_eq!(mit_magic_cookie("/nonexistent/pf-xauth", ":0"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// L6: gamescope's `-w/-h` (nested root, the space `QueryPointer` answers in) and `-W/-H`
|
||||||
|
/// (output + PipeWire node, the space `CursorOverlay` is contracted in) are independent.
|
||||||
|
#[test]
|
||||||
|
fn pointer_positions_are_mapped_into_frame_space() {
|
||||||
|
// The measured case: root 640x360 inside a 1280x720 output — 2x.
|
||||||
|
assert_eq!(
|
||||||
|
scale_to_frame((320, 180), (640, 360), (1280, 720)),
|
||||||
|
(640, 360)
|
||||||
|
);
|
||||||
|
assert_eq!(scale_to_frame((0, 0), (640, 360), (1280, 720)), (0, 0));
|
||||||
|
// Down-scaling works the same way.
|
||||||
|
assert_eq!(
|
||||||
|
scale_to_frame((1280, 720), (1280, 720), (640, 360)),
|
||||||
|
(640, 360)
|
||||||
|
);
|
||||||
|
// Equal spaces are a pass-through (the common case — no rounding drift).
|
||||||
|
assert_eq!(scale_to_frame((7, 9), (1920, 1080), (1920, 1080)), (7, 9));
|
||||||
|
// Not negotiated yet, or a degenerate root: pass through rather than divide by zero.
|
||||||
|
assert_eq!(scale_to_frame((7, 9), (1920, 1080), (0, 0)), (7, 9));
|
||||||
|
assert_eq!(scale_to_frame((7, 9), (0, 0), (1920, 1080)), (7, 9));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 5K frame times a 5K coordinate overflows `i32` — the scale must be computed wide.
|
||||||
|
#[test]
|
||||||
|
fn scaling_does_not_overflow_at_5k() {
|
||||||
|
assert_eq!(
|
||||||
|
scale_to_frame((2879, 1619), (2880, 1620), (5120, 2880)),
|
||||||
|
(5118, 2878)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Steam Gaming Mode shape: display 0 = Big Picture's Xwayland, 1 = the game's.
|
/// Steam Gaming Mode shape: display 0 = Big Picture's Xwayland, 1 = the game's.
|
||||||
const BPM: usize = 0;
|
const BPM: usize = 0;
|
||||||
|
|||||||
@@ -14,6 +14,15 @@
|
|||||||
|
|
||||||
pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, PyroFrameShare, WinCaptureTarget};
|
pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, PyroFrameShare, WinCaptureTarget};
|
||||||
|
|
||||||
|
// The P010 colour self-test (sweep Phase 5.5) — the `hdr-p010-selftest` subcommand, its f64
|
||||||
|
// reference math and the f16 uploader. None of it runs in a session; re-exported at the old paths
|
||||||
|
// (`main.rs` drives the subcommand, `pf-encode`'s qsv live e2e uses the bars helper).
|
||||||
|
// Explicit `#[path]`, like `idd_push`'s children: this file is itself reached through a `#[path]`
|
||||||
|
// from `lib.rs`, so a bare `mod selftest;` would resolve to `windows/selftest.rs`.
|
||||||
|
#[path = "dxgi/selftest.rs"]
|
||||||
|
mod selftest;
|
||||||
|
pub use selftest::{hdr_p010_convert_bars_on_luid, hdr_p010_selftest_at};
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
@@ -28,24 +37,31 @@ use windows::Win32::Graphics::Direct3D11::{
|
|||||||
ID3D11RenderTargetView, ID3D11SamplerState, ID3D11ShaderResourceView, ID3D11Texture2D,
|
ID3D11RenderTargetView, ID3D11SamplerState, ID3D11ShaderResourceView, ID3D11Texture2D,
|
||||||
ID3D11VertexShader, D3D11_BIND_CONSTANT_BUFFER, D3D11_BIND_RENDER_TARGET,
|
ID3D11VertexShader, D3D11_BIND_CONSTANT_BUFFER, D3D11_BIND_RENDER_TARGET,
|
||||||
D3D11_BIND_SHADER_RESOURCE, D3D11_BUFFER_DESC, D3D11_COMPARISON_NEVER, D3D11_CPU_ACCESS_READ,
|
D3D11_BIND_SHADER_RESOURCE, D3D11_BUFFER_DESC, D3D11_COMPARISON_NEVER, D3D11_CPU_ACCESS_READ,
|
||||||
D3D11_CPU_ACCESS_WRITE, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_FILTER_MIN_MAG_MIP_POINT,
|
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_FILTER_MIN_MAG_MIP_POINT, D3D11_MAPPED_SUBRESOURCE,
|
||||||
D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ, D3D11_MAP_WRITE_DISCARD,
|
D3D11_MAP_READ, D3D11_RENDER_TARGET_VIEW_DESC, D3D11_RENDER_TARGET_VIEW_DESC_0,
|
||||||
D3D11_RENDER_TARGET_VIEW_DESC, D3D11_RENDER_TARGET_VIEW_DESC_0, D3D11_RTV_DIMENSION_TEXTURE2D,
|
D3D11_RTV_DIMENSION_TEXTURE2D, D3D11_SAMPLER_DESC, D3D11_SDK_VERSION, D3D11_SUBRESOURCE_DATA,
|
||||||
D3D11_SAMPLER_DESC, D3D11_SDK_VERSION, D3D11_SUBRESOURCE_DATA, D3D11_TEX2D_RTV,
|
D3D11_TEX2D_RTV, D3D11_TEXTURE2D_DESC, D3D11_TEXTURE_ADDRESS_CLAMP, D3D11_USAGE_DEFAULT,
|
||||||
D3D11_TEXTURE2D_DESC, D3D11_TEXTURE_ADDRESS_CLAMP, D3D11_USAGE_DEFAULT, D3D11_USAGE_DYNAMIC,
|
D3D11_USAGE_IMMUTABLE, D3D11_USAGE_STAGING, D3D11_VIEWPORT,
|
||||||
D3D11_USAGE_STAGING, D3D11_VIEWPORT,
|
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::Common::{
|
use windows::Win32::Graphics::Dxgi::Common::{
|
||||||
DXGI_FORMAT, DXGI_FORMAT_P010, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM,
|
DXGI_FORMAT, DXGI_FORMAT_P010, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM,
|
||||||
DXGI_FORMAT_R16_UNORM, DXGI_SAMPLE_DESC,
|
DXGI_FORMAT_R16_UNORM, DXGI_SAMPLE_DESC,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// How many times DXGI has actually called our hooked `NtGdiDdDDIGetCachedHybridQueryValue`. If this
|
/// How many times DXGI has actually called our hooked `NtGdiDdDDIGetCachedHybridQueryValue`.
|
||||||
/// stays 0 while DDA churns with ACCESS_LOST, the hook is NOT on DXGI's GPU-preference path on this
|
/// Reported by [`hybrid_hook_hits`] on every IDD-push open, which is the first point at which DXGI
|
||||||
/// build (so reparenting can't be the cause — look at composition/independent-flip instead). >0 with
|
/// has been exercised (factory → `EnumAdapterByLuid` → device). The patch-readback check in
|
||||||
/// continuing churn means the hook fires but reparenting isn't the trigger here.
|
/// [`install_gpu_pref_hook`] proves the BYTES landed; only this counter proves DXGI actually
|
||||||
|
/// reaches the export on this build — so `0` here means the hook is inert and a
|
||||||
|
/// reparenting-flavoured symptom (see [`install_gpu_pref_hook`]) has some other cause.
|
||||||
static HYBRID_HOOK_HITS: AtomicU64 = AtomicU64::new(0);
|
static HYBRID_HOOK_HITS: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
/// See [`HYBRID_HOOK_HITS`] — surfaced in the IDD-push open log so a dead hook is visible in the
|
||||||
|
/// field instead of being a silent write-only counter.
|
||||||
|
pub(crate) fn hybrid_hook_hits() -> u64 {
|
||||||
|
HYBRID_HOOK_HITS.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
// kernel32 — declared directly so we don't pull the whole Win32_System_Diagnostics_Debug feature for
|
// kernel32 — declared directly so we don't pull the whole Win32_System_Diagnostics_Debug feature for
|
||||||
// one call. FlushInstructionCache serializes the i-cache after the inline patch: the patch is written
|
// one call. FlushInstructionCache serializes the i-cache after the inline patch: the patch is written
|
||||||
// on the main thread but DXGI runs the hooked export from the encode/worker thread (possibly a
|
// on the main thread but DXGI runs the hooked export from the encode/worker thread (possibly a
|
||||||
@@ -65,18 +81,29 @@ unsafe extern "system" fn hybrid_query_hook(gpu_preference: *mut u32) -> i32 {
|
|||||||
if gpu_preference.is_null() {
|
if gpu_preference.is_null() {
|
||||||
return 0xC000_000Du32 as i32; // STATUS_INVALID_PARAMETER
|
return 0xC000_000Du32 as i32; // STATUS_INVALID_PARAMETER
|
||||||
}
|
}
|
||||||
*gpu_preference = 3; // D3DKMT_GPU_PREFERENCE_STATE_UNSPECIFIED
|
// SAFETY: win32u's contract for this export — the caller (DXGI) passes a writable `*mut u32`
|
||||||
|
// out-param — and the null case has just been rejected above, so this is an in-bounds,
|
||||||
|
// 4-aligned single-word store into the caller's live local.
|
||||||
|
unsafe { *gpu_preference = 3 }; // D3DKMT_GPU_PREFERENCE_STATE_UNSPECIFIED
|
||||||
0 // STATUS_SUCCESS
|
0 // STATUS_SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The win32u GPU-preference hook (the same technique Apollo applies, reimplemented here from the
|
/// The win32u GPU-preference hook (the same technique Apollo applies, reimplemented here from the
|
||||||
/// documented DDI — no GPL source copied). On a HYBRID-GPU box DXGI resolves a GPU preference
|
/// documented DDI — no GPL source copied). On a HYBRID-GPU box DXGI resolves a GPU preference
|
||||||
/// (registry + power settings + the hybrid-adapter DDI) and REPARENTS outputs onto the chosen render
|
/// (registry + power settings + the hybrid-adapter DDI) and REPARENTS outputs onto the chosen render
|
||||||
/// GPU — which constantly invalidates Desktop Duplication (DXGI_ERROR_ACCESS_LOST 0x887A0026, the
|
/// GPU, ignoring `SET_RENDER_ADAPTER` (observed on the RTX 4090 + AMD iGPU box). Faking a cached
|
||||||
/// freeze/churn observed on the RTX 4090 + AMD iGPU box; `SET_RENDER_ADAPTER` is ignored there). Faking
|
/// preference of UNSPECIFIED makes DXGI skip that resolution, so an output is NOT reparented and
|
||||||
/// a cached preference of UNSPECIFIED makes DXGI skip the resolution, so the output is NOT reparented
|
/// stays on one adapter.
|
||||||
/// and DDA stays stable on one adapter (this is what makes Apollo's DDA work on this hardware).
|
///
|
||||||
/// Installed once, before the first DXGI factory/enumeration; lasts the process lifetime (like Apollo).
|
/// **Why it is still installed now that DXGI Desktop Duplication is gone:** the IDD-push ring and
|
||||||
|
/// the driver's swap-chain must live on the SAME adapter, and the host pins that with
|
||||||
|
/// `SET_RENDER_ADAPTER` at monitor ADD (see `idd_push::open_inner`). A DXGI reparent moves the
|
||||||
|
/// virtual output off the pinned GPU behind the host's back — which surfaces as the driver's
|
||||||
|
/// `DRV_STATUS_TEX_FAIL` ("could not open our textures — render-adapter mismatch") and costs a
|
||||||
|
/// ring rebind. So the hook's job changed from "keep DDA on one adapter" to "keep the VIRTUAL
|
||||||
|
/// DISPLAY on the adapter we pinned"; the mechanism is unchanged. Installed once from
|
||||||
|
/// `main.rs`, before the virtual-display setup creates the first DXGI factory; lasts the process
|
||||||
|
/// lifetime. [`hybrid_hook_hits`] reports whether DXGI ever actually calls it.
|
||||||
pub fn install_gpu_pref_hook() {
|
pub fn install_gpu_pref_hook() {
|
||||||
use std::sync::Once;
|
use std::sync::Once;
|
||||||
static HOOK: Once = Once::new();
|
static HOOK: Once = Once::new();
|
||||||
@@ -100,25 +127,38 @@ pub fn install_gpu_pref_hook() {
|
|||||||
GetAwarenessFromDpiAwarenessContext, GetThreadDpiAwarenessContext,
|
GetAwarenessFromDpiAwarenessContext, GetThreadDpiAwarenessContext,
|
||||||
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
||||||
};
|
};
|
||||||
// Per-monitor-v2 DPI awareness — REQUIRED for IDXGIOutput5::DuplicateOutput1 (without it the
|
// Per-monitor-v2 DPI awareness. It was originally set here because
|
||||||
// call returns E_ACCESSDENIED forever, forcing the legacy DuplicateOutput path). Matches
|
// `IDXGIOutput5::DuplicateOutput1` returns E_ACCESSDENIED without it; DDA is gone, but the
|
||||||
// Apollo's startup. SetProcessDpiAwarenessContext fails with E_ACCESS_DENIED if awareness was
|
// awareness still matters — an UNAWARE/SYSTEM-aware process gets DPI-VIRTUALIZED window and
|
||||||
// already set (manifest / earlier call) — log the outcome AND the effective awareness so a
|
// cursor coordinates, while every geometry the host computes comes from CCD in PHYSICAL
|
||||||
// 100% DuplicateOutput1 E_ACCESSDENIED is diagnosable instead of silent.
|
// pixels (`source_desktop_rect`, `desktop_bounds`). Mixing the two mis-aims the compose
|
||||||
|
// kick's `SetCursorPos` and the cursor-blend placement on any scaled display. Set here
|
||||||
|
// because this is the earliest process-wide hook point. `SetProcessDpiAwarenessContext`
|
||||||
|
// fails with E_ACCESS_DENIED if awareness was already set (manifest / earlier call) — log
|
||||||
|
// the outcome AND the effective awareness so a mis-scaled pointer is diagnosable.
|
||||||
match SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) {
|
match SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) {
|
||||||
Ok(()) => tracing::info!("DPI awareness set: PER_MONITOR_AWARE_V2"),
|
Ok(()) => tracing::info!("DPI awareness set: PER_MONITOR_AWARE_V2"),
|
||||||
Err(e) => tracing::warn!(error = ?e,
|
Err(e) => tracing::warn!(error = ?e,
|
||||||
"SetProcessDpiAwarenessContext failed (already set?) — DuplicateOutput1 may E_ACCESSDENIED"),
|
"SetProcessDpiAwarenessContext failed (already set?) — cursor/desktop coordinates \
|
||||||
|
may be DPI-virtualized against the host's physical-pixel CCD geometry"),
|
||||||
}
|
}
|
||||||
// 0=UNAWARE 1=SYSTEM 2=PER_MONITOR(_V2). DuplicateOutput1 needs 2.
|
// 0=UNAWARE 1=SYSTEM 2=PER_MONITOR(_V2). Physical-pixel coordinates need 2.
|
||||||
let awareness = GetAwarenessFromDpiAwarenessContext(GetThreadDpiAwarenessContext()).0;
|
let awareness = GetAwarenessFromDpiAwarenessContext(GetThreadDpiAwarenessContext()).0;
|
||||||
tracing::info!(awareness, "effective DPI awareness (need 2=PER_MONITOR for DuplicateOutput1)");
|
tracing::info!(
|
||||||
|
awareness,
|
||||||
|
"effective DPI awareness (need 2=PER_MONITOR for physical-pixel coordinates)"
|
||||||
|
);
|
||||||
let Ok(lib) = LoadLibraryA(s!("win32u.dll")) else {
|
let Ok(lib) = LoadLibraryA(s!("win32u.dll")) else {
|
||||||
tracing::warn!("GPU-pref hook: win32u.dll not loadable — skipping (DDA may churn on hybrid GPUs)");
|
tracing::warn!(
|
||||||
|
"GPU-pref hook: win32u.dll not loadable — skipping (on a hybrid-GPU box DXGI may \
|
||||||
|
reparent the virtual display off the pinned render adapter → TEX_FAIL rebinds)"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(target) = GetProcAddress(lib, s!("NtGdiDdDDIGetCachedHybridQueryValue")) else {
|
let Some(target) = GetProcAddress(lib, s!("NtGdiDdDDIGetCachedHybridQueryValue")) else {
|
||||||
tracing::warn!("GPU-pref hook: NtGdiDdDDIGetCachedHybridQueryValue not exported — skipping");
|
tracing::warn!(
|
||||||
|
"GPU-pref hook: NtGdiDdDDIGetCachedHybridQueryValue not exported — skipping"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let target = target as usize as *mut u8;
|
let target = target as usize as *mut u8;
|
||||||
@@ -132,7 +172,14 @@ pub fn install_gpu_pref_hook() {
|
|||||||
patch[10] = 0xFF;
|
patch[10] = 0xFF;
|
||||||
patch[11] = 0xE0; // jmp rax
|
patch[11] = 0xE0; // jmp rax
|
||||||
let mut old = PAGE_PROTECTION_FLAGS(0);
|
let mut old = PAGE_PROTECTION_FLAGS(0);
|
||||||
if VirtualProtect(target as *const c_void, 12, PAGE_EXECUTE_READWRITE, &mut old).is_err() {
|
if VirtualProtect(
|
||||||
|
target as *const c_void,
|
||||||
|
12,
|
||||||
|
PAGE_EXECUTE_READWRITE,
|
||||||
|
&mut old,
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
tracing::warn!("GPU-pref hook: VirtualProtect failed — skipping");
|
tracing::warn!("GPU-pref hook: VirtualProtect failed — skipping");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -150,18 +197,33 @@ pub fn install_gpu_pref_hook() {
|
|||||||
std::ptr::copy_nonoverlapping(target, readback.as_mut_ptr(), 12);
|
std::ptr::copy_nonoverlapping(target, readback.as_mut_ptr(), 12);
|
||||||
if readback == patch {
|
if readback == patch {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"GPU-pref hook installed + verified (win32u hybrid-query -> UNSPECIFIED): reparenting disabled"
|
"GPU-pref hook installed + verified (win32u hybrid-query -> UNSPECIFIED): DXGI \
|
||||||
|
output reparenting disabled. Whether DXGI actually CALLS it shows up as \
|
||||||
|
hybrid_hook_hits on the IDD-push open line."
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
want = %format!("{patch:02x?}"), got = %format!("{readback:02x?}"),
|
want = %format!("{patch:02x?}"), got = %format!("{readback:02x?}"),
|
||||||
"GPU-pref hook patch did NOT land — hook is DEAD (DXGI will still reparent → ACCESS_LOST churn)"
|
"GPU-pref hook patch did NOT land — hook is DEAD (on a hybrid-GPU box DXGI can \
|
||||||
|
still reparent the virtual display off the pinned render adapter)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compile one HLSL entry point. Returns the bytecode blob's bytes.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `entry` and `target` must be valid NUL-terminated ASCII pointers (an `s!()` literal at every
|
||||||
|
/// call site); `src` is a plain Rust `&str`, read only for the duration of the call.
|
||||||
pub(crate) unsafe fn compile_shader(src: &str, entry: PCSTR, target: PCSTR) -> Result<Vec<u8>> {
|
pub(crate) unsafe fn compile_shader(src: &str, entry: PCSTR, target: PCSTR) -> Result<Vec<u8>> {
|
||||||
|
// SAFETY: `D3DCompile` reads `src.as_ptr()` for exactly `src.len()` bytes (a live `&str` that
|
||||||
|
// outlives the synchronous call) plus the two caller-supplied NUL-terminated `PCSTR`s (per the
|
||||||
|
// contract above); `&mut blob` / `Some(&mut errs)` are live out-params. Both
|
||||||
|
// `slice::from_raw_parts` calls pair a blob's OWN `GetBufferPointer` with its OWN
|
||||||
|
// `GetBufferSize` while that blob is still alive, and the slice is copied
|
||||||
|
// (`to_string` / `to_vec`) before it goes out of scope.
|
||||||
|
unsafe {
|
||||||
let mut blob: Option<ID3DBlob> = None;
|
let mut blob: Option<ID3DBlob> = None;
|
||||||
let mut errs: Option<ID3DBlob> = None;
|
let mut errs: Option<ID3DBlob> = None;
|
||||||
let r = D3DCompile(
|
let r = D3DCompile(
|
||||||
@@ -192,6 +254,7 @@ pub(crate) unsafe fn compile_shader(src: &str, entry: PCSTR, target: PCSTR) -> R
|
|||||||
let p = blob.GetBufferPointer() as *const u8;
|
let p = blob.GetBufferPointer() as *const u8;
|
||||||
Ok(std::slice::from_raw_parts(p, blob.GetBufferSize()).to_vec())
|
Ok(std::slice::from_raw_parts(p, blob.GetBufferSize()).to_vec())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fullscreen-triangle vertex shader for the HDR conversion pass (3 verts, no input layout).
|
/// Fullscreen-triangle vertex shader for the HDR conversion pass (3 verts, no input layout).
|
||||||
pub(crate) const HDR_VS: &str = r"
|
pub(crate) const HDR_VS: &str = r"
|
||||||
@@ -316,12 +379,24 @@ pub(crate) struct HdrP010Converter {
|
|||||||
ps_y: ID3D11PixelShader,
|
ps_y: ID3D11PixelShader,
|
||||||
ps_uv: ID3D11PixelShader,
|
ps_uv: ID3D11PixelShader,
|
||||||
sampler: ID3D11SamplerState,
|
sampler: ID3D11SamplerState,
|
||||||
/// Constant buffer for the chroma pass (inv_src texel size). 16 bytes.
|
/// Constant buffer for the chroma pass: `inv_src` = (1/srcW, 1/srcH), 16 bytes. IMMUTABLE and
|
||||||
|
/// filled at construction — it depends only on the source size, and the converter is already
|
||||||
|
/// rebuilt whenever the mode changes. It used to be a DYNAMIC buffer that `convert` Mapped,
|
||||||
|
/// wrote and Unmapped on EVERY HDR frame, inside the ring slot's keyed-mutex hold, with the
|
||||||
|
/// `Map`'s failure silently ignored (leaving the pass reading a stale/garbage texel size).
|
||||||
cbuf: ID3D11Buffer,
|
cbuf: ID3D11Buffer,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HdrP010Converter {
|
impl HdrP010Converter {
|
||||||
pub(crate) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
|
/// `w`/`h` are the SOURCE dimensions this converter's chroma pass will sample, baked into the
|
||||||
|
/// immutable constant buffer. Rebuild the converter if they change (the IDD capturer's
|
||||||
|
/// `recreate_ring` already drops it).
|
||||||
|
pub(crate) unsafe fn new(device: &ID3D11Device, w: u32, h: u32) -> Result<Self> {
|
||||||
|
// SAFETY: every call is a `?`-checked D3D11 method on the live `device` borrow, over
|
||||||
|
// fully-initialized stack descriptors and live `Option` out-params; `compile_shader` receives
|
||||||
|
// `s!()` literals (its contract). Each created COM interface owns its own reference, and no
|
||||||
|
// raw pointer outlives the call that produced it.
|
||||||
|
unsafe {
|
||||||
// Inline the shared HLSL (D3DCompile has no include handler wired here). The two PS sources
|
// Inline the shared HLSL (D3DCompile has no include handler wired here). The two PS sources
|
||||||
// carry a `#include_common` marker we substitute before compiling.
|
// carry a `#include_common` marker we substitute before compiling.
|
||||||
let y_src = HDR_P010_Y_PS.replace("#include_common", HDR_P010_COMMON);
|
let y_src = HDR_P010_Y_PS.replace("#include_common", HDR_P010_COMMON);
|
||||||
@@ -349,15 +424,23 @@ impl HdrP010Converter {
|
|||||||
};
|
};
|
||||||
let mut sampler = None;
|
let mut sampler = None;
|
||||||
device.CreateSamplerState(&sd, Some(&mut sampler))?;
|
device.CreateSamplerState(&sd, Some(&mut sampler))?;
|
||||||
|
// `inv_src` never changes for a given source size — build the buffer IMMUTABLE with its
|
||||||
|
// contents, so the chroma pass needs no per-frame Map (and has no unchecked failure).
|
||||||
|
let inv_src: [f32; 4] = [1.0 / w.max(1) as f32, 1.0 / h.max(1) as f32, 0.0, 0.0];
|
||||||
let cbd = D3D11_BUFFER_DESC {
|
let cbd = D3D11_BUFFER_DESC {
|
||||||
ByteWidth: 16, // float2 inv_src + float2 pad
|
ByteWidth: 16, // float2 inv_src + float2 pad
|
||||||
Usage: D3D11_USAGE_DYNAMIC,
|
Usage: D3D11_USAGE_IMMUTABLE,
|
||||||
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
|
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
|
||||||
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
|
CPUAccessFlags: 0,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
let init = D3D11_SUBRESOURCE_DATA {
|
||||||
|
pSysMem: inv_src.as_ptr().cast(),
|
||||||
|
SysMemPitch: 0,
|
||||||
|
SysMemSlicePitch: 0,
|
||||||
|
};
|
||||||
let mut cbuf = None;
|
let mut cbuf = None;
|
||||||
device.CreateBuffer(&cbd, None, Some(&mut cbuf))?;
|
device.CreateBuffer(&cbd, Some(&init), Some(&mut cbuf))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
vs: vs.context("p010 vs")?,
|
vs: vs.context("p010 vs")?,
|
||||||
ps_y: ps_y.context("p010 y ps")?,
|
ps_y: ps_y.context("p010 y ps")?,
|
||||||
@@ -366,15 +449,24 @@ impl HdrP010Converter {
|
|||||||
cbuf: cbuf.context("p010 cbuf")?,
|
cbuf: cbuf.context("p010 cbuf")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a per-plane RTV of the P010 texture `dst` with the given single-plane `format`
|
/// Create a per-plane RTV of the P010 texture `dst` with the given single-plane `format`
|
||||||
/// (`R16_UNORM` for plane 0 luma, `R16G16_UNORM` for plane 1 chroma). The plane is selected by the
|
/// (`R16_UNORM` for plane 0 luma, `R16G16_UNORM` for plane 1 chroma). The plane is selected by the
|
||||||
/// view format (planar-RTV semantics); MipSlice 0.
|
/// view format (planar-RTV semantics); MipSlice 0.
|
||||||
unsafe fn plane_rtv(
|
///
|
||||||
|
/// Called ONCE PER OUT-RING SLOT by the owner of the P010 textures, not per frame — see
|
||||||
|
/// [`Self::convert`]. Fails when the driver rejects a planar RTV, which is the one hard
|
||||||
|
/// requirement of this whole path (a D3D11.3+ runtime plus driver support).
|
||||||
|
pub(crate) unsafe fn plane_rtv(
|
||||||
device: &ID3D11Device,
|
device: &ID3D11Device,
|
||||||
dst: &ID3D11Texture2D,
|
dst: &ID3D11Texture2D,
|
||||||
format: DXGI_FORMAT,
|
format: DXGI_FORMAT,
|
||||||
) -> Result<ID3D11RenderTargetView> {
|
) -> Result<ID3D11RenderTargetView> {
|
||||||
|
// SAFETY: one `?`-checked `CreateRenderTargetView` on the live `device` borrow, with a
|
||||||
|
// fully-initialized `D3D11_RENDER_TARGET_VIEW_DESC` local whose address is taken only for the
|
||||||
|
// duration of the synchronous call, plus a live `Option` out-param.
|
||||||
|
unsafe {
|
||||||
let desc = D3D11_RENDER_TARGET_VIEW_DESC {
|
let desc = D3D11_RENDER_TARGET_VIEW_DESC {
|
||||||
Format: format,
|
Format: format,
|
||||||
ViewDimension: D3D11_RTV_DIMENSION_TEXTURE2D,
|
ViewDimension: D3D11_RTV_DIMENSION_TEXTURE2D,
|
||||||
@@ -394,34 +486,31 @@ impl HdrP010Converter {
|
|||||||
})?;
|
})?;
|
||||||
rtv.context("p010 plane rtv null")
|
rtv.context("p010 plane rtv null")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert `src_srv` (FP16 scRGB, WxH) into `dst` (a `DXGI_FORMAT_P010` texture with
|
/// Convert `src_srv` (FP16 scRGB, WxH) into a `DXGI_FORMAT_P010` texture through the two plane
|
||||||
/// `BIND_RENDER_TARGET`). Two opaque passes: full-res luma → plane 0, half-res chroma → plane 1.
|
/// RTVs the CALLER built for it ([`Self::plane_rtv`]): full-res luma → `y_rtv` (plane 0),
|
||||||
/// `w`/`h` are the full luma dimensions (must be even). Returns `Err` if a plane RTV can't be
|
/// half-res chroma → `uv_rtv` (plane 1). `w`/`h` are the full luma dimensions (must be even) and
|
||||||
/// created (driver) so the caller can fall back to the R10 path.
|
/// must match the `w`/`h` this converter was constructed with.
|
||||||
|
///
|
||||||
|
/// Takes the views rather than creating them: two `CreateRenderTargetView`s per frame — plus a
|
||||||
|
/// Map/Unmap of a never-changing 16-byte constant buffer — was pure per-frame cost inside the
|
||||||
|
/// ring slot's keyed-mutex hold, i.e. time the DRIVER spent blocked on that slot. Both are
|
||||||
|
/// lifetime-of-mode facts: the views belong to the out-ring slot (built in `ensure_out_ring`),
|
||||||
|
/// the buffer to this converter, which is already rebuilt on every mode change.
|
||||||
pub(crate) unsafe fn convert(
|
pub(crate) unsafe fn convert(
|
||||||
&self,
|
&self,
|
||||||
device: &ID3D11Device,
|
|
||||||
ctx: &ID3D11DeviceContext,
|
ctx: &ID3D11DeviceContext,
|
||||||
src_srv: &ID3D11ShaderResourceView,
|
src_srv: &ID3D11ShaderResourceView,
|
||||||
dst: &ID3D11Texture2D,
|
y_rtv: &ID3D11RenderTargetView,
|
||||||
|
uv_rtv: &ID3D11RenderTargetView,
|
||||||
w: u32,
|
w: u32,
|
||||||
h: u32,
|
h: u32,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let y_rtv = Self::plane_rtv(device, dst, DXGI_FORMAT_R16_UNORM)?;
|
// SAFETY: all D3D11 work runs on the caller's live `ctx` borrow (the owning capture thread's
|
||||||
let uv_rtv = Self::plane_rtv(device, dst, DXGI_FORMAT_R16G16_UNORM)?;
|
// immediate context) over borrowed slices of fully-initialized locals (the viewports) and
|
||||||
|
// clones of the caller's live SRV/RTVs. No raw pointers and no mapping on this path.
|
||||||
// Update the chroma constant buffer (inverse source texel size).
|
unsafe {
|
||||||
let cb: [f32; 4] = [1.0 / w as f32, 1.0 / h as f32, 0.0, 0.0];
|
|
||||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
|
||||||
if ctx
|
|
||||||
.Map(&self.cbuf, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
std::ptr::copy_nonoverlapping(cb.as_ptr(), mapped.pData as *mut f32, cb.len());
|
|
||||||
ctx.Unmap(&self.cbuf, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared pipeline state.
|
// Shared pipeline state.
|
||||||
ctx.OMSetBlendState(None, None, 0xffff_ffff); // opaque overwrite
|
ctx.OMSetBlendState(None, None, 0xffff_ffff); // opaque overwrite
|
||||||
ctx.VSSetShader(&self.vs, None);
|
ctx.VSSetShader(&self.vs, None);
|
||||||
@@ -466,6 +555,7 @@ impl HdrP010Converter {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// PyroWave LUMA pass PS — full-res, writes Y′ to a separate `R8_UNORM` texture. BT.709 limited from
|
/// PyroWave LUMA pass PS — full-res, writes Y′ to a separate `R8_UNORM` texture. BT.709 limited from
|
||||||
/// the 8-bit sRGB (gamma) BGRA slot, BYTE-IDENTICAL to the Linux `rgb2yuv.comp` `lumaY` (so the
|
/// the 8-bit sRGB (gamma) BGRA slot, BYTE-IDENTICAL to the Linux `rgb2yuv.comp` `lumaY` (so the
|
||||||
@@ -604,6 +694,9 @@ pub(crate) struct BgraToYuvPlanes {
|
|||||||
|
|
||||||
impl BgraToYuvPlanes {
|
impl BgraToYuvPlanes {
|
||||||
pub(crate) unsafe fn new(device: &ID3D11Device, hdr: bool, chroma444: bool) -> Result<Self> {
|
pub(crate) unsafe fn new(device: &ID3D11Device, hdr: bool, chroma444: bool) -> Result<Self> {
|
||||||
|
// SAFETY: as `HdrP010Converter::new` — `?`-checked D3D11 shader creation on the live
|
||||||
|
// `device` borrow, with `s!()` literals into `compile_shader` and live out-params.
|
||||||
|
unsafe {
|
||||||
let (y_src, uv_src) = match (hdr, chroma444) {
|
let (y_src, uv_src) = match (hdr, chroma444) {
|
||||||
(false, false) => (PYRO_Y_PS.to_string(), PYRO_UV_PS.to_string()),
|
(false, false) => (PYRO_Y_PS.to_string(), PYRO_UV_PS.to_string()),
|
||||||
(false, true) => (PYRO_Y_PS.to_string(), PYRO_UV444_PS.to_string()),
|
(false, true) => (PYRO_Y_PS.to_string(), PYRO_UV444_PS.to_string()),
|
||||||
@@ -632,6 +725,7 @@ impl BgraToYuvPlanes {
|
|||||||
chroma444,
|
chroma444,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert `src_srv` (BGRA slot for SDR / scRGB FP16 slot for HDR, WxH) → `y_rtv` (full-res Y
|
/// Convert `src_srv` (BGRA slot for SDR / scRGB FP16 slot for HDR, WxH) → `y_rtv` (full-res Y
|
||||||
/// texture) + `cbcr_rtv` (half- or full-res CbCr texture per the constructed mode). Two opaque
|
/// texture) + `cbcr_rtv` (half- or full-res CbCr texture per the constructed mode). Two opaque
|
||||||
@@ -646,6 +740,10 @@ impl BgraToYuvPlanes {
|
|||||||
w: u32,
|
w: u32,
|
||||||
h: u32,
|
h: u32,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
// SAFETY: D3D11 state-setting plus two `Draw`s on the caller's live immediate-context
|
||||||
|
// borrow, over borrowed slices of fully-initialized locals (the viewports) and clones of the
|
||||||
|
// caller's live SRV/RTVs. No raw pointers and no mapping on this path.
|
||||||
|
unsafe {
|
||||||
ctx.OMSetBlendState(None, None, 0xffff_ffff); // opaque overwrite
|
ctx.OMSetBlendState(None, None, 0xffff_ffff); // opaque overwrite
|
||||||
ctx.VSSetShader(&self.vs, None);
|
ctx.VSSetShader(&self.vs, None);
|
||||||
ctx.PSSetShaderResources(0, Some(&[Some(src_srv.clone())]));
|
ctx.PSSetShaderResources(0, Some(&[Some(src_srv.clone())]));
|
||||||
@@ -689,540 +787,6 @@ impl BgraToYuvPlanes {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// f64 reference for the P010 colour math — the EXACT analogue of the HLSL in [`HDR_P010_COMMON`].
|
|
||||||
/// Input is one scRGB pixel (linear, Rec.709 primaries, 1.0 = 80 nits, may be >1 for HDR). Output is
|
|
||||||
/// the 10-bit studio-range (Y, Cb, Cr) codes the shader should produce for a flat (constant) block.
|
|
||||||
/// Used by [`hdr_p010_selftest`].
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
|
|
||||||
fn pq_oetf(l: f64) -> f64 {
|
|
||||||
let l = l.clamp(0.0, 1.0);
|
|
||||||
let m1 = 0.1593017578125;
|
|
||||||
let m2 = 78.84375;
|
|
||||||
let c1 = 0.8359375;
|
|
||||||
let c2 = 18.8515625;
|
|
||||||
let c3 = 18.6875;
|
|
||||||
let lp = l.powf(m1);
|
|
||||||
((c1 + c2 * lp) / (1.0 + c3 * lp)).powf(m2)
|
|
||||||
}
|
|
||||||
// scRGB -> nits -> BT.2020 linear (row-major matrix, mul(M, v)).
|
|
||||||
let (r, g, b) = (r.max(0.0) * 80.0, g.max(0.0) * 80.0, b.max(0.0) * 80.0);
|
|
||||||
let m = [
|
|
||||||
[0.627403914, 0.329283038, 0.043313048],
|
|
||||||
[0.069097292, 0.919540405, 0.011362303],
|
|
||||||
[0.016391439, 0.088013308, 0.895595253],
|
|
||||||
];
|
|
||||||
let lr = m[0][0] * r + m[0][1] * g + m[0][2] * b;
|
|
||||||
let lg = m[1][0] * r + m[1][1] * g + m[1][2] * b;
|
|
||||||
let lb = m[2][0] * r + m[2][1] * g + m[2][2] * b;
|
|
||||||
// PQ encode (normalize to 10k nits).
|
|
||||||
let pr = pq_oetf(lr / 10000.0);
|
|
||||||
let pg = pq_oetf(lg / 10000.0);
|
|
||||||
let pb = pq_oetf(lb / 10000.0);
|
|
||||||
// BT.2020 non-constant-luminance, limited 10-bit.
|
|
||||||
let (kr, kg, kb) = (0.2627, 0.6780, 0.0593);
|
|
||||||
let y = kr * pr + kg * pg + kb * pb;
|
|
||||||
let cb = (pb - y) / 1.8814;
|
|
||||||
let cr = (pr - y) / 1.4746;
|
|
||||||
let yc = (64.0 + 876.0 * y).clamp(64.0, 940.0);
|
|
||||||
let cbc = (512.0 + 896.0 * cb).clamp(64.0, 960.0);
|
|
||||||
let crc = (512.0 + 896.0 * cr).clamp(64.0, 960.0);
|
|
||||||
(yc, cbc, crc)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Colour self-test for [`HdrP010Converter`] (the `hdr-p010-selftest` subcommand): create a hardware
|
|
||||||
/// D3D11 device, upload a known scRGB FP16 pattern, run the P010 shader passes, read the Y (plane 0)
|
|
||||||
/// and UV (plane 1) planes back from a staging copy, and compare against the [`p010_reference`] f64
|
|
||||||
/// math. The ONLY validation we have without green-screening a live HDR stream. PASS if max abs error
|
|
||||||
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
pub fn hdr_p010_selftest() -> Result<()> {
|
|
||||||
hdr_p010_selftest_at(64, 64, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`hdr_p010_selftest`] at an arbitrary even size and (optionally) on a specific GPU vendor
|
|
||||||
/// (PCI vendor id, e.g. `0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD). The size matters on
|
|
||||||
/// top of the 64×64 default because the field sessions run at capture resolutions whose height
|
|
||||||
/// is NOT 16-aligned (1080 → the encoder's align16 pool seam) and a driver may treat the planar
|
|
||||||
/// RTVs differently at real sizes; the vendor pin matters on dual-GPU boxes where the default
|
|
||||||
/// adapter is not the one the session encodes on.
|
|
||||||
/// Test support (used by pf-encode's live e2e): the 8 sRGB colour bars (white/yellow/cyan/green/
|
|
||||||
/// magenta/red/blue/black, sRGB 1.0 = scRGB 1.0 = 80 nits) as a w×h FP16 scRGB texture on the
|
|
||||||
/// adapter with `luid`, converted through the REAL [`HdrP010Converter`] into a P010 texture with
|
|
||||||
/// **`BIND_RENDER_TARGET` only, `MiscFlags` 0 — the exact bind profile of the IDD out-ring** (the
|
|
||||||
/// CPU-upload encoder tests can't use that profile, so only this path exercises "RTV-written P010
|
|
||||||
/// → encoder ingest copy"). Returns `(device, p010)`; expected decoded codes per bar are the
|
|
||||||
/// bars_pq2020 fixture's: (490,512,512) (478,423,518) (464,525,473) (450,432,476) (350,584,585)
|
|
||||||
/// (325,448,598) (226,650,535) (64,512,512).
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
#[doc(hidden)]
|
|
||||||
pub fn hdr_p010_convert_bars_on_luid(
|
|
||||||
luid: [u8; 8],
|
|
||||||
w: u32,
|
|
||||||
h: u32,
|
|
||||||
) -> Result<(ID3D11Device, ID3D11Texture2D)> {
|
|
||||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
|
||||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
|
||||||
|
|
||||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
|
||||||
bail!("bars pattern needs even non-zero dimensions, got {w}x{h}");
|
|
||||||
}
|
|
||||||
// sRGB primaries at full/zero channels: sRGB EOTF(1.0)=1.0, (0)=0 → the scRGB pattern is
|
|
||||||
// pure 0/1 floats and the PQ/BT.2020 reference codes above are exact.
|
|
||||||
const BARS: [(f32, f32, f32); 8] = [
|
|
||||||
(1.0, 1.0, 1.0),
|
|
||||||
(1.0, 1.0, 0.0),
|
|
||||||
(0.0, 1.0, 1.0),
|
|
||||||
(0.0, 1.0, 0.0),
|
|
||||||
(1.0, 0.0, 1.0),
|
|
||||||
(1.0, 0.0, 0.0),
|
|
||||||
(0.0, 0.0, 1.0),
|
|
||||||
(0.0, 0.0, 0.0),
|
|
||||||
];
|
|
||||||
let bar_w = (w / 8).max(1) as usize;
|
|
||||||
let mut fp16 = vec![0u16; (w * h * 4) as usize];
|
|
||||||
for y in 0..h as usize {
|
|
||||||
for x in 0..w as usize {
|
|
||||||
let (r, g, b) = BARS[(x / bar_w).min(7)];
|
|
||||||
let i = (y * w as usize + x) * 4;
|
|
||||||
fp16[i] = f32_to_f16(r);
|
|
||||||
fp16[i + 1] = f32_to_f16(g);
|
|
||||||
fp16[i + 2] = f32_to_f16(b);
|
|
||||||
fp16[i + 3] = f32_to_f16(1.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// SAFETY: same single-device/single-thread contract as `hdr_p010_selftest_at`; the FP16
|
|
||||||
// initial-data Vec outlives the synchronous CreateTexture2D; the returned COM handles own
|
|
||||||
// their references.
|
|
||||||
unsafe {
|
|
||||||
let luid = windows::Win32::Foundation::LUID {
|
|
||||||
LowPart: u32::from_le_bytes(luid[..4].try_into().unwrap()),
|
|
||||||
HighPart: i32::from_le_bytes(luid[4..].try_into().unwrap()),
|
|
||||||
};
|
|
||||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("dxgi factory")?;
|
|
||||||
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).context("adapter by luid")?;
|
|
||||||
let mut device: Option<ID3D11Device> = None;
|
|
||||||
let mut context: Option<ID3D11DeviceContext> = None;
|
|
||||||
D3D11CreateDevice(
|
|
||||||
&adapter,
|
|
||||||
D3D_DRIVER_TYPE_UNKNOWN,
|
|
||||||
HMODULE::default(),
|
|
||||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
|
||||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
|
||||||
D3D11_SDK_VERSION,
|
|
||||||
Some(&mut device),
|
|
||||||
None,
|
|
||||||
Some(&mut context),
|
|
||||||
)
|
|
||||||
.context("D3D11CreateDevice(luid) for bars convert")?;
|
|
||||||
let device = device.context("null device")?;
|
|
||||||
let context = context.context("null context")?;
|
|
||||||
|
|
||||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
|
||||||
Width: w,
|
|
||||||
Height: h,
|
|
||||||
MipLevels: 1,
|
|
||||||
ArraySize: 1,
|
|
||||||
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
|
||||||
SampleDesc: DXGI_SAMPLE_DESC {
|
|
||||||
Count: 1,
|
|
||||||
Quality: 0,
|
|
||||||
},
|
|
||||||
Usage: D3D11_USAGE_DEFAULT,
|
|
||||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let init = D3D11_SUBRESOURCE_DATA {
|
|
||||||
pSysMem: fp16.as_ptr() as *const c_void,
|
|
||||||
SysMemPitch: w * 8,
|
|
||||||
SysMemSlicePitch: 0,
|
|
||||||
};
|
|
||||||
let mut src_tex: Option<ID3D11Texture2D> = None;
|
|
||||||
device
|
|
||||||
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
|
||||||
.context("CreateTexture2D(fp16 bars)")?;
|
|
||||||
let src_tex = src_tex.context("null src tex")?;
|
|
||||||
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
|
||||||
device
|
|
||||||
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
|
||||||
.context("CreateShaderResourceView(fp16 bars)")?;
|
|
||||||
let src_srv = src_srv.context("null src srv")?;
|
|
||||||
|
|
||||||
// The IDD out-ring's exact profile: P010, RENDER_TARGET only, MiscFlags 0.
|
|
||||||
let p010_desc = D3D11_TEXTURE2D_DESC {
|
|
||||||
Width: w,
|
|
||||||
Height: h,
|
|
||||||
MipLevels: 1,
|
|
||||||
ArraySize: 1,
|
|
||||||
Format: DXGI_FORMAT_P010,
|
|
||||||
SampleDesc: DXGI_SAMPLE_DESC {
|
|
||||||
Count: 1,
|
|
||||||
Quality: 0,
|
|
||||||
},
|
|
||||||
Usage: D3D11_USAGE_DEFAULT,
|
|
||||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut p010: Option<ID3D11Texture2D> = None;
|
|
||||||
device
|
|
||||||
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
|
||||||
.context("CreateTexture2D(P010 bars dst)")?;
|
|
||||||
let p010 = p010.context("null p010 tex")?;
|
|
||||||
|
|
||||||
let conv = HdrP010Converter::new(&device)?;
|
|
||||||
conv.convert(&device, &context, &src_srv, &p010, w, h)?;
|
|
||||||
Ok((device, p010))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
|
|
||||||
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN};
|
|
||||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
|
|
||||||
|
|
||||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
|
||||||
bail!("hdr-p010-selftest needs even non-zero dimensions, got {w}x{h}");
|
|
||||||
}
|
|
||||||
// A grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform → exact chroma
|
|
||||||
// comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus a couple
|
|
||||||
// of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
|
|
||||||
#[allow(non_snake_case)]
|
|
||||||
let (W, H) = (w, h);
|
|
||||||
const BLK: u32 = 16;
|
|
||||||
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values.
|
|
||||||
let named: [(&str, f32, f32, f32); 8] = [
|
|
||||||
("red1.0", 1.0, 0.0, 0.0),
|
|
||||||
("green0.5", 0.0, 0.5, 0.0),
|
|
||||||
("blue4.0", 0.0, 0.0, 4.0),
|
|
||||||
("white1.0", 1.0, 1.0, 1.0),
|
|
||||||
("black", 0.0, 0.0, 0.0),
|
|
||||||
("gray0.5", 0.5, 0.5, 0.5),
|
|
||||||
("white4.0", 4.0, 4.0, 4.0),
|
|
||||||
("amber2.0", 2.0, 1.0, 0.0),
|
|
||||||
];
|
|
||||||
|
|
||||||
let grid_cols = W / BLK; // 4
|
|
||||||
let pixel_rgb = |x: u32, y: u32| -> (f32, f32, f32, bool) {
|
|
||||||
let idx = ((y / BLK) * grid_cols + (x / BLK)) as usize;
|
|
||||||
if idx < named.len() {
|
|
||||||
let (_, r, g, b) = named[idx];
|
|
||||||
(r, g, b, true)
|
|
||||||
} else {
|
|
||||||
// Gradient (distinct per pixel; Y-only compare), within HDR scRGB range.
|
|
||||||
let r = (x as f32 / W as f32) * 3.0;
|
|
||||||
let g = (y as f32 / H as f32) * 3.0;
|
|
||||||
let b = ((x + y) as f32 / (W + H) as f32) * 3.0;
|
|
||||||
(r, g, b, false)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build the scRGB FP16 (R16G16B16A16_FLOAT) source as f16 bits.
|
|
||||||
let mut fp16 = vec![0u16; (W * H * 4) as usize];
|
|
||||||
let mut flat = vec![false; (W * H) as usize];
|
|
||||||
for y in 0..H {
|
|
||||||
for x in 0..W {
|
|
||||||
let (r, g, b, is_flat) = pixel_rgb(x, y);
|
|
||||||
let i = ((y * W + x) * 4) as usize;
|
|
||||||
fp16[i] = f32_to_f16(r);
|
|
||||||
fp16[i + 1] = f32_to_f16(g);
|
|
||||||
fp16[i + 2] = f32_to_f16(b);
|
|
||||||
fp16[i + 3] = f32_to_f16(1.0);
|
|
||||||
flat[(y * W + x) as usize] = is_flat;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: this self-test creates its own D3D11 device + immediate context (`D3D11CreateDevice`,
|
|
||||||
// both checked non-null) and uses ONLY that device for the rest of the block: every
|
|
||||||
// `CreateTexture2D`/`CreateShaderResourceView`/`HdrP010Converter::{new,convert}`/`CopyResource`/
|
|
||||||
// `Map` is invoked on that device or its context, so all resources share one device and run on this
|
|
||||||
// single thread. The source texture's `D3D11_SUBRESOURCE_DATA` points at `fp16`, a live
|
|
||||||
// `Vec<u16>` of `W*H*4` samples with `SysMemPitch = W*8`, matching the W×H R16G16B16A16 texture;
|
|
||||||
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
|
|
||||||
// proven individually at the `read_u16` closure below.
|
|
||||||
unsafe {
|
|
||||||
// Device on the requested vendor's adapter (dual-GPU boxes encode on a specific one), else
|
|
||||||
// the default hardware GPU. Always says which adapter ran — a PASS is only meaningful for
|
|
||||||
// the GPU it actually tested.
|
|
||||||
let adapter: Option<IDXGIAdapter> = match vendor {
|
|
||||||
None => None,
|
|
||||||
Some(want) => {
|
|
||||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().context("dxgi factory")?;
|
|
||||||
let mut found = None;
|
|
||||||
for i in 0.. {
|
|
||||||
let Ok(a) = factory.EnumAdapters(i) else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
let desc = a.GetDesc().context("adapter desc")?;
|
|
||||||
if desc.VendorId == want {
|
|
||||||
found = Some(a);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(found.with_context(|| format!("no adapter with vendor id {want:#x}"))?)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut device: Option<ID3D11Device> = None;
|
|
||||||
let mut context: Option<ID3D11DeviceContext> = None;
|
|
||||||
D3D11CreateDevice(
|
|
||||||
adapter.as_ref(),
|
|
||||||
if adapter.is_some() {
|
|
||||||
D3D_DRIVER_TYPE_UNKNOWN
|
|
||||||
} else {
|
|
||||||
D3D_DRIVER_TYPE_HARDWARE
|
|
||||||
},
|
|
||||||
HMODULE::default(),
|
|
||||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
|
||||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
|
||||||
D3D11_SDK_VERSION,
|
|
||||||
Some(&mut device),
|
|
||||||
None,
|
|
||||||
Some(&mut context),
|
|
||||||
)
|
|
||||||
.context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?;
|
|
||||||
let device = device.context("null device")?;
|
|
||||||
let context = context.context("null context")?;
|
|
||||||
{
|
|
||||||
let dxgi: windows::Win32::Graphics::Dxgi::IDXGIDevice =
|
|
||||||
device.cast().context("device -> IDXGIDevice")?;
|
|
||||||
let desc = dxgi.GetAdapter().context("GetAdapter")?.GetDesc()?;
|
|
||||||
let name = String::from_utf16_lossy(
|
|
||||||
&desc.Description[..desc
|
|
||||||
.Description
|
|
||||||
.iter()
|
|
||||||
.position(|&c| c == 0)
|
|
||||||
.unwrap_or(desc.Description.len())],
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"adapter: {name} (vendor {:#06x}, luid {:08x}:{:08x})",
|
|
||||||
desc.VendorId, desc.AdapterLuid.HighPart, desc.AdapterLuid.LowPart
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source FP16 texture (initialized) + SRV.
|
|
||||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
|
||||||
Width: W,
|
|
||||||
Height: H,
|
|
||||||
MipLevels: 1,
|
|
||||||
ArraySize: 1,
|
|
||||||
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
|
||||||
SampleDesc: DXGI_SAMPLE_DESC {
|
|
||||||
Count: 1,
|
|
||||||
Quality: 0,
|
|
||||||
},
|
|
||||||
Usage: D3D11_USAGE_DEFAULT,
|
|
||||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let init = D3D11_SUBRESOURCE_DATA {
|
|
||||||
pSysMem: fp16.as_ptr() as *const c_void,
|
|
||||||
SysMemPitch: W * 8, // 4 channels * 2 bytes
|
|
||||||
SysMemSlicePitch: 0,
|
|
||||||
};
|
|
||||||
let mut src_tex: Option<ID3D11Texture2D> = None;
|
|
||||||
device
|
|
||||||
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
|
||||||
.context("CreateTexture2D(fp16 src)")?;
|
|
||||||
let src_tex = src_tex.context("null src tex")?;
|
|
||||||
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
|
||||||
device
|
|
||||||
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
|
||||||
.context("CreateShaderResourceView(fp16 src)")?;
|
|
||||||
let src_srv = src_srv.context("null src srv")?;
|
|
||||||
|
|
||||||
// P010 destination texture (render-target bindable).
|
|
||||||
let p010_desc = D3D11_TEXTURE2D_DESC {
|
|
||||||
Width: W,
|
|
||||||
Height: H,
|
|
||||||
MipLevels: 1,
|
|
||||||
ArraySize: 1,
|
|
||||||
Format: DXGI_FORMAT_P010,
|
|
||||||
SampleDesc: DXGI_SAMPLE_DESC {
|
|
||||||
Count: 1,
|
|
||||||
Quality: 0,
|
|
||||||
},
|
|
||||||
Usage: D3D11_USAGE_DEFAULT,
|
|
||||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut p010: Option<ID3D11Texture2D> = None;
|
|
||||||
device
|
|
||||||
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
|
||||||
.context("CreateTexture2D(P010 dst)")?;
|
|
||||||
let p010 = p010.context("null p010 tex")?;
|
|
||||||
|
|
||||||
let conv = HdrP010Converter::new(&device)?;
|
|
||||||
conv.convert(&device, &context, &src_srv, &p010, W, H)?;
|
|
||||||
|
|
||||||
// Staging copy of the whole P010 texture (both planes), MAP_READ.
|
|
||||||
let stage_desc = D3D11_TEXTURE2D_DESC {
|
|
||||||
Width: W,
|
|
||||||
Height: H,
|
|
||||||
MipLevels: 1,
|
|
||||||
ArraySize: 1,
|
|
||||||
Format: DXGI_FORMAT_P010,
|
|
||||||
SampleDesc: DXGI_SAMPLE_DESC {
|
|
||||||
Count: 1,
|
|
||||||
Quality: 0,
|
|
||||||
},
|
|
||||||
Usage: D3D11_USAGE_STAGING,
|
|
||||||
BindFlags: 0,
|
|
||||||
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut staging: Option<ID3D11Texture2D> = None;
|
|
||||||
device
|
|
||||||
.CreateTexture2D(&stage_desc, None, Some(&mut staging))
|
|
||||||
.context("CreateTexture2D(P010 staging)")?;
|
|
||||||
let staging = staging.context("null staging")?;
|
|
||||||
context.CopyResource(&staging, &p010);
|
|
||||||
|
|
||||||
let mut map = D3D11_MAPPED_SUBRESOURCE::default();
|
|
||||||
context
|
|
||||||
.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut map))
|
|
||||||
.context("Map(P010 staging)")?;
|
|
||||||
let row_pitch = map.RowPitch as usize; // bytes per luma row (in 16-bit samples: /2)
|
|
||||||
let base = map.pData as *const u8;
|
|
||||||
// DIAGNOSTIC (the uncertain layout spot — verify on the box if chroma is wrong): the mapped
|
|
||||||
// P010 plane offsets. Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2
|
|
||||||
// *interleaved* (Cb,Cr) u16 pairs. P010 packs plane 1 after plane 0 at the SAME row pitch; the
|
|
||||||
// chroma plane begins at byte offset RowPitch * (luma height). For a STAGING texture that
|
|
||||||
// height is the created H (no inter-plane alignment). DepthPitch (total mapped size) lets us
|
|
||||||
// sanity-check: it should be ~ RowPitch * H * 3/2. If chroma reads garbage on the box, print
|
|
||||||
// these and adjust `chroma_base` (e.g. an aligned luma height).
|
|
||||||
tracing::info!(
|
|
||||||
row_pitch,
|
|
||||||
depth_pitch = map.DepthPitch,
|
|
||||||
expected_chroma_base = row_pitch * H as usize,
|
|
||||||
expected_total = row_pitch * H as usize * 3 / 2,
|
|
||||||
"hdr-p010-selftest: mapped P010 layout (verify chroma plane offset here if chroma is wrong)"
|
|
||||||
);
|
|
||||||
// Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2 *interleaved* (Cb,Cr)
|
|
||||||
// u16 pairs, i.e. W u16 per chroma row. P010 packs plane 1 immediately after plane 0 at the
|
|
||||||
// SAME row pitch; per spec the chroma plane begins at an allocation offset of
|
|
||||||
// RowPitch * Height (luma rows). We read it from there. (DepthPitch is the full surface size;
|
|
||||||
// not all drivers report the chroma offset, so RowPitch*Height is the portable choice.)
|
|
||||||
let read_u16 = |byte_off: usize| -> u16 {
|
|
||||||
// SAFETY: `base` is the mapped staging pointer; all offsets are within the P010 surface
|
|
||||||
// (luma H*RowPitch + chroma (H/2)*RowPitch ≤ DepthPitch). Already in the fn's unsafe scope.
|
|
||||||
let p = base.add(byte_off) as *const u16;
|
|
||||||
p.read_unaligned()
|
|
||||||
};
|
|
||||||
// Luma codes: stored u16 in the high 10 bits -> code10 = stored >> 6.
|
|
||||||
let mut y_codes = vec![0u16; (W * H) as usize];
|
|
||||||
for y in 0..H {
|
|
||||||
for x in 0..W {
|
|
||||||
let off = (y as usize) * row_pitch + (x as usize) * 2;
|
|
||||||
y_codes[(y * W + x) as usize] = read_u16(off) >> 6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let cw = W / 2;
|
|
||||||
let ch = H / 2;
|
|
||||||
let chroma_base = row_pitch * H as usize; // plane 1 offset
|
|
||||||
let mut cb_codes = vec![0u16; (cw * ch) as usize];
|
|
||||||
let mut cr_codes = vec![0u16; (cw * ch) as usize];
|
|
||||||
for cy in 0..ch {
|
|
||||||
for cx in 0..cw {
|
|
||||||
// Interleaved (Cb, Cr) per chroma sample → 2 u16 = 4 bytes per sample.
|
|
||||||
let off = chroma_base + (cy as usize) * row_pitch + (cx as usize) * 4;
|
|
||||||
cb_codes[(cy * cw + cx) as usize] = read_u16(off) >> 6;
|
|
||||||
cr_codes[(cy * cw + cx) as usize] = read_u16(off + 2) >> 6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
context.Unmap(&staging, 0);
|
|
||||||
|
|
||||||
// Compare Y over every pixel.
|
|
||||||
let mut max_y_err = 0.0f64;
|
|
||||||
for y in 0..H {
|
|
||||||
for x in 0..W {
|
|
||||||
let (r, g, b, _) = pixel_rgb(x, y);
|
|
||||||
let (ry, _, _) = p010_reference(r as f64, g as f64, b as f64);
|
|
||||||
let got = y_codes[(y * W + x) as usize] as f64;
|
|
||||||
max_y_err = max_y_err.max((got - ry).abs());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Compare Cb/Cr over flat blocks only (uniform 2x2 footprint → exact reference).
|
|
||||||
let mut max_u_err = 0.0f64;
|
|
||||||
let mut max_v_err = 0.0f64;
|
|
||||||
for cy in 0..ch {
|
|
||||||
for cx in 0..cw {
|
|
||||||
let (sx, sy) = (cx * 2, cy * 2);
|
|
||||||
let all_flat =
|
|
||||||
(0..2).all(|dy| (0..2).all(|dx| flat[((sy + dy) * W + (sx + dx)) as usize]));
|
|
||||||
if !all_flat {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (r, g, b, _) = pixel_rgb(sx, sy);
|
|
||||||
let (_, rcb, rcr) = p010_reference(r as f64, g as f64, b as f64);
|
|
||||||
let gu = cb_codes[(cy * cw + cx) as usize] as f64;
|
|
||||||
let gv = cr_codes[(cy * cw + cx) as usize] as f64;
|
|
||||||
max_u_err = max_u_err.max((gu - rcb).abs());
|
|
||||||
max_v_err = max_v_err.max((gv - rcr).abs());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-colour table.
|
|
||||||
println!("HDR P010 self-test ({W}x{H}, BT.2020 PQ, 10-bit limited range)");
|
|
||||||
println!(
|
|
||||||
" {:<10} {:>14} {:>14} {:>14}",
|
|
||||||
"color", "Y exp/got", "Cb exp/got", "Cr exp/got"
|
|
||||||
);
|
|
||||||
for (idx, (name, r, g, b)) in named.iter().enumerate() {
|
|
||||||
let bx = (idx as u32 % grid_cols) * BLK + BLK / 2;
|
|
||||||
let by = (idx as u32 / grid_cols) * BLK + BLK / 2;
|
|
||||||
let (ey, ecb, ecr) = p010_reference(*r as f64, *g as f64, *b as f64);
|
|
||||||
let gy = y_codes[(by * W + bx) as usize] as f64;
|
|
||||||
let (ccx, ccy) = (bx / 2, by / 2);
|
|
||||||
let gu = cb_codes[(ccy * cw + ccx) as usize] as f64;
|
|
||||||
let gv = cr_codes[(ccy * cw + ccx) as usize] as f64;
|
|
||||||
println!(
|
|
||||||
" {:<10} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0}",
|
|
||||||
name, ey, gy, ecb, gu, ecr, gv
|
|
||||||
);
|
|
||||||
}
|
|
||||||
println!(
|
|
||||||
" max abs error: Y={max_y_err:.2} (≤4) Cb={max_u_err:.2} (≤5) Cr={max_v_err:.2} (≤5)"
|
|
||||||
);
|
|
||||||
|
|
||||||
if max_y_err <= 4.0 && max_u_err <= 5.0 && max_v_err <= 5.0 {
|
|
||||||
println!("PASS");
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
println!("FAIL");
|
|
||||||
bail!(
|
|
||||||
"HDR P010 self-test FAILED (Y={max_y_err:.2} Cb={max_u_err:.2} Cr={max_v_err:.2})"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal f32 → IEEE-754 half (f16) bit pattern, for uploading the FP16 scRGB self-test pattern. Not
|
|
||||||
/// on any hot path; handles normals, subnormals, and the 1.0/0.0 constants we feed. (round-to-nearest)
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn f32_to_f16(v: f32) -> u16 {
|
|
||||||
let bits = v.to_bits();
|
|
||||||
let sign = ((bits >> 16) & 0x8000) as u16;
|
|
||||||
let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15;
|
|
||||||
let mant = bits & 0x007f_ffff;
|
|
||||||
if exp <= 0 {
|
|
||||||
// Subnormal / zero in half precision.
|
|
||||||
if exp < -10 {
|
|
||||||
return sign; // too small → ±0
|
|
||||||
}
|
|
||||||
let mant = mant | 0x0080_0000; // implicit 1
|
|
||||||
let shift = (14 - exp) as u32;
|
|
||||||
let half_mant = (mant >> shift) as u16;
|
|
||||||
// Round to nearest.
|
|
||||||
let round = ((mant >> (shift - 1)) & 1) as u16;
|
|
||||||
sign | (half_mant + round)
|
|
||||||
} else if exp >= 0x1f {
|
|
||||||
sign | 0x7c00 // Inf/NaN → Inf (our inputs never hit this)
|
|
||||||
} else {
|
|
||||||
let half_exp = (exp as u16) << 10;
|
|
||||||
let half_mant = (mant >> 13) as u16;
|
|
||||||
let round = ((mant >> 12) & 1) as u16;
|
|
||||||
sign | half_exp | (half_mant + round)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
use windows::Win32::Graphics::Direct3D11::{
|
use windows::Win32::Graphics::Direct3D11::{
|
||||||
@@ -1242,8 +806,15 @@ use windows::Win32::Graphics::Dxgi::Common::{
|
|||||||
/// D3D11 **Video Processor** colour/format converter — runs on the GPU's dedicated VIDEO engine, NOT
|
/// D3D11 **Video Processor** colour/format converter — runs on the GPU's dedicated VIDEO engine, NOT
|
||||||
/// the 3D engine, so the per-frame RGB→YUV conversion does not contend with a GPU-saturating game (the
|
/// the 3D engine, so the per-frame RGB→YUV conversion does not contend with a GPU-saturating game (the
|
||||||
/// HDR pixel-shader path and NVENC's internal RGB→YUV both use the 3D/compute engine, which an AAA
|
/// HDR pixel-shader path and NVENC's internal RGB→YUV both use the 3D/compute engine, which an AAA
|
||||||
/// title pins at ~100%). Output is NV12 (SDR, BT.709 studio-range) or P010 (HDR, BT.2020 PQ
|
/// title pins at ~100%). Output is **always NV12, BT.709 studio-range** — one of NVENC's native YUV
|
||||||
/// studio-range) — NVENC's native YUV inputs, so it encodes them with no further conversion.
|
/// inputs, so it encodes with no further conversion.
|
||||||
|
///
|
||||||
|
/// It does NOT produce P010/BT.2020 PQ: [`VideoConverter::new`] pins the output colour space to
|
||||||
|
/// `YCBCR_STUDIO_G22_LEFT_P709` unconditionally, and NVIDIA's video processor cannot do RGB→P010 at
|
||||||
|
/// all (it renders green) — the HDR path is [`HdrP010Converter`]'s shader instead. The `scrgb_input`
|
||||||
|
/// arm of `new` is likewise the only part of the HDR story here (an FP16 desktop tone-mapped DOWN to
|
||||||
|
/// 8-bit BT.709), and it currently has no caller: `idd_push::ensure_converter` builds this converter
|
||||||
|
/// only on the SDR/BGRA path and always passes `false`.
|
||||||
pub(crate) struct VideoConverter {
|
pub(crate) struct VideoConverter {
|
||||||
vdev: ID3D11VideoDevice,
|
vdev: ID3D11VideoDevice,
|
||||||
vctx: ID3D11VideoContext1,
|
vctx: ID3D11VideoContext1,
|
||||||
@@ -1264,8 +835,15 @@ impl VideoConverter {
|
|||||||
height: u32,
|
height: u32,
|
||||||
scrgb_input: bool,
|
scrgb_input: bool,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
|
// SAFETY: the `cast()`s and the `?`-checked video-device factory calls run on the caller's
|
||||||
|
// live `device`/`context` borrows; `&desc` is a fully-initialized stack
|
||||||
|
// `D3D11_VIDEO_PROCESSOR_CONTENT_DESC` read only for the duration of the call, and the
|
||||||
|
// colour-space/frame-format setters take the just-created processor by borrow plus plain
|
||||||
|
// enum values.
|
||||||
|
unsafe {
|
||||||
let vdev: ID3D11VideoDevice = device.cast().context("device -> ID3D11VideoDevice")?;
|
let vdev: ID3D11VideoDevice = device.cast().context("device -> ID3D11VideoDevice")?;
|
||||||
let vctx: ID3D11VideoContext1 = context.cast().context("context -> ID3D11VideoContext1")?;
|
let vctx: ID3D11VideoContext1 =
|
||||||
|
context.cast().context("context -> ID3D11VideoContext1")?;
|
||||||
let rate = DXGI_RATIONAL {
|
let rate = DXGI_RATIONAL {
|
||||||
Numerator: 240,
|
Numerator: 240,
|
||||||
Denominator: 1,
|
Denominator: 1,
|
||||||
@@ -1298,8 +876,14 @@ impl VideoConverter {
|
|||||||
let out_cs = DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709;
|
let out_cs = DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709;
|
||||||
vctx.VideoProcessorSetStreamColorSpace1(&vp, 0, in_cs);
|
vctx.VideoProcessorSetStreamColorSpace1(&vp, 0, in_cs);
|
||||||
vctx.VideoProcessorSetOutputColorSpace1(&vp, out_cs);
|
vctx.VideoProcessorSetOutputColorSpace1(&vp, out_cs);
|
||||||
// One frame in, one frame out — no interpolation/auto-processing.
|
// Progressive: one frame in, one frame out — no deinterlace, no frame-rate conversion.
|
||||||
vctx.VideoProcessorSetStreamFrameFormat(&vp, 0, D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE);
|
vctx.VideoProcessorSetStreamFrameFormat(&vp, 0, D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE);
|
||||||
|
// …and no vendor "auto processing" either. Its documented DEFAULT is ENABLED, so until
|
||||||
|
// now the comment above claimed something only this call delivers: denoise, edge
|
||||||
|
// enhancement and whatever else the driver folds in were free to run inside every SDR
|
||||||
|
// `VideoProcessorBlt` — on the desktop-capture hot path, altering the pixels we encode
|
||||||
|
// and costing video-engine time nobody asked for.
|
||||||
|
vctx.VideoProcessorSetStreamAutoProcessingMode(&vp, 0, false);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
vdev,
|
vdev,
|
||||||
@@ -1308,14 +892,22 @@ impl VideoConverter {
|
|||||||
vp,
|
vp,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert `input` (BGRA or scRGB FP16) → `output` (NV12 or P010) on the video engine. Views are
|
/// Convert `input` (BGRA, or scRGB FP16 for a converter built with `scrgb_input`) → `output`
|
||||||
|
/// (NV12, BT.709 studio-range — see the type doc: never P010) on the video engine. Views are
|
||||||
/// created per call (cheap relative to the Blt) so the input texture can vary frame to frame.
|
/// created per call (cheap relative to the Blt) so the input texture can vary frame to frame.
|
||||||
pub(crate) unsafe fn convert(
|
pub(crate) unsafe fn convert(
|
||||||
&self,
|
&self,
|
||||||
input: &ID3D11Texture2D,
|
input: &ID3D11Texture2D,
|
||||||
output: &ID3D11Texture2D,
|
output: &ID3D11Texture2D,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
// SAFETY: both view creations are `?`-checked calls on `self.vdev` with fully-initialized
|
||||||
|
// stack descriptors and live out-params. `stream.pInputSurface` is a `ManuallyDrop` of the
|
||||||
|
// input view just created: `VideoProcessorBlt` only BORROWS it (a COM in-param never transfers
|
||||||
|
// ownership), and the explicit `into_inner` drop below releases that reference exactly once on
|
||||||
|
// both the success and the failure path. `slice::from_ref(&stream)` borrows the live local.
|
||||||
|
unsafe {
|
||||||
let in_desc = D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC {
|
let in_desc = D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC {
|
||||||
FourCC: 0,
|
FourCC: 0,
|
||||||
ViewDimension: D3D11_VPIV_DIMENSION_TEXTURE2D,
|
ViewDimension: D3D11_VPIV_DIMENSION_TEXTURE2D,
|
||||||
@@ -1359,16 +951,4 @@ impl VideoConverter {
|
|||||||
blt.context("VideoProcessorBlt")
|
blt.context("VideoProcessorBlt")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod hdr_selftests {
|
|
||||||
/// LIVE (needs the GPU): [`super::hdr_p010_selftest_at`] at the field capture size — 1080 is
|
|
||||||
/// NOT 16-aligned, and the planar-RTV write path is driver-specific per vendor. Pinned to the
|
|
||||||
/// Intel adapter (`0x8086`), so it runs on the Intel validation boxes and errors out cleanly
|
|
||||||
/// ("no adapter") elsewhere. `cargo test -p pf-capture -- --ignored hdr_p010 --nocapture`.
|
|
||||||
#[test]
|
|
||||||
#[ignore]
|
|
||||||
fn hdr_p010_selftest_intel_1080_live() {
|
|
||||||
super::hdr_p010_selftest_at(1920, 1080, Some(0x8086)).expect("hdr p010 selftest @1080");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,648 @@
|
|||||||
|
//! The P010 colour SELF-TEST and its helpers — the `hdr-p010-selftest` subcommand's whole
|
||||||
|
//! implementation, plus the f64 reference math it compares against and the f16 encoder it uploads
|
||||||
|
//! with.
|
||||||
|
//!
|
||||||
|
//! Split out of `windows/dxgi.rs` in sweep Phase 5.5: it was ~560 of that file's 1,374 lines and
|
||||||
|
//! none of it runs in a session. What remains in the parent is the production path (the win32u hook,
|
||||||
|
//! the shader sources, the three converters); this is the validation path.
|
||||||
|
//!
|
||||||
|
//! `hdr_p010_selftest_at` and `hdr_p010_convert_bars_on_luid` are re-exported by the parent, so
|
||||||
|
//! every existing `crate::capture::dxgi::…` / `pf_capture::dxgi::…` path keeps resolving.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// f64 reference for the P010 colour math — the EXACT analogue of the HLSL in [`HDR_P010_COMMON`].
|
||||||
|
/// Input is one scRGB pixel (linear, Rec.709 primaries, 1.0 = 80 nits, may be >1 for HDR). Output is
|
||||||
|
/// the 10-bit studio-range (Y, Cb, Cr) codes the shader should produce for a flat (constant) block.
|
||||||
|
/// Used by [`hdr_p010_selftest`].
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
|
||||||
|
fn pq_oetf(l: f64) -> f64 {
|
||||||
|
let l = l.clamp(0.0, 1.0);
|
||||||
|
let m1 = 0.1593017578125;
|
||||||
|
let m2 = 78.84375;
|
||||||
|
let c1 = 0.8359375;
|
||||||
|
let c2 = 18.8515625;
|
||||||
|
let c3 = 18.6875;
|
||||||
|
let lp = l.powf(m1);
|
||||||
|
((c1 + c2 * lp) / (1.0 + c3 * lp)).powf(m2)
|
||||||
|
}
|
||||||
|
// scRGB -> nits -> BT.2020 linear (row-major matrix, mul(M, v)).
|
||||||
|
let (r, g, b) = (r.max(0.0) * 80.0, g.max(0.0) * 80.0, b.max(0.0) * 80.0);
|
||||||
|
let m = [
|
||||||
|
[0.627403914, 0.329283038, 0.043313048],
|
||||||
|
[0.069097292, 0.919540405, 0.011362303],
|
||||||
|
[0.016391439, 0.088013308, 0.895595253],
|
||||||
|
];
|
||||||
|
let lr = m[0][0] * r + m[0][1] * g + m[0][2] * b;
|
||||||
|
let lg = m[1][0] * r + m[1][1] * g + m[1][2] * b;
|
||||||
|
let lb = m[2][0] * r + m[2][1] * g + m[2][2] * b;
|
||||||
|
// PQ encode (normalize to 10k nits).
|
||||||
|
let pr = pq_oetf(lr / 10000.0);
|
||||||
|
let pg = pq_oetf(lg / 10000.0);
|
||||||
|
let pb = pq_oetf(lb / 10000.0);
|
||||||
|
// BT.2020 non-constant-luminance, limited 10-bit.
|
||||||
|
let (kr, kg, kb) = (0.2627, 0.6780, 0.0593);
|
||||||
|
let y = kr * pr + kg * pg + kb * pb;
|
||||||
|
let cb = (pb - y) / 1.8814;
|
||||||
|
let cr = (pr - y) / 1.4746;
|
||||||
|
let yc = (64.0 + 876.0 * y).clamp(64.0, 940.0);
|
||||||
|
let cbc = (512.0 + 896.0 * cb).clamp(64.0, 960.0);
|
||||||
|
let crc = (512.0 + 896.0 * cr).clamp(64.0, 960.0);
|
||||||
|
(yc, cbc, crc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test support (used by pf-encode's live e2e): the 8 sRGB colour bars (white/yellow/cyan/green/
|
||||||
|
/// magenta/red/blue/black, sRGB 1.0 = scRGB 1.0 = 80 nits) as a w×h FP16 scRGB texture on the
|
||||||
|
/// adapter with `luid`, converted through the REAL [`HdrP010Converter`] into a P010 texture with
|
||||||
|
/// **`BIND_RENDER_TARGET` only, `MiscFlags` 0 — the exact bind profile of the IDD out-ring** (the
|
||||||
|
/// CPU-upload encoder tests can't use that profile, so only this path exercises "RTV-written P010
|
||||||
|
/// → encoder ingest copy"). Returns `(device, p010)`; expected decoded codes per bar are the
|
||||||
|
/// bars_pq2020 fixture's: (490,512,512) (478,423,518) (464,525,473) (450,432,476) (350,584,585)
|
||||||
|
/// (325,448,598) (226,650,535) (64,512,512).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn hdr_p010_convert_bars_on_luid(
|
||||||
|
luid: [u8; 8],
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
) -> Result<(ID3D11Device, ID3D11Texture2D)> {
|
||||||
|
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
||||||
|
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
||||||
|
|
||||||
|
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||||||
|
bail!("bars pattern needs even non-zero dimensions, got {w}x{h}");
|
||||||
|
}
|
||||||
|
// sRGB primaries at full/zero channels: sRGB EOTF(1.0)=1.0, (0)=0 → the scRGB pattern is
|
||||||
|
// pure 0/1 floats and the PQ/BT.2020 reference codes above are exact.
|
||||||
|
const BARS: [(f32, f32, f32); 8] = [
|
||||||
|
(1.0, 1.0, 1.0),
|
||||||
|
(1.0, 1.0, 0.0),
|
||||||
|
(0.0, 1.0, 1.0),
|
||||||
|
(0.0, 1.0, 0.0),
|
||||||
|
(1.0, 0.0, 1.0),
|
||||||
|
(1.0, 0.0, 0.0),
|
||||||
|
(0.0, 0.0, 1.0),
|
||||||
|
(0.0, 0.0, 0.0),
|
||||||
|
];
|
||||||
|
let bar_w = (w / 8).max(1) as usize;
|
||||||
|
let mut fp16 = vec![0u16; (w * h * 4) as usize];
|
||||||
|
for y in 0..h as usize {
|
||||||
|
for x in 0..w as usize {
|
||||||
|
let (r, g, b) = BARS[(x / bar_w).min(7)];
|
||||||
|
let i = (y * w as usize + x) * 4;
|
||||||
|
fp16[i] = f32_to_f16(r);
|
||||||
|
fp16[i + 1] = f32_to_f16(g);
|
||||||
|
fp16[i + 2] = f32_to_f16(b);
|
||||||
|
fp16[i + 3] = f32_to_f16(1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SAFETY: same single-device/single-thread contract as `hdr_p010_selftest_at`; the FP16
|
||||||
|
// initial-data Vec outlives the synchronous CreateTexture2D; the returned COM handles own
|
||||||
|
// their references.
|
||||||
|
unsafe {
|
||||||
|
let luid = windows::Win32::Foundation::LUID {
|
||||||
|
LowPart: u32::from_le_bytes(luid[..4].try_into().unwrap()),
|
||||||
|
HighPart: i32::from_le_bytes(luid[4..].try_into().unwrap()),
|
||||||
|
};
|
||||||
|
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("dxgi factory")?;
|
||||||
|
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).context("adapter by luid")?;
|
||||||
|
let mut device: Option<ID3D11Device> = None;
|
||||||
|
let mut context: Option<ID3D11DeviceContext> = None;
|
||||||
|
D3D11CreateDevice(
|
||||||
|
&adapter,
|
||||||
|
D3D_DRIVER_TYPE_UNKNOWN,
|
||||||
|
HMODULE::default(),
|
||||||
|
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||||
|
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||||
|
D3D11_SDK_VERSION,
|
||||||
|
Some(&mut device),
|
||||||
|
None,
|
||||||
|
Some(&mut context),
|
||||||
|
)
|
||||||
|
.context("D3D11CreateDevice(luid) for bars convert")?;
|
||||||
|
let device = device.context("null device")?;
|
||||||
|
let context = context.context("null context")?;
|
||||||
|
|
||||||
|
let src_desc = D3D11_TEXTURE2D_DESC {
|
||||||
|
Width: w,
|
||||||
|
Height: h,
|
||||||
|
MipLevels: 1,
|
||||||
|
ArraySize: 1,
|
||||||
|
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||||
|
SampleDesc: DXGI_SAMPLE_DESC {
|
||||||
|
Count: 1,
|
||||||
|
Quality: 0,
|
||||||
|
},
|
||||||
|
Usage: D3D11_USAGE_DEFAULT,
|
||||||
|
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let init = D3D11_SUBRESOURCE_DATA {
|
||||||
|
pSysMem: fp16.as_ptr() as *const c_void,
|
||||||
|
SysMemPitch: w * 8,
|
||||||
|
SysMemSlicePitch: 0,
|
||||||
|
};
|
||||||
|
let mut src_tex: Option<ID3D11Texture2D> = None;
|
||||||
|
device
|
||||||
|
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
||||||
|
.context("CreateTexture2D(fp16 bars)")?;
|
||||||
|
let src_tex = src_tex.context("null src tex")?;
|
||||||
|
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
||||||
|
device
|
||||||
|
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
||||||
|
.context("CreateShaderResourceView(fp16 bars)")?;
|
||||||
|
let src_srv = src_srv.context("null src srv")?;
|
||||||
|
|
||||||
|
// The IDD out-ring's exact profile: P010, RENDER_TARGET only, MiscFlags 0.
|
||||||
|
let p010_desc = D3D11_TEXTURE2D_DESC {
|
||||||
|
Width: w,
|
||||||
|
Height: h,
|
||||||
|
MipLevels: 1,
|
||||||
|
ArraySize: 1,
|
||||||
|
Format: DXGI_FORMAT_P010,
|
||||||
|
SampleDesc: DXGI_SAMPLE_DESC {
|
||||||
|
Count: 1,
|
||||||
|
Quality: 0,
|
||||||
|
},
|
||||||
|
Usage: D3D11_USAGE_DEFAULT,
|
||||||
|
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut p010: Option<ID3D11Texture2D> = None;
|
||||||
|
device
|
||||||
|
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
||||||
|
.context("CreateTexture2D(P010 bars dst)")?;
|
||||||
|
let p010 = p010.context("null p010 tex")?;
|
||||||
|
|
||||||
|
let conv = HdrP010Converter::new(&device, w, h)?;
|
||||||
|
let y_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16_UNORM)?;
|
||||||
|
let uv_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16G16_UNORM)?;
|
||||||
|
conv.convert(&context, &src_srv, &y_rtv, &uv_rtv, w, h)?;
|
||||||
|
Ok((device, p010))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Colour self-test for [`HdrP010Converter`] (the `hdr-p010-selftest` subcommand): create a hardware
|
||||||
|
/// D3D11 device, upload a known scRGB FP16 pattern, run the P010 shader passes, read the Y (plane 0)
|
||||||
|
/// and UV (plane 1) planes back from a staging copy, and compare against the [`p010_reference`] f64
|
||||||
|
/// math. The ONLY validation we have without green-screening a live HDR stream. PASS if max abs error
|
||||||
|
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
|
||||||
|
///
|
||||||
|
/// `w`/`h` must be even and non-zero. Run it at the FIELD capture size, not a toy one: sessions run
|
||||||
|
/// at resolutions whose height is not 16-aligned (1080 → the encoder's align16 pool seam) and a
|
||||||
|
/// driver may treat the planar RTVs differently at real sizes. `vendor` pins the adapter by PCI
|
||||||
|
/// vendor id (`0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD) — it matters on dual-GPU boxes where
|
||||||
|
/// the default adapter is not the one the session encodes on. The chosen adapter is always printed,
|
||||||
|
/// because a PASS only means anything for the GPU it actually ran on.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
|
||||||
|
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN};
|
||||||
|
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
|
||||||
|
|
||||||
|
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||||||
|
bail!("hdr-p010-selftest needs even non-zero dimensions, got {w}x{h}");
|
||||||
|
}
|
||||||
|
// A grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform → exact chroma
|
||||||
|
// comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus a couple
|
||||||
|
// of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
let (W, H) = (w, h);
|
||||||
|
const BLK: u32 = 16;
|
||||||
|
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values.
|
||||||
|
let named: [(&str, f32, f32, f32); 8] = [
|
||||||
|
("red1.0", 1.0, 0.0, 0.0),
|
||||||
|
("green0.5", 0.0, 0.5, 0.0),
|
||||||
|
("blue4.0", 0.0, 0.0, 4.0),
|
||||||
|
("white1.0", 1.0, 1.0, 1.0),
|
||||||
|
("black", 0.0, 0.0, 0.0),
|
||||||
|
("gray0.5", 0.5, 0.5, 0.5),
|
||||||
|
("white4.0", 4.0, 4.0, 4.0),
|
||||||
|
("amber2.0", 2.0, 1.0, 0.0),
|
||||||
|
];
|
||||||
|
|
||||||
|
let grid_cols = W / BLK; // 4
|
||||||
|
let pixel_rgb = |x: u32, y: u32| -> (f32, f32, f32, bool) {
|
||||||
|
let idx = ((y / BLK) * grid_cols + (x / BLK)) as usize;
|
||||||
|
if idx < named.len() {
|
||||||
|
let (_, r, g, b) = named[idx];
|
||||||
|
(r, g, b, true)
|
||||||
|
} else {
|
||||||
|
// Gradient (distinct per pixel; Y-only compare), within HDR scRGB range.
|
||||||
|
let r = (x as f32 / W as f32) * 3.0;
|
||||||
|
let g = (y as f32 / H as f32) * 3.0;
|
||||||
|
let b = ((x + y) as f32 / (W + H) as f32) * 3.0;
|
||||||
|
(r, g, b, false)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the scRGB FP16 (R16G16B16A16_FLOAT) source as f16 bits.
|
||||||
|
let mut fp16 = vec![0u16; (W * H * 4) as usize];
|
||||||
|
let mut flat = vec![false; (W * H) as usize];
|
||||||
|
for y in 0..H {
|
||||||
|
for x in 0..W {
|
||||||
|
let (r, g, b, is_flat) = pixel_rgb(x, y);
|
||||||
|
let i = ((y * W + x) * 4) as usize;
|
||||||
|
fp16[i] = f32_to_f16(r);
|
||||||
|
fp16[i + 1] = f32_to_f16(g);
|
||||||
|
fp16[i + 2] = f32_to_f16(b);
|
||||||
|
fp16[i + 3] = f32_to_f16(1.0);
|
||||||
|
flat[(y * W + x) as usize] = is_flat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: this self-test creates its own D3D11 device + immediate context (`D3D11CreateDevice`,
|
||||||
|
// both checked non-null) and uses ONLY that device for the rest of the block: every
|
||||||
|
// `CreateTexture2D`/`CreateShaderResourceView`/`HdrP010Converter::{new,convert}`/`CopyResource`/
|
||||||
|
// `Map` is invoked on that device or its context, so all resources share one device and run on this
|
||||||
|
// single thread. The source texture's `D3D11_SUBRESOURCE_DATA` points at `fp16`, a live
|
||||||
|
// `Vec<u16>` of `W*H*4` samples with `SysMemPitch = W*8`, matching the W×H R16G16B16A16 texture;
|
||||||
|
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
|
||||||
|
// proven individually at the `read_u16` closure below.
|
||||||
|
unsafe {
|
||||||
|
// Device on the requested vendor's adapter (dual-GPU boxes encode on a specific one), else
|
||||||
|
// the default hardware GPU. Always says which adapter ran — a PASS is only meaningful for
|
||||||
|
// the GPU it actually tested.
|
||||||
|
let adapter: Option<IDXGIAdapter> = match vendor {
|
||||||
|
None => None,
|
||||||
|
Some(want) => {
|
||||||
|
let factory: IDXGIFactory1 = CreateDXGIFactory1().context("dxgi factory")?;
|
||||||
|
let mut found = None;
|
||||||
|
for i in 0.. {
|
||||||
|
let Ok(a) = factory.EnumAdapters(i) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let desc = a.GetDesc().context("adapter desc")?;
|
||||||
|
if desc.VendorId == want {
|
||||||
|
found = Some(a);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(found.with_context(|| format!("no adapter with vendor id {want:#x}"))?)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut device: Option<ID3D11Device> = None;
|
||||||
|
let mut context: Option<ID3D11DeviceContext> = None;
|
||||||
|
D3D11CreateDevice(
|
||||||
|
adapter.as_ref(),
|
||||||
|
if adapter.is_some() {
|
||||||
|
D3D_DRIVER_TYPE_UNKNOWN
|
||||||
|
} else {
|
||||||
|
D3D_DRIVER_TYPE_HARDWARE
|
||||||
|
},
|
||||||
|
HMODULE::default(),
|
||||||
|
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||||
|
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||||
|
D3D11_SDK_VERSION,
|
||||||
|
Some(&mut device),
|
||||||
|
None,
|
||||||
|
Some(&mut context),
|
||||||
|
)
|
||||||
|
.context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?;
|
||||||
|
let device = device.context("null device")?;
|
||||||
|
let context = context.context("null context")?;
|
||||||
|
{
|
||||||
|
let dxgi: windows::Win32::Graphics::Dxgi::IDXGIDevice =
|
||||||
|
device.cast().context("device -> IDXGIDevice")?;
|
||||||
|
let desc = dxgi.GetAdapter().context("GetAdapter")?.GetDesc()?;
|
||||||
|
let name = String::from_utf16_lossy(
|
||||||
|
&desc.Description[..desc
|
||||||
|
.Description
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == 0)
|
||||||
|
.unwrap_or(desc.Description.len())],
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"adapter: {name} (vendor {:#06x}, luid {:08x}:{:08x})",
|
||||||
|
desc.VendorId, desc.AdapterLuid.HighPart, desc.AdapterLuid.LowPart
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source FP16 texture (initialized) + SRV.
|
||||||
|
let src_desc = D3D11_TEXTURE2D_DESC {
|
||||||
|
Width: W,
|
||||||
|
Height: H,
|
||||||
|
MipLevels: 1,
|
||||||
|
ArraySize: 1,
|
||||||
|
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||||
|
SampleDesc: DXGI_SAMPLE_DESC {
|
||||||
|
Count: 1,
|
||||||
|
Quality: 0,
|
||||||
|
},
|
||||||
|
Usage: D3D11_USAGE_DEFAULT,
|
||||||
|
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let init = D3D11_SUBRESOURCE_DATA {
|
||||||
|
pSysMem: fp16.as_ptr() as *const c_void,
|
||||||
|
SysMemPitch: W * 8, // 4 channels * 2 bytes
|
||||||
|
SysMemSlicePitch: 0,
|
||||||
|
};
|
||||||
|
let mut src_tex: Option<ID3D11Texture2D> = None;
|
||||||
|
device
|
||||||
|
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
||||||
|
.context("CreateTexture2D(fp16 src)")?;
|
||||||
|
let src_tex = src_tex.context("null src tex")?;
|
||||||
|
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
||||||
|
device
|
||||||
|
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
||||||
|
.context("CreateShaderResourceView(fp16 src)")?;
|
||||||
|
let src_srv = src_srv.context("null src srv")?;
|
||||||
|
|
||||||
|
// P010 destination texture (render-target bindable).
|
||||||
|
let p010_desc = D3D11_TEXTURE2D_DESC {
|
||||||
|
Width: W,
|
||||||
|
Height: H,
|
||||||
|
MipLevels: 1,
|
||||||
|
ArraySize: 1,
|
||||||
|
Format: DXGI_FORMAT_P010,
|
||||||
|
SampleDesc: DXGI_SAMPLE_DESC {
|
||||||
|
Count: 1,
|
||||||
|
Quality: 0,
|
||||||
|
},
|
||||||
|
Usage: D3D11_USAGE_DEFAULT,
|
||||||
|
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut p010: Option<ID3D11Texture2D> = None;
|
||||||
|
device
|
||||||
|
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
||||||
|
.context("CreateTexture2D(P010 dst)")?;
|
||||||
|
let p010 = p010.context("null p010 tex")?;
|
||||||
|
|
||||||
|
let conv = HdrP010Converter::new(&device, W, H)?;
|
||||||
|
let y_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16_UNORM)?;
|
||||||
|
let uv_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16G16_UNORM)?;
|
||||||
|
conv.convert(&context, &src_srv, &y_rtv, &uv_rtv, W, H)?;
|
||||||
|
|
||||||
|
// Staging copy of the whole P010 texture (both planes), MAP_READ.
|
||||||
|
let stage_desc = D3D11_TEXTURE2D_DESC {
|
||||||
|
Width: W,
|
||||||
|
Height: H,
|
||||||
|
MipLevels: 1,
|
||||||
|
ArraySize: 1,
|
||||||
|
Format: DXGI_FORMAT_P010,
|
||||||
|
SampleDesc: DXGI_SAMPLE_DESC {
|
||||||
|
Count: 1,
|
||||||
|
Quality: 0,
|
||||||
|
},
|
||||||
|
Usage: D3D11_USAGE_STAGING,
|
||||||
|
BindFlags: 0,
|
||||||
|
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut staging: Option<ID3D11Texture2D> = None;
|
||||||
|
device
|
||||||
|
.CreateTexture2D(&stage_desc, None, Some(&mut staging))
|
||||||
|
.context("CreateTexture2D(P010 staging)")?;
|
||||||
|
let staging = staging.context("null staging")?;
|
||||||
|
context.CopyResource(&staging, &p010);
|
||||||
|
|
||||||
|
let mut map = D3D11_MAPPED_SUBRESOURCE::default();
|
||||||
|
context
|
||||||
|
.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut map))
|
||||||
|
.context("Map(P010 staging)")?;
|
||||||
|
let row_pitch = map.RowPitch as usize; // bytes per luma row (in 16-bit samples: /2)
|
||||||
|
let base = map.pData as *const u8;
|
||||||
|
// DIAGNOSTIC (the uncertain layout spot — verify on the box if chroma is wrong): the mapped
|
||||||
|
// P010 plane offsets. Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2
|
||||||
|
// *interleaved* (Cb,Cr) u16 pairs. P010 packs plane 1 after plane 0 at the SAME row pitch; the
|
||||||
|
// chroma plane begins at byte offset RowPitch * (luma height). For a STAGING texture that
|
||||||
|
// height is the created H (no inter-plane alignment). DepthPitch (total mapped size) lets us
|
||||||
|
// sanity-check: it should be ~ RowPitch * H * 3/2. If chroma reads garbage on the box, print
|
||||||
|
// these and adjust `chroma_base` (e.g. an aligned luma height).
|
||||||
|
tracing::info!(
|
||||||
|
row_pitch,
|
||||||
|
depth_pitch = map.DepthPitch,
|
||||||
|
expected_chroma_base = row_pitch * H as usize,
|
||||||
|
expected_total = row_pitch * H as usize * 3 / 2,
|
||||||
|
"hdr-p010-selftest: mapped P010 layout (verify chroma plane offset here if chroma is wrong)"
|
||||||
|
);
|
||||||
|
// Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2 *interleaved* (Cb,Cr)
|
||||||
|
// u16 pairs, i.e. W u16 per chroma row. P010 packs plane 1 immediately after plane 0 at the
|
||||||
|
// SAME row pitch; per spec the chroma plane begins at an allocation offset of
|
||||||
|
// RowPitch * Height (luma rows). We read it from there. (DepthPitch is the full surface size;
|
||||||
|
// not all drivers report the chroma offset, so RowPitch*Height is the portable choice.)
|
||||||
|
let read_u16 = |byte_off: usize| -> u16 {
|
||||||
|
// SAFETY: `base` is the mapped staging pointer; all offsets are within the P010 surface
|
||||||
|
// (luma H*RowPitch + chroma (H/2)*RowPitch ≤ DepthPitch). Already in the fn's unsafe scope.
|
||||||
|
let p = base.add(byte_off) as *const u16;
|
||||||
|
p.read_unaligned()
|
||||||
|
};
|
||||||
|
// Luma codes: stored u16 in the high 10 bits -> code10 = stored >> 6.
|
||||||
|
let mut y_codes = vec![0u16; (W * H) as usize];
|
||||||
|
for y in 0..H {
|
||||||
|
for x in 0..W {
|
||||||
|
let off = (y as usize) * row_pitch + (x as usize) * 2;
|
||||||
|
y_codes[(y * W + x) as usize] = read_u16(off) >> 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cw = W / 2;
|
||||||
|
let ch = H / 2;
|
||||||
|
let chroma_base = row_pitch * H as usize; // plane 1 offset
|
||||||
|
let mut cb_codes = vec![0u16; (cw * ch) as usize];
|
||||||
|
let mut cr_codes = vec![0u16; (cw * ch) as usize];
|
||||||
|
for cy in 0..ch {
|
||||||
|
for cx in 0..cw {
|
||||||
|
// Interleaved (Cb, Cr) per chroma sample → 2 u16 = 4 bytes per sample.
|
||||||
|
let off = chroma_base + (cy as usize) * row_pitch + (cx as usize) * 4;
|
||||||
|
cb_codes[(cy * cw + cx) as usize] = read_u16(off) >> 6;
|
||||||
|
cr_codes[(cy * cw + cx) as usize] = read_u16(off + 2) >> 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.Unmap(&staging, 0);
|
||||||
|
|
||||||
|
// Compare Y over every pixel.
|
||||||
|
let mut max_y_err = 0.0f64;
|
||||||
|
for y in 0..H {
|
||||||
|
for x in 0..W {
|
||||||
|
let (r, g, b, _) = pixel_rgb(x, y);
|
||||||
|
let (ry, _, _) = p010_reference(r as f64, g as f64, b as f64);
|
||||||
|
let got = y_codes[(y * W + x) as usize] as f64;
|
||||||
|
max_y_err = max_y_err.max((got - ry).abs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Compare Cb/Cr over flat blocks only (uniform 2x2 footprint → exact reference).
|
||||||
|
let mut max_u_err = 0.0f64;
|
||||||
|
let mut max_v_err = 0.0f64;
|
||||||
|
for cy in 0..ch {
|
||||||
|
for cx in 0..cw {
|
||||||
|
let (sx, sy) = (cx * 2, cy * 2);
|
||||||
|
let all_flat =
|
||||||
|
(0..2).all(|dy| (0..2).all(|dx| flat[((sy + dy) * W + (sx + dx)) as usize]));
|
||||||
|
if !all_flat {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (r, g, b, _) = pixel_rgb(sx, sy);
|
||||||
|
let (_, rcb, rcr) = p010_reference(r as f64, g as f64, b as f64);
|
||||||
|
let gu = cb_codes[(cy * cw + cx) as usize] as f64;
|
||||||
|
let gv = cr_codes[(cy * cw + cx) as usize] as f64;
|
||||||
|
max_u_err = max_u_err.max((gu - rcb).abs());
|
||||||
|
max_v_err = max_v_err.max((gv - rcr).abs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-colour table.
|
||||||
|
println!("HDR P010 self-test ({W}x{H}, BT.2020 PQ, 10-bit limited range)");
|
||||||
|
println!(
|
||||||
|
" {:<10} {:>14} {:>14} {:>14}",
|
||||||
|
"color", "Y exp/got", "Cb exp/got", "Cr exp/got"
|
||||||
|
);
|
||||||
|
for (idx, (name, r, g, b)) in named.iter().enumerate() {
|
||||||
|
let bx = (idx as u32 % grid_cols) * BLK + BLK / 2;
|
||||||
|
let by = (idx as u32 / grid_cols) * BLK + BLK / 2;
|
||||||
|
let (ey, ecb, ecr) = p010_reference(*r as f64, *g as f64, *b as f64);
|
||||||
|
let gy = y_codes[(by * W + bx) as usize] as f64;
|
||||||
|
let (ccx, ccy) = (bx / 2, by / 2);
|
||||||
|
let gu = cb_codes[(ccy * cw + ccx) as usize] as f64;
|
||||||
|
let gv = cr_codes[(ccy * cw + ccx) as usize] as f64;
|
||||||
|
println!(
|
||||||
|
" {:<10} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0}",
|
||||||
|
name, ey, gy, ecb, gu, ecr, gv
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
" max abs error: Y={max_y_err:.2} (≤4) Cb={max_u_err:.2} (≤5) Cr={max_v_err:.2} (≤5)"
|
||||||
|
);
|
||||||
|
|
||||||
|
if max_y_err <= 4.0 && max_u_err <= 5.0 && max_v_err <= 5.0 {
|
||||||
|
println!("PASS");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
println!("FAIL");
|
||||||
|
bail!(
|
||||||
|
"HDR P010 self-test FAILED (Y={max_y_err:.2} Cb={max_u_err:.2} Cr={max_v_err:.2})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal f32 → IEEE-754 half (f16) bit pattern, for uploading the FP16 scRGB self-test pattern. Not
|
||||||
|
/// on any hot path; handles normals, subnormals, and the 1.0/0.0 constants we feed. (round-to-nearest)
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn f32_to_f16(v: f32) -> u16 {
|
||||||
|
let bits = v.to_bits();
|
||||||
|
let sign = ((bits >> 16) & 0x8000) as u16;
|
||||||
|
let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15;
|
||||||
|
let mant = bits & 0x007f_ffff;
|
||||||
|
if exp <= 0 {
|
||||||
|
// Subnormal / zero in half precision.
|
||||||
|
if exp < -10 {
|
||||||
|
return sign; // too small → ±0
|
||||||
|
}
|
||||||
|
let mant = mant | 0x0080_0000; // implicit 1
|
||||||
|
let shift = (14 - exp) as u32;
|
||||||
|
let half_mant = (mant >> shift) as u16;
|
||||||
|
// Round to nearest.
|
||||||
|
let round = ((mant >> (shift - 1)) & 1) as u16;
|
||||||
|
sign | (half_mant + round)
|
||||||
|
} else if exp >= 0x1f {
|
||||||
|
sign | 0x7c00 // Inf/NaN → Inf (our inputs never hit this)
|
||||||
|
} else {
|
||||||
|
let half_exp = (exp as u16) << 10;
|
||||||
|
let half_mant = (mant >> 13) as u16;
|
||||||
|
let round = ((mant >> 12) & 1) as u16;
|
||||||
|
// ADD, never OR. `half_mant + round` can carry out of the 10-bit mantissa (all ones, then
|
||||||
|
// rounded up), and that carry must INCREMENT the exponent — which is exactly what an
|
||||||
|
// IEEE-754 round-to-nearest overflow means. `sign | half_exp | (…)` instead ORed it into bit
|
||||||
|
// 10, so for every ODD biased exponent (bit 10 already set) the carry vanished and the
|
||||||
|
// result came back a factor of ~2 low: `f32_to_f16(1.9998779) → 0x3C00 = 1.0`,
|
||||||
|
// `0.49996948 → 0.25`. Only values one ULP below a power of two are affected — which is
|
||||||
|
// precisely what a gradient test pattern is full of, so this made `hdr-p010-selftest` FAIL a
|
||||||
|
// correct shader. The subnormal branch above was already additive.
|
||||||
|
sign | (half_exp + half_mant + round)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod f16_tests {
|
||||||
|
use super::f32_to_f16;
|
||||||
|
|
||||||
|
/// Round-trip through the reference conversion the rest of the test uses as an oracle.
|
||||||
|
fn f16_to_f32(h: u16) -> f32 {
|
||||||
|
let sign = if h & 0x8000 != 0 { -1.0f32 } else { 1.0 };
|
||||||
|
let exp = ((h >> 10) & 0x1f) as i32;
|
||||||
|
let mant = (h & 0x3ff) as f32;
|
||||||
|
match exp {
|
||||||
|
0 => sign * mant * 2f32.powi(-24), // subnormal
|
||||||
|
31 => sign * f32::INFINITY, // our encoder never emits NaN
|
||||||
|
e => sign * (1.0 + mant / 1024.0) * 2f32.powi(e - 15),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// W7: the rounding carry out of the mantissa must INCREMENT the exponent. The composition used
|
||||||
|
/// `sign | half_exp | (half_mant + round)`, which swallowed that carry for every odd biased
|
||||||
|
/// exponent — a silent factor-of-2 error on exactly the values a gradient test pattern is full
|
||||||
|
/// of, which made `hdr-p010-selftest` fail a correct shader.
|
||||||
|
#[test]
|
||||||
|
fn a_rounding_carry_increments_the_exponent() {
|
||||||
|
// The plan's canonical case: biased exponent 127 (2^0) with a mantissa that rounds up out
|
||||||
|
// of 10 bits ⇒ 2.0 = 0x4000, NOT 1.0 = 0x3C00.
|
||||||
|
assert_eq!(f32_to_f16(f32::from_bits((127 << 23) | 0x7FF000)), 0x4000);
|
||||||
|
// The two measured regressions, by value.
|
||||||
|
assert_eq!(
|
||||||
|
f32_to_f16(1.9998779),
|
||||||
|
0x4000,
|
||||||
|
"1.9998779 must not read as 1.0"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
f32_to_f16(0.49996948),
|
||||||
|
0x3800,
|
||||||
|
"0.49996948 must not read as 0.25"
|
||||||
|
);
|
||||||
|
// …and an EVEN biased exponent, where the bug happened to be invisible (bit 10 clear), so
|
||||||
|
// the fix must not change it.
|
||||||
|
assert_eq!(f32_to_f16(f32::from_bits((128 << 23) | 0x7FF000)), 0x4400); // → 4.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_constants_the_selftest_uploads_are_exact() {
|
||||||
|
assert_eq!(f32_to_f16(0.0), 0x0000);
|
||||||
|
assert_eq!(f32_to_f16(-0.0), 0x8000);
|
||||||
|
assert_eq!(f32_to_f16(1.0), 0x3C00);
|
||||||
|
assert_eq!(f32_to_f16(-1.0), 0xBC00);
|
||||||
|
assert_eq!(f32_to_f16(0.5), 0x3800);
|
||||||
|
assert_eq!(f32_to_f16(2.0), 0x4000);
|
||||||
|
assert_eq!(f32_to_f16(4.0), 0x4400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every HDR scRGB value the self-test patterns use must survive the round trip to within one
|
||||||
|
/// f16 ULP — the property the P010 comparison actually depends on.
|
||||||
|
#[test]
|
||||||
|
fn hdr_scrgb_values_round_trip_within_one_ulp() {
|
||||||
|
for &v in &[
|
||||||
|
0.0f32, 0.25, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 0.1, 0.3, 0.7, 1.9998779, 0.49996948, 2.5,
|
||||||
|
3.999, 0.001,
|
||||||
|
] {
|
||||||
|
let back = f16_to_f32(f32_to_f16(v));
|
||||||
|
// One ULP at this magnitude: f16 carries 11 significand bits.
|
||||||
|
let ulp = (v.abs() / 1024.0).max(2f32.powi(-24));
|
||||||
|
assert!(
|
||||||
|
(back - v).abs() <= ulp,
|
||||||
|
"{v} round-tripped to {back} (ulp {ulp})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn out_of_range_magnitudes_saturate_rather_than_wrap() {
|
||||||
|
// Above f16's max finite (65504) our encoder reports Inf; below its subnormal floor, ±0.
|
||||||
|
assert_eq!(f32_to_f16(1.0e30), 0x7C00);
|
||||||
|
assert_eq!(f32_to_f16(-1.0e30), 0xFC00);
|
||||||
|
assert_eq!(f32_to_f16(1.0e-30), 0x0000);
|
||||||
|
assert_eq!(f32_to_f16(-1.0e-30), 0x8000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod hdr_selftests {
|
||||||
|
/// LIVE (needs the GPU): [`super::hdr_p010_selftest_at`] at the field capture size — 1080 is
|
||||||
|
/// NOT 16-aligned, and the planar-RTV write path is driver-specific per vendor. Pinned to the
|
||||||
|
/// Intel adapter (`0x8086`), so it runs on the Intel validation boxes and errors out cleanly
|
||||||
|
/// ("no adapter") elsewhere. `cargo test -p pf-capture -- --ignored hdr_p010 --nocapture`.
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn hdr_p010_selftest_intel_1080_live() {
|
||||||
|
super::hdr_p010_selftest_at(1920, 1080, Some(0x8086)).expect("hdr p010 selftest @1080");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
|||||||
|
//! The LAST-RESORT DWM compose kick — synthetic pointer input that dirties a specific virtual
|
||||||
|
//! display so DWM presents it.
|
||||||
|
//!
|
||||||
|
//! Split out of `idd_push.rs` in sweep Phase 5.4. It is self-contained (one function plus a
|
||||||
|
//! process-global throttle) and it is the one piece of the capture path that reaches for synthetic
|
||||||
|
//! INPUT, which is worth keeping visibly separate from the frame machinery: it is unreliable by
|
||||||
|
//! nature, user-visible in the sibling-display case, and only ever a fallback for the driver's own
|
||||||
|
//! `FrameStash` republish.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// LAST-RESORT fallback: nudge DWM into composing THE TARGET virtual display. DWM presents a
|
||||||
|
/// display only when something DIRTIES it — an idle desktop never does, so a freshly-attached ring
|
||||||
|
/// (session open, or a mid-session ring recreate) can sit at E_PENDING with no first frame even
|
||||||
|
/// though everything is healthy.
|
||||||
|
///
|
||||||
|
/// The PRIMARY first-frame mechanism is the driver's `FrameStash` (frame_transport.rs): the driver
|
||||||
|
/// retains the last composed frame and republishes it into every freshly-attached ring, so with a
|
||||||
|
/// stash-capable driver the first frame lands milliseconds after the channel delivery and this kick
|
||||||
|
/// never fires. It remains for pre-stash drivers and for the empty-stash cold start (a monitor that
|
||||||
|
/// has NEVER composed — normally the activation compose covers that). Synthetic input is inherently
|
||||||
|
/// unreliable — blocked on the secure desktop, defeated by a fullscreen game's ClipCursor, and
|
||||||
|
/// user-visible in the sibling-display case — which is exactly why it was demoted to fallback.
|
||||||
|
///
|
||||||
|
/// pf-vdisplay implements no hardware-cursor plane, so a cursor move is composited into
|
||||||
|
/// the frame — a guaranteed real present onto the IDD swap-chain (empirically what
|
||||||
|
/// `punktfunk-probe --input-test` always relied on).
|
||||||
|
///
|
||||||
|
/// The cursor only dirties the display it is ON — proven on-glass in the Stage-W3 two-display
|
||||||
|
/// validation: display B's session-open kicks wiggled the cursor on display A and B never composed
|
||||||
|
/// a first frame. So the kick is per-TARGET: when the cursor already sits inside `target_id`'s
|
||||||
|
/// desktop region (always true single-display), two net-zero 1 px relative moves (the historical
|
||||||
|
/// behavior, pointer ends exactly where it started); when it sits on a SIBLING display, jump the
|
||||||
|
/// cursor to the target's center and straight back (`SetCursorPos` ×2 — each absolute move dirties
|
||||||
|
/// the cursor layer of the display it lands on, so the target composes at least one frame).
|
||||||
|
/// Best-effort — injection can be unavailable on the secure desktop, where a fresh compose just
|
||||||
|
/// happened anyway.
|
||||||
|
///
|
||||||
|
/// **COST:** the sibling-display branch SLEEPS 35 ms on the calling thread between the two
|
||||||
|
/// `SetCursorPos`es. The dwell is load-bearing (see the comment at that branch: a sub-tick
|
||||||
|
/// jump-and-return never dirties anything), but the caller is the capture/encode thread, so a kick
|
||||||
|
/// on that branch costs ~2 frames of latency at 60 Hz. Every call site is a first-frame or
|
||||||
|
/// post-recreate recovery window where no frames are flowing anyway, and the global 50 ms throttle
|
||||||
|
/// plus the callers' own 600–800 ms schedules bound how often it can happen.
|
||||||
|
///
|
||||||
|
/// **HID-first**: when the host has registered [`HID_COMPOSE_KICK`] (the resident pf-mouse virtual
|
||||||
|
/// HID pointer), the kick goes through it INSTEAD of the `SendInput` paths below. A report from a
|
||||||
|
/// HID device is real input to win32k — delivered regardless of this process's session or the
|
||||||
|
/// active desktop, it wakes a powered-off display subsystem (lid-closed laptop / display idle-off /
|
||||||
|
/// modern standby) and counts as user presence — every condition under which `SendInput` is
|
||||||
|
/// silently impotent (wrong session → wrong input queue; secure desktop → blocked; display off →
|
||||||
|
/// nothing composes at all). That set is exactly the lid-closed field-report state.
|
||||||
|
pub(super) fn kick_dwm_compose(target_id: u32) {
|
||||||
|
// Process-GLOBAL throttle (Stage W3): with N parallel capturers each nudging on its own
|
||||||
|
// schedule, DWM needs only one dirty per composition window — and the nudge is synthetic INPUT
|
||||||
|
// (global, user-visible pointer state), so it must not multiply with capturer count. 50 ms
|
||||||
|
// covers every composition interval we ship (≥ 60 Hz) while staying far under the callers' own
|
||||||
|
// 600–800 ms per-capturer schedules.
|
||||||
|
static LAST_KICK: Mutex<Option<Instant>> = Mutex::new(None);
|
||||||
|
{
|
||||||
|
let mut last = LAST_KICK.lock().unwrap();
|
||||||
|
let now = Instant::now();
|
||||||
|
if last.is_some_and(|t| now.duration_since(t) < Duration::from_millis(50)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*last = Some(now);
|
||||||
|
}
|
||||||
|
// Where is the cursor, and where does the target display live in desktop space?
|
||||||
|
let mut pos = POINT::default();
|
||||||
|
// SAFETY: plain FFI; `pos` is a valid out-param for this synchronous call.
|
||||||
|
let have_pos = unsafe { GetCursorPos(&mut pos) }.is_ok();
|
||||||
|
// SAFETY: `source_desktop_rect` only runs the CCD QueryDisplayConfig FFI over owned local
|
||||||
|
// buffers; the `Copy` target id crosses by value.
|
||||||
|
let rect = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
|
||||||
|
// HID-first (see the doc comment): the registered virtual-mouse kick works from any
|
||||||
|
// session/desktop and wakes an off display. Both geometries come from CCD (global database),
|
||||||
|
// NOT per-session GDI metrics, so the aim is right even from a non-console session. Fall
|
||||||
|
// through to SendInput only when the hook isn't registered / the mouse isn't up.
|
||||||
|
if let (Some(kick), Some(rect)) = (crate::HID_COMPOSE_KICK.get(), rect) {
|
||||||
|
// SAFETY: `desktop_bounds` only runs the CCD QueryDisplayConfig FFI over owned local
|
||||||
|
// buffers.
|
||||||
|
let bounds = unsafe { pf_win_display::win_display::desktop_bounds() };
|
||||||
|
if let Some(bounds) = bounds {
|
||||||
|
if kick(rect, bounds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let (true, Some((x, y, w, h))) = (have_pos, rect) {
|
||||||
|
let inside = pos.x >= x && pos.x < x + w.max(1) && pos.y >= y && pos.y < y + h.max(1);
|
||||||
|
if !inside {
|
||||||
|
// The cursor is on a sibling display — a wiggle there dirties the WRONG display. Jump
|
||||||
|
// to the target's center, DWELL one composition interval, then restore. The dwell is
|
||||||
|
// load-bearing (proven on-glass, Stage W3): DWM computes dirty state from the CURRENT
|
||||||
|
// cursor position at the next vsync tick, so a sub-tick jump-and-return is invisible
|
||||||
|
// and the target never composes — 35 ms covers a 30 Hz tick with margin. The cursor
|
||||||
|
// visibly leaves the sibling display for those ~2 frames; kicks only fire during THIS
|
||||||
|
// display's session-open / recovery windows (throttled), so the blip is rare and brief.
|
||||||
|
// SAFETY: plain FFI; coordinates are plain ints, and the second call restores the
|
||||||
|
// observed original position.
|
||||||
|
unsafe {
|
||||||
|
let _ = SetCursorPos(x + w / 2, y + h / 2);
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(35));
|
||||||
|
// SAFETY: as above.
|
||||||
|
unsafe {
|
||||||
|
let _ = SetCursorPos(pos.x, pos.y);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mk = |dx: i32| INPUT {
|
||||||
|
r#type: INPUT_MOUSE,
|
||||||
|
Anonymous: INPUT_0 {
|
||||||
|
mi: MOUSEINPUT {
|
||||||
|
dx,
|
||||||
|
dy: 0,
|
||||||
|
mouseData: 0,
|
||||||
|
dwFlags: MOUSEEVENTF_MOVE,
|
||||||
|
time: 0,
|
||||||
|
dwExtraInfo: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// SAFETY: plain FFI; the input slice is valid, fully-initialized local data for this synchronous
|
||||||
|
// call, and `cbsize` is the true element size.
|
||||||
|
unsafe {
|
||||||
|
let _ = SendInput(&[mk(1), mk(-1)], std::mem::size_of::<INPUT>() as i32);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,10 @@ pub(super) struct CursorBlendPass {
|
|||||||
|
|
||||||
impl CursorBlendPass {
|
impl CursorBlendPass {
|
||||||
pub(super) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
|
pub(super) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
|
||||||
|
// SAFETY: `?`-checked D3D11 resource creation on the live `device` borrow, over
|
||||||
|
// fully-initialized stack descriptors and live out-params; `compile_shader` receives `s!()`
|
||||||
|
// literals (its contract).
|
||||||
|
unsafe {
|
||||||
let vsb = crate::dxgi::compile_shader(crate::dxgi::HDR_VS, s!("main"), s!("vs_5_0"))?;
|
let vsb = crate::dxgi::compile_shader(crate::dxgi::HDR_VS, s!("main"), s!("vs_5_0"))?;
|
||||||
let psb = crate::dxgi::compile_shader(CURSOR_PS, s!("main"), s!("ps_5_0"))?;
|
let psb = crate::dxgi::compile_shader(CURSOR_PS, s!("main"), s!("ps_5_0"))?;
|
||||||
let mut vs = None;
|
let mut vs = None;
|
||||||
@@ -109,6 +113,7 @@ impl CursorBlendPass {
|
|||||||
shape: None,
|
shape: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise.
|
/// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise.
|
||||||
unsafe fn ensure_shape(
|
unsafe fn ensure_shape(
|
||||||
@@ -116,6 +121,11 @@ impl CursorBlendPass {
|
|||||||
device: &ID3D11Device,
|
device: &ID3D11Device,
|
||||||
ov: &pf_frame::CursorOverlay,
|
ov: &pf_frame::CursorOverlay,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
// SAFETY: `CreateTexture2D`/`CreateShaderResourceView` are `?`-checked calls on the live
|
||||||
|
// `device` borrow. `init.pSysMem` points into `ov.rgba`, which the length check above proves
|
||||||
|
// holds at least `ov.w * ov.h * 4` bytes for the declared `SysMemPitch = ov.w * 4`, and which
|
||||||
|
// outlives the synchronous upload.
|
||||||
|
unsafe {
|
||||||
if self.shape.as_ref().is_some_and(|(s, ..)| *s == ov.serial) {
|
if self.shape.as_ref().is_some_and(|(s, ..)| *s == ov.serial) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -153,6 +163,7 @@ impl CursorBlendPass {
|
|||||||
self.shape = Some((ov.serial, srv.context("null cursor shape srv")?, ov.w, ov.h));
|
self.shape = Some((ov.serial, srv.context("null cursor shape srv")?, ov.w, ov.h));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Alpha-blend `ov` onto `dst` (frame-sized, RENDER_TARGET-capable — the blend scratch).
|
/// Alpha-blend `ov` onto `dst` (frame-sized, RENDER_TARGET-capable — the blend scratch).
|
||||||
/// `linear_scale`: 0 = SDR passthrough; non-zero = the frame is FP16 scRGB (HDR
|
/// `linear_scale`: 0 = SDR passthrough; non-zero = the frame is FP16 scRGB (HDR
|
||||||
@@ -167,6 +178,13 @@ impl CursorBlendPass {
|
|||||||
ov: &pf_frame::CursorOverlay,
|
ov: &pf_frame::CursorOverlay,
|
||||||
linear_scale: f32,
|
linear_scale: f32,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
// SAFETY: all D3D11 work on the caller's live `device`/`ctx` borrows. The
|
||||||
|
// `copy_nonoverlapping` writes `cb.len()` `f32`s into the pointer the immediately preceding
|
||||||
|
// `Map` of `self.cbuf` (16 bytes = 4×`f32`, DYNAMIC/WRITE_DISCARD) returned, inside the
|
||||||
|
// `is_ok()` arm and before the paired `Unmap`. `ensure_shape` forwards this fn's `device`
|
||||||
|
// borrow, and every `*Set*`/`Draw` takes borrowed slices of live locals or clones of live COM
|
||||||
|
// interfaces.
|
||||||
|
unsafe {
|
||||||
self.ensure_shape(device, ov)?;
|
self.ensure_shape(device, ov)?;
|
||||||
let (_, srv, w, h) = self.shape.as_ref().expect("shape just ensured");
|
let (_, srv, w, h) = self.shape.as_ref().expect("shape just ensured");
|
||||||
if self.cbuf_scale != Some(linear_scale) {
|
if self.cbuf_scale != Some(linear_scale) {
|
||||||
@@ -178,9 +196,14 @@ impl CursorBlendPass {
|
|||||||
{
|
{
|
||||||
std::ptr::copy_nonoverlapping(cb.as_ptr(), mapped.pData as *mut f32, cb.len());
|
std::ptr::copy_nonoverlapping(cb.as_ptr(), mapped.pData as *mut f32, cb.len());
|
||||||
ctx.Unmap(&self.cbuf, 0);
|
ctx.Unmap(&self.cbuf, 0);
|
||||||
}
|
// Cache ONLY on a successful upload. Caching unconditionally meant one transient
|
||||||
|
// `Map` failure wedged the HDR/SDR cursor scale for the rest of the session: the
|
||||||
|
// buffer still held the OLD value while this believed it held the new one, and
|
||||||
|
// no later call would retry. On failure the cache is left alone and the next
|
||||||
|
// blend tries again — a stale scale for a frame instead of forever.
|
||||||
self.cbuf_scale = Some(linear_scale);
|
self.cbuf_scale = Some(linear_scale);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let mut rtv: Option<ID3D11RenderTargetView> = None;
|
let mut rtv: Option<ID3D11RenderTargetView> = None;
|
||||||
device
|
device
|
||||||
.CreateRenderTargetView(dst, None, Some(&mut rtv))
|
.CreateRenderTargetView(dst, None, Some(&mut rtv))
|
||||||
@@ -214,3 +237,4 @@ impl CursorBlendPass {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,11 +85,23 @@ impl CursorPoller {
|
|||||||
/// stand-down) — a 2 s freeze at every UAC prompt is user-visible, ~4 `OpenInputDesktop`
|
/// stand-down) — a 2 s freeze at every UAC prompt is user-visible, ~4 `OpenInputDesktop`
|
||||||
/// syscalls/s are not.
|
/// syscalls/s are not.
|
||||||
const REATTACH: Duration = Duration::from_millis(250);
|
const REATTACH: Duration = Duration::from_millis(250);
|
||||||
|
/// Cadence of the same-handle extent re-probe (see the [`run`] loop). Display-scale changes are
|
||||||
|
/// human/OS-timescale events and the probe reads dimensions only — no pixel copy — so 4 Hz is
|
||||||
|
/// both ample and negligible, and a ≤250 ms lag on the pointer's size is imperceptible.
|
||||||
|
const EXTENT_PROBE: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
/// Spawn the poller for the virtual display `target_id`. `rect` = the target's desktop rect
|
/// Spawn the poller for the virtual display `target_id`. `rect` SEEDS the target's desktop rect
|
||||||
/// (`source_desktop_rect` order: x, y, w, h) — cursor positions are desktop-global; the
|
/// (`source_desktop_rect` order: x, y, w, h) — cursor positions are desktop-global; the
|
||||||
/// overlay wants frame-relative, and a pointer outside the rect reports `visible: false`
|
/// overlay wants frame-relative, and a pointer outside the rect reports `visible: false`
|
||||||
/// (per-output semantics, matching the driver shm path and the Linux portal).
|
/// (per-output semantics, matching the driver shm path and the Linux portal).
|
||||||
|
///
|
||||||
|
/// A SEED, not the value: the poll thread re-queries the rect on its [`Self::REATTACH`] cadence.
|
||||||
|
/// It used to be captured once here and used forever for BOTH the desktop→frame offset and the
|
||||||
|
/// `in_rect` test, while both mid-session mode-change paths (`resize_output` and
|
||||||
|
/// `poll_display_hdr` → `recreate_ring`) keep the same poller — so after an in-place resize the
|
||||||
|
/// pointer was clipped to the OLD rect and offset by a stale origin. Re-querying on the poll
|
||||||
|
/// thread is what keeps the CCD call off the capture/encode thread, which is the whole reason
|
||||||
|
/// this poller exists (see `DescriptorPoller`).
|
||||||
pub(super) fn spawn(target_id: u32, rect: (i32, i32, i32, i32)) -> Self {
|
pub(super) fn spawn(target_id: u32, rect: (i32, i32, i32, i32)) -> Self {
|
||||||
let slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>> = Arc::new(Mutex::new(None));
|
let slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>> = Arc::new(Mutex::new(None));
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
@@ -139,7 +151,7 @@ impl Drop for CursorPoller {
|
|||||||
/// The poll loop. Owns the thread's input-desktop binding and the shape cache.
|
/// The poll loop. Owns the thread's input-desktop binding and the shape cache.
|
||||||
fn run(
|
fn run(
|
||||||
target_id: u32,
|
target_id: u32,
|
||||||
rect: (i32, i32, i32, i32),
|
mut rect: (i32, i32, i32, i32),
|
||||||
slot: &Mutex<Option<pf_frame::CursorOverlay>>,
|
slot: &Mutex<Option<pf_frame::CursorOverlay>>,
|
||||||
stop: &AtomicBool,
|
stop: &AtomicBool,
|
||||||
secure: &AtomicBool,
|
secure: &AtomicBool,
|
||||||
@@ -161,12 +173,35 @@ fn run(
|
|||||||
let mut failed_handle: isize = 0; // don't re-rasterise a failing handle every tick
|
let mut failed_handle: isize = 0; // don't re-rasterise a failing handle every tick
|
||||||
let mut serial: u64 = 0;
|
let mut serial: u64 = 0;
|
||||||
let mut logged_live = false;
|
let mut logged_live = false;
|
||||||
|
let mut last_extent = Instant::now();
|
||||||
|
|
||||||
while !stop.load(Ordering::Relaxed) {
|
while !stop.load(Ordering::Relaxed) {
|
||||||
std::thread::sleep(CursorPoller::INTERVAL);
|
std::thread::sleep(CursorPoller::INTERVAL);
|
||||||
if last_attach.elapsed() >= CursorPoller::REATTACH {
|
if last_attach.elapsed() >= CursorPoller::REATTACH {
|
||||||
last_attach = Instant::now();
|
last_attach = Instant::now();
|
||||||
publish_secure(secure, desktop.reattach());
|
publish_secure(secure, desktop.reattach());
|
||||||
|
// …and re-read the target's desktop rect on the same cadence: a mid-session resize (or
|
||||||
|
// an HDR recreate, or the user moving this display in the desktop arrangement) changes
|
||||||
|
// BOTH the origin the position is made relative to and the extent `in_rect` tests
|
||||||
|
// against, and this poller outlives all of them. `None` keeps the last good value — a
|
||||||
|
// transient CCD failure must not park the pointer at a `(0, 0, 0, 0)` rect, which would
|
||||||
|
// report every position invisible.
|
||||||
|
//
|
||||||
|
// SAFETY: `source_desktop_rect` is an `unsafe fn` running the read-only CCD
|
||||||
|
// `QueryDisplayConfig` over owned local buffers; the `Copy` target id crosses by value
|
||||||
|
// and it returns owned `(x, y, w, h)` values, borrowing nothing.
|
||||||
|
let fresh = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
|
||||||
|
if let Some(fresh) = fresh {
|
||||||
|
if fresh != rect {
|
||||||
|
tracing::info!(
|
||||||
|
target_id,
|
||||||
|
from = ?rect,
|
||||||
|
to = ?fresh,
|
||||||
|
"cursor poller: target desktop rect changed — re-basing pointer positions"
|
||||||
|
);
|
||||||
|
rect = fresh;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut ci = CURSORINFO {
|
let mut ci = CURSORINFO {
|
||||||
@@ -191,6 +226,42 @@ fn run(
|
|||||||
// bitmap to have been seen. v1: animated cursors publish their first frame (the OBS
|
// bitmap to have been seen. v1: animated cursors publish their first frame (the OBS
|
||||||
// behavior); frame cycling via DrawIconEx istep is a known follow-up.
|
// behavior); frame cycling via DrawIconEx istep is a known follow-up.
|
||||||
let handle = ci.hCursor.0 as isize;
|
let handle = ci.hCursor.0 as isize;
|
||||||
|
|
||||||
|
// …but the handle alone CANNOT see a re-render. Windows rebuilds the system cursors at a
|
||||||
|
// new size whenever the scale under the pointer changes — crossing to a differently-scaled
|
||||||
|
// monitor, or a monitor's own scale settling after a mode change (a fresh virtual display
|
||||||
|
// is created at the RECOMMENDED scale and gets the client's saved `PerMonitorSettings`
|
||||||
|
// override a beat later) — while the SHARED handle stays put for the session's life (the
|
||||||
|
// arrow is 0x10003 throughout). Keyed on the handle alone the cache latched whatever size
|
||||||
|
// the pointer happened to have when the poller started and never let go: a session that
|
||||||
|
// sampled inside that pre-settle window forwarded — and composited — a 96 px pointer over
|
||||||
|
// a 100 % desktop until it ended, which is exactly the "cursor is 3× too big while
|
||||||
|
// everything else is fine" report. Re-read the bitmap's EXTENT on a slow cadence
|
||||||
|
// (dimensions only, no pixel copy) and drop the cache when it moved.
|
||||||
|
if showing && handle != 0 && handle == cached_handle {
|
||||||
|
if last_extent.elapsed() >= CursorPoller::EXTENT_PROBE {
|
||||||
|
last_extent = Instant::now();
|
||||||
|
if let (Some(now), Some(s)) = (cursor_extent(ci.hCursor), shape.as_ref()) {
|
||||||
|
if now != (s.w, s.h) {
|
||||||
|
tracing::info!(
|
||||||
|
target_id,
|
||||||
|
"cursor: the pointer bitmap resized under a stable handle \
|
||||||
|
({}x{} -> {}x{}) — re-rasterising (the scale under the pointer moved)",
|
||||||
|
s.w,
|
||||||
|
s.h,
|
||||||
|
now.0,
|
||||||
|
now.1
|
||||||
|
);
|
||||||
|
cached_handle = 0; // re-rasterise below, on this same tick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// A handle change re-rasterises on its own — hold the probe off so it can't fire on
|
||||||
|
// the very next tick against a shape that is current by construction.
|
||||||
|
last_extent = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
if showing && handle != 0 && handle != cached_handle && handle != failed_handle {
|
if showing && handle != 0 && handle != cached_handle && handle != failed_handle {
|
||||||
match rasterize(ci.hCursor) {
|
match rasterize(ci.hCursor) {
|
||||||
Some((rgba, w, h, hot_x, hot_y)) => {
|
Some((rgba, w, h, hot_x, hot_y)) => {
|
||||||
@@ -355,6 +426,57 @@ fn rasterize(hcursor: windows::Win32::UI::WindowsAndMessaging::HCURSOR) -> Raste
|
|||||||
|
|
||||||
type RasterOut = Option<(Vec<u8>, u32, u32, u32, u32)>;
|
type RasterOut = Option<(Vec<u8>, u32, u32, u32, u32)>;
|
||||||
|
|
||||||
|
/// The CURRENT bitmap extent of `hcursor` — exactly the `(w, h)` [`convert`] would derive, without
|
||||||
|
/// the pixel read. Feeds the poll loop's staleness check (a re-render keeps the handle, see there).
|
||||||
|
/// `None` on any failure, which the caller reads as "no verdict" and keeps its cached shape.
|
||||||
|
fn cursor_extent(hcursor: windows::Win32::UI::WindowsAndMessaging::HCURSOR) -> Option<(u32, u32)> {
|
||||||
|
// CopyIcon first, for the reason `rasterize` does it: the owning process can destroy its
|
||||||
|
// HCURSOR between GetCursorInfo and the reads below; the copy is ours.
|
||||||
|
// SAFETY: `HICON(hcursor.0)` reinterprets the cursor handle as an icon handle (cursors ARE
|
||||||
|
// icons in user32); CopyIcon yields an owned HICON destroyed below.
|
||||||
|
let icon = unsafe { CopyIcon(HICON(hcursor.0)) }.ok()?;
|
||||||
|
let mut ii = ICONINFO::default();
|
||||||
|
// SAFETY: `ii` is a live out-param. On Ok it hands us COPIES of the mask/color bitmaps — both
|
||||||
|
// deleted below (GDI-handle leak otherwise).
|
||||||
|
let got = unsafe { GetIconInfo(icon, &mut ii) };
|
||||||
|
// Mirrors `convert`'s two families: a color cursor's extent is its color bitmap's; a
|
||||||
|
// monochrome one's mask carries the AND plane OVER the XOR plane, so its height is doubled.
|
||||||
|
let extent = got.is_ok().then_some(()).and_then(|()| {
|
||||||
|
if !ii.hbmColor.is_invalid() {
|
||||||
|
bitmap_extent(ii.hbmColor)
|
||||||
|
} else {
|
||||||
|
let (w, h) = bitmap_extent(ii.hbmMask)?;
|
||||||
|
(h >= 2 && h % 2 == 0).then_some((w, h / 2))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// SAFETY: deleting the two bitmap copies GetIconInfo returned (null-safe: DeleteObject on a
|
||||||
|
// null HGDIOBJ fails harmlessly) and the icon copy — each exactly once.
|
||||||
|
unsafe {
|
||||||
|
let _ = DeleteObject(ii.hbmColor.into());
|
||||||
|
let _ = DeleteObject(ii.hbmMask.into());
|
||||||
|
let _ = DestroyIcon(icon);
|
||||||
|
}
|
||||||
|
extent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A GDI bitmap's dimensions, under [`read_bitmap_32`]'s sanity caps so the two agree on what a
|
||||||
|
/// plausible cursor is — a bitmap `rasterize` would reject must not read here as a size CHANGE.
|
||||||
|
fn bitmap_extent(hbm: HBITMAP) -> Option<(u32, u32)> {
|
||||||
|
let mut bm = BITMAP::default();
|
||||||
|
// SAFETY: `bm` is a live out-param sized exactly as passed; GetObjectW only writes into it.
|
||||||
|
let n = unsafe {
|
||||||
|
GetObjectW(
|
||||||
|
hbm.into(),
|
||||||
|
std::mem::size_of::<BITMAP>() as i32,
|
||||||
|
Some((&mut bm as *mut BITMAP).cast()),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if n == 0 || bm.bmWidth <= 0 || bm.bmHeight <= 0 || bm.bmWidth > 512 || bm.bmHeight > 1024 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((bm.bmWidth as u32, bm.bmHeight as u32))
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert the ICONINFO bitmaps to straight RGBA. Two families:
|
/// Convert the ICONINFO bitmaps to straight RGBA. Two families:
|
||||||
/// - color (`hbmColor` set): 32bpp BGRA; if the alpha channel is entirely empty (old-style
|
/// - color (`hbmColor` set): 32bpp BGRA; if the alpha channel is entirely empty (old-style
|
||||||
/// cursors) the AND mask supplies it (mask bit 1 = transparent).
|
/// cursors) the AND mask supplies it (mask bit 1 = transparent).
|
||||||
@@ -372,15 +494,13 @@ fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
|
|||||||
let color = read_bitmap_32(dc, ii.hbmColor)?;
|
let color = read_bitmap_32(dc, ii.hbmColor)?;
|
||||||
let (w, h) = (color.w as u32, color.h as u32);
|
let (w, h) = (color.w as u32, color.h as u32);
|
||||||
let mut rgba = bgra_to_rgba(&color.bgra);
|
let mut rgba = bgra_to_rgba(&color.bgra);
|
||||||
if rgba.chunks_exact(4).all(|p| p[3] == 0) {
|
if alpha_is_empty(&rgba) {
|
||||||
// Alpha-less color cursor: transparency lives in the AND mask.
|
// Alpha-less color cursor: transparency lives in the AND mask.
|
||||||
let mask = read_bitmap_32(dc, ii.hbmMask)?;
|
let mask = read_bitmap_32(dc, ii.hbmMask)?;
|
||||||
if mask.w != color.w || mask.h < color.h {
|
if mask.w != color.w || mask.h < color.h {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
for (px, m) in rgba.chunks_exact_mut(4).zip(mask.bgra.chunks_exact(4)) {
|
apply_and_mask_alpha(&mut rgba, &mask.bgra);
|
||||||
px[3] = if m[0] != 0 { 0 } else { 0xFF }; // mask white (AND=1) = transparent
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Some((rgba, w, h))
|
Some((rgba, w, h))
|
||||||
} else {
|
} else {
|
||||||
@@ -389,42 +509,8 @@ fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let (w, h) = (mask.w as usize, (mask.h / 2) as usize);
|
let (w, h) = (mask.w as usize, (mask.h / 2) as usize);
|
||||||
let row = w * 4;
|
let (and_plane, xor_plane) = mask.bgra.split_at(h * w * 4);
|
||||||
let (and_plane, xor_plane) = mask.bgra.split_at(h * row);
|
let rgba = mono_planes_to_rgba(and_plane, xor_plane, w, h);
|
||||||
let mut rgba = vec![0u8; w * h * 4];
|
|
||||||
let mut invert = vec![false; w * h];
|
|
||||||
for i in 0..w * h {
|
|
||||||
let (a, x) = (and_plane[i * 4] != 0, xor_plane[i * 4] != 0);
|
|
||||||
let px = &mut rgba[i * 4..i * 4 + 4];
|
|
||||||
match (a, x) {
|
|
||||||
(false, false) => px.copy_from_slice(&[0, 0, 0, 0xFF]),
|
|
||||||
(false, true) => px.copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]),
|
|
||||||
(true, false) => {} // transparent (already zeroed)
|
|
||||||
(true, true) => {
|
|
||||||
px.copy_from_slice(&[0, 0, 0, 0xFF]);
|
|
||||||
invert[i] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// White outline around invert regions so the (now black) shape survives dark
|
|
||||||
// backgrounds: any transparent 8-neighbor of an invert pixel turns opaque white.
|
|
||||||
for y in 0..h as i32 {
|
|
||||||
for x in 0..w as i32 {
|
|
||||||
if !invert[(y * w as i32 + x) as usize] {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (dx, dy) in NEIGHBORS {
|
|
||||||
let (nx, ny) = (x + dx, y + dy);
|
|
||||||
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let o = (ny * w as i32 + nx) as usize * 4;
|
|
||||||
if rgba[o + 3] == 0 {
|
|
||||||
rgba[o..o + 4].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some((rgba, w as u32, h as u32))
|
Some((rgba, w as u32, h as u32))
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -506,3 +592,204 @@ fn bgra_to_rgba(bgra: &[u8]) -> Vec<u8> {
|
|||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a 32bpp RGBA buffer's alpha channel is entirely zero — the "old-style cursor with no
|
||||||
|
/// alpha" test, whose transparency lives in the AND mask instead ([`apply_and_mask_alpha`]).
|
||||||
|
fn alpha_is_empty(rgba: &[u8]) -> bool {
|
||||||
|
rgba.chunks_exact(4).all(|p| p[3] == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take alpha from an expanded AND mask: mask WHITE (AND bit 1) means transparent, black opaque.
|
||||||
|
/// `mask_bgra` is the 32bpp expansion `GetDIBits` produces from the 1bpp mask, so any non-zero
|
||||||
|
/// channel byte is "set".
|
||||||
|
fn apply_and_mask_alpha(rgba: &mut [u8], mask_bgra: &[u8]) {
|
||||||
|
for (px, m) in rgba.chunks_exact_mut(4).zip(mask_bgra.chunks_exact(4)) {
|
||||||
|
px[3] = if m[0] != 0 { 0 } else { 0xFF };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The monochrome-cursor truth table, plus the white outline that makes an INVERT region legible.
|
||||||
|
///
|
||||||
|
/// A monochrome `HCURSOR` has no colour bitmap: `hbmMask` is DOUBLE height — the AND plane over the
|
||||||
|
/// XOR plane — and the pair encodes four states (the WebRTC/Chromium table):
|
||||||
|
///
|
||||||
|
/// | AND | XOR | meaning | straight-alpha result |
|
||||||
|
/// |-----|-----|-------------|------------------------------------------|
|
||||||
|
/// | 0 | 0 | black | opaque black |
|
||||||
|
/// | 0 | 1 | white | opaque white |
|
||||||
|
/// | 1 | 0 | transparent | fully transparent |
|
||||||
|
/// | 1 | 1 | INVERT dst | opaque black + a grown white outline |
|
||||||
|
///
|
||||||
|
/// INVERT is unrepresentable in straight alpha (it is a per-pixel XOR against whatever is behind
|
||||||
|
/// it), so it becomes opaque black and every TRANSPARENT 8-neighbour of an invert pixel is turned
|
||||||
|
/// opaque white. That outline is what keeps the text I-beam — which is almost entirely invert
|
||||||
|
/// pixels — legible over dark content; the earlier translucent-grey stand-in did not.
|
||||||
|
///
|
||||||
|
/// Extracted from `convert`'s GDI plumbing (sweep Phase 6.6) so the table is testable: the caller
|
||||||
|
/// needs a live `HCURSOR` and a screen DC, this needs two byte slices.
|
||||||
|
fn mono_planes_to_rgba(and_plane: &[u8], xor_plane: &[u8], w: usize, h: usize) -> Vec<u8> {
|
||||||
|
let mut rgba = vec![0u8; w * h * 4];
|
||||||
|
let mut invert = vec![false; w * h];
|
||||||
|
for i in 0..w * h {
|
||||||
|
let (a, x) = (and_plane[i * 4] != 0, xor_plane[i * 4] != 0);
|
||||||
|
let px = &mut rgba[i * 4..i * 4 + 4];
|
||||||
|
match (a, x) {
|
||||||
|
(false, false) => px.copy_from_slice(&[0, 0, 0, 0xFF]),
|
||||||
|
(false, true) => px.copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]),
|
||||||
|
(true, false) => {} // transparent (already zeroed)
|
||||||
|
(true, true) => {
|
||||||
|
px.copy_from_slice(&[0, 0, 0, 0xFF]);
|
||||||
|
invert[i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for y in 0..h as i32 {
|
||||||
|
for x in 0..w as i32 {
|
||||||
|
if !invert[(y * w as i32 + x) as usize] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (dx, dy) in NEIGHBORS {
|
||||||
|
let (nx, ny) = (x + dx, y + dy);
|
||||||
|
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let o = (ny * w as i32 + nx) as usize * 4;
|
||||||
|
if rgba[o + 3] == 0 {
|
||||||
|
rgba[o..o + 4].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rgba
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Expand a 1-bit-per-pixel plane (as `GetDIBits` does) into the 32bpp form the converters read:
|
||||||
|
/// any non-zero channel byte means "bit set".
|
||||||
|
fn plane(bits: &[u8]) -> Vec<u8> {
|
||||||
|
bits.iter()
|
||||||
|
.flat_map(|&b| {
|
||||||
|
let v = if b != 0 { 0xFF } else { 0 };
|
||||||
|
[v, v, v, 0]
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn px(rgba: &[u8], i: usize) -> [u8; 4] {
|
||||||
|
rgba[i * 4..i * 4 + 4].try_into().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPAQUE_BLACK: [u8; 4] = [0, 0, 0, 0xFF];
|
||||||
|
const OPAQUE_WHITE: [u8; 4] = [0xFF, 0xFF, 0xFF, 0xFF];
|
||||||
|
const TRANSPARENT: [u8; 4] = [0, 0, 0, 0];
|
||||||
|
|
||||||
|
/// All four AND/XOR states, in one 4×1 row — the table `mono_planes_to_rgba` documents.
|
||||||
|
#[test]
|
||||||
|
fn the_monochrome_truth_table_is_exact() {
|
||||||
|
// (0,0) black (0,1) white (1,0) transparent (1,1) invert
|
||||||
|
let and = plane(&[0, 0, 1, 1]);
|
||||||
|
let xor = plane(&[0, 1, 0, 1]);
|
||||||
|
let out = mono_planes_to_rgba(&and, &xor, 4, 1);
|
||||||
|
assert_eq!(px(&out, 0), OPAQUE_BLACK, "AND=0 XOR=0 ⇒ black");
|
||||||
|
assert_eq!(px(&out, 1), OPAQUE_WHITE, "AND=0 XOR=1 ⇒ white");
|
||||||
|
// Pixel 2 is transparent by the table, but it is an 8-neighbour of the invert pixel at 3,
|
||||||
|
// so the outline claims it — that IS the documented behaviour.
|
||||||
|
assert_eq!(
|
||||||
|
px(&out, 2),
|
||||||
|
OPAQUE_WHITE,
|
||||||
|
"outline grows into adjacent transparency"
|
||||||
|
);
|
||||||
|
assert_eq!(px(&out, 3), OPAQUE_BLACK, "AND=1 XOR=1 ⇒ black + outline");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transparency survives when there is no invert pixel next to it.
|
||||||
|
#[test]
|
||||||
|
fn transparent_pixels_stay_transparent_without_an_invert_neighbour() {
|
||||||
|
let and = plane(&[1, 1, 1, 1]);
|
||||||
|
let xor = plane(&[0, 0, 0, 0]);
|
||||||
|
let out = mono_planes_to_rgba(&and, &xor, 4, 1);
|
||||||
|
for i in 0..4 {
|
||||||
|
assert_eq!(px(&out, i), TRANSPARENT, "pixel {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outline grows into all eight neighbours, and only into TRANSPARENT ones — it must not
|
||||||
|
/// repaint a black or white shape pixel.
|
||||||
|
#[test]
|
||||||
|
fn the_invert_outline_covers_eight_neighbours_and_overwrites_nothing() {
|
||||||
|
// 3×3, invert at the centre, everything else transparent.
|
||||||
|
let and = plane(&[1, 1, 1, 1, 1, 1, 1, 1, 1]);
|
||||||
|
let mut xor = plane(&[0; 9]);
|
||||||
|
for b in &mut xor[4 * 4..4 * 4 + 3] {
|
||||||
|
*b = 0xFF; // centre pixel's XOR bit
|
||||||
|
}
|
||||||
|
let out = mono_planes_to_rgba(&and, &xor, 3, 3);
|
||||||
|
assert_eq!(px(&out, 4), OPAQUE_BLACK, "the invert pixel itself");
|
||||||
|
for i in [0, 1, 2, 3, 5, 6, 7, 8] {
|
||||||
|
assert_eq!(px(&out, i), OPAQUE_WHITE, "neighbour {i} outlined");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now surround it with BLACK shape pixels (AND=0, XOR=0): the outline must leave them alone.
|
||||||
|
let and = plane(&[0, 0, 0, 0, 1, 0, 0, 0, 0]);
|
||||||
|
let out = mono_planes_to_rgba(&and, &xor, 3, 3);
|
||||||
|
for i in [0, 1, 2, 3, 5, 6, 7, 8] {
|
||||||
|
assert_eq!(
|
||||||
|
px(&out, i),
|
||||||
|
OPAQUE_BLACK,
|
||||||
|
"neighbour {i} must not be repainted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outline must clip at the bitmap edges rather than wrap to the opposite side.
|
||||||
|
#[test]
|
||||||
|
fn the_outline_clips_at_the_edges() {
|
||||||
|
// 2×2 with the invert at (0, 0): only (1,0), (0,1) and (1,1) can be outlined.
|
||||||
|
let and = plane(&[1, 1, 1, 1]);
|
||||||
|
let mut xor = plane(&[0; 4]);
|
||||||
|
for b in &mut xor[0..3] {
|
||||||
|
*b = 0xFF;
|
||||||
|
}
|
||||||
|
let out = mono_planes_to_rgba(&and, &xor, 2, 2);
|
||||||
|
assert_eq!(px(&out, 0), OPAQUE_BLACK);
|
||||||
|
for i in [1, 2, 3] {
|
||||||
|
assert_eq!(px(&out, i), OPAQUE_WHITE, "in-bounds neighbour {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the alpha-less colour path ---------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_alpha_channel_is_detected() {
|
||||||
|
assert!(alpha_is_empty(&[1, 2, 3, 0, 4, 5, 6, 0]));
|
||||||
|
assert!(!alpha_is_empty(&[1, 2, 3, 0, 4, 5, 6, 1]));
|
||||||
|
assert!(alpha_is_empty(&[]), "no pixels ⇒ vacuously empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mask WHITE (AND bit 1) = transparent, black = opaque — and the colour bytes are untouched.
|
||||||
|
#[test]
|
||||||
|
fn the_and_mask_supplies_alpha_for_an_alpha_less_cursor() {
|
||||||
|
let mut rgba = vec![
|
||||||
|
10, 20, 30, 0, // pixel 0
|
||||||
|
40, 50, 60, 0, // pixel 1
|
||||||
|
];
|
||||||
|
let mask = plane(&[1, 0]); // pixel 0 masked out, pixel 1 kept
|
||||||
|
apply_and_mask_alpha(&mut rgba, &mask);
|
||||||
|
assert_eq!(px(&rgba, 0), [10, 20, 30, 0], "masked ⇒ transparent");
|
||||||
|
assert_eq!(px(&rgba, 1), [40, 50, 60, 0xFF], "unmasked ⇒ opaque");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mask with FEWER pixels than the colour bitmap must not panic — `zip` stops at the shorter
|
||||||
|
/// side, leaving the tail at whatever alpha it had (the caller has already required
|
||||||
|
/// `mask.h >= color.h`, so this is the belt).
|
||||||
|
#[test]
|
||||||
|
fn a_short_mask_does_not_panic() {
|
||||||
|
let mut rgba = vec![1, 2, 3, 0, 4, 5, 6, 0, 7, 8, 9, 0];
|
||||||
|
apply_and_mask_alpha(&mut rgba, &plane(&[0]));
|
||||||
|
assert_eq!(px(&rgba, 0), [1, 2, 3, 0xFF]);
|
||||||
|
assert_eq!(px(&rgba, 1), [4, 5, 6, 0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,866 @@
|
|||||||
|
//! IDD-push CONSTRUCTION: everything that runs once, before frames flow.
|
||||||
|
//!
|
||||||
|
//! The sealed channel's whole bring-up — the render-adapter resolution and its one TEX_FAIL rebind,
|
||||||
|
//! the advanced-colour (HDR) negotiation, the shared header + ring + event creation, the channel
|
||||||
|
//! delivery, the cursor-channel/poller opt-in, and the bounded first-frame gate — plus the two types
|
||||||
|
//! that exist only for it ([`SharedObjectSa`], [`AttachTexFail`]).
|
||||||
|
//!
|
||||||
|
//! Split out of `idd_push.rs` in sweep Phase 5.4. The steady state (`try_consume`, `repeat_last`,
|
||||||
|
//! the pollers, the `Capturer` impl) deliberately stays with the parent: this file is the part you
|
||||||
|
//! read when a session will not START, and the parent is the part you read when one stops flowing.
|
||||||
|
//! A `#[path]` child sees the parent's private items through `use super::*`, so nothing had to be
|
||||||
|
//! made more visible to move here.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM only** — `D:P(A;;GA;;;SY)`, protected
|
||||||
|
/// (no inherited ACEs), `bInheritHandle: false`. The sealed channel makes this the strictly-minimal
|
||||||
|
/// DACL: the objects are UNNAMED and the driver reaches them via **duplicated handles** (which carry the
|
||||||
|
/// source handle's access — `OpenSharedResourceByName`/`OpenSharedResource1` on a handle does not
|
||||||
|
/// re-check the object DACL against the opener), so the pf_vdisplay WUDFHost (LocalService) no longer
|
||||||
|
/// needs a DACL ACE. Dropping the `LS` ACE removes the last theoretical surface where a leaked handle or
|
||||||
|
/// a name-grown-by-accident could be opened by the (many-service-shared) LocalService SID. Empirically
|
||||||
|
/// confirmed unreachable regardless: a LocalService token is DACL-denied `OpenProcess` on the WUDFHost
|
||||||
|
/// (`PROCESS_DUP_HANDLE`/`VM_READ`/even `QUERY_LIMITED` → ACCESS_DENIED, tested on the RTX box
|
||||||
|
/// 2026-07-03), so it cannot dup the handles out either. History: `Global\`-named + world-openable
|
||||||
|
/// (`WD`, security-review 2026-06-28 #5) → SY+LS-scoped → nameless → now SY-only. See
|
||||||
|
/// `design/idd-push-security.md`.
|
||||||
|
///
|
||||||
|
/// RAII, because the descriptor is a `LocalAlloc` the caller must `LocalFree` and the previous
|
||||||
|
/// `(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)` tuple never did — leaking it twice per open (once
|
||||||
|
/// for the header section, once for the frame-ready event) and again on every ring recreate. Pairing
|
||||||
|
/// them in one owner also enforces the "descriptor must outlive the attributes" rule structurally:
|
||||||
|
/// `sa.lpSecurityDescriptor` points at the allocation and [`as_ptr`](Self::as_ptr) only lends a
|
||||||
|
/// borrow, so the attributes cannot escape this value's lifetime. Moving the struct is fine — the
|
||||||
|
/// pointer targets the heap allocation, not a field.
|
||||||
|
struct SharedObjectSa {
|
||||||
|
sa: SECURITY_ATTRIBUTES,
|
||||||
|
psd: PSECURITY_DESCRIPTOR,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedObjectSa {
|
||||||
|
fn new() -> Result<Self> {
|
||||||
|
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||||
|
// SAFETY: `ConvertStringSecurityDescriptorToSecurityDescriptorW` reads the `w!()` literal and
|
||||||
|
// writes the descriptor it allocates into the live local `psd`; `?` rejects a failure before
|
||||||
|
// `psd` is read.
|
||||||
|
unsafe {
|
||||||
|
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||||
|
w!("D:P(A;;GA;;;SY)"),
|
||||||
|
SDDL_REVISION_1,
|
||||||
|
&mut psd,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.context("build SDDL for IDD-push shared objects")?;
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
sa: SECURITY_ATTRIBUTES {
|
||||||
|
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||||
|
lpSecurityDescriptor: psd.0,
|
||||||
|
bInheritHandle: false.into(),
|
||||||
|
},
|
||||||
|
psd,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `SECURITY_ATTRIBUTES` to hand a create call, borrowed from this owner.
|
||||||
|
fn as_ptr(&self) -> *const SECURITY_ATTRIBUTES {
|
||||||
|
&self.sa
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SharedObjectSa {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `psd` is the descriptor `ConvertStringSecurityDescriptorToSecurityDescriptorW`
|
||||||
|
// allocated for this value and nothing else owns it; `LocalFree` releases it exactly once
|
||||||
|
// (this `Drop` runs once, and `as_ptr` only ever lends a borrow of `sa`).
|
||||||
|
unsafe {
|
||||||
|
let _ = LocalFree(Some(HLOCAL(self.psd.0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IddPushCapturer {
|
||||||
|
/// Create the `RING_LEN` shared keyed-mutex textures for one ring generation, at `format` (matched
|
||||||
|
/// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared through an
|
||||||
|
/// UNNAMED NT handle (nothing to open by name — the sealed channel); the driver reaches it only via
|
||||||
|
/// the duplicate the [`ChannelBroker`] sends after the ring is published.
|
||||||
|
pub(super) unsafe fn create_ring_slots(
|
||||||
|
device: &ID3D11Device,
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
format: DXGI_FORMAT,
|
||||||
|
) -> Result<Vec<HostSlot>> {
|
||||||
|
// SAFETY: every D3D11/DXGI call is `?`-checked on the live `device` borrow, over
|
||||||
|
// fully-initialized stack descriptors and live out-params; `&sa` stays valid for the whole loop
|
||||||
|
// because `_psd`, the security descriptor backing it, is held in scope alongside.
|
||||||
|
// `OwnedHandle::from_raw_handle` adopts the handle `CreateSharedHandle` JUST minted for this
|
||||||
|
// slot — a unique, still-open NT handle owned by this process — making the slot its sole owner.
|
||||||
|
unsafe {
|
||||||
|
let sa = SharedObjectSa::new()?;
|
||||||
|
let mut slots = Vec::new();
|
||||||
|
for _ in 0..RING_LEN {
|
||||||
|
let desc = D3D11_TEXTURE2D_DESC {
|
||||||
|
Width: w,
|
||||||
|
Height: h,
|
||||||
|
MipLevels: 1,
|
||||||
|
ArraySize: 1,
|
||||||
|
// Match the OS-composed swap-chain surfaces so the driver's CopyResource into the slot +
|
||||||
|
// its format-guard both succeed.
|
||||||
|
Format: format,
|
||||||
|
SampleDesc: DXGI_SAMPLE_DESC {
|
||||||
|
Count: 1,
|
||||||
|
Quality: 0,
|
||||||
|
},
|
||||||
|
Usage: D3D11_USAGE_DEFAULT,
|
||||||
|
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
||||||
|
CPUAccessFlags: 0,
|
||||||
|
MiscFlags: (D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0
|
||||||
|
| D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX.0)
|
||||||
|
as u32,
|
||||||
|
};
|
||||||
|
let mut tex: Option<ID3D11Texture2D> = None;
|
||||||
|
device
|
||||||
|
.CreateTexture2D(&desc, None, Some(&mut tex))
|
||||||
|
.context("CreateTexture2D(IDD-push ring slot)")?;
|
||||||
|
let tex = tex.context("null ring texture")?;
|
||||||
|
let res1: IDXGIResource1 = tex.cast()?;
|
||||||
|
let shared = res1
|
||||||
|
.CreateSharedHandle(
|
||||||
|
Some(sa.as_ptr()),
|
||||||
|
DXGI_SHARED_RESOURCE_RW,
|
||||||
|
PCWSTR::null(), // UNNAMED — reachable only through the broker's duplicate
|
||||||
|
)
|
||||||
|
.context("CreateSharedHandle(IDD-push ring slot)")?;
|
||||||
|
// Own the shared handle so the slot's `Drop` closes it via RAII (was a manual `CloseHandle`).
|
||||||
|
let shared = OwnedHandle::from_raw_handle(shared.0 as _);
|
||||||
|
let mutex: IDXGIKeyedMutex = tex.cast()?;
|
||||||
|
let mut srv: Option<ID3D11ShaderResourceView> = None;
|
||||||
|
device
|
||||||
|
.CreateShaderResourceView(&tex, None, Some(&mut srv))
|
||||||
|
.context("CreateShaderResourceView(IDD-push ring slot)")?;
|
||||||
|
let srv = srv.context("null slot srv")?;
|
||||||
|
slots.push(HostSlot {
|
||||||
|
tex,
|
||||||
|
mutex,
|
||||||
|
shared,
|
||||||
|
srv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(slots)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the IDD-push capturer. On success the caller's `keepalive` is attached (the capturer owns the
|
||||||
|
/// virtual display); on FAILURE the keepalive is handed BACK so the caller can fall back to DDA
|
||||||
|
/// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the
|
||||||
|
/// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn open(
|
||||||
|
target: WinCaptureTarget,
|
||||||
|
preferred: Option<(u32, u32, u32)>,
|
||||||
|
client_10bit: bool,
|
||||||
|
want_444: bool,
|
||||||
|
pyrowave: bool,
|
||||||
|
keepalive: Box<dyn Send>,
|
||||||
|
sender: crate::FrameChannelSender,
|
||||||
|
cursor_sender: Option<crate::CursorChannelSender>,
|
||||||
|
cursor_forward: Option<crate::CursorForwardSender>,
|
||||||
|
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
||||||
|
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so
|
||||||
|
// the stall log can correlate DWM holes with OS display events for the session's lifetime.
|
||||||
|
pf_win_display::display_events::spawn_once();
|
||||||
|
match Self::open_inner(
|
||||||
|
target,
|
||||||
|
preferred,
|
||||||
|
client_10bit,
|
||||||
|
want_444,
|
||||||
|
pyrowave,
|
||||||
|
sender,
|
||||||
|
cursor_sender,
|
||||||
|
cursor_forward,
|
||||||
|
) {
|
||||||
|
Ok(mut me) => {
|
||||||
|
me._keepalive = keepalive;
|
||||||
|
Ok(me)
|
||||||
|
}
|
||||||
|
Err(e) => Err((e, keepalive)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn open_inner(
|
||||||
|
target: WinCaptureTarget,
|
||||||
|
preferred: Option<(u32, u32, u32)>,
|
||||||
|
client_10bit: bool,
|
||||||
|
want_444: bool,
|
||||||
|
pyrowave: bool,
|
||||||
|
sender: crate::FrameChannelSender,
|
||||||
|
cursor_sender: Option<crate::CursorChannelSender>,
|
||||||
|
cursor_forward: Option<crate::CursorForwardSender>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
|
||||||
|
// selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor
|
||||||
|
// ADD, so on a healthy box they agree, and NVENC gets a device on a real GPU adapter.
|
||||||
|
// (`target.adapter_luid` is NOT that adapter: the ADD reply carries
|
||||||
|
// `IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` = the IddCx DISPLAY adapter — verified
|
||||||
|
// on-glass; it stays a last-resort fallback for a pickerless box only.) When the pick and
|
||||||
|
// the driver HAVE drifted — identical twin GPUs whose max-VRAM tie moved between ADD and
|
||||||
|
// this open, or a stale kept monitor across an adapter re-init — the driver reports
|
||||||
|
// TEX_FAIL plus the adapter it actually renders on, and the rebind below reopens on that.
|
||||||
|
let luid = pf_gpu::resolve_render_adapter_luid().unwrap_or(LUID {
|
||||||
|
LowPart: (target.adapter_luid & 0xffff_ffff) as u32,
|
||||||
|
HighPart: (target.adapter_luid >> 32) as i32,
|
||||||
|
});
|
||||||
|
match Self::open_on(
|
||||||
|
target.clone(),
|
||||||
|
preferred,
|
||||||
|
client_10bit,
|
||||||
|
want_444,
|
||||||
|
pyrowave,
|
||||||
|
luid,
|
||||||
|
sender.clone(),
|
||||||
|
cursor_sender.clone(),
|
||||||
|
cursor_forward.clone(),
|
||||||
|
) {
|
||||||
|
Ok(me) => Ok(me),
|
||||||
|
Err(e) => {
|
||||||
|
// Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the
|
||||||
|
// adapter its swap-chain ACTUALLY renders on (a stale monitor across an adapter
|
||||||
|
// re-init, or a driver that ignored SET_RENDER_ADAPTER). Rebinding the ring to that
|
||||||
|
// adapter beats failing the session — the outer pipeline retries would repeat the
|
||||||
|
// exact same mismatch.
|
||||||
|
let driver_luid = e
|
||||||
|
.downcast_ref::<AttachTexFail>()
|
||||||
|
.map(|tf| tf.driver_luid)
|
||||||
|
.filter(|d| *d != 0 && *d != crate::dxgi::pack_luid(luid));
|
||||||
|
let Some(packed) = driver_luid else {
|
||||||
|
return Err(e);
|
||||||
|
};
|
||||||
|
let drv = LUID {
|
||||||
|
LowPart: (packed & 0xffff_ffff) as u32,
|
||||||
|
HighPart: (packed >> 32) as i32,
|
||||||
|
};
|
||||||
|
tracing::warn!(
|
||||||
|
ring_adapter = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||||
|
driver_adapter = format!("{:08x}:{:08x}", drv.HighPart, drv.LowPart),
|
||||||
|
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
|
||||||
|
driver's reported adapter"
|
||||||
|
);
|
||||||
|
Self::open_on(
|
||||||
|
target,
|
||||||
|
preferred,
|
||||||
|
client_10bit,
|
||||||
|
want_444,
|
||||||
|
pyrowave,
|
||||||
|
drv,
|
||||||
|
sender,
|
||||||
|
cursor_sender,
|
||||||
|
cursor_forward,
|
||||||
|
)
|
||||||
|
.context("IDD-push rebind to the driver's reported render adapter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn open_on(
|
||||||
|
target: WinCaptureTarget,
|
||||||
|
preferred: Option<(u32, u32, u32)>,
|
||||||
|
client_10bit: bool,
|
||||||
|
want_444: bool,
|
||||||
|
pyrowave: bool,
|
||||||
|
luid: LUID,
|
||||||
|
sender: crate::FrameChannelSender,
|
||||||
|
cursor_sender: Option<crate::CursorChannelSender>,
|
||||||
|
cursor_forward: Option<crate::CursorForwardSender>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let (pw, ph, _hz) = preferred
|
||||||
|
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
|
||||||
|
// Size the ring to the display's ACTUAL current resolution if it differs from the negotiated mode:
|
||||||
|
// a fullscreen game can hold the virtual display at a different mode (esp. across a reconnect), so
|
||||||
|
// matching the actual mode lets the first frame flow instead of being dropped (game-capture bug
|
||||||
|
// GB1). Falls back to the negotiated mode when the CCD read is unavailable.
|
||||||
|
// SAFETY: `active_resolution` is an `unsafe fn` (Win32 CCD `QueryDisplayConfig`) that takes only a
|
||||||
|
// copy of the plain `u32` CCD target id and returns owned `(w, h)` values; it forms no borrows from
|
||||||
|
// us and validates the id internally, returning `None` on any failure (handled by `unwrap_or`).
|
||||||
|
let (w, h) = unsafe { pf_win_display::win_display::active_resolution(target.target_id) }
|
||||||
|
.unwrap_or((pw, ph));
|
||||||
|
if (w, h) != (pw, ph) {
|
||||||
|
tracing::info!(
|
||||||
|
target_id = target.target_id,
|
||||||
|
negotiated = format!("{pw}x{ph}"),
|
||||||
|
actual = format!("{w}x{h}"),
|
||||||
|
"IDD push: sizing the ring to the display's actual mode (differs from negotiated)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The driver composes the virtual display in FP16 (R16G16B16A16_FLOAT scRGB) when the display is
|
||||||
|
// in advanced-color (HDR) mode, and 8-bit BGRA otherwise (per swap_chain_processor.rs + the
|
||||||
|
// COMMIT_MODES2 colorspace/rgb_bpc log). For a 10-bit-capable client we PROACTIVELY enable
|
||||||
|
// advanced color so HDR streams without the user toggling anything, then TRACK the display's
|
||||||
|
// actual mode (a mid-session "Use HDR" flip; the driver's format-guard drops a mismatch), polling
|
||||||
|
// the live state here and on every recreate. An SDR-only client instead forces advanced color OFF
|
||||||
|
// and is PINNED there (below + the descriptor poller), so the SDR negotiation is honored and the
|
||||||
|
// encoder never emits the in-band PQ upgrade to a client that asked for SDR.
|
||||||
|
// SAFETY: one block over the whole ring setup; every operation in it is sound:
|
||||||
|
// - `set_advanced_color`/`advanced_color_enabled` are `unsafe fn`s taking only a copy of the plain
|
||||||
|
// `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing.
|
||||||
|
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `SharedObjectSa::new`,
|
||||||
|
// `CreateFileMappingW`, `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all
|
||||||
|
// `?`-checked, so every returned interface/handle/view is non-error before use;
|
||||||
|
// `sa.as_ptr()`/`&adapter`/`&device` are live borrows that outlive each synchronous call, and
|
||||||
|
// `sa.lpSecurityDescriptor` stays valid because the owning `SharedObjectSa` is held in scope
|
||||||
|
// for the whole block (and frees the descriptor on the way out).
|
||||||
|
// - The header mapping is created AND viewed at `bytes == size_of::<SharedHeader>().max(64)`; the
|
||||||
|
// view's null is checked (`bail!` on failure, after which the owned `map` closes the mapping). The
|
||||||
|
// OS view base is page-aligned, so `section.ptr::<SharedHeader>()` is suitably aligned for a
|
||||||
|
// `SharedHeader`, and `write_bytes(.., 0, bytes)` plus the `(*header).field = ..` writes all stay
|
||||||
|
// within those `bytes` and write THROUGH the raw pointer without forming any `&mut`.
|
||||||
|
// - The `magic` publish stores through `addr_of!((*header).magic) as *const AtomicU32`: `addr_of!`
|
||||||
|
// takes the field address without a reference; the field is a 4-aligned `u32` (valid for
|
||||||
|
// `AtomicU32`), and the `Release` store after the `Release` fence is the cross-process handshake
|
||||||
|
// that orders all preceding writes before the driver may observe `MAGIC`.
|
||||||
|
// - `broker.send` requires live `header`/`event` handles of this process: both borrow the just-
|
||||||
|
// created owned section/event for the duration of that synchronous call.
|
||||||
|
// - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section`
|
||||||
|
// into `me` leaves it valid (see the `MappedSection` doc comment).
|
||||||
|
unsafe {
|
||||||
|
// An SDR-NEGOTIATED session (either codec) must run on an SDR (BGRA) composition, so
|
||||||
|
// actively turn advanced color OFF — undoing any leftover HDR state from a prior 10-bit
|
||||||
|
// session on a reused/lingering monitor, the driver's default, or the host's global
|
||||||
|
// "Use HDR" — and settle before sizing the ring. Non-optional for two reasons:
|
||||||
|
// - PyroWave: its CSC reads 8-bit BGRA and the NVIDIA D3D11 VideoProcessor can't ingest
|
||||||
|
// the FP16 ring at all.
|
||||||
|
// - H.26x: off an HDR composition the capturer emits P010 and the encoder stamps
|
||||||
|
// Main10 + BT.2020 PQ from the pixel format alone (the in-band HDR upgrade), sending a
|
||||||
|
// 10-bit PQ stream to a client that advertised SDR-only ("HDR off = never send me
|
||||||
|
// 10-bit"). On a client whose monitor is HDR-capable but has "Use HDR" off, that PQ
|
||||||
|
// lands on an SDR desktop and blows out — the composition must honor the negotiation.
|
||||||
|
// An HDR-negotiated (10-bit) session instead enables HDR below and rides the FP16 scRGB
|
||||||
|
// ring (design/pyrowave-444-hdr.md Phase 3 for PyroWave; the H.26x P010 path otherwise).
|
||||||
|
if !client_10bit {
|
||||||
|
let _ = pf_win_display::win_display::set_advanced_color(target.target_id, false);
|
||||||
|
let settle = Instant::now();
|
||||||
|
while settle.elapsed() < Duration::from_millis(250) {
|
||||||
|
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||||
|
== Some(false)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||||
|
== Some(true)
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
target = target.target_id,
|
||||||
|
pyrowave,
|
||||||
|
"IDD push: SDR session but advanced color (HDR) could NOT be turned off on the \
|
||||||
|
virtual display (a physical display forcing HDR?) — PyroWave will likely fail \
|
||||||
|
its first frame; H.26x would emit PQ the SDR-only client never asked for"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
target = target.target_id,
|
||||||
|
pyrowave,
|
||||||
|
settle_ms = settle.elapsed().as_millis() as u64,
|
||||||
|
"IDD push: SDR-negotiated session — advanced color forced OFF (SDR/BGRA composition)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and
|
||||||
|
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
|
||||||
|
// settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
|
||||||
|
// mismatch → an immediate ring recreate + dropped first frames (audit §5.4).
|
||||||
|
let enabled_hdr = client_10bit
|
||||||
|
&& pf_win_display::win_display::set_advanced_color(target.target_id, true);
|
||||||
|
if enabled_hdr {
|
||||||
|
// Let the colorspace change settle before the driver composes + we size the ring:
|
||||||
|
// poll the CCD advanced-color state instead of a fixed sleep (latency plan P0.4),
|
||||||
|
// ceiling = the old 250 ms. A read that never flips within the ceiling proceeds
|
||||||
|
// exactly like the fixed sleep did — the ring is sized FP16 from `enabled_hdr`
|
||||||
|
// either way (the set succeeded; only the driver's compose flip may lag, which the
|
||||||
|
// stash/format-guard machinery absorbs).
|
||||||
|
let hdr_settle = Instant::now();
|
||||||
|
while hdr_settle.elapsed() < Duration::from_millis(250) {
|
||||||
|
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||||
|
== Some(true)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
tracing::debug!(
|
||||||
|
target_id = target.target_id,
|
||||||
|
settle_ms = hdr_settle.elapsed().as_millis() as u64,
|
||||||
|
"IDD push: advanced-color (HDR) enable settle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) —
|
||||||
|
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
|
||||||
|
// An SDR-negotiated session (either codec) forced advanced color OFF above and composes
|
||||||
|
// SDR unconditionally: `client_10bit` gates HDR so a client that advertised SDR-only is
|
||||||
|
// never handed a PQ stream, even if a physical display forces HDR on (the descriptor
|
||||||
|
// poller re-asserts OFF; PyroWave's format guard/stash absorbs any lingering FP16 compose).
|
||||||
|
// Keep the raw observation so Downgrade point D below can say whether the read reported
|
||||||
|
// OFF or failed outright — "we asked, it said no" and "we could not tell" have different
|
||||||
|
// causes and different fixes.
|
||||||
|
let observed_hdr =
|
||||||
|
pf_win_display::win_display::advanced_color_enabled(target.target_id);
|
||||||
|
let display_hdr = client_10bit && (enabled_hdr || observed_hdr.unwrap_or(false));
|
||||||
|
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
|
||||||
|
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
|
||||||
|
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
|
||||||
|
// BT.709, so the client's label overstates the stream until the descriptor poller sees
|
||||||
|
// HDR come on. Loud, because every frame of this session is affected.
|
||||||
|
if client_10bit && !display_hdr {
|
||||||
|
tracing::error!(
|
||||||
|
target = target.target_id,
|
||||||
|
want_hdr = true,
|
||||||
|
set_advanced_color_returned = enabled_hdr,
|
||||||
|
observed_hdr = ?observed_hdr,
|
||||||
|
"IDD push: 10-bit HDR was negotiated but enabling advanced color on the \
|
||||||
|
virtual display FAILED — encoding 8-bit SDR while the client was told HDR \
|
||||||
|
(check the display driver / Windows HDR support on this box). \
|
||||||
|
observed_hdr=Some(false) ⇒ the display reports advanced colour OFF after the \
|
||||||
|
set; None ⇒ the CCD read itself failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let ring_fmt = if display_hdr {
|
||||||
|
DXGI_FORMAT_R16G16B16A16_FLOAT
|
||||||
|
} else {
|
||||||
|
DXGI_FORMAT_B8G8R8A8_UNORM
|
||||||
|
};
|
||||||
|
// Our device (ring + zero-copy NVENC) lives on `luid` — the selected render GPU per
|
||||||
|
// `open_inner`; the driver must render the swap-chain on the SAME adapter for the
|
||||||
|
// shared textures to open (it reports its actual render LUID into the header, which
|
||||||
|
// `open_inner` uses to rebind once if this mismatches).
|
||||||
|
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
||||||
|
let adapter: IDXGIAdapter1 = factory
|
||||||
|
.EnumAdapterByLuid(luid)
|
||||||
|
.context("EnumAdapterByLuid(render adapter) for IDD push")?;
|
||||||
|
let (device, context) = make_device(&adapter).context("make_device for IDD push")?;
|
||||||
|
|
||||||
|
let sa = SharedObjectSa::new()?;
|
||||||
|
let bytes = std::mem::size_of::<SharedHeader>().max(64);
|
||||||
|
|
||||||
|
// Header — UNNAMED (the sealed channel: the driver gets a duplicated handle, not a name).
|
||||||
|
let map = CreateFileMappingW(
|
||||||
|
INVALID_HANDLE_VALUE,
|
||||||
|
Some(sa.as_ptr()),
|
||||||
|
PAGE_READWRITE,
|
||||||
|
0,
|
||||||
|
bytes as u32,
|
||||||
|
PCWSTR::null(),
|
||||||
|
)
|
||||||
|
.context("CreateFileMapping(IDD-push header)")?;
|
||||||
|
// Own the mapping handle so it (and its view) free via `MappedSection` RAII even on bail.
|
||||||
|
let map = OwnedHandle::from_raw_handle(map.0 as _);
|
||||||
|
let view = MapViewOfFile(
|
||||||
|
HANDLE(map.as_raw_handle()),
|
||||||
|
FILE_MAP_ALL_ACCESS,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
bytes,
|
||||||
|
);
|
||||||
|
if view.Value.is_null() {
|
||||||
|
bail!("MapViewOfFile failed for IDD-push header"); // `map` drops → mapping closed
|
||||||
|
}
|
||||||
|
let section = MappedSection { handle: map, view };
|
||||||
|
let generation = next_generation();
|
||||||
|
let header = section.ptr::<SharedHeader>();
|
||||||
|
std::ptr::write_bytes(header.cast::<u8>(), 0, bytes);
|
||||||
|
(*header).version = VERSION;
|
||||||
|
(*header).generation = generation;
|
||||||
|
(*header).ring_len = RING_LEN;
|
||||||
|
(*header).width = w;
|
||||||
|
(*header).height = h;
|
||||||
|
// Ring format = the display's composition format (FP16 in HDR, BGRA in SDR). The driver
|
||||||
|
// reads this into its `ring_format` and drops any surface that doesn't match.
|
||||||
|
(*header).dxgi_format = ring_fmt.0 as u32;
|
||||||
|
// The ring NAMES its monitor (proto v3, `design/idd-push-security.md` invariant #10) —
|
||||||
|
// stamped before the magic (below), never changed for the ring's life (a mid-session
|
||||||
|
// recreate reuses this mapping). The driver refuses to attach a ring naming a different
|
||||||
|
// monitor, so a stash cross-wire fails closed instead of leaking frames cross-client
|
||||||
|
// (fail-closed refusal VALIDATED on-glass 2026-07-10 via a fault-injected build: driver
|
||||||
|
// DRV_STATUS_BIND_FAIL + loud host open failure + sibling stream undisturbed).
|
||||||
|
(*header).target_id = target.target_id;
|
||||||
|
|
||||||
|
// Frame-ready event (auto-reset) — UNNAMED, like everything on this channel.
|
||||||
|
let event = CreateEventW(Some(sa.as_ptr()), false, false, PCWSTR::null())
|
||||||
|
.context("CreateEvent(IDD-push)")?;
|
||||||
|
let event = OwnedHandle::from_raw_handle(event.0 as _);
|
||||||
|
|
||||||
|
// Ring of shared keyed-mutex textures, format matched to the display's current mode.
|
||||||
|
let slots = Self::create_ring_slots(&device, w, h, ring_fmt)?;
|
||||||
|
|
||||||
|
// Publish: magic LAST (Release) — the ring must be fully initialized before the driver
|
||||||
|
// (which receives the channel strictly afterwards) can observe MAGIC.
|
||||||
|
std::sync::atomic::fence(Ordering::Release);
|
||||||
|
(*(std::ptr::addr_of!((*header).magic) as *const AtomicU32))
|
||||||
|
.store(MAGIC, Ordering::Release);
|
||||||
|
|
||||||
|
// Deliver the sealed channel: duplicate header + event + every slot texture into the
|
||||||
|
// driver's WUDFHost and hand it the values over the control device. All-or-nothing (the
|
||||||
|
// broker reaps its remote duplicates on failure), and a failure fails the open — without
|
||||||
|
// the delivery the driver can never attach.
|
||||||
|
let broker = ChannelBroker::open(target.wudf_pid, sender)?;
|
||||||
|
broker
|
||||||
|
.send(
|
||||||
|
target.target_id,
|
||||||
|
generation,
|
||||||
|
HANDLE(section.handle.as_raw_handle()),
|
||||||
|
HANDLE(event.as_raw_handle()),
|
||||||
|
&slots,
|
||||||
|
)
|
||||||
|
.context("deliver IDD-push frame channel to the driver")?;
|
||||||
|
|
||||||
|
// v5 hardware-cursor channel (M2c): create + deliver the CursorShm section. Failure
|
||||||
|
// is NON-fatal — the driver never declares the hardware cursor without this delivery,
|
||||||
|
// so the session degrades to today's composited pointer (and the forwarder simply
|
||||||
|
// never sees a live overlay).
|
||||||
|
let cursor_shared = cursor_sender.as_ref().and_then(|send_cursor| {
|
||||||
|
match cursor::CursorShared::create(target.target_id) {
|
||||||
|
Ok(cs) => {
|
||||||
|
// Deliver via the shared helper (also used for RE-delivery after a
|
||||||
|
// driver-side monitor re-arrival destroyed the worker).
|
||||||
|
deliver_cursor_channel(&broker, target.target_id, &cs, send_cursor)
|
||||||
|
.then_some(cs)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"cursor section creation failed — the driver will not declare a \
|
||||||
|
hardware cursor, so this session cannot forward the pointer: {e:#}"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// No LIVE channel this session, but the target's sticky declare (an EARLIER session's —
|
||||||
|
// irrevocable, §8.6) keeps DWM's frames pointer-free with no client drawing either:
|
||||||
|
// the only visible pointer is the one composited here, so force composite mode on.
|
||||||
|
//
|
||||||
|
// Gated on `cursor_shared`, NOT on `cursor_sender`. §8.6's rationale is "this session has
|
||||||
|
// no cursor CHANNEL", and the delivery just above is explicitly allowed to fail
|
||||||
|
// non-fatally — which is precisely the state that needs this rescue, yet the
|
||||||
|
// `cursor_sender.is_none()` test was the one state that skipped it: the host negotiated a
|
||||||
|
// channel, failed to create or deliver it, and then declined to composite, leaving a
|
||||||
|
// cursor-excluded target with NO pointer at all.
|
||||||
|
let composite_forced = target.cursor_excluded && cursor_shared.is_none();
|
||||||
|
if composite_forced {
|
||||||
|
tracing::info!(
|
||||||
|
target_id = target.target_id,
|
||||||
|
negotiated_channel = cursor_sender.is_some(),
|
||||||
|
"target carries an irrevocable hardware-cursor declare from an earlier \
|
||||||
|
desktop-mode session and this session has no LIVE cursor channel — the host \
|
||||||
|
composites the pointer into frames (forced, for the session's life). \
|
||||||
|
negotiated_channel=true ⇒ one was negotiated but its creation/delivery failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The GDI shape poller rides the SAME gate as the delivered channel: with the driver's
|
||||||
|
// hardware cursor keeping the frame cursor-free, the poller supplies the full-fidelity
|
||||||
|
// shape (masked/monochrome included — the IddCx query can't; see cursor_poll.rs).
|
||||||
|
// Forced-composite sessions need it too — it is their only shape/position source.
|
||||||
|
let cursor_poll = (cursor_shared.is_some() || composite_forced).then(|| {
|
||||||
|
// Safety of the CCD call: read-only QueryDisplayConfig over owned locals (same
|
||||||
|
// call CursorShared::create makes) — already inside open_on's unsafe region.
|
||||||
|
let rect = pf_win_display::win_display::source_desktop_rect(target.target_id)
|
||||||
|
.unwrap_or((0, 0, i32::MAX, i32::MAX));
|
||||||
|
cursor_poll::CursorPoller::spawn(target.target_id, rect)
|
||||||
|
});
|
||||||
|
// Heal the driver's persisted cursor-forward state: a session that died on the
|
||||||
|
// secure desktop (client drops at the lock screen — the common case) leaves the
|
||||||
|
// per-target desired state `false`, and the NEXT session's channel delivery would
|
||||||
|
// adopt UNdeclared (the exact cross-session composite trap of §8.6). A fresh
|
||||||
|
// session always starts declared; the secure-desktop guard re-disables if the
|
||||||
|
// secure desktop is (still) up, via its first `poll_secure_desktop` edge.
|
||||||
|
if let (Some(_), Some(fwd)) = (cursor_shared.as_ref(), cursor_forward.as_ref()) {
|
||||||
|
if let Err(e) = fwd(true) {
|
||||||
|
tracing::debug!("cursor-forward reset at open failed (pre-v6 driver?): {e:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
target_id = target.target_id,
|
||||||
|
wudf_pid = target.wudf_pid,
|
||||||
|
render_luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||||
|
mode = format!("{w}x{h}"),
|
||||||
|
display_hdr,
|
||||||
|
client_10bit,
|
||||||
|
want_444,
|
||||||
|
ring_fp16 = display_hdr,
|
||||||
|
// Whether DXGI ever reached the win32u GPU-preference hook. By this point the
|
||||||
|
// factory + `EnumAdapterByLuid` + `make_device` above have exercised DXGI, so a
|
||||||
|
// 0 here means the hook is inert on this build — the first thing to check if a
|
||||||
|
// hybrid-GPU box keeps reporting TEX_FAIL render-adapter mismatches
|
||||||
|
// (`dxgi::install_gpu_pref_hook`).
|
||||||
|
hybrid_hook_hits = crate::dxgi::hybrid_hook_hits(),
|
||||||
|
"IDD push(host): created sealed ring + delivered the channel; waiting for the driver \
|
||||||
|
to attach + publish"
|
||||||
|
);
|
||||||
|
let mut me = Self {
|
||||||
|
device,
|
||||||
|
context,
|
||||||
|
target_id: target.target_id,
|
||||||
|
section,
|
||||||
|
header,
|
||||||
|
event,
|
||||||
|
broker,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
slots,
|
||||||
|
generation,
|
||||||
|
client_10bit,
|
||||||
|
display_hdr,
|
||||||
|
hdr_pin_warned: false,
|
||||||
|
want_444,
|
||||||
|
pyrowave,
|
||||||
|
pyro_fence: None,
|
||||||
|
pyro_fence_handle: None,
|
||||||
|
pyro_fence_value: 0,
|
||||||
|
pyro_ring: Vec::new(),
|
||||||
|
pyro_conv: None,
|
||||||
|
pyro_last: None,
|
||||||
|
desc_poller: DescriptorPoller::spawn(
|
||||||
|
target.target_id,
|
||||||
|
DisplayDescriptor {
|
||||||
|
hdr: display_hdr,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
desc_seq: 0,
|
||||||
|
pending_desc: None,
|
||||||
|
recovering_since: None,
|
||||||
|
last_fresh: Instant::now(),
|
||||||
|
last_liveness: Instant::now(),
|
||||||
|
last_kick: Instant::now(),
|
||||||
|
stall_watch: StallWatch::new(),
|
||||||
|
out_ring: Vec::new(),
|
||||||
|
out_idx: 0,
|
||||||
|
video_conv: None,
|
||||||
|
hdr_p010_conv: None,
|
||||||
|
last_seq: 0,
|
||||||
|
last_present: None,
|
||||||
|
status_logged: false,
|
||||||
|
cursor_shared,
|
||||||
|
cursor_poll,
|
||||||
|
cursor_sender,
|
||||||
|
cursor_forward,
|
||||||
|
secure_active: false,
|
||||||
|
composite_cursor: composite_forced,
|
||||||
|
composite_forced,
|
||||||
|
cursor_blend: None,
|
||||||
|
cursor_blend_failed: false,
|
||||||
|
cursor_shm_latched: false,
|
||||||
|
blend_scratch: None,
|
||||||
|
last_blend_key: None,
|
||||||
|
last_slot: None,
|
||||||
|
sdr_white_scale: 1.0,
|
||||||
|
// Held from BEFORE the first-frame gate (the display must not idle off while we
|
||||||
|
// wait for the first compose) until the capturer drops with the session.
|
||||||
|
_display_wake: pf_frame::session_tuning::DisplayWakeRequest::new(),
|
||||||
|
// Placeholder; `open()` attaches the real keepalive on success, so a FAILED open can hand
|
||||||
|
// it back to the caller for the DDA fallback (audit §5.1).
|
||||||
|
_keepalive: Box::new(()),
|
||||||
|
};
|
||||||
|
// The HDR SDR-white reference for the composited cursor, queried ONCE here rather than
|
||||||
|
// from the blend (which holds the ring slot's keyed mutex — see
|
||||||
|
// `refresh_sdr_white_scale`). No-op on an SDR composition.
|
||||||
|
me.refresh_sdr_white_scale();
|
||||||
|
// Bounded wait for the driver to ATTACH to the ring AND publish a first frame. An attach
|
||||||
|
// failure (DRV_STATUS_TEX_FAIL) or an attach-but-no-frames (a game left the display in a
|
||||||
|
// format/size the ring can't match) becomes an open failure the caller falls back from (→ DDA),
|
||||||
|
// instead of next_frame's 20 s black-then-bail.
|
||||||
|
me.wait_for_attach()?;
|
||||||
|
Ok(me)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block (bounded) until the driver has ATTACHED to the host ring (`DRV_STATUS_OPENED`) **and published
|
||||||
|
/// a first frame**, else fail so the caller can fall back to DDA (audit §5.1 +
|
||||||
|
/// `design/windows-host-rewrite.md` §2.5 — the GB1 game-capture fix).
|
||||||
|
///
|
||||||
|
/// Requiring the first frame — not just the attach — catches the *reconnect-into-a-broken-state* case:
|
||||||
|
/// a fullscreen game can leave the virtual display in a format/size that the driver's `publish()` guard
|
||||||
|
/// rejects, so the driver ATTACHES but silently drops every frame; without this the host sails past
|
||||||
|
/// `open()` and only dies on `next_frame`'s 20 s deadline (the "reconnect = black + audio" symptom).
|
||||||
|
/// A stash-capable driver republishes its retained desktop frame the moment it attaches (the
|
||||||
|
/// first-frame guarantee — `FrameStash`, driver frame_transport.rs), so the normal case clears this
|
||||||
|
/// gate in milliseconds even on an idle desktop; failing that, at session open the OS activates the
|
||||||
|
/// virtual display → DWM composites it → a frame arrives within ~1 s, plus the compose-kick fallback
|
||||||
|
/// below — no frame within the window = genuinely broken.
|
||||||
|
fn wait_for_attach(&self) -> Result<()> {
|
||||||
|
// Symmetric host-side binding sanity (proto v3 §3.2): OUR header must still name OUR
|
||||||
|
// monitor. The stamp is ours and nothing legitimate rewrites it, so a mismatch means a
|
||||||
|
// host-side bug (a stash/capturer cross-wire) — the exact class the driver-side check
|
||||||
|
// catches from the other end; failing here names the culprit in the same release.
|
||||||
|
// SAFETY: in-bounds, aligned u32 read of the live, owned shared-header mapping (same access
|
||||||
|
// pattern as the `driver_status` read below); no reference into the shared region is formed.
|
||||||
|
let stamped = unsafe { (*self.header).target_id };
|
||||||
|
if stamped != self.target_id {
|
||||||
|
bail!(
|
||||||
|
"IDD-push: our ring header names target {stamped} but this capturer serves target \
|
||||||
|
{} — host-side ring↔monitor cross-wire (bug); failing the open",
|
||||||
|
self.target_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(4);
|
||||||
|
// First-frame expectation: a stash-capable driver republishes its retained desktop frame
|
||||||
|
// the moment it attaches (`FrameStash`, frame_transport.rs), so on a healthy pairing the
|
||||||
|
// gate below clears in milliseconds even on a perfectly idle desktop. The compose-kick
|
||||||
|
// schedule is the FALLBACK for pre-stash drivers / an empty stash (a display that has
|
||||||
|
// never composed): DWM only presents a display something DIRTIED, so on an idle desktop
|
||||||
|
// an attach would otherwise sit at E_PENDING forever and fail this gate — the
|
||||||
|
// "idle desktop → no frames" gotcha. Give the natural post-activate compose (and the
|
||||||
|
// stash republish) a moment, then nudge; log when we do, so field logs show whether the
|
||||||
|
// stash path is working.
|
||||||
|
let mut next_kick = Instant::now() + Duration::from_millis(600);
|
||||||
|
loop {
|
||||||
|
// SAFETY: `self.header` points into the live shared-header mapping this capturer owns (sized
|
||||||
|
// `>= size_of::<SharedHeader>()`, page-aligned), so the field read is in-bounds + aligned, and
|
||||||
|
// no reference into the shared region is formed. Plain read: the driver writes this `u32`
|
||||||
|
// cross-process, but an aligned `u32` read can't tear and `driver_status` is best-effort
|
||||||
|
// diagnostics — the real handshake is the atomic `magic`/`latest` (same access as
|
||||||
|
// log_driver_status_once).
|
||||||
|
let st = unsafe { (*self.header).driver_status };
|
||||||
|
if st == DRV_STATUS_TEX_FAIL {
|
||||||
|
// The driver wrote its render LUID BEFORE attempting the texture opens
|
||||||
|
// (frame_transport.rs step 2), so it is valid here.
|
||||||
|
let (_, detail, lo, hi) = self.driver_diag();
|
||||||
|
// Typed so `open_inner` can rebind the ring to the driver's adapter once.
|
||||||
|
return Err(anyhow::Error::new(AttachTexFail {
|
||||||
|
detail,
|
||||||
|
driver_luid: ((hi as i64) << 32) | (lo as i64 & 0xffff_ffff),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if st == DRV_STATUS_NO_DEVICE1 {
|
||||||
|
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
|
||||||
|
// through the owned, live header mapping; no reference into the shared region is formed.
|
||||||
|
let detail = unsafe { (*self.header).driver_status_detail };
|
||||||
|
bail!(
|
||||||
|
"IDD-push driver failed to attach (driver_status={st} detail=0x{detail:08x} — \
|
||||||
|
the driver has no ID3D11Device1 to open shared resources)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if st == DRV_STATUS_BIND_FAIL {
|
||||||
|
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
|
||||||
|
// through the owned, live header mapping; no reference into the shared region is formed.
|
||||||
|
let claimed = unsafe { (*self.header).driver_status_detail };
|
||||||
|
bail!(
|
||||||
|
"IDD-push driver REFUSED the ring↔monitor binding (DRV_STATUS_BIND_FAIL: the \
|
||||||
|
delivered ring names target {claimed}, the monitor is {}) — host \
|
||||||
|
stash/delivery cross-wire (bug); failing the open loudly (proto v3 §3.2)",
|
||||||
|
self.target_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Attached AND a frame has been published — the publish token's seq advances past 0.
|
||||||
|
if st == DRV_STATUS_OPENED && frame::FrameToken::unpack(self.latest()).seq != 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if Instant::now() >= next_kick {
|
||||||
|
// Reaching a kick at all means the driver did NOT republish a retained frame
|
||||||
|
// (pre-stash driver, or a never-composed display) — worth a line in the field log.
|
||||||
|
tracing::debug!(
|
||||||
|
target_id = self.target_id,
|
||||||
|
driver_status = st,
|
||||||
|
"IDD push: no first frame after attach delivery — falling back to a synthetic \
|
||||||
|
compose kick (stash-capable drivers republish instantly; old driver?)"
|
||||||
|
);
|
||||||
|
// May BLOCK this thread ~35 ms (the cursor-on-a-sibling-display branch — see
|
||||||
|
// `kick_dwm_compose`'s COST note). Fine here: we are inside the open-time
|
||||||
|
// first-frame gate, so no frames are flowing yet.
|
||||||
|
kick_dwm_compose(self.target_id);
|
||||||
|
next_kick = Instant::now() + Duration::from_millis(800);
|
||||||
|
}
|
||||||
|
if Instant::now() > deadline {
|
||||||
|
bail!(
|
||||||
|
"IDD-push: no frame published within 4s (despite compose kicks) — {}; \
|
||||||
|
falling back",
|
||||||
|
self.no_first_frame_diagnosis(st)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Event-driven wait (latency plan P0.6): the driver signals the frame-ready event on
|
||||||
|
// every publish, so wake on it instead of a blind sleep — the 20 ms timeout keeps the
|
||||||
|
// driver_status polls above live (status writes don't signal the event). Consuming a
|
||||||
|
// signal here is fine: `next_frame` re-checks the atomic `latest` token, never the
|
||||||
|
// event, for truth.
|
||||||
|
// SAFETY: `self.event` is this capturer's owned, live auto-reset event handle;
|
||||||
|
// `WaitForSingleObject` only reads the handle and the 20 ms timeout bounds the wait.
|
||||||
|
let _ = unsafe { WaitForSingleObject(HANDLE(self.event.as_raw_handle()), 20) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name a first-frame timeout from the driver's own evidence — `driver_status` plus the live
|
||||||
|
/// OPENED detail word (proto `pack_opened_detail`) — instead of guessing. The three no-frames
|
||||||
|
/// states look identical from the host side but have disjoint causes and fixes; the lid-closed
|
||||||
|
/// field report burned days for lack of exactly this line. Appends a console-session hint when
|
||||||
|
/// the host itself is in the wrong session (display writes + input kicks can't work from there).
|
||||||
|
fn no_first_frame_diagnosis(&self, st: u32) -> String {
|
||||||
|
let what = match st {
|
||||||
|
// The delivery was never consumed: no swap-chain worker ran for this monitor at all.
|
||||||
|
DRV_STATUS_NONE => "the driver never attached — the channel delivery was never \
|
||||||
|
consumed, so the OS ran no swap-chain worker for this monitor (display not \
|
||||||
|
composed at all: console display-off / modern standby, or the mode commit \
|
||||||
|
never reached the adapter)"
|
||||||
|
.to_string(),
|
||||||
|
DRV_STATUS_OPENED => {
|
||||||
|
// SAFETY: in-bounds, aligned u32 read of the live, owned shared-header mapping
|
||||||
|
// (same best-effort diagnostic access as the `driver_status` read in the caller);
|
||||||
|
// no reference into the shared region is formed.
|
||||||
|
let detail = unsafe { (*self.header).driver_status_detail };
|
||||||
|
match unpack_opened_detail(detail) {
|
||||||
|
Some((0, _)) => "driver attached with a live swap-chain, but DWM composed \
|
||||||
|
ZERO frames — an undamaged or powered-off desktop, and the compose \
|
||||||
|
kicks didn't bite (synthetic input is blocked on the secure desktop)"
|
||||||
|
.to_string(),
|
||||||
|
Some((offered, mismatched)) => format!(
|
||||||
|
"driver attached and DWM composed {offered} frame(s), but none matched \
|
||||||
|
the ring — {mismatched} dropped for a size/format mismatch (the \
|
||||||
|
display's actual mode differs from what the host sized the ring to: \
|
||||||
|
a mid-open mode-set, a fullscreen game, or a stale GDI view)"
|
||||||
|
),
|
||||||
|
// A pre-detail driver never stamps the live bit — say so rather than guess.
|
||||||
|
None => "driver attached but published nothing; this pf-vdisplay build \
|
||||||
|
predates attach diagnostics, so the cause can't be named — update the \
|
||||||
|
driver for a precise line here"
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => format!("driver_status={other} (unexpected at this point)"),
|
||||||
|
};
|
||||||
|
match pf_win_display::console_session_mismatch() {
|
||||||
|
Some((own, console)) => format!(
|
||||||
|
"{what} [host is in session {own} but the console is session {console} — display \
|
||||||
|
writes and input kicks cannot work from a non-console session; reconnect the \
|
||||||
|
console or run via the installed service]"
|
||||||
|
),
|
||||||
|
None => what,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `wait_for_attach`'s DRV_STATUS_TEX_FAIL as a typed error: the driver could not open the ring
|
||||||
|
/// textures, and `driver_luid` (packed, from the shared header) is the adapter its swap-chain
|
||||||
|
/// ACTUALLY renders on — `open_inner` downcasts to this to rebind the ring there once.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct AttachTexFail {
|
||||||
|
detail: u32,
|
||||||
|
driver_luid: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AttachTexFail {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"IDD-push driver failed to attach (driver_status={DRV_STATUS_TEX_FAIL} \
|
||||||
|
detail=0x{:08x}): it could not open the ring textures — its swap-chain renders on \
|
||||||
|
adapter {:08x}:{:08x}, not the ring's (render-adapter mismatch)",
|
||||||
|
self.detail,
|
||||||
|
(self.driver_luid >> 32) as i32,
|
||||||
|
(self.driver_luid & 0xffff_ffff) as u32,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for AttachTexFail {}
|
||||||
@@ -31,6 +31,10 @@ pub(super) struct StallWatch {
|
|||||||
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
|
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
|
||||||
recent: std::collections::VecDeque<Instant>,
|
recent: std::collections::VecDeque<Instant>,
|
||||||
cadence: pf_frame::metronome::Metronome,
|
cadence: pf_frame::metronome::Metronome,
|
||||||
|
/// Stalls seen this session, and how many had a coinciding OS display event — the discriminator
|
||||||
|
/// [`Self::report`] uses. They were capturer fields that nothing outside the report touched.
|
||||||
|
seen: u32,
|
||||||
|
with_os_events: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StallWatch {
|
impl StallWatch {
|
||||||
@@ -48,6 +52,8 @@ impl StallWatch {
|
|||||||
Self {
|
Self {
|
||||||
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
|
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
|
||||||
cadence: pf_frame::metronome::Metronome::new(),
|
cadence: pf_frame::metronome::Metronome::new(),
|
||||||
|
seen: 0,
|
||||||
|
with_os_events: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,4 +85,78 @@ impl StallWatch {
|
|||||||
metronomic: self.cadence.note(now),
|
metronomic: self.cadence.note(now),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
/// Log a detected stall, correlate it against OS display events, and — once the cadence turns
|
||||||
|
/// metronomic — name the class of disturbance and its cures.
|
||||||
|
///
|
||||||
|
/// Lives here rather than in `try_consume` (sweep Phase 5.4): it is ~65 lines of log prose plus
|
||||||
|
/// a running tally, all of it about stalls and none of it about consuming a frame, in a function
|
||||||
|
/// that runs per frame. `now` is the instant of the frame that ENDED the stall — the same one
|
||||||
|
/// passed to [`Self::note_fresh`] — which is what bounds the event-correlation window.
|
||||||
|
pub(super) fn report(&mut self, stall: &Stall, now: Instant) {
|
||||||
|
// OS display events inside the gap (plus a lead-in margin: the event that CAUSED the
|
||||||
|
// hole lands just before DWM stops delivering) — the attribution that turns "DWM
|
||||||
|
// stopped composing" into "…because Windows re-enumerated SAMSUNG on HDMI".
|
||||||
|
let window = stall.gap + Duration::from_millis(300);
|
||||||
|
let events = now
|
||||||
|
.checked_sub(window)
|
||||||
|
.map(|from| pf_win_display::display_events::events_between(from, now))
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.seen = self.seen.saturating_add(1);
|
||||||
|
if !events.is_empty() {
|
||||||
|
self.with_os_events = self.with_os_events.saturating_add(1);
|
||||||
|
}
|
||||||
|
// debug (not warn): a single hole also happens when content legitimately pauses;
|
||||||
|
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
||||||
|
// at debug level, and the web-console debug ring captures these.
|
||||||
|
tracing::debug!(
|
||||||
|
gap_ms = stall.gap.as_millis() as u64,
|
||||||
|
os_display_events = %pf_win_display::display_events::summarize(&events),
|
||||||
|
"IDD-push capture stall — the desktop was composing at speed, then DWM \
|
||||||
|
delivered no frame for the gap; the present path stalled below capture"
|
||||||
|
);
|
||||||
|
if let Some(period) = stall.metronomic {
|
||||||
|
let suspects = pf_win_display::display_events::connected_inactive_externals();
|
||||||
|
let suspects = if suspects.is_empty() {
|
||||||
|
"none".to_string()
|
||||||
|
} else {
|
||||||
|
suspects.join(", ")
|
||||||
|
};
|
||||||
|
let correlated = format!("{}/{}", self.with_os_events, self.seen);
|
||||||
|
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
|
||||||
|
// cascade is OS-visible; otherwise the disturbance never surfaces above the
|
||||||
|
// driver. Different classes, different cures — say which one this box has.
|
||||||
|
if self.with_os_events * 2 >= self.seen {
|
||||||
|
tracing::warn!(
|
||||||
|
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||||
|
os_correlated = correlated,
|
||||||
|
connected_inactive = %suspects,
|
||||||
|
"capture stalls are METRONOMIC and coincide with Windows monitor \
|
||||||
|
hot-plug/re-enumeration events — a connected display (or its \
|
||||||
|
cable/switch/AVR) re-probes the link on a timer and Windows re-reacts \
|
||||||
|
each time. Cures, best-first: that display's OSD 'auto input \
|
||||||
|
scan/detect' OFF (and on TVs: instant-on/quick-start + CEC off), \
|
||||||
|
unplug its cable at the GPU, an HPD-holding adapter/dummy plug, or \
|
||||||
|
keep it active while streaming; the pnp_disable_monitors policy axis \
|
||||||
|
suppresses the Windows-side reaction (see connected_inactive for the \
|
||||||
|
suspects)"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||||
|
os_correlated = correlated,
|
||||||
|
connected_inactive = %suspects,
|
||||||
|
"capture stalls are METRONOMIC with NO coinciding OS display event — \
|
||||||
|
the disturbance is BELOW Windows: the GPU driver servicing a \
|
||||||
|
connected-but-asleep sink (standby HPD/DDC/link probing), \
|
||||||
|
display-poller software (the SteelSeries-GG/SignalRGB class — \
|
||||||
|
correlate 'slow display-descriptor poll' lines), or the DWM present \
|
||||||
|
clock (try a different refresh rate). If connected_inactive lists a \
|
||||||
|
display, its standby probing is the prime suspect: unplug it at the \
|
||||||
|
GPU, disable its OSD auto input scan (TVs: instant-on/quick-start + \
|
||||||
|
CEC off), use an HPD-holding adapter/dummy, or keep it active while \
|
||||||
|
streaming"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,9 @@ impl Capturer for SyntheticNv12Capturer {
|
|||||||
/// # Safety
|
/// # Safety
|
||||||
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error.
|
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error.
|
||||||
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
|
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
|
||||||
|
// SAFETY: three `?`/`Ok`-checked DXGI enumeration calls over owned locals — the factory is
|
||||||
|
// created here and the adapters it returns own their own COM references. No raw pointers.
|
||||||
|
unsafe {
|
||||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
||||||
if let Some(luid) = pf_gpu::resolve_render_adapter_luid() {
|
if let Some(luid) = pf_gpu::resolve_render_adapter_luid() {
|
||||||
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) {
|
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) {
|
||||||
@@ -148,6 +151,7 @@ unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
|
|||||||
}
|
}
|
||||||
factory.EnumAdapters1(0).context("EnumAdapters1(0)")
|
factory.EnumAdapters1(0).context("EnumAdapters1(0)")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Create an NV12 `Texture2D` with the given usage/CPU-access/bind flags.
|
/// Create an NV12 `Texture2D` with the given usage/CPU-access/bind flags.
|
||||||
///
|
///
|
||||||
@@ -177,8 +181,12 @@ unsafe fn create_nv12(
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut tex: Option<ID3D11Texture2D> = None;
|
let mut tex: Option<ID3D11Texture2D> = None;
|
||||||
|
// SAFETY: one `?`-checked `CreateTexture2D` on the live `device` borrow (per the contract
|
||||||
|
// above), with a fully-initialized stack descriptor and a live `Option` out-param.
|
||||||
|
unsafe {
|
||||||
device
|
device
|
||||||
.CreateTexture2D(&desc, None, Some(&mut tex))
|
.CreateTexture2D(&desc, None, Some(&mut tex))
|
||||||
.context("CreateTexture2D(NV12)")?;
|
.context("CreateTexture2D(NV12)")?;
|
||||||
|
}
|
||||||
tex.context("CreateTexture2D returned a null NV12 texture")
|
tex.context("CreateTexture2D returned a null NV12 texture")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,6 +299,23 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The kind a slot DECLARES to the host ([`InputKind::GamepadArrival`]) given the user's
|
||||||
|
/// controller-type `setting` and the pad's `physical` kind: an explicit setting emulates that pad
|
||||||
|
/// for every slot, `Auto` keeps per-pad detection (what makes a mixed session honest).
|
||||||
|
///
|
||||||
|
/// This has to be applied per pad and not just in the Hello: the host builds each virtual device
|
||||||
|
/// from that pad's arrival and only falls back to the session default for a pad that never
|
||||||
|
/// declares one, so a client that always declared the detected kind would silently undo the
|
||||||
|
/// setting the moment a controller connected. The physical kind is still what the LOCAL feedback
|
||||||
|
/// paths use (DualSense raw effects, the Deck rumble keep-alive) — those talk to the controller in
|
||||||
|
/// the user's hands, not the one the host is pretending to have.
|
||||||
|
fn declared_kind(setting: GamepadPref, physical: GamepadPref) -> GamepadPref {
|
||||||
|
match setting {
|
||||||
|
GamepadPref::Auto => physical,
|
||||||
|
explicit => explicit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-effort "this machine is a Steam Deck". The Gaming-Mode env short-circuits; desktop
|
/// Best-effort "this machine is a Steam Deck". The Gaming-Mode env short-circuits; desktop
|
||||||
/// mode falls back to DMI (Valve board, Jupiter = LCD / Galileo = OLED — readable inside the
|
/// mode falls back to DMI (Valve board, Jupiter = LCD / Galileo = OLED — readable inside the
|
||||||
/// flatpak sandbox). Cached: the answer can't change while we run.
|
/// flatpak sandbox). Cached: the answer can't change while we run.
|
||||||
@@ -318,6 +335,7 @@ enum Ctl {
|
|||||||
Attach(Arc<NativeClient>),
|
Attach(Arc<NativeClient>),
|
||||||
Detach,
|
Detach,
|
||||||
Pin(Option<String>),
|
Pin(Option<String>),
|
||||||
|
KindOverride(GamepadPref),
|
||||||
MenuMode(bool),
|
MenuMode(bool),
|
||||||
MenuRumble(MenuPulse),
|
MenuRumble(MenuPulse),
|
||||||
}
|
}
|
||||||
@@ -452,6 +470,18 @@ impl GamepadService {
|
|||||||
let _ = self.ctl.send(Ctl::Pin(key));
|
let _ = self.ctl.send(Ctl::Pin(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adopt the user's explicit controller-type setting for the session about to start
|
||||||
|
/// (`GamepadPref::Auto` = detect per pad, the default).
|
||||||
|
///
|
||||||
|
/// This is NOT redundant with the session default in the Hello: a current host honors a pad's
|
||||||
|
/// [`InputKind::GamepadArrival`] over the session default, so a client that declared only the
|
||||||
|
/// detected kind would silently undo the setting the moment a controller connected. Call it
|
||||||
|
/// before [`Self::attach`] — slots declare their kind at open time and the host does not
|
||||||
|
/// hot-swap a device that already exists.
|
||||||
|
pub fn set_kind_override(&self, pref: GamepadPref) {
|
||||||
|
let _ = self.ctl.send(Ctl::KindOverride(pref));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||||
let _ = self.ctl.send(Ctl::Attach(connector));
|
let _ = self.ctl.send(Ctl::Attach(connector));
|
||||||
}
|
}
|
||||||
@@ -691,6 +721,10 @@ struct Worker {
|
|||||||
/// connected pads, so it survives restarts and disconnects. A pin forwards ONLY that pad
|
/// connected pads, so it survives restarts and disconnects. A pin forwards ONLY that pad
|
||||||
/// (an explicit single-player choice); Automatic forwards every real controller.
|
/// (an explicit single-player choice); Automatic forwards every real controller.
|
||||||
pinned: Option<String>,
|
pinned: Option<String>,
|
||||||
|
/// The user's explicit "controller type" setting ([`GamepadService::set_kind_override`]);
|
||||||
|
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
|
||||||
|
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
|
||||||
|
kind_override: GamepadPref,
|
||||||
attached: Option<Arc<NativeClient>>,
|
attached: Option<Arc<NativeClient>>,
|
||||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||||
escape_tx: async_channel::Sender<()>,
|
escape_tx: async_channel::Sender<()>,
|
||||||
@@ -886,6 +920,7 @@ impl Worker {
|
|||||||
Some(p) => p.pref,
|
Some(p) => p.pref,
|
||||||
None => GamepadPref::Xbox360,
|
None => GamepadPref::Xbox360,
|
||||||
};
|
};
|
||||||
|
let declared = declared_kind(self.kind_override, pref);
|
||||||
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
|
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
|
||||||
Ok(pad) => {
|
Ok(pad) => {
|
||||||
let mut slot = Slot::new(id, index, pref, pad);
|
let mut slot = Slot::new(id, index, pref, pad);
|
||||||
@@ -895,7 +930,13 @@ impl Worker {
|
|||||||
// re-sends it a few times against datagram loss; an older host ignores it and
|
// re-sends it a few times against datagram loss; an older host ignores it and
|
||||||
// uses the session-default kind.
|
// uses the session-default kind.
|
||||||
if let Some(c) = &self.attached {
|
if let Some(c) = &self.attached {
|
||||||
send(c, InputKind::GamepadArrival, pref.to_u8() as u32, 0, index);
|
send(
|
||||||
|
c,
|
||||||
|
InputKind::GamepadArrival,
|
||||||
|
declared.to_u8() as u32,
|
||||||
|
0,
|
||||||
|
index,
|
||||||
|
);
|
||||||
// Declare the actuator's quirks to the shared rumble policy engine. ALWAYS
|
// Declare the actuator's quirks to the shared rumble policy engine. ALWAYS
|
||||||
// set (defaults for a well-behaved pad): wire indices are reused within a
|
// set (defaults for a well-behaved pad): wire indices are reused within a
|
||||||
// connection, so a Deck slot that closes must not leave its keepalive quirk
|
// connection, so a Deck slot that closes must not leave its keepalive quirk
|
||||||
@@ -911,7 +952,13 @@ impl Worker {
|
|||||||
};
|
};
|
||||||
c.set_rumble_quirks(index as u16, quirks);
|
c.set_rumble_quirks(index as u16, quirks);
|
||||||
}
|
}
|
||||||
tracing::info!(id, index, pref = ?pref, "gamepad forwarding (slot opened)");
|
tracing::info!(
|
||||||
|
id,
|
||||||
|
index,
|
||||||
|
pref = ?pref,
|
||||||
|
declared = ?declared,
|
||||||
|
"gamepad forwarding (slot opened)"
|
||||||
|
);
|
||||||
self.slots.push(slot);
|
self.slots.push(slot);
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"),
|
Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"),
|
||||||
@@ -1219,6 +1266,7 @@ impl Worker {
|
|||||||
self.pinned = key;
|
self.pinned = key;
|
||||||
self.refresh_active();
|
self.refresh_active();
|
||||||
}
|
}
|
||||||
|
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
|
||||||
Ok(Ctl::MenuMode(on)) => {
|
Ok(Ctl::MenuMode(on)) => {
|
||||||
self.menu_mode = on;
|
self.menu_mode = on;
|
||||||
if on {
|
if on {
|
||||||
@@ -1558,6 +1606,7 @@ impl Worker {
|
|||||||
menu_open: None,
|
menu_open: None,
|
||||||
order: Vec::new(),
|
order: Vec::new(),
|
||||||
pinned: None,
|
pinned: None,
|
||||||
|
kind_override: GamepadPref::Auto,
|
||||||
attached: None,
|
attached: None,
|
||||||
escape_tx,
|
escape_tx,
|
||||||
disconnect_tx,
|
disconnect_tx,
|
||||||
@@ -1823,6 +1872,38 @@ mod slot_tests {
|
|||||||
assert_eq!(lowest_free_index(&but_seven), Some(7));
|
assert_eq!(lowest_free_index(&but_seven), Some(7));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_explicit_setting_is_what_every_pad_declares() {
|
||||||
|
// The regression this pins: the setting used to reach the Hello only, and each pad's
|
||||||
|
// arrival then re-declared the DETECTED kind — which the host honors over the session
|
||||||
|
// default, so "emulate my DualSense as a DualShock 4" produced a DualSense.
|
||||||
|
assert_eq!(
|
||||||
|
declared_kind(GamepadPref::DualShock4, GamepadPref::DualSense),
|
||||||
|
GamepadPref::DualShock4
|
||||||
|
);
|
||||||
|
// Every physical pad in a mixed session follows the one explicit choice.
|
||||||
|
for physical in [
|
||||||
|
GamepadPref::DualSense,
|
||||||
|
GamepadPref::Xbox360,
|
||||||
|
GamepadPref::SwitchPro,
|
||||||
|
GamepadPref::SteamDeck,
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
declared_kind(GamepadPref::Xbox360, physical),
|
||||||
|
GamepadPref::Xbox360
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Automatic keeps per-pad detection — otherwise a mixed session collapses to one type.
|
||||||
|
assert_eq!(
|
||||||
|
declared_kind(GamepadPref::Auto, GamepadPref::DualSense),
|
||||||
|
GamepadPref::DualSense
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
declared_kind(GamepadPref::Auto, GamepadPref::SteamDeck),
|
||||||
|
GamepadPref::SteamDeck
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hidout_pad_reads_every_variant() {
|
fn hidout_pad_reads_every_variant() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ pub struct GameEntry {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub art: Artwork,
|
pub art: Artwork,
|
||||||
|
/// The system the title runs on (`"PC"`, `"PS2"`, …) — free-form display string from the
|
||||||
|
/// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub platform: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||||
@@ -280,7 +284,7 @@ mod tests {
|
|||||||
fn game_entry_decodes_the_wire_shape() {
|
fn game_entry_decodes_the_wire_shape() {
|
||||||
// The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored).
|
// The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored).
|
||||||
let json = r#"[
|
let json = r#"[
|
||||||
{"id":"steam:570","store":"steam","title":"Dota 2",
|
{"id":"steam:570","store":"steam","title":"Dota 2","platform":"PC",
|
||||||
"art":{"portrait":"/api/v1/library/art/steam:570/portrait"},
|
"art":{"portrait":"/api/v1/library/art/steam:570/portrait"},
|
||||||
"launch":{"kind":"steam_appid","value":"570"}},
|
"launch":{"kind":"steam_appid","value":"570"}},
|
||||||
{"id":"custom:abc","store":"custom","title":"My Emu","art":{}}
|
{"id":"custom:abc","store":"custom","title":"My Emu","art":{}}
|
||||||
@@ -288,7 +292,12 @@ mod tests {
|
|||||||
let games: Vec<GameEntry> = serde_json::from_str(json).unwrap();
|
let games: Vec<GameEntry> = serde_json::from_str(json).unwrap();
|
||||||
assert_eq!(games.len(), 2);
|
assert_eq!(games.len(), 2);
|
||||||
assert_eq!(games[0].id, "steam:570");
|
assert_eq!(games[0].id, "steam:570");
|
||||||
|
assert_eq!(games[0].platform.as_deref(), Some("PC"));
|
||||||
assert!(games[1].art.portrait.is_none());
|
assert!(games[1].art.portrait.is_none());
|
||||||
|
assert!(
|
||||||
|
games[1].platform.is_none(),
|
||||||
|
"pre-metadata hosts still parse"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -249,9 +249,16 @@ impl Codec {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR
|
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and
|
||||||
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed
|
/// cursor plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap
|
||||||
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters).
|
/// `Copy`; fixed for the session (an HDR toggle re-initialises the encoder — re-query if that
|
||||||
|
/// matters).
|
||||||
|
///
|
||||||
|
/// (There is deliberately NO `supports_hdr_metadata` cap: in-band HDR SEI/OBU embedding needs no
|
||||||
|
/// host-side routing — every first-party client reads the static grade exclusively out-of-band
|
||||||
|
/// (the native 0xCE datagram / the GameStream 0x010e control message), both planes send it
|
||||||
|
/// unconditionally, and the in-band grade is a decoder-side bonus for stock clients. A cap field
|
||||||
|
/// nothing reads is a contract nobody honors; it was deleted after shipping write-only.)
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
pub struct EncoderCaps {
|
pub struct EncoderCaps {
|
||||||
/// The encoder can perform real reference-frame invalidation — i.e.
|
/// The encoder can perform real reference-frame invalidation — i.e.
|
||||||
@@ -261,10 +268,6 @@ pub struct EncoderCaps {
|
|||||||
/// AMF (user-LTR force-reference, when the driver accepted the LTR slots at open). The
|
/// AMF (user-LTR force-reference, when the driver accepted the LTR slots at open). The
|
||||||
/// libavcodec paths (Linux NVENC, VAAPI, QSV) can't express it and always keyframe.
|
/// libavcodec paths (Linux NVENC, VAAPI, QSV) can't express it and always keyframe.
|
||||||
pub supports_rfi: bool,
|
pub supports_rfi: bool,
|
||||||
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
|
|
||||||
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
|
|
||||||
/// Windows direct-NVENC path attaches it today.
|
|
||||||
pub supports_hdr_metadata: bool,
|
|
||||||
/// The opened encoder is actually producing a full-chroma 4:4:4 (`chroma_format_idc = 3`) stream.
|
/// The opened encoder is actually producing a full-chroma 4:4:4 (`chroma_format_idc = 3`) stream.
|
||||||
/// `false` on every 4:2:0 session (the default) and on a backend that declined 4:4:4. Set by the
|
/// `false` on every 4:2:0 session (the default) and on a backend that declined 4:4:4. Set by the
|
||||||
/// NVENC backends (Linux + Windows). The chroma is committed to the wire (`Welcome::chroma_format`)
|
/// NVENC backends (Linux + Windows). The chroma is committed to the wire (`Welcome::chroma_format`)
|
||||||
@@ -304,8 +307,11 @@ pub struct EncoderCaps {
|
|||||||
///
|
///
|
||||||
/// This makes the answer queryable instead of assumed. It is deliberately a plain fact about the
|
/// This makes the answer queryable instead of assumed. It is deliberately a plain fact about the
|
||||||
/// encoder, not a policy: what to DO when a session wants blending and the backend cannot is the
|
/// encoder, not a policy: what to DO when a session wants blending and the backend cannot is the
|
||||||
/// host's call, since only the host can re-plan capture (fall back to capturer-side compositing).
|
/// host's call, since only the host can re-plan capture. That call is wired now — the
|
||||||
/// `open_video` can only warn, which it does.
|
/// negotiation consults the pre-open mirror ([`cursor_blend_capable`](crate::cursor_blend_capable))
|
||||||
|
/// to gate the cursor channel and to keep capture on embedded-cursor / CSC-capable shapes for
|
||||||
|
/// any backend that can't blend; `open_video`'s post-open check remains as the backstop for
|
||||||
|
/// open-time fallbacks the plan can't see.
|
||||||
pub blends_cursor: bool,
|
pub blends_cursor: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,11 +339,10 @@ pub trait Encoder: Send {
|
|||||||
let _ = wire_index;
|
let _ = wire_index;
|
||||||
self.submit(frame)
|
self.submit(frame)
|
||||||
}
|
}
|
||||||
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can
|
/// This encoder's static [capabilities](EncoderCaps) (RFI, intra-refresh, chroma, cursor
|
||||||
/// route by query rather than rely on the no-op/`false` defaults of
|
/// blending), so the session glue can route by query rather than rely on the no-op/`false`
|
||||||
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta).
|
/// defaults of methods like [`invalidate_ref_frames`](Self::invalidate_ref_frames).
|
||||||
/// Default: no optional capabilities (the SDR / libavcodec backends) — only the direct-NVENC
|
/// Default: no optional capabilities (the software / libavcodec backends).
|
||||||
/// path overrides it.
|
|
||||||
fn caps(&self) -> EncoderCaps {
|
fn caps(&self) -> EncoderCaps {
|
||||||
EncoderCaps::default()
|
EncoderCaps::default()
|
||||||
}
|
}
|
||||||
@@ -345,10 +350,12 @@ pub trait Encoder: Send {
|
|||||||
/// reference-frame-invalidation request). Default: no-op.
|
/// reference-frame-invalidation request). Default: no-op.
|
||||||
fn request_keyframe(&mut self) {}
|
fn request_keyframe(&mut self) {}
|
||||||
/// Set the source's static HDR mastering metadata (from the capturer). An HDR encoder emits it
|
/// Set the source's static HDR mastering metadata (from the capturer). An HDR encoder emits it
|
||||||
/// as in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each
|
/// in-band (HEVC/H.264 `mastering_display_colour_volume` + `content_light_level_info` SEI, or
|
||||||
/// keyframe so any decoder — including stock Moonlight — tone-maps from the source's real grade.
|
/// AV1 metadata OBUs) on keyframes so a stock decoder — e.g. stock Moonlight — tone-maps from
|
||||||
/// Default: no-op (SDR encoders / libavcodec paths that don't attach it yet). Cheap to call
|
/// the source's real grade. Default: no-op (SDR encoders / paths that don't attach it).
|
||||||
/// every frame; only the direct-NVENC path consumes it.
|
/// Cheap to call every frame; consumed by Windows direct-NVENC, native AMF, and native QSV.
|
||||||
|
/// Every first-party client reads the grade out-of-band (the 0xCE datagram) regardless, so
|
||||||
|
/// this is a bonus for stock decoders, never the primary channel.
|
||||||
fn set_hdr_meta(&mut self, _meta: Option<punktfunk_core::quic::HdrMeta>) {}
|
fn set_hdr_meta(&mut self, _meta: Option<punktfunk_core::quic::HdrMeta>) {}
|
||||||
/// Invalidate a contiguous range of previously-encoded reference frames (client frame numbers
|
/// Invalidate a contiguous range of previously-encoded reference frames (client frame numbers
|
||||||
/// — WIRE frame indexes, the domain [`submit_indexed`](Self::submit_indexed) pins the encoder's
|
/// — WIRE frame indexes, the domain [`submit_indexed`](Self::submit_indexed) pins the encoder's
|
||||||
|
|||||||
@@ -1613,14 +1613,22 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
// `async_rt` must be absent too: in two-thread mode the frame may be recycled right after
|
// `async_rt` must be absent too: in two-thread mode the frame may be recycled right after
|
||||||
// submit returns while the stream still holds its copy (belt-and-braces — an escalated
|
// submit returns while the stream still holds its copy (belt-and-braces — an escalated
|
||||||
// session was rebuilt without the binding, so `stream_ordered` is false there anyway).
|
// session was rebuilt without the binding, so `stream_ordered` is false there anyway).
|
||||||
// Cursor-bearing frames additionally force the CPU-synced path: the Vulkan blend sits
|
let base_ordered =
|
||||||
// between the CUDA copy and the encode, and its cross-API ordering is fence/CPU-
|
self.stream_ordered && self.async_rt.is_none() && self.pending.is_empty();
|
||||||
// established, not stream-ordered. Frames without a cursor (games hide it; client-draws
|
// Cursor-bearing frames stay on the fast path when the blend itself can be stream-
|
||||||
// sessions strip it) keep the stream-ordered fast path untouched.
|
// ordered: the Vulkan dispatch waits/advances a timeline semaphore CUDA also holds, so
|
||||||
let ordered = self.stream_ordered
|
// copy→blend→encode orders entirely on-device (`VkSlotBlend::blend_ref_ordered`). Where
|
||||||
&& self.async_rt.is_none()
|
// that isn't available (no timeline export, or the ring fell back to plain CUDA slots)
|
||||||
&& self.pending.is_empty()
|
// a cursor forces the CPU-synced path: the blend's cross-API ordering is then fence/CPU-
|
||||||
&& captured.cursor.is_none();
|
// established, sitting between the copy and the encode. That slow path is why cursor
|
||||||
|
// frames USED to be gated out entirely — under gamescope the compositor re-attaches the
|
||||||
|
// live pointer to EVERY frame, and the per-frame CPU syncs (exposed to the running
|
||||||
|
// game's GPU load) capped a 120 fps session near 80 (submit p50 ~10 ms).
|
||||||
|
let cursor_ordered = base_ordered
|
||||||
|
&& captured.cursor.is_some()
|
||||||
|
&& matches!(self.ring[slot].surface, SlotSurface::Vk(_))
|
||||||
|
&& self.vk_blend.as_ref().is_some_and(|vk| vk.ordered_ready());
|
||||||
|
let ordered = base_ordered && (captured.cursor.is_none() || cursor_ordered);
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
|
|
||||||
// Copy the captured buffer into this slot's input surface before encoding it.
|
// Copy the captured buffer into this slot's input surface before encoding it.
|
||||||
@@ -1629,21 +1637,26 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
|
|
||||||
// Cursor-as-metadata: blend the overlay into this slot's OWNED input surface via the
|
// Cursor-as-metadata: blend the overlay into this slot's OWNED input surface via the
|
||||||
// SPIR-V compute pass (a dispatch over the cursor's rect — never the compositor's
|
// SPIR-V compute pass (a dispatch over the cursor's rect — never the compositor's
|
||||||
// dmabuf). Cursor-bearing frames forced `ordered = false` above, so the CUDA copy has
|
// dmabuf). On the `cursor_ordered` path the enqueued copy, the dispatch, and the encode
|
||||||
// completed before the Vulkan dispatch and the fence-waited dispatch completes before
|
// are ordered on-device through the timeline semaphore (no CPU sync — see the gate
|
||||||
// the encode below — the cross-API ordering is CPU-established. Any failure degrades to
|
// above). Otherwise `ordered` is false: the CUDA copy completed before the Vulkan
|
||||||
// no cursor, never a dropped frame.
|
// dispatch and the fence-waited dispatch completes before the encode below — the
|
||||||
|
// cross-API ordering is CPU-established. Any failure degrades to no cursor, never a
|
||||||
|
// dropped frame (a failed ordered blend leaves the copy→encode stream ordering intact).
|
||||||
if let Some(ov) = &captured.cursor {
|
if let Some(ov) = &captured.cursor {
|
||||||
if let (Some(vk), SlotSurface::Vk(vref)) =
|
if let (Some(vk), SlotSurface::Vk(vref)) =
|
||||||
(self.vk_blend.as_mut(), &self.ring[slot].surface)
|
(self.vk_blend.as_mut(), &self.ring[slot].surface)
|
||||||
{
|
{
|
||||||
if self.cursor_serial != ov.serial {
|
if self.cursor_serial != ov.serial {
|
||||||
|
// Quiesces any in-flight ordered blend internally before touching the
|
||||||
|
// staging buffer (bitmap changes are rare — shape flips).
|
||||||
vk.upload_cursor(ov.rgba.as_slice(), ov.w, ov.h);
|
vk.upload_cursor(ov.rgba.as_slice(), ov.w, ov.h);
|
||||||
self.cursor_serial = ov.serial;
|
self.cursor_serial = ov.serial;
|
||||||
}
|
}
|
||||||
// surfW = content width; the blend derives plane strides from the slot's luma
|
// surfW = content width; the blend derives plane strides from the slot's luma
|
||||||
// height. Cursor pixels past the content land in cropped padding rows — harmless.
|
// height. Cursor pixels past the content land in cropped padding rows — harmless.
|
||||||
let r = vk.blend_ref(
|
let r = if cursor_ordered {
|
||||||
|
vk.blend_ref_ordered(
|
||||||
vref,
|
vref,
|
||||||
slot_fmt_of(self.buffer_fmt),
|
slot_fmt_of(self.buffer_fmt),
|
||||||
self.width,
|
self.width,
|
||||||
@@ -1651,7 +1664,18 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
ov.h,
|
ov.h,
|
||||||
ov.x,
|
ov.x,
|
||||||
ov.y,
|
ov.y,
|
||||||
);
|
)
|
||||||
|
} else {
|
||||||
|
vk.blend_ref(
|
||||||
|
vref,
|
||||||
|
slot_fmt_of(self.buffer_fmt),
|
||||||
|
self.width,
|
||||||
|
ov.w,
|
||||||
|
ov.h,
|
||||||
|
ov.x,
|
||||||
|
ov.y,
|
||||||
|
)
|
||||||
|
};
|
||||||
if let Err(e) = r {
|
if let Err(e) = r {
|
||||||
if !self.cursor_blend_warned {
|
if !self.cursor_blend_warned {
|
||||||
self.cursor_blend_warned = true;
|
self.cursor_blend_warned = true;
|
||||||
@@ -1686,7 +1710,9 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
// stack-local and outlives the synchronous `encode_picture`. The input surface for `slot` was
|
// stack-local and outlives the synchronous `encode_picture`. The input surface for `slot` was
|
||||||
// just filled by the device→device copy — either synchronized (blocking mode) or ordered
|
// just filled by the device→device copy — either synchronized (blocking mode) or ordered
|
||||||
// before this encode by the session's IO-stream binding (`ordered` — same stream, see the
|
// before this encode by the session's IO-stream binding (`ordered` — same stream, see the
|
||||||
// gate above) — and is not overwritten until this slot is reused POOL submits later, by
|
// gate above; on the `cursor_ordered` path the blend's writes are likewise ordered before
|
||||||
|
// the encode, via the timeline-semaphore wait `blend_ref_ordered` enqueued on that same
|
||||||
|
// stream) — and is not overwritten until this slot is reused POOL submits later, by
|
||||||
// which time this encode was polled (POOL ≥ in-flight depth; in ordered mode the poll's
|
// which time this encode was polled (POOL ≥ in-flight depth; in ordered mode the poll's
|
||||||
// blocking lock additionally proves the enqueued copy completed).
|
// blocking lock additionally proves the enqueued copy completed).
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1850,7 +1876,6 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
// Composites `frame.cursor` via the SPIR-V blend over the Vulkan-allocated input slot.
|
// Composites `frame.cursor` via the SPIR-V blend over the Vulkan-allocated input slot.
|
||||||
blends_cursor: true,
|
blends_cursor: true,
|
||||||
supports_rfi: self.rfi_supported,
|
supports_rfi: self.rfi_supported,
|
||||||
supports_hdr_metadata: self.hdr,
|
|
||||||
chroma_444: self.chroma_444,
|
chroma_444: self.chroma_444,
|
||||||
intra_refresh: false,
|
intra_refresh: false,
|
||||||
intra_refresh_recovery: false,
|
intra_refresh_recovery: false,
|
||||||
@@ -2674,6 +2699,85 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ON-HARDWARE (RTX box `.21`): cursor-bearing frames must KEEP the stream-ordered fast
|
||||||
|
/// path — the gamescope 80-fps-on-a-120-session fix. With the timeline-semaphore blend
|
||||||
|
/// available, `submit` takes `blend_ref_ordered` (the ticket advances by 2 per frame)
|
||||||
|
/// instead of the CPU-synced fence-wait blend, and AUs keep flowing — including across a
|
||||||
|
/// cursor-bitmap change (exercises the upload quiesce) and per-frame position moves.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||||
|
fn nvenc_cuda_cursor_blend_stream_ordered() {
|
||||||
|
const W: u32 = 1280;
|
||||||
|
const H: u32 = 720;
|
||||||
|
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||||
|
// Respect an explicit operator opt-out (or two-thread mode) rather than fail.
|
||||||
|
if !stream_ordered_requested() || async_retrieve_requested() {
|
||||||
|
println!("skipped: stream-ordered submit disabled by env");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut enc = NvencCudaEncoder::open(
|
||||||
|
Codec::H265,
|
||||||
|
PixelFormat::Nv12,
|
||||||
|
W,
|
||||||
|
H,
|
||||||
|
60,
|
||||||
|
8_000_000,
|
||||||
|
true,
|
||||||
|
8,
|
||||||
|
ChromaFormat::Yuv420,
|
||||||
|
true, // cursor_blend: bring up the Vulkan slot ring + blend
|
||||||
|
)
|
||||||
|
.expect("open NVENC CUDA session");
|
||||||
|
let cursor = |serial: u64, x: i32, y: i32| pf_frame::CursorOverlay {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w: 32,
|
||||||
|
h: 32,
|
||||||
|
rgba: std::sync::Arc::new(vec![0xFF; 32 * 32 * 4]),
|
||||||
|
serial,
|
||||||
|
hot_x: 0,
|
||||||
|
hot_y: 0,
|
||||||
|
visible: true,
|
||||||
|
};
|
||||||
|
let mut aus = 0usize;
|
||||||
|
for i in 0..6u32 {
|
||||||
|
let mut frame = nv12_frame(W, H, i);
|
||||||
|
// Bitmap serial flips at frame 3 (upload quiesce over in-flight ordered blends);
|
||||||
|
// the position moves every frame (push-constant path).
|
||||||
|
frame.cursor = Some(cursor(
|
||||||
|
if i < 3 { 1 } else { 2 },
|
||||||
|
40 + i as i32 * 9,
|
||||||
|
60 + i as i32 * 5,
|
||||||
|
));
|
||||||
|
enc.submit_indexed(&frame, i).expect("submit cursor frame");
|
||||||
|
while enc.poll().expect("poll").is_some() {
|
||||||
|
aus += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(aus, 6, "every cursor frame must deliver an AU");
|
||||||
|
assert!(
|
||||||
|
enc.stream_ordered,
|
||||||
|
"IO-stream binding must arm on a default-env session"
|
||||||
|
);
|
||||||
|
let vk = enc
|
||||||
|
.vk_blend
|
||||||
|
.as_ref()
|
||||||
|
.expect("Vulkan slot blend must come up on an RTX box");
|
||||||
|
assert!(
|
||||||
|
vk.ordered_ready(),
|
||||||
|
"timeline semaphore must export to CUDA on this driver"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
vk.ordered_ticket(),
|
||||||
|
12,
|
||||||
|
"all 6 cursor blends must take the ordered path (2 timeline values each)"
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"nvenc_cuda cursor stream-ordered: 6 cursor AUs, ticket={}",
|
||||||
|
vk.ordered_ticket()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// ON-HARDWARE (RTX box `.21`): the §7 LN3 pipelined-retrieve escalation —
|
/// ON-HARDWARE (RTX box `.21`): the §7 LN3 pipelined-retrieve escalation —
|
||||||
/// `set_pipelined(true)` on a live sync session must rebuild it without the IO-stream
|
/// `set_pipelined(true)` on a live sync session must rebuild it without the IO-stream
|
||||||
/// binding, spawn the retrieve thread on the re-open, and keep delivering AUs (the first
|
/// binding, spawn the retrieve thread on the re-open, and keep delivering AUs (the first
|
||||||
|
|||||||
@@ -94,23 +94,152 @@ type LpKey = (String, &'static str, bool);
|
|||||||
/// The [`LP_MODE`] key for this device/codec/depth. `render_node()` is re-read rather than cached
|
/// The [`LP_MODE`] key for this device/codec/depth. `render_node()` is re-read rather than cached
|
||||||
/// so a GPU-preference change is picked up on the next open.
|
/// so a GPU-preference change is picked up on the next open.
|
||||||
fn lp_key(codec: Codec, ten_bit: bool) -> LpKey {
|
fn lp_key(codec: Codec, ten_bit: bool) -> LpKey {
|
||||||
(
|
lp_key_for(&render_node().to_string_lossy(), codec, ten_bit)
|
||||||
render_node().to_string_lossy().into_owned(),
|
}
|
||||||
codec.label(),
|
|
||||||
ten_bit,
|
/// [`lp_key`] with the render node explicit (device-free for the unit tests). Every part of the
|
||||||
)
|
/// key is load-bearing — see [`LP_MODE`] for the two field-motivated bugs (node: cross-GPU
|
||||||
|
/// session-killer; depth: HDR under-advertisement).
|
||||||
|
fn lp_key_for(node: &str, codec: Codec, ten_bit: bool) -> LpKey {
|
||||||
|
(node.to_owned(), codec.label(), ten_bit)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PUNKTFUNK_VAAPI_LOW_POWER` pins the entrypoint mode (`1` = low-power only, `0` = full-feature
|
/// `PUNKTFUNK_VAAPI_LOW_POWER` pins the entrypoint mode (`1` = low-power only, `0` = full-feature
|
||||||
/// only); unset → try full-feature first, fall back to low-power.
|
/// only); unset → try full-feature first, fall back to low-power.
|
||||||
fn low_power_override() -> Option<bool> {
|
fn low_power_override() -> Option<bool> {
|
||||||
match std::env::var("PUNKTFUNK_VAAPI_LOW_POWER").ok()?.trim() {
|
parse_low_power(&std::env::var("PUNKTFUNK_VAAPI_LOW_POWER").ok()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`low_power_override`]'s value grammar (device-free for the unit tests). Anything outside the
|
||||||
|
/// two literal sets means "no pin" — the `[full-feature, low-power]` ladder runs.
|
||||||
|
fn parse_low_power(raw: &str) -> Option<bool> {
|
||||||
|
match raw.trim() {
|
||||||
"1" | "true" | "yes" | "on" => Some(true),
|
"1" | "true" | "yes" | "on" => Some(true),
|
||||||
"0" | "false" | "no" | "off" => Some(false),
|
"0" | "false" | "no" | "off" => Some(false),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The entrypoint modes to attempt, in order (`false` = full-feature `EncSlice`, `true` =
|
||||||
|
/// low-power VDEnc): an operator pin tries exactly that one; a cached resolution ([`LP_MODE`]:
|
||||||
|
/// 1 = full-feature, 2 = low-power) skips the known-failing attempt; anything else runs the full
|
||||||
|
/// ladder — full-feature first so AMD's first-try open stays byte-for-byte unchanged. Never
|
||||||
|
/// empty: [`open_vaapi_encoder`] relies on at least one attempt running.
|
||||||
|
fn entrypoint_ladder(pin: Option<bool>, cached: u8) -> &'static [bool] {
|
||||||
|
match pin {
|
||||||
|
Some(true) => &[true],
|
||||||
|
Some(false) => &[false],
|
||||||
|
None => match cached {
|
||||||
|
1 => &[false],
|
||||||
|
2 => &[true],
|
||||||
|
_ => &[false, true],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [`LP_MODE`] value a successful open latches (1 = full-feature, 2 = low-power). Paired with
|
||||||
|
/// [`entrypoint_ladder`], which maps it back to the single-mode retry list.
|
||||||
|
fn latched_mode(low_power: bool) -> u8 {
|
||||||
|
if low_power {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The VUI colour metadata the encoder signals for the session's depth.
|
||||||
|
struct Vui {
|
||||||
|
colorspace: ffi::AVColorSpace,
|
||||||
|
range: ffi::AVColorRange,
|
||||||
|
primaries: ffi::AVColorPrimaries,
|
||||||
|
trc: ffi::AVColorTransferCharacteristic,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010 the
|
||||||
|
/// CSC produces (swscale BT.2020 on the CPU path; `scale_vaapi` pinned to bt2020 on the zero-copy
|
||||||
|
/// path); the client decoder auto-detects PQ from the VUI. SDR: we hand the encoder BT.709
|
||||||
|
/// *limited* NV12 (swscale CSC on the CPU path; `scale_vaapi` pinned to
|
||||||
|
/// `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range RGB input
|
||||||
|
/// tagged), so signal that VUI — else the client decoder washes the picture out.
|
||||||
|
fn vui_for(ten_bit: bool) -> Vui {
|
||||||
|
if ten_bit {
|
||||||
|
Vui {
|
||||||
|
colorspace: ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL,
|
||||||
|
range: ffi::AVColorRange::AVCOL_RANGE_MPEG,
|
||||||
|
primaries: ffi::AVColorPrimaries::AVCOL_PRI_BT2020,
|
||||||
|
trc: ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Vui {
|
||||||
|
colorspace: ffi::AVColorSpace::AVCOL_SPC_BT709,
|
||||||
|
range: ffi::AVColorRange::AVCOL_RANGE_MPEG,
|
||||||
|
primaries: ffi::AVColorPrimaries::AVCOL_PRI_BT709,
|
||||||
|
trc: ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The explicit `profile` option for this codec/depth, or `None` to let the encoder derive it.
|
||||||
|
/// HEVC Main10 is pinned explicitly so the depth is never silently dropped (`hevc_vaapi` would
|
||||||
|
/// derive it from the P010 surfaces, but a derivation can regress quietly); 10-bit AV1 is
|
||||||
|
/// input-driven — no profile knob — and every 8-bit open keeps the encoder default.
|
||||||
|
fn explicit_profile(codec: Codec, ten_bit: bool) -> Option<&'static str> {
|
||||||
|
(ten_bit && codec == Codec::H265).then_some("main10")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUNKTFUNK_VAAPI_ASYNC_DEPTH` grammar: 1..=8 accepted verbatim; unset, junk, and out-of-range
|
||||||
|
/// values all resolve to depth 1 — the lowest-latency structure (see the `async_depth` note at the
|
||||||
|
/// call site for why 1 is the default and what depth ≥ 2 trades).
|
||||||
|
fn async_depth(raw: Option<&str>) -> u32 {
|
||||||
|
raw.and_then(|s| s.parse::<u32>().ok())
|
||||||
|
.filter(|d| (1..=8).contains(d))
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `scale_vaapi` filter args for the session's depth. The VPP's output colour is pinned to
|
||||||
|
/// exactly what the encoder's VUI signals ([`vui_for`]) — without the explicit options the
|
||||||
|
/// conversion matrix is whatever the driver defaults to for an unspecified output (Mesa: BT.601),
|
||||||
|
/// a hue shift against the signaled VUI. (The PQ transfer is per-channel and rides through the
|
||||||
|
/// matrix untouched.)
|
||||||
|
fn scale_vaapi_args(ten_bit: bool) -> &'static CStr {
|
||||||
|
if ten_bit {
|
||||||
|
c"format=p010:out_color_matrix=bt2020:out_range=limited"
|
||||||
|
} else {
|
||||||
|
c"format=nv12:out_color_matrix=bt709:out_range=limited"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a (captured format, negotiated bit depth) pair resolves to at open. 10-bit rides on the
|
||||||
|
/// captured PIXELS, not the negotiated depth — see [`crate::ten_bit_input`] for the failure the
|
||||||
|
/// reverse shape produces.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum DepthResolution {
|
||||||
|
/// PQ-graded 10-bit frames on a non-10-bit session: refuse the open, so PQ content is never
|
||||||
|
/// mislabeled BT.709.
|
||||||
|
RefuseMislabeledPq,
|
||||||
|
/// 10-bit negotiated but the capture stayed SDR: honestly encode 8-bit (with a warning).
|
||||||
|
SdrDowngrade,
|
||||||
|
/// Format and negotiated depth agree.
|
||||||
|
Agreed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See [`DepthResolution`].
|
||||||
|
fn resolve_depth(format: PixelFormat, bit_depth: u8) -> DepthResolution {
|
||||||
|
if format.is_hdr_rgb10() && bit_depth != 10 {
|
||||||
|
DepthResolution::RefuseMislabeledPq
|
||||||
|
} else if bit_depth == 10 && !format.is_hdr_rgb10() {
|
||||||
|
DepthResolution::SdrDowngrade
|
||||||
|
} else {
|
||||||
|
DepthResolution::Agreed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a codec is even eligible for the 10-bit probe: PyroWave answers 10-bit support on its
|
||||||
|
/// own path (no VAAPI involved), and a codec without a 10-bit profile can never pass.
|
||||||
|
fn ten_bit_probe_eligible(codec: Codec) -> bool {
|
||||||
|
codec.supports_10bit() && codec != Codec::PyroWave
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the VAAPI encoder, resolving the entrypoint mode: try the full-feature entrypoint first
|
/// Open the VAAPI encoder, resolving the entrypoint mode: try the full-feature entrypoint first
|
||||||
/// and, if the driver rejects it, retry with `low_power=1` — modern Intel (Gen12+/Arc) exposes
|
/// and, if the driver rejects it, retry with `low_power=1` — modern Intel (Gen12+/Arc) exposes
|
||||||
/// ONLY the low-power VDEnc entrypoint (ffmpeg's `vaapi_encode` defaults `low_power=0` and errors
|
/// ONLY the low-power VDEnc entrypoint (ffmpeg's `vaapi_encode` defaults `low_power=0` and errors
|
||||||
@@ -135,15 +264,7 @@ unsafe fn open_vaapi_encoder(
|
|||||||
.lock()
|
.lock()
|
||||||
.map(|m| m.get(&key).copied().unwrap_or(0))
|
.map(|m| m.get(&key).copied().unwrap_or(0))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let modes: &[bool] = match low_power_override() {
|
let modes: &[bool] = entrypoint_ladder(low_power_override(), cached);
|
||||||
Some(true) => &[true],
|
|
||||||
Some(false) => &[false],
|
|
||||||
None => match cached {
|
|
||||||
1 => &[false],
|
|
||||||
2 => &[true],
|
|
||||||
_ => &[false, true],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let mut first_err = None;
|
let mut first_err = None;
|
||||||
for &lp in modes {
|
for &lp in modes {
|
||||||
match open_vaapi_encoder_mode(
|
match open_vaapi_encoder_mode(
|
||||||
@@ -159,7 +280,7 @@ unsafe fn open_vaapi_encoder(
|
|||||||
) {
|
) {
|
||||||
Ok(enc) => {
|
Ok(enc) => {
|
||||||
if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() {
|
if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() {
|
||||||
m.insert(key.clone(), if lp { 2 } else { 1 });
|
m.insert(key.clone(), latched_mode(lp));
|
||||||
}
|
}
|
||||||
if lp {
|
if lp {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -216,32 +337,18 @@ unsafe fn open_vaapi_encoder_mode(
|
|||||||
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
||||||
let raw = video.as_mut_ptr();
|
let raw = video.as_mut_ptr();
|
||||||
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
||||||
if ten_bit {
|
let vui = vui_for(ten_bit);
|
||||||
// HDR10: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010
|
(*raw).colorspace = vui.colorspace;
|
||||||
// the CSC produces (swscale BT.2020 on the CPU path; scale_vaapi pinned to bt2020 on the
|
(*raw).color_range = vui.range;
|
||||||
// zero-copy path). The client decoder auto-detects PQ from the VUI.
|
(*raw).color_primaries = vui.primaries;
|
||||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
|
(*raw).color_trc = vui.trc;
|
||||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
|
||||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
|
|
||||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
|
|
||||||
} else {
|
|
||||||
// We hand the encoder BT.709 *limited* NV12 (swscale CSC on the CPU path; scale_vaapi pinned
|
|
||||||
// to `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range
|
|
||||||
// RGB input tagged), so signal that VUI — else the client decoder washes the picture out.
|
|
||||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
|
||||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
|
||||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
|
|
||||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
|
||||||
}
|
|
||||||
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
||||||
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
|
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
|
||||||
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
||||||
|
|
||||||
let mut opts = Dictionary::new();
|
let mut opts = Dictionary::new();
|
||||||
if ten_bit && codec == Codec::H265 {
|
if let Some(profile) = explicit_profile(codec, ten_bit) {
|
||||||
// HEVC Main10. `hevc_vaapi` derives it from the P010 surfaces, but pin it explicitly so
|
opts.set("profile", profile);
|
||||||
// the depth is never silently dropped. (10-bit AV1 is input-driven — no profile knob.)
|
|
||||||
opts.set("profile", "main10");
|
|
||||||
}
|
}
|
||||||
// async_depth=1: `send_frame` blocks until THIS frame's ASIC encode completes — the lowest
|
// async_depth=1: `send_frame` blocks until THIS frame's ASIC encode completes — the lowest
|
||||||
// latency structure libavcodec's vaapi_encode offers. Measured on the 780M at 1440p60: depth 1
|
// latency structure libavcodec's vaapi_encode offers. Measured on the 780M at 1440p60: depth 1
|
||||||
@@ -251,11 +358,7 @@ unsafe fn open_vaapi_encoder_mode(
|
|||||||
// where depth 2 restores throughput at that one-frame cost. NOTE: the per-frame block tracks
|
// where depth 2 restores throughput at that one-frame cost. NOTE: the per-frame block tracks
|
||||||
// GPU CLOCKS — a paced 60 fps trickle lets the VCN downclock (~8 ms/frame vs ~4.4 ms hot);
|
// GPU CLOCKS — a paced 60 fps trickle lets the VCN downclock (~8 ms/frame vs ~4.4 ms hot);
|
||||||
// see `gpuclocks` for the session clock pin that removes the ramp tax.
|
// see `gpuclocks` for the session clock pin that removes the ramp tax.
|
||||||
let depth = std::env::var("PUNKTFUNK_VAAPI_ASYNC_DEPTH")
|
let depth = async_depth(std::env::var("PUNKTFUNK_VAAPI_ASYNC_DEPTH").ok().as_deref());
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
|
||||||
.filter(|d| (1..=8).contains(d))
|
|
||||||
.unwrap_or(1);
|
|
||||||
opts.set("async_depth", &depth.to_string());
|
opts.set("async_depth", &depth.to_string());
|
||||||
if low_power {
|
if low_power {
|
||||||
opts.set("low_power", "1"); // VDEnc — the only encode entrypoint on modern Intel
|
opts.set("low_power", "1"); // VDEnc — the only encode entrypoint on modern Intel
|
||||||
@@ -309,7 +412,7 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
|||||||
/// ([`crate::can_encode_10bit`]), so a non-Main10 GPU resolves every session to 8-bit SDR before
|
/// ([`crate::can_encode_10bit`]), so a non-Main10 GPU resolves every session to 8-bit SDR before
|
||||||
/// the Welcome (honest downgrade).
|
/// the Welcome (honest downgrade).
|
||||||
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||||
if !codec.supports_10bit() || codec == Codec::PyroWave {
|
if !ten_bit_probe_eligible(codec) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ffmpeg::init().is_err() {
|
if ffmpeg::init().is_err() {
|
||||||
@@ -834,24 +937,7 @@ impl DmabufInner {
|
|||||||
}
|
}
|
||||||
init!(src, ptr::null(), "buffer");
|
init!(src, ptr::null(), "buffer");
|
||||||
init!(hwmap, c"mode=read".as_ptr(), "hwmap");
|
init!(hwmap, c"mode=read".as_ptr(), "hwmap");
|
||||||
// Pin the VPP's output colour to what the encoder's VUI signals (BT.709 limited SDR,
|
init!(scale, scale_vaapi_args(ten_bit).as_ptr(), "scale_vaapi");
|
||||||
// or BT.2020 limited P010 for HDR — the PQ transfer is per-channel and rides through
|
|
||||||
// the matrix untouched). Without the explicit options the conversion matrix is
|
|
||||||
// whatever the driver defaults to for an unspecified output (Mesa: BT.601) — a hue
|
|
||||||
// shift against the signaled VUI.
|
|
||||||
if ten_bit {
|
|
||||||
init!(
|
|
||||||
scale,
|
|
||||||
c"format=p010:out_color_matrix=bt2020:out_range=limited".as_ptr(),
|
|
||||||
"scale_vaapi"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
init!(
|
|
||||||
scale,
|
|
||||||
c"format=nv12:out_color_matrix=bt709:out_range=limited".as_ptr(),
|
|
||||||
"scale_vaapi"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
init!(sink, ptr::null(), "buffersink");
|
init!(sink, ptr::null(), "buffersink");
|
||||||
|
|
||||||
let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int {
|
let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int {
|
||||||
@@ -1143,21 +1229,18 @@ impl VaapiEncoder {
|
|||||||
chroma: super::ChromaFormat,
|
chroma: super::ChromaFormat,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
// 10-bit rides on the captured format: an HDR capture (X2RGB10/X2BGR10) opens the P010 /
|
// 10-bit rides on the captured format: an HDR capture (X2RGB10/X2BGR10) opens the P010 /
|
||||||
// Main10 / PQ-VUI variant of whichever inner path the first frame selects. A 10-bit
|
// Main10 / PQ-VUI variant of whichever inner path the first frame selects.
|
||||||
// request whose capture stayed SDR honestly encodes 8-bit; the reverse (PQ frames on an
|
match resolve_depth(format, bit_depth) {
|
||||||
// 8-bit session) is refused so PQ content is never mislabeled BT.709.
|
DepthResolution::RefuseMislabeledPq => bail!(
|
||||||
if format.is_hdr_rgb10() && bit_depth != 10 {
|
|
||||||
bail!(
|
|
||||||
"captured 10-bit HDR frames ({format:?}) on an {bit_depth}-bit VAAPI session — \
|
"captured 10-bit HDR frames ({format:?}) on an {bit_depth}-bit VAAPI session — \
|
||||||
refusing to mislabel PQ content"
|
refusing to mislabel PQ content"
|
||||||
);
|
),
|
||||||
}
|
DepthResolution::SdrDowngrade => tracing::warn!(
|
||||||
if bit_depth == 10 && !format.is_hdr_rgb10() {
|
|
||||||
tracing::warn!(
|
|
||||||
bit_depth,
|
bit_depth,
|
||||||
?format,
|
?format,
|
||||||
"10-bit requested but the capture stayed SDR — encoding 8-bit"
|
"10-bit requested but the capture stayed SDR — encoding 8-bit"
|
||||||
);
|
),
|
||||||
|
DepthResolution::Agreed => {}
|
||||||
}
|
}
|
||||||
// VAAPI 4:4:4 is deferred (see `probe_can_encode_444`): no validated AMD/Intel hardware in the
|
// VAAPI 4:4:4 is deferred (see `probe_can_encode_444`): no validated AMD/Intel hardware in the
|
||||||
// lab exposes a HEVC 4:4:4 encode entrypoint, and the probe returns false so the host never
|
// lab exposes a HEVC 4:4:4 encode entrypoint, and the probe returns false so the host never
|
||||||
@@ -1307,3 +1390,261 @@ impl Encoder for VaapiEncoder {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The operator pin tries exactly one mode; a cached resolution ([`LP_MODE`]) tries only the
|
||||||
|
/// mode that worked; anything else runs the full ladder, full-feature FIRST — AMD's first-try
|
||||||
|
/// open must stay byte-for-byte unchanged.
|
||||||
|
#[test]
|
||||||
|
fn entrypoint_ladder_orders_and_pins() {
|
||||||
|
assert_eq!(entrypoint_ladder(None, 0), &[false, true]);
|
||||||
|
assert_eq!(entrypoint_ladder(None, 1), &[false]);
|
||||||
|
assert_eq!(entrypoint_ladder(None, 2), &[true]);
|
||||||
|
// A corrupt/unknown cache value degrades to the full ladder, never to a wrong pin.
|
||||||
|
assert_eq!(entrypoint_ladder(None, 77), &[false, true]);
|
||||||
|
// The pin beats the cache in BOTH directions — what makes PUNKTFUNK_VAAPI_LOW_POWER a
|
||||||
|
// real escape hatch from a stale latch.
|
||||||
|
for cached in [0u8, 1, 2, 77] {
|
||||||
|
assert_eq!(entrypoint_ladder(Some(true), cached), &[true]);
|
||||||
|
assert_eq!(entrypoint_ladder(Some(false), cached), &[false]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The latch round-trip: what a successful open stores makes the NEXT open attempt exactly
|
||||||
|
/// the mode that worked (the "skip the known-failing attempt and its libav error spew"
|
||||||
|
/// contract — and, per [`LP_MODE`], the shape that must never cross devices or depths).
|
||||||
|
#[test]
|
||||||
|
fn latch_round_trip_pins_the_resolved_mode() {
|
||||||
|
for lp in [false, true] {
|
||||||
|
assert_eq!(entrypoint_ladder(None, latched_mode(lp)), &[lp]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUNKTFUNK_VAAPI_LOW_POWER` grammar: the two lowercase literal sets pin; anything else —
|
||||||
|
/// including uppercase — means "no pin" (the ladder runs). Case-sensitivity is pinned as
|
||||||
|
/// shipped behavior.
|
||||||
|
#[test]
|
||||||
|
fn low_power_grammar() {
|
||||||
|
for s in ["1", "true", "yes", "on", " on ", "yes\n"] {
|
||||||
|
assert_eq!(parse_low_power(s), Some(true), "{s:?}");
|
||||||
|
}
|
||||||
|
for s in ["0", "false", "no", "off", " off "] {
|
||||||
|
assert_eq!(parse_low_power(s), Some(false), "{s:?}");
|
||||||
|
}
|
||||||
|
for s in ["", "2", "TRUE", "On", "enabled", "low_power"] {
|
||||||
|
assert_eq!(parse_low_power(s), None, "{s:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every part of the LP_MODE key is load-bearing (see the [`LP_MODE`] field comments): the
|
||||||
|
/// node (a GPU-preference switch must re-resolve — the old codec-only key was a
|
||||||
|
/// session-killer), the codec, and the depth (an 8-bit answer must never pin the 10-bit open —
|
||||||
|
/// the HDR under-advertisement).
|
||||||
|
#[test]
|
||||||
|
fn lp_key_separates_node_codec_and_depth() {
|
||||||
|
let base = lp_key_for("/dev/dri/renderD128", Codec::H265, false);
|
||||||
|
assert_ne!(base, lp_key_for("/dev/dri/renderD129", Codec::H265, false));
|
||||||
|
assert_ne!(base, lp_key_for("/dev/dri/renderD128", Codec::H264, false));
|
||||||
|
assert_ne!(base, lp_key_for("/dev/dri/renderD128", Codec::H265, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUNKTFUNK_VAAPI_ASYNC_DEPTH` grammar: 1..=8 verbatim; unset, junk, zero, and past-the-cap
|
||||||
|
/// all resolve to the lowest-latency depth 1.
|
||||||
|
#[test]
|
||||||
|
fn async_depth_grammar() {
|
||||||
|
assert_eq!(async_depth(None), 1);
|
||||||
|
assert_eq!(async_depth(Some("1")), 1);
|
||||||
|
assert_eq!(async_depth(Some("2")), 2);
|
||||||
|
assert_eq!(async_depth(Some("8")), 8);
|
||||||
|
assert_eq!(async_depth(Some("0")), 1);
|
||||||
|
assert_eq!(async_depth(Some("9")), 1);
|
||||||
|
assert_eq!(async_depth(Some("-1")), 1);
|
||||||
|
assert_eq!(async_depth(Some("fast")), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The swscale source map: packed RGB/BGR and the GNOME 50+ HDR 2:10:10:10 layouts convert;
|
||||||
|
/// planar/video formats are refused (the CPU path is a packed-RGB fallback, not a general
|
||||||
|
/// converter).
|
||||||
|
#[test]
|
||||||
|
fn sws_src_accepts_packed_rgb_only() {
|
||||||
|
assert_eq!(vaapi_sws_src(PixelFormat::Bgrx).unwrap(), Pixel::BGRZ);
|
||||||
|
assert_eq!(vaapi_sws_src(PixelFormat::Rgbx).unwrap(), Pixel::RGBZ);
|
||||||
|
assert_eq!(vaapi_sws_src(PixelFormat::Bgra).unwrap(), Pixel::BGRA);
|
||||||
|
assert_eq!(vaapi_sws_src(PixelFormat::Rgba).unwrap(), Pixel::RGBA);
|
||||||
|
assert_eq!(vaapi_sws_src(PixelFormat::Rgb).unwrap(), Pixel::RGB24);
|
||||||
|
assert_eq!(vaapi_sws_src(PixelFormat::Bgr).unwrap(), Pixel::BGR24);
|
||||||
|
assert_eq!(
|
||||||
|
vaapi_sws_src(PixelFormat::X2Rgb10).unwrap(),
|
||||||
|
Pixel::X2RGB10LE
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
vaapi_sws_src(PixelFormat::X2Bgr10).unwrap(),
|
||||||
|
Pixel::X2BGR10LE
|
||||||
|
);
|
||||||
|
for f in [
|
||||||
|
PixelFormat::Nv12,
|
||||||
|
PixelFormat::P010,
|
||||||
|
PixelFormat::Rgb10a2,
|
||||||
|
PixelFormat::Yuv444,
|
||||||
|
] {
|
||||||
|
assert!(vaapi_sws_src(f).is_err(), "{f:?} must be refused");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VUI ↔ CSC agreement: the signaled VUI and the zero-copy VPP's pinned output colour must
|
||||||
|
/// name the SAME matrix/range per depth — a mismatch is exactly the Mesa-BT.601 hue shift the
|
||||||
|
/// pin exists to prevent.
|
||||||
|
#[test]
|
||||||
|
fn vui_and_scale_args_agree_per_depth() {
|
||||||
|
let sdr = vui_for(false);
|
||||||
|
assert!(matches!(sdr.colorspace, ffi::AVColorSpace::AVCOL_SPC_BT709));
|
||||||
|
assert!(matches!(sdr.range, ffi::AVColorRange::AVCOL_RANGE_MPEG));
|
||||||
|
assert!(matches!(
|
||||||
|
sdr.primaries,
|
||||||
|
ffi::AVColorPrimaries::AVCOL_PRI_BT709
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
sdr.trc,
|
||||||
|
ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709
|
||||||
|
));
|
||||||
|
let args = scale_vaapi_args(false).to_str().unwrap();
|
||||||
|
for needle in ["format=nv12", "out_color_matrix=bt709", "out_range=limited"] {
|
||||||
|
assert!(
|
||||||
|
args.contains(needle),
|
||||||
|
"SDR scale args miss {needle}: {args}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hdr = vui_for(true);
|
||||||
|
assert!(matches!(
|
||||||
|
hdr.colorspace,
|
||||||
|
ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL
|
||||||
|
));
|
||||||
|
assert!(matches!(hdr.range, ffi::AVColorRange::AVCOL_RANGE_MPEG));
|
||||||
|
assert!(matches!(
|
||||||
|
hdr.primaries,
|
||||||
|
ffi::AVColorPrimaries::AVCOL_PRI_BT2020
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
hdr.trc,
|
||||||
|
ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084
|
||||||
|
));
|
||||||
|
let args = scale_vaapi_args(true).to_str().unwrap();
|
||||||
|
for needle in [
|
||||||
|
"format=p010",
|
||||||
|
"out_color_matrix=bt2020",
|
||||||
|
"out_range=limited",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
args.contains(needle),
|
||||||
|
"HDR scale args miss {needle}: {args}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The honest-downgrade table at open: PQ pixels demand a 10-bit session (refused otherwise —
|
||||||
|
/// PQ content must never be mislabeled BT.709); a 10-bit session over SDR pixels downgrades
|
||||||
|
/// honestly to 8-bit; agreement passes both ways.
|
||||||
|
#[test]
|
||||||
|
fn depth_resolution_table() {
|
||||||
|
use DepthResolution::*;
|
||||||
|
assert_eq!(resolve_depth(PixelFormat::X2Rgb10, 8), RefuseMislabeledPq);
|
||||||
|
assert_eq!(resolve_depth(PixelFormat::X2Bgr10, 8), RefuseMislabeledPq);
|
||||||
|
assert_eq!(resolve_depth(PixelFormat::Bgrx, 10), SdrDowngrade);
|
||||||
|
assert_eq!(resolve_depth(PixelFormat::Bgrx, 8), Agreed);
|
||||||
|
assert_eq!(resolve_depth(PixelFormat::X2Rgb10, 10), Agreed);
|
||||||
|
assert_eq!(resolve_depth(PixelFormat::X2Bgr10, 10), Agreed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main10 is pinned explicitly for 10-bit HEVC ONLY: 10-bit AV1 is input-driven (no profile
|
||||||
|
/// knob), and every 8-bit open keeps the encoder's default profile.
|
||||||
|
#[test]
|
||||||
|
fn explicit_profile_is_hevc_main10_only() {
|
||||||
|
assert_eq!(explicit_profile(Codec::H265, true), Some("main10"));
|
||||||
|
assert_eq!(explicit_profile(Codec::H265, false), None);
|
||||||
|
assert_eq!(explicit_profile(Codec::Av1, true), None);
|
||||||
|
assert_eq!(explicit_profile(Codec::Av1, false), None);
|
||||||
|
assert_eq!(explicit_profile(Codec::H264, true), None);
|
||||||
|
assert_eq!(explicit_profile(Codec::H264, false), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 10-bit probe gate: HEVC and AV1 are probe-eligible; H.264 (no 10-bit path here) and
|
||||||
|
/// PyroWave (answers 10-bit on its own path, no VAAPI involved) are refused before any device
|
||||||
|
/// is touched — this is what keeps the probe safe on GPU-less CI.
|
||||||
|
#[test]
|
||||||
|
fn ten_bit_probe_gate() {
|
||||||
|
assert!(ten_bit_probe_eligible(Codec::H265));
|
||||||
|
assert!(ten_bit_probe_eligible(Codec::Av1));
|
||||||
|
assert!(!ten_bit_probe_eligible(Codec::H264));
|
||||||
|
assert!(!ten_bit_probe_eligible(Codec::PyroWave));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe smoke on real silicon: H.264 VAAPI encode opens on every supported AMD/Intel GPU;
|
||||||
|
/// the narrower codecs are printed, not asserted (device-dependent). Build anywhere, run on a
|
||||||
|
/// VAAPI host:
|
||||||
|
/// cargo test -p pf-encode --no-run
|
||||||
|
/// <host> target/debug/deps/pf_encode-<hash> --ignored --nocapture vaapi_probe_smoke
|
||||||
|
#[test]
|
||||||
|
#[ignore = "needs a real VAAPI device (run on an AMD/Intel host, not the build box)"]
|
||||||
|
fn vaapi_probe_smoke() {
|
||||||
|
assert!(
|
||||||
|
probe_can_encode(Codec::H264),
|
||||||
|
"H.264 VAAPI encode should open on any supported AMD/Intel GPU"
|
||||||
|
);
|
||||||
|
for codec in [Codec::H265, Codec::Av1] {
|
||||||
|
eprintln!("probe_can_encode({codec:?}) = {}", probe_can_encode(codec));
|
||||||
|
eprintln!(
|
||||||
|
"probe_can_encode_10bit({codec:?}) = {}",
|
||||||
|
probe_can_encode_10bit(codec)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CPU-path encode round-trip on real silicon: open → BGRX frames → poll AUs → the first AU
|
||||||
|
/// is the IDR. Exercises the swscale CSC, the VA surface upload, and the entrypoint ladder
|
||||||
|
/// end-to-end (same recipe as [`vaapi_probe_smoke`]).
|
||||||
|
#[test]
|
||||||
|
#[ignore = "needs a real VAAPI device (run on an AMD/Intel host, not the build box)"]
|
||||||
|
fn vaapi_cpu_encode_smoke() {
|
||||||
|
let (w, h) = (256u32, 256u32);
|
||||||
|
let mut enc = VaapiEncoder::open(
|
||||||
|
Codec::H264,
|
||||||
|
PixelFormat::Bgrx,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
30,
|
||||||
|
2_000_000,
|
||||||
|
8,
|
||||||
|
crate::ChromaFormat::Yuv420,
|
||||||
|
)
|
||||||
|
.expect("open");
|
||||||
|
let mut aus = Vec::new();
|
||||||
|
for i in 0..30u32 {
|
||||||
|
let mut buf = vec![0u8; (w * h * 4) as usize];
|
||||||
|
for px in buf.chunks_exact_mut(4) {
|
||||||
|
px.copy_from_slice(&[(i * 8) as u8, 0x40, 0xC0, 0xFF]);
|
||||||
|
}
|
||||||
|
let frame = CapturedFrame {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
pts_ns: u64::from(i) * 33_333_333,
|
||||||
|
format: PixelFormat::Bgrx,
|
||||||
|
payload: FramePayload::Cpu(buf),
|
||||||
|
cursor: None,
|
||||||
|
};
|
||||||
|
enc.submit(&frame).expect("submit");
|
||||||
|
while let Some(au) = enc.poll().expect("poll") {
|
||||||
|
aus.push(au);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enc.flush().expect("flush");
|
||||||
|
while let Some(au) = enc.poll().expect("poll") {
|
||||||
|
aus.push(au);
|
||||||
|
}
|
||||||
|
assert!(!aus.is_empty(), "no AUs out of 30 submitted frames");
|
||||||
|
assert!(aus[0].keyframe, "the first AU must be the IDR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,9 +76,11 @@ fn quality_request() -> u32 {
|
|||||||
/// (design/vulkan-rgb-direct-encode.md): the captured RGB frame is the encode source and the
|
/// (design/vulkan-rgb-direct-encode.md): the captured RGB frame is the encode source and the
|
||||||
/// VCN EFC front-end does the 709-narrow CSC inline — no compute CSC, no plane copies, one
|
/// VCN EFC front-end does the 709-narrow CSC inline — no compute CSC, no plane copies, one
|
||||||
/// queue submit per frame (unaligned modes go through the padded-copy staging blit). B2
|
/// queue submit per frame (unaligned modes go through the padded-copy staging blit). B2
|
||||||
/// default: ON wherever the probe passes, EXCEPT sessions that may need the CSC's cursor
|
/// default: ON wherever the probe passes, EXCEPT sessions that need the CSC's cursor blend
|
||||||
/// blend (see [`VulkanVideoEncoder::open`]). `=0` disables outright; `=1` forces it even on
|
/// (see [`VulkanVideoEncoder::open`]). `=0` disables outright; `=1` forces it on non-cursor
|
||||||
/// cursor-blend sessions (the pointer will be missing from the stream); unset = the default.
|
/// sessions; unset = the default. A cursor-blend session IGNORES `=1` — EFC cannot composite
|
||||||
|
/// the pointer, and the caps-aware negotiation promised the client a composited one, so the
|
||||||
|
/// pointer outranks the lab pin (the open logs the override).
|
||||||
///
|
///
|
||||||
/// Parses like every sibling knob (`matches!(v.trim(), …)`): anything unrecognised — including
|
/// Parses like every sibling knob (`matches!(v.trim(), …)`): anything unrecognised — including
|
||||||
/// an empty value and a value that is only whitespace — falls back to the default rather than
|
/// an empty value and a value that is only whitespace — falls back to the default rather than
|
||||||
@@ -614,10 +616,6 @@ pub struct VulkanVideoEncoder {
|
|||||||
/// GPU reset (those paths keep their aligned-size sources/staging).
|
/// GPU reset (those paths keep their aligned-size sources/staging).
|
||||||
native_nv12: bool,
|
native_nv12: bool,
|
||||||
|
|
||||||
/// One-shot warning latch: a cursor bitmap arrived on an RGB-direct or native-NV12 session
|
|
||||||
/// (neither has a compositing stage — the cursor will be missing from the stream until the
|
|
||||||
/// CSC path is used).
|
|
||||||
warned_cursor: bool,
|
|
||||||
/// A [`reconfigure_bitrate`](Encoder::reconfigure_bitrate) rate not yet installed in the video
|
/// A [`reconfigure_bitrate`](Encoder::reconfigure_bitrate) rate not yet installed in the video
|
||||||
/// session. The next `record_submit` emits an `ENCODE_RATE_CONTROL` control command carrying it
|
/// session. The next `record_submit` emits an `ENCODE_RATE_CONTROL` control command carrying it
|
||||||
/// (mid-stream) or folds it into the first frame's RESET+RC install, then promotes it into
|
/// (mid-stream) or folds it into the first frame's RESET+RC install, then promotes it into
|
||||||
@@ -671,7 +669,16 @@ impl VulkanVideoEncoder {
|
|||||||
cursor_blend: bool,
|
cursor_blend: bool,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let native_nv12 = format == PixelFormat::Nv12;
|
let native_nv12 = format == PixelFormat::Nv12;
|
||||||
let want_rgb = !native_nv12 && rgb_request().unwrap_or(!cursor_blend);
|
// A cursor-blend session must keep the compute-CSC path — the only arm with the cursor
|
||||||
|
// blend — so it outranks an explicit RGB-direct pin (EFC cannot composite; the
|
||||||
|
// negotiation promised the client a composited pointer).
|
||||||
|
if cursor_blend && rgb_request() == Some(true) {
|
||||||
|
tracing::info!(
|
||||||
|
"PUNKTFUNK_VULKAN_RGB_DIRECT=1 ignored for this session — it composites the \
|
||||||
|
pointer, which the EFC front-end cannot; using the compute-CSC path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let want_rgb = !native_nv12 && !cursor_blend && rgb_request().unwrap_or(true);
|
||||||
Self::open_opts_inner(
|
Self::open_opts_inner(
|
||||||
codec,
|
codec,
|
||||||
width,
|
width,
|
||||||
@@ -1448,7 +1455,6 @@ impl VulkanVideoEncoder {
|
|||||||
cpu_expand: Vec::new(),
|
cpu_expand: Vec::new(),
|
||||||
rgb: rgb_cfg,
|
rgb: rgb_cfg,
|
||||||
native_nv12,
|
native_nv12,
|
||||||
warned_cursor: false,
|
|
||||||
pending_bitrate: None,
|
pending_bitrate: None,
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
@@ -2621,17 +2627,10 @@ impl VulkanVideoEncoder {
|
|||||||
d.modifier
|
d.modifier
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// No compositing stage exists here (like RGB-direct/EFC): gamescope embeds its pointer
|
// No compositing stage exists here (like RGB-direct/EFC) — and none is needed: the
|
||||||
// in the produced pixels, but any other NV12 producer's metadata cursor would be lost —
|
// session plan negotiates native NV12 only for a non-cursor-blend session
|
||||||
// say so once instead of silently.
|
// (`SessionPlan::output_format` gates `nv12_native` on `!cursor_blend`), so no cursor
|
||||||
if frame.cursor.is_some() && !self.warned_cursor {
|
// bitmap ever reaches this arm. Gamescope embeds its pointer in the produced pixels.
|
||||||
self.warned_cursor = true;
|
|
||||||
tracing::warn!(
|
|
||||||
"cursor bitmap on a native-NV12 session — nothing composites it; the cursor \
|
|
||||||
will be missing from the stream (unset PUNKTFUNK_PIPEWIRE_NV12 for \
|
|
||||||
metadata-cursor captures)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let dev = self.device.clone();
|
let dev = self.device.clone();
|
||||||
let cmd = self.frames[slot].cmd;
|
let cmd = self.frames[slot].cmd;
|
||||||
let fence = self.frames[slot].fence;
|
let fence = self.frames[slot].fence;
|
||||||
@@ -2691,16 +2690,9 @@ impl VulkanVideoEncoder {
|
|||||||
let query_pool = self.frames[slot].query_pool;
|
let query_pool = self.frames[slot].query_pool;
|
||||||
let bs_buf = self.frames[slot].bs_buf;
|
let bs_buf = self.frames[slot].bs_buf;
|
||||||
let ts_pool = self.frames[slot].ts_pool;
|
let ts_pool = self.frames[slot].ts_pool;
|
||||||
// EFC cannot composite the cursor bitmap the metadata-cursor captures hand us — say so
|
// EFC cannot composite a cursor bitmap — and never has to: `open` refuses the RGB-direct
|
||||||
// once instead of silently losing the pointer (gamescope, the flagship, embeds it).
|
// shape for a cursor-blend session (the pin override above), so no cursor bitmap ever
|
||||||
if frame.cursor.is_some() && !self.warned_cursor {
|
// reaches this arm. Gamescope, the flagship, embeds its pointer in the produced pixels.
|
||||||
self.warned_cursor = true;
|
|
||||||
tracing::warn!(
|
|
||||||
"cursor bitmap on an RGB-direct session — EFC cannot composite it; the cursor \
|
|
||||||
will be missing from the stream (unset PUNKTFUNK_VULKAN_RGB_DIRECT for \
|
|
||||||
metadata-cursor captures)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let padded = self.rgb.as_ref().is_some_and(|r| r.padded);
|
let padded = self.rgb.as_ref().is_some_and(|r| r.padded);
|
||||||
// Only the padded Dmabuf arm below records timestamps (via `record_pad_blit`); the
|
// Only the padded Dmabuf arm below records timestamps (via `record_pad_blit`); the
|
||||||
// CPU-upload arm records its own command buffer and writes none. Default to "not written"
|
// CPU-upload arm records its own command buffer and writes none. Default to "not written"
|
||||||
@@ -4486,44 +4478,50 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A CPU-capture source that CHANGES SIZE mid-session must not copy past the cached staging
|
/// The CSC arm REFUSES a source that doesn't match the session mode (the e3354b6d guard —
|
||||||
/// image. `ensure_cpu_rgb` keys that image on (format, width, height); when it was keyed on
|
/// the check every sibling arm always had; a clamped-texelFetch mismatch used to stream a
|
||||||
/// format alone the image kept the FIRST frame's size while `cmd_copy_buffer_to_image` used the
|
/// silently cropped/edge-padded picture). This test previously DROVE mismatched sizes through
|
||||||
/// current frame's extent, so a same-format size increase wrote out of bounds — and `submit`
|
/// the lenient arm to exercise the per-slot `cpu_img` staging across a size change (the
|
||||||
/// still returned `Ok`, so nothing upstream noticed.
|
/// format-only-keyed cache wrote out of bounds while `submit` returned `Ok`); the guard makes
|
||||||
///
|
/// that scenario unrepresentable through `submit`, structurally retiring the hazard — the
|
||||||
/// The out-of-bounds copy is only *observable* through the Vulkan validation layers, so run this
|
/// size-keyed staging from that fix stays as belt-and-braces. What's left to pin:
|
||||||
/// as: `VK_LOADER_LAYERS_ENABLE='*validation*' cargo test ... -- --ignored`. Confirmed on RADV
|
/// refusal in BOTH directions, and that a refused submit does not WEDGE the session — the
|
||||||
/// PHOENIX 2026-07-25: 8 x VUID-vkCmdCopyBufferToImage-imageSubresource-07971 before the fix
|
/// bail happens after step 1's frame-type bookkeeping, so "the next well-sized frame still
|
||||||
/// ("extent.width (512) exceeds imageSubresource width extent (128)"), zero after.
|
/// encodes" is a real property, not a formality (the host routes the error to its
|
||||||
|
/// encoder-rebuild path and the session must be able to continue if that path retries).
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "needs a real VK_KHR_video_encode_h265 device; meaningful only under validation layers"]
|
#[ignore = "needs a real VK_KHR_video_encode_h265 device; meaningful only under validation layers"]
|
||||||
fn vulkan_cpu_img_survives_a_source_size_change() {
|
fn vulkan_csc_refuses_a_mismatched_source() {
|
||||||
// CSC mode (rgb=false) — that is the arm where the image is sized to the SOURCE frame.
|
// CSC mode (rgb=false) — the arm the guard covers.
|
||||||
let mut enc = VulkanVideoEncoder::open_opts(Codec::H265, 512, 512, 60, 10_000_000, false)
|
let mut enc = VulkanVideoEncoder::open_opts(Codec::H265, 512, 512, 60, 10_000_000, false)
|
||||||
.expect("open");
|
.expect("open");
|
||||||
// `cpu_img` is cached PER RING SLOT, so the small frames must first populate every slot;
|
enc.submit_indexed(&cpu_frame(512, 512, 0, [40, 40, 200, 255]), 0)
|
||||||
// only when the ring wraps does a slot holding a 128x128 image get a 512x512 copy.
|
.expect("well-sized baseline");
|
||||||
eprintln!("phase 1: 8x 128x128 — populates every ring slot with a 128x128 cpu_img");
|
while enc.poll().expect("poll").is_some() {}
|
||||||
for i in 0..8u64 {
|
// Smaller AND larger both refuse — the guard is equality on the MODE (render size), not
|
||||||
|
// a ceiling; the render-vs-CODED padding tolerance lives in the direct arms, untouched.
|
||||||
|
let e = enc
|
||||||
|
.submit_indexed(&cpu_frame(128, 128, 16_666_667, [200, 40, 40, 255]), 1)
|
||||||
|
.expect_err("smaller source must refuse");
|
||||||
|
assert!(e.to_string().contains("mismatched"), "{e:#}");
|
||||||
|
let e = enc
|
||||||
|
.submit_indexed(&cpu_frame(640, 640, 33_333_334, [200, 40, 40, 255]), 2)
|
||||||
|
.expect_err("larger source must refuse");
|
||||||
|
assert!(e.to_string().contains("mismatched"), "{e:#}");
|
||||||
|
// The refusals must not wedge the session: a well-sized frame still encodes and an AU
|
||||||
|
// still comes out the other end.
|
||||||
|
let mut got_au = false;
|
||||||
|
for i in 3..11u64 {
|
||||||
enc.submit_indexed(
|
enc.submit_indexed(
|
||||||
&cpu_frame(128, 128, i * 16_666_667, [40, 40, 200, 255]),
|
&cpu_frame(512, 512, i * 16_666_667, [40, 200, 40, 255]),
|
||||||
i as u32,
|
i as u32,
|
||||||
)
|
)
|
||||||
.expect("submit small");
|
.expect("well-sized after refusal");
|
||||||
while enc.poll().expect("poll").is_some() {}
|
while let Ok(Some(_)) = enc.poll() {
|
||||||
|
got_au = true;
|
||||||
}
|
}
|
||||||
eprintln!("phase 2: 8x 512x512 — SAME format, so each slot REUSES its 128x128 image");
|
|
||||||
for i in 8..16u64 {
|
|
||||||
let r = enc.submit_indexed(
|
|
||||||
&cpu_frame(512, 512, i * 16_666_667, [200, 40, 40, 255]),
|
|
||||||
i as u32,
|
|
||||||
);
|
|
||||||
r.expect("submit after the source grew");
|
|
||||||
while matches!(enc.poll(), Ok(Some(_))) {}
|
|
||||||
}
|
}
|
||||||
let _ = enc.flush();
|
assert!(got_au, "no AU after the refused submits — session wedged");
|
||||||
while matches!(enc.poll(), Ok(Some(_))) {}
|
|
||||||
eprintln!("done — under validation layers this run must report ZERO VUID errors");
|
eprintln!("done — under validation layers this run must report ZERO VUID errors");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2014,9 +2014,6 @@ impl Encoder for AmfEncoder {
|
|||||||
// frame, force a later one to re-reference it). True only when the live driver accepted
|
// frame, force a later one to re-reference it). True only when the live driver accepted
|
||||||
// the LTR slots at open — otherwise loss recovery falls back to a full IDR.
|
// the LTR slots at open — otherwise loss recovery falls back to a full IDR.
|
||||||
supports_rfi: self.ltr_active,
|
supports_rfi: self.ltr_active,
|
||||||
// In-band mastering/CLL via `*InHDRMetadata` (HEVC SEI / AV1 metadata OBU); AVC has
|
|
||||||
// no such property (and no HDR sessions negotiate H.264).
|
|
||||||
supports_hdr_metadata: self.ten_bit && self.props.hdr_metadata.is_some(),
|
|
||||||
// Permanent: VCN hardware does not encode 4:4:4.
|
// Permanent: VCN hardware does not encode 4:4:4.
|
||||||
chroma_444: false,
|
chroma_444: false,
|
||||||
// True only when `PUNKTFUNK_INTRA_REFRESH` asked for the wave AND the live driver
|
// True only when `PUNKTFUNK_INTRA_REFRESH` asked for the wave AND the live driver
|
||||||
@@ -2715,10 +2712,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
enc.set_hdr_meta(Some(sample_hdr_meta()));
|
enc.set_hdr_meta(Some(sample_hdr_meta()));
|
||||||
assert!(
|
|
||||||
enc.caps().supports_hdr_metadata,
|
|
||||||
"HEVC 10-bit reports HDR SEI capability"
|
|
||||||
);
|
|
||||||
let mut aus: Vec<EncodedFrame> = Vec::new();
|
let mut aus: Vec<EncodedFrame> = Vec::new();
|
||||||
for i in 0..6 {
|
for i in 0..6 {
|
||||||
let frame = CapturedFrame {
|
let frame = CapturedFrame {
|
||||||
|
|||||||
@@ -129,9 +129,13 @@ impl WinVendor {
|
|||||||
/// open-failure fallback only catches *setup* errors; a derive that opens but maps wrong would
|
/// open-failure fallback only catches *setup* errors; a derive that opens but maps wrong would
|
||||||
/// corrupt silently, so it stays opt-in per the probe-never-assume rule).
|
/// corrupt silently, so it stays opt-in per the probe-never-assume rule).
|
||||||
fn zerocopy_enabled(vendor: WinVendor) -> bool {
|
fn zerocopy_enabled(vendor: WinVendor) -> bool {
|
||||||
pf_host_config::config()
|
zerocopy_active(pf_host_config::config().zerocopy, vendor)
|
||||||
.zerocopy
|
}
|
||||||
.unwrap_or(matches!(vendor, WinVendor::Amf))
|
|
||||||
|
/// The pure half of [`zerocopy_enabled`]: an operator override wins; unset resolves to the
|
||||||
|
/// per-vendor default (AMF on, QSV off — see the validation status above).
|
||||||
|
fn zerocopy_active(override_: Option<bool>, vendor: WinVendor) -> bool {
|
||||||
|
override_.unwrap_or(matches!(vendor, WinVendor::Amf))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upper bound on `PUNKTFUNK_FFWIN_POLL_MS`. This knob spins the **encode thread** waiting for an
|
/// Upper bound on `PUNKTFUNK_FFWIN_POLL_MS`. This knob spins the **encode thread** waiting for an
|
||||||
@@ -164,14 +168,19 @@ const MAX_POLL_SPIN_MS: u64 = 1_000;
|
|||||||
fn poll_spin_cap_us() -> u64 {
|
fn poll_spin_cap_us() -> u64 {
|
||||||
static CAP_US: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
|
static CAP_US: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
|
||||||
*CAP_US.get_or_init(|| {
|
*CAP_US.get_or_init(|| {
|
||||||
std::env::var("PUNKTFUNK_FFWIN_POLL_MS")
|
parse_poll_spin_cap_us(std::env::var("PUNKTFUNK_FFWIN_POLL_MS").ok().as_deref())
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
|
||||||
.map(|ms| ms.min(MAX_POLL_SPIN_MS) * 1000)
|
|
||||||
.unwrap_or(0) // default: no spin — the libavcodec AMF buffer can't be spun out
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pure half of [`poll_spin_cap_us`]: parse, clamp to [`MAX_POLL_SPIN_MS`] BEFORE the µs
|
||||||
|
/// conversion (the ordering the doc above proves is load-bearing), and default to 0 — no spin,
|
||||||
|
/// the libavcodec AMF buffer can't be spun out.
|
||||||
|
fn parse_poll_spin_cap_us(raw: Option<&str>) -> u64 {
|
||||||
|
raw.and_then(|s| s.trim().parse::<u64>().ok())
|
||||||
|
.map(|ms| ms.min(MAX_POLL_SPIN_MS) * 1000)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
|
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
|
||||||
fn sws_src(format: PixelFormat) -> Result<Pixel> {
|
fn sws_src(format: PixelFormat) -> Result<Pixel> {
|
||||||
Ok(match format {
|
Ok(match format {
|
||||||
@@ -206,6 +215,86 @@ fn is_10bit_format(format: PixelFormat) -> bool {
|
|||||||
matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
|
matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which lane the system-memory path routes a captured D3D11 format through. Device-free — the
|
||||||
|
/// routing DECISION, split from the D3D11 copies so it is testable.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum ReadbackRoute {
|
||||||
|
/// Same-format `CopyResource` + plane-by-plane copy (NV12/P010 from the video processor).
|
||||||
|
Yuv,
|
||||||
|
/// BGRA staging + swscale BGRA→NV12 — the 8-bit fallback when the capturer's video
|
||||||
|
/// processor latched off.
|
||||||
|
Bgra,
|
||||||
|
/// R10G10B10A2 staging + swscale X2BGR10→P010 — the HDR twin of that fallback.
|
||||||
|
Rgb10,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route a captured format, guarding the mid-stream depth change first: the predicate matches
|
||||||
|
/// what the encoder was built from (`ten_bit_input`), so the guard can only fire on a GENUINE
|
||||||
|
/// depth change under the encoder — never, as it used to, on every frame of a session that
|
||||||
|
/// merely negotiated 10-bit over an 8-bit capture (see [`is_10bit_format`]).
|
||||||
|
fn readback_route(format: PixelFormat, ten_bit: bool) -> Result<ReadbackRoute> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
is_10bit_format(format) == ten_bit,
|
||||||
|
"captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
|
||||||
|
if ten_bit { 10 } else { 8 }
|
||||||
|
);
|
||||||
|
Ok(match format {
|
||||||
|
PixelFormat::Nv12 | PixelFormat::P010 => ReadbackRoute::Yuv,
|
||||||
|
PixelFormat::Bgra | PixelFormat::Bgrx => ReadbackRoute::Bgra,
|
||||||
|
PixelFormat::Rgb10a2 => ReadbackRoute::Rgb10,
|
||||||
|
other => {
|
||||||
|
bail!("ffmpeg_win system path cannot read back captured D3D11 format {other:?}")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The vendor-specific low-latency option set for [`open_win_encoder`], pure so the latency
|
||||||
|
/// contract is pinned by tests. Unknown private options are ignored by `avcodec_open2` (left in
|
||||||
|
/// the dict), so vendor/codec-specific keys are safe to set unconditionally.
|
||||||
|
fn vendor_opts(vendor: WinVendor, amf_usage: &str) -> Vec<(&'static str, String)> {
|
||||||
|
match vendor {
|
||||||
|
WinVendor::Amf => vec![
|
||||||
|
// Field-tuning override (ultralowlatency | lowlatency | lowlatency_high_quality |
|
||||||
|
// transcoding): AMF usage presets bundle driver-side pipeline behavior that varies
|
||||||
|
// by VCN generation/driver — measured on-box rather than assumed.
|
||||||
|
("usage", amf_usage.to_owned()),
|
||||||
|
("rc", "cbr".into()),
|
||||||
|
// Streaming is latency-first: `speed` trims per-frame motion-estimation depth — the
|
||||||
|
// difference between ~encode-time and ~frame-budget on iGPU-class VCN (matches the
|
||||||
|
// low-latency preset choice on the NVENC path).
|
||||||
|
("quality", "speed".into()),
|
||||||
|
("preanalysis", "false".into()),
|
||||||
|
("enforce_hrd", "true".into()),
|
||||||
|
// AMF low-latency submission mode (FFmpeg ≥ 6.1; unknown-option-ignored on older).
|
||||||
|
("latency", "true".into()),
|
||||||
|
// Never B-frames: h264_amf defaults >0 on RDNA3+ HW that supports them, and each
|
||||||
|
// B-frame is a full frame period of added latency. (HEVC VCN has none; ignored there.)
|
||||||
|
("bf", "0".into()),
|
||||||
|
// VPS/SPS/PPS on each IDR (clean mid-stream join) — HEVC/AV1 only; ignored elsewhere.
|
||||||
|
("header_insertion_mode", "idr".into()),
|
||||||
|
],
|
||||||
|
WinVendor::Qsv => vec![
|
||||||
|
("preset", "veryfast".into()),
|
||||||
|
("async_depth", "1".into()), // bound in-flight frames — the big QSV latency lever
|
||||||
|
("low_power", "1".into()), // VDEnc fixed-function path (lower latency)
|
||||||
|
("look_ahead", "0".into()), // (h264_qsv only; ignored on hevc/av1)
|
||||||
|
("forced_idr", "1".into()), // a forced key frame becomes a real IDR
|
||||||
|
("scenario", "displayremoting".into()),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind flags on the FFmpeg-allocated zero-copy pool. AMF reads it as encoder input
|
||||||
|
/// (RENDER_TARGET + SHADER_RESOURCE, matching the video-processor output); QSV maps it as an mfx
|
||||||
|
/// surface (DECODER | VIDEO_ENCODER). The `CopySubresourceRegion` into the pool works with any
|
||||||
|
/// usable DEFAULT-usage texture regardless.
|
||||||
|
fn pool_bind_flags(vendor: WinVendor) -> u32 {
|
||||||
|
match vendor {
|
||||||
|
WinVendor::Amf => (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
||||||
|
WinVendor::Qsv => (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the FFmpeg encoder context shared by both inner paths: name, mode, low-latency RC,
|
/// Build the FFmpeg encoder context shared by both inner paths: name, mode, low-latency RC,
|
||||||
/// infinite GOP, the BT.709-limited (SDR) or BT.2020-PQ (HDR) VUI, the given `pix_fmt`, and the
|
/// infinite GOP, the BT.709-limited (SDR) or BT.2020-PQ (HDR) VUI, the given `pix_fmt`, and the
|
||||||
/// optional hw device/frames contexts (null for the system path). Returns the opened encoder.
|
/// optional hw device/frames contexts (null for the system path). Returns the opened encoder.
|
||||||
@@ -266,40 +355,11 @@ unsafe fn open_win_encoder(
|
|||||||
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Low-latency tuning. Unknown private options are ignored by avcodec_open2 (left in the dict),
|
// Low-latency tuning — the per-vendor contract lives in `vendor_opts` (pure, test-pinned).
|
||||||
// so vendor-specific keys are safe to set unconditionally.
|
|
||||||
let mut opts = Dictionary::new();
|
let mut opts = Dictionary::new();
|
||||||
match vendor {
|
let usage = std::env::var("PUNKTFUNK_AMF_USAGE").unwrap_or_else(|_| "ultralowlatency".into());
|
||||||
WinVendor::Amf => {
|
for (k, v) in vendor_opts(vendor, &usage) {
|
||||||
// Field-tuning override (ultralowlatency | lowlatency | lowlatency_high_quality |
|
opts.set(k, &v);
|
||||||
// transcoding): AMF usage presets bundle driver-side pipeline behavior that varies by
|
|
||||||
// VCN generation/driver — measured on-box rather than assumed.
|
|
||||||
let usage =
|
|
||||||
std::env::var("PUNKTFUNK_AMF_USAGE").unwrap_or_else(|_| "ultralowlatency".into());
|
|
||||||
opts.set("usage", &usage);
|
|
||||||
opts.set("rc", "cbr");
|
|
||||||
// Streaming is latency-first: `speed` trims per-frame motion-estimation depth — the
|
|
||||||
// difference between ~encode-time and ~frame-budget on iGPU-class VCN (matches the
|
|
||||||
// low-latency preset choice on the NVENC path).
|
|
||||||
opts.set("quality", "speed");
|
|
||||||
opts.set("preanalysis", "false");
|
|
||||||
opts.set("enforce_hrd", "true");
|
|
||||||
// AMF low-latency submission mode (FFmpeg ≥ 6.1; unknown-option-ignored on older).
|
|
||||||
opts.set("latency", "true");
|
|
||||||
// Never B-frames: h264_amf defaults >0 on RDNA3+ HW that supports them, and each
|
|
||||||
// B-frame is a full frame period of added latency. (HEVC VCN has none; ignored there.)
|
|
||||||
opts.set("bf", "0");
|
|
||||||
// VPS/SPS/PPS on each IDR (clean mid-stream join) — HEVC/AV1 only; ignored elsewhere.
|
|
||||||
opts.set("header_insertion_mode", "idr");
|
|
||||||
}
|
|
||||||
WinVendor::Qsv => {
|
|
||||||
opts.set("preset", "veryfast");
|
|
||||||
opts.set("async_depth", "1"); // bound in-flight frames — the big QSV latency lever
|
|
||||||
opts.set("low_power", "1"); // VDEnc fixed-function path (lower latency)
|
|
||||||
opts.set("look_ahead", "0"); // (h264_qsv only; ignored on hevc/av1)
|
|
||||||
opts.set("forced_idr", "1"); // a forced key frame becomes a real IDR
|
|
||||||
opts.set("scenario", "displayremoting");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
video
|
video
|
||||||
.open_with(opts)
|
.open_with(opts)
|
||||||
@@ -528,22 +588,10 @@ impl SystemInner {
|
|||||||
pts: i64,
|
pts: i64,
|
||||||
idr: bool,
|
idr: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Same predicate the encoder was built from (`ten_bit_input`), so this can only fire on a
|
match readback_route(format, self.ten_bit)? {
|
||||||
// genuine MID-STREAM depth change — never, as it used to, on every frame of a session that
|
ReadbackRoute::Yuv => self.readback_yuv(frame, pts, idr),
|
||||||
// merely negotiated 10-bit over an 8-bit capture.
|
ReadbackRoute::Bgra => self.readback_bgra(frame, pts, idr),
|
||||||
let fmt_10 = is_10bit_format(format);
|
ReadbackRoute::Rgb10 => self.readback_rgb10(frame, pts, idr),
|
||||||
anyhow::ensure!(
|
|
||||||
fmt_10 == self.ten_bit,
|
|
||||||
"captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
|
|
||||||
if self.ten_bit { 10 } else { 8 }
|
|
||||||
);
|
|
||||||
match format {
|
|
||||||
PixelFormat::Nv12 | PixelFormat::P010 => self.readback_yuv(frame, pts, idr),
|
|
||||||
PixelFormat::Bgra | PixelFormat::Bgrx => self.readback_bgra(frame, pts, idr),
|
|
||||||
PixelFormat::Rgb10a2 => self.readback_rgb10(frame, pts, idr),
|
|
||||||
other => {
|
|
||||||
bail!("ffmpeg_win system path cannot read back captured D3D11 format {other:?}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,14 +969,7 @@ impl ZeroCopyInner {
|
|||||||
} else {
|
} else {
|
||||||
PixelFormat::Nv12
|
PixelFormat::Nv12
|
||||||
};
|
};
|
||||||
// Bind flags on the FFmpeg-allocated pool. AMF reads it as encoder input (RENDER_TARGET +
|
let bind_flags = pool_bind_flags(vendor);
|
||||||
// SHADER_RESOURCE, matching the video-processor output); QSV maps it as an mfx surface
|
|
||||||
// (DECODER | VIDEO_ENCODER). The CopySubresourceRegion into the pool works with any usable
|
|
||||||
// DEFAULT-usage texture regardless.
|
|
||||||
let bind_flags = match vendor {
|
|
||||||
WinVendor::Amf => (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
|
||||||
WinVendor::Qsv => (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32,
|
|
||||||
};
|
|
||||||
const POOL: c_int = 8;
|
const POOL: c_int = 8;
|
||||||
// SAFETY: `D3d11Hw::new` wraps the capturer's `device` as a D3D11VA hwdevice (handing FFmpeg an
|
// SAFETY: `D3d11Hw::new` wraps the capturer's `device` as a D3D11VA hwdevice (handing FFmpeg an
|
||||||
// owned AddRef of it, balanced by FFmpeg's teardown Release) and builds an owned
|
// owned AddRef of it, balanced by FFmpeg's teardown Release) and builds an owned
|
||||||
@@ -1402,3 +1443,193 @@ impl Encoder for FfmpegWinEncoder {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Zero-copy default matrix: the operator override wins in both directions; unset resolves
|
||||||
|
/// AMF on (on-glass validated) and QSV off (opt-in until validated on Intel glass — the
|
||||||
|
/// probe-never-assume rule).
|
||||||
|
#[test]
|
||||||
|
fn zerocopy_default_is_per_vendor_and_override_wins() {
|
||||||
|
assert!(zerocopy_active(None, WinVendor::Amf));
|
||||||
|
assert!(!zerocopy_active(None, WinVendor::Qsv));
|
||||||
|
for vendor in [WinVendor::Amf, WinVendor::Qsv] {
|
||||||
|
assert!(zerocopy_active(Some(true), vendor));
|
||||||
|
assert!(!zerocopy_active(Some(false), vendor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUNKTFUNK_FFWIN_POLL_MS` grammar: default 0 (no spin), verbatim ms → µs inside the clamp,
|
||||||
|
/// and the clamp applies BEFORE the µs conversion — the slipped-digit value that used to be a
|
||||||
|
/// 27.7-hour spin resolves to the 1 s cap, not a wedged encode thread.
|
||||||
|
#[test]
|
||||||
|
fn poll_spin_cap_clamps_before_the_us_conversion() {
|
||||||
|
assert_eq!(parse_poll_spin_cap_us(None), 0);
|
||||||
|
assert_eq!(parse_poll_spin_cap_us(Some("0")), 0);
|
||||||
|
assert_eq!(parse_poll_spin_cap_us(Some("5")), 5_000);
|
||||||
|
assert_eq!(parse_poll_spin_cap_us(Some(" 12 ")), 12_000);
|
||||||
|
assert_eq!(
|
||||||
|
parse_poll_spin_cap_us(Some("100000000")),
|
||||||
|
MAX_POLL_SPIN_MS * 1000
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_poll_spin_cap_us(Some(&u64::MAX.to_string())),
|
||||||
|
MAX_POLL_SPIN_MS * 1000
|
||||||
|
);
|
||||||
|
assert_eq!(parse_poll_spin_cap_us(Some("junk")), 0);
|
||||||
|
assert_eq!(parse_poll_spin_cap_us(Some("-1")), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The swscale source map: packed RGB/BGR converts; every YUV/10-bit layout is refused (the
|
||||||
|
/// swscale lane is the 8-bit BGRA fallback, not a general converter — the Linux HDR formats
|
||||||
|
/// are listed explicitly so a `PixelFormat` addition re-breaks the match on purpose).
|
||||||
|
#[test]
|
||||||
|
fn sws_src_accepts_packed_rgb_only() {
|
||||||
|
assert_eq!(sws_src(PixelFormat::Bgrx).unwrap(), Pixel::BGRZ);
|
||||||
|
assert_eq!(sws_src(PixelFormat::Rgbx).unwrap(), Pixel::RGBZ);
|
||||||
|
assert_eq!(sws_src(PixelFormat::Bgra).unwrap(), Pixel::BGRA);
|
||||||
|
assert_eq!(sws_src(PixelFormat::Rgba).unwrap(), Pixel::RGBA);
|
||||||
|
assert_eq!(sws_src(PixelFormat::Rgb).unwrap(), Pixel::RGB24);
|
||||||
|
assert_eq!(sws_src(PixelFormat::Bgr).unwrap(), Pixel::BGR24);
|
||||||
|
for f in [
|
||||||
|
PixelFormat::Nv12,
|
||||||
|
PixelFormat::P010,
|
||||||
|
PixelFormat::Rgb10a2,
|
||||||
|
PixelFormat::Yuv444,
|
||||||
|
PixelFormat::X2Rgb10,
|
||||||
|
PixelFormat::X2Bgr10,
|
||||||
|
] {
|
||||||
|
assert!(sws_src(f).is_err(), "{f:?} must be refused");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The readback routing table, and its depth guard: NV12/P010 take the plane copy, the
|
||||||
|
/// video-processor fallbacks take their swscale lanes, a depth CHANGE under the encoder is
|
||||||
|
/// refused (in both directions), and depth-consistent routing never trips the guard.
|
||||||
|
#[test]
|
||||||
|
fn readback_routing_and_depth_guard() {
|
||||||
|
assert_eq!(
|
||||||
|
readback_route(PixelFormat::Nv12, false).unwrap(),
|
||||||
|
ReadbackRoute::Yuv
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
readback_route(PixelFormat::P010, true).unwrap(),
|
||||||
|
ReadbackRoute::Yuv
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
readback_route(PixelFormat::Bgra, false).unwrap(),
|
||||||
|
ReadbackRoute::Bgra
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
readback_route(PixelFormat::Bgrx, false).unwrap(),
|
||||||
|
ReadbackRoute::Bgra
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
readback_route(PixelFormat::Rgb10a2, true).unwrap(),
|
||||||
|
ReadbackRoute::Rgb10
|
||||||
|
);
|
||||||
|
// Mid-stream depth changes — the genuine error the guard exists for.
|
||||||
|
assert!(readback_route(PixelFormat::P010, false).is_err());
|
||||||
|
assert!(readback_route(PixelFormat::Rgb10a2, false).is_err());
|
||||||
|
assert!(readback_route(PixelFormat::Nv12, true).is_err());
|
||||||
|
assert!(readback_route(PixelFormat::Bgra, true).is_err());
|
||||||
|
// A format neither lane can read back.
|
||||||
|
assert!(readback_route(PixelFormat::Yuv444, false).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 10-bit predicate follows the PIXELS (P010/Rgb10a2), not the negotiated depth — see
|
||||||
|
/// `ten_bit_input` for the forever-failing-session shape the reverse produced here.
|
||||||
|
#[test]
|
||||||
|
fn ten_bit_follows_the_pixels() {
|
||||||
|
assert!(is_10bit_format(PixelFormat::P010));
|
||||||
|
assert!(is_10bit_format(PixelFormat::Rgb10a2));
|
||||||
|
assert!(!is_10bit_format(PixelFormat::Nv12));
|
||||||
|
assert!(!is_10bit_format(PixelFormat::Bgra));
|
||||||
|
assert!(!is_10bit_format(PixelFormat::Bgrx));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The QSV low-latency contract, pinned: these five knobs are the difference between
|
||||||
|
/// display-remoting latency and transcode behavior — a silent regression here changes every
|
||||||
|
/// Intel Windows session.
|
||||||
|
#[test]
|
||||||
|
fn qsv_opts_pin_the_latency_contract() {
|
||||||
|
let opts = vendor_opts(WinVendor::Qsv, "ignored");
|
||||||
|
let get = |k: &str| {
|
||||||
|
opts.iter()
|
||||||
|
.find(|(key, _)| *key == k)
|
||||||
|
.map(|(_, v)| v.as_str())
|
||||||
|
};
|
||||||
|
assert_eq!(get("async_depth"), Some("1"));
|
||||||
|
assert_eq!(get("low_power"), Some("1"));
|
||||||
|
assert_eq!(get("look_ahead"), Some("0"));
|
||||||
|
assert_eq!(get("forced_idr"), Some("1"));
|
||||||
|
assert_eq!(get("scenario"), Some("displayremoting"));
|
||||||
|
assert_eq!(get("preset"), Some("veryfast"));
|
||||||
|
assert_eq!(get("usage"), None, "AMF-only knob must not leak into QSV");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The AMF (benchmark-comparator) contract: usage passes through, B-frames are pinned OFF
|
||||||
|
/// (each one is a full frame period of latency on RDNA3+), and the low-latency submission
|
||||||
|
/// mode + IDR header insertion are requested.
|
||||||
|
#[test]
|
||||||
|
fn amf_opts_pin_no_bframes_and_the_usage_passthrough() {
|
||||||
|
let opts = vendor_opts(WinVendor::Amf, "lowlatency");
|
||||||
|
let get = |k: &str| {
|
||||||
|
opts.iter()
|
||||||
|
.find(|(key, _)| *key == k)
|
||||||
|
.map(|(_, v)| v.as_str())
|
||||||
|
};
|
||||||
|
assert_eq!(get("usage"), Some("lowlatency"));
|
||||||
|
assert_eq!(get("bf"), Some("0"));
|
||||||
|
assert_eq!(get("rc"), Some("cbr"));
|
||||||
|
assert_eq!(get("quality"), Some("speed"));
|
||||||
|
assert_eq!(get("latency"), Some("true"));
|
||||||
|
assert_eq!(get("header_insertion_mode"), Some("idr"));
|
||||||
|
assert_eq!(get("preanalysis"), Some("false"));
|
||||||
|
assert_eq!(get("enforce_hrd"), Some("true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The zero-copy pool's bind flags per vendor — AMF's encoder-input shape vs QSV's mfx
|
||||||
|
/// surface shape (a wrong flag set fails `av_hwframe_ctx_init`, or worse, opens and maps
|
||||||
|
/// wrong).
|
||||||
|
#[test]
|
||||||
|
fn pool_bind_flags_per_vendor() {
|
||||||
|
assert_eq!(
|
||||||
|
pool_bind_flags(WinVendor::Amf),
|
||||||
|
(D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pool_bind_flags(WinVendor::Qsv),
|
||||||
|
(D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The libavcodec encoder-name dispatch (name-selected — the codec id would pick the
|
||||||
|
/// software encoder).
|
||||||
|
#[test]
|
||||||
|
fn encoder_names_dispatch_by_vendor() {
|
||||||
|
assert_eq!(WinVendor::Qsv.encoder_name(Codec::H264), "h264_qsv");
|
||||||
|
assert_eq!(WinVendor::Qsv.encoder_name(Codec::H265), "hevc_qsv");
|
||||||
|
assert_eq!(WinVendor::Qsv.encoder_name(Codec::Av1), "av1_qsv");
|
||||||
|
assert_eq!(WinVendor::Amf.encoder_name(Codec::H265), "hevc_amf");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe smoke: resolve the QSV probe on this machine without crashing — `false` on a box
|
||||||
|
/// without the Intel runtime is a valid outcome; the value is printed, not asserted. Only
|
||||||
|
/// compiled in the `amf-qsv`-without-`qsv` combo (the shipped combo answers via native VPL).
|
||||||
|
/// Run on the Windows CI runner:
|
||||||
|
/// cargo test -p pf-encode --no-default-features --features amf-qsv -- --ignored ffmpeg_win
|
||||||
|
#[cfg(not(feature = "qsv"))]
|
||||||
|
#[test]
|
||||||
|
#[ignore = "needs a real FFmpeg runtime probe (run on the Windows CI runner, not a dev box)"]
|
||||||
|
fn ffmpeg_win_probe_smoke() {
|
||||||
|
for codec in [Codec::H264, Codec::H265, Codec::Av1] {
|
||||||
|
eprintln!(
|
||||||
|
"probe_can_encode(Qsv, {codec:?}) = {}",
|
||||||
|
probe_can_encode(WinVendor::Qsv, codec)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1690,17 +1690,14 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn caps(&self) -> EncoderCaps {
|
fn caps(&self) -> EncoderCaps {
|
||||||
// RFI is probed once at open (`rfi_supported`); HDR SEI rides keyframes whenever the
|
// RFI is probed once at open (`rfi_supported`) — the real capability the session glue
|
||||||
// session is in HDR mode. Both are the real capabilities the session glue routes on.
|
// routes on. (In-band HDR SEI needs no cap: it rides keyframes on HEVC/H.264 HDR
|
||||||
|
// sessions — see `submit` — and every first-party client reads the grade out-of-band
|
||||||
|
// via the 0xCE datagram regardless.)
|
||||||
EncoderCaps {
|
EncoderCaps {
|
||||||
// The Windows capture path composites the pointer; this backend never reads `frame.cursor`.
|
// The Windows capture path composites the pointer; this backend never reads `frame.cursor`.
|
||||||
blends_cursor: false,
|
blends_cursor: false,
|
||||||
supports_rfi: self.rfi_supported,
|
supports_rfi: self.rfi_supported,
|
||||||
// In-band mastering/CLL is attached as keyframe SEI on HEVC/H.264 only — AV1 carries
|
|
||||||
// it in METADATA OBUs (`HDR_MDCV`/`HDR_CLL`), which this backend doesn't emit yet
|
|
||||||
// (see `submit`); the grade still reaches punktfunk clients out-of-band via the 0xCE
|
|
||||||
// datagram. Don't claim a capability the AV1 path doesn't have.
|
|
||||||
supports_hdr_metadata: self.hdr && self.codec != Codec::Av1,
|
|
||||||
// Reflects what the session actually configured (cleared in `query_caps` if the GPU lacks
|
// Reflects what the session actually configured (cleared in `query_caps` if the GPU lacks
|
||||||
// YUV444 encode), so the glue can confirm 4:4:4 vs the negotiated request.
|
// YUV444 encode), so the glue can confirm 4:4:4 vs the negotiated request.
|
||||||
chroma_444: self.chroma_444,
|
chroma_444: self.chroma_444,
|
||||||
|
|||||||
@@ -313,11 +313,15 @@ fn create_session(target_luid: Option<[u8; 8]>) -> Result<(Loader, Session, (u16
|
|||||||
.unwrap_or(&impls[0]);
|
.unwrap_or(&impls[0]);
|
||||||
if let Some(want) = target_luid {
|
if let Some(want) = target_luid {
|
||||||
if !(chosen.luid_valid && chosen.luid == want) {
|
if !(chosen.luid_valid && chosen.luid == want) {
|
||||||
bail!(
|
// Static configuration mismatch — the capture adapter has no Intel VPL
|
||||||
|
// implementation, so every rebuild re-hits this same wall. The terminal marker
|
||||||
|
// makes the stream loop end the session at once instead of spending its reset
|
||||||
|
// budget (5 futile rebuilds, then an opaque loop as the client reconnects).
|
||||||
|
return Err(anyhow::Error::new(super::TerminalEncoderError).context(
|
||||||
"capture device's adapter is not an Intel VPL implementation (hybrid box? \
|
"capture device's adapter is not an Intel VPL implementation (hybrid box? \
|
||||||
point PUNKTFUNK_RENDER_ADAPTER / the web-console GPU preference at the Intel \
|
point PUNKTFUNK_RENDER_ADAPTER / the web-console GPU preference at the \
|
||||||
adapter for a QSV session)"
|
Intel adapter for a QSV session)",
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// SAFETY: `loader.0` is live; `MFXCreateSession` fills `session` only on success and the
|
// SAFETY: `loader.0` is live; `MFXCreateSession` fills `session` only on success and the
|
||||||
@@ -1471,9 +1475,6 @@ impl Encoder for QsvEncoder {
|
|||||||
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
|
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
|
||||||
blends_cursor: false,
|
blends_cursor: false,
|
||||||
supports_rfi: self.ltr_active,
|
supports_rfi: self.ltr_active,
|
||||||
// In-band mastering/CLL at IDR (HEVC prefix SEI / AV1 metadata OBU); AVC sessions
|
|
||||||
// are never HDR.
|
|
||||||
supports_hdr_metadata: self.ten_bit && self.codec != Codec::H264,
|
|
||||||
chroma_444: false,
|
chroma_444: false,
|
||||||
intra_refresh: self.ir_active,
|
intra_refresh: self.ir_active,
|
||||||
// Unvalidated on-glass — the host keeps the IDR recovery path until then.
|
// Unvalidated on-glass — the host keeps the IDR recovery path until then.
|
||||||
|
|||||||
+311
-38
@@ -203,14 +203,15 @@ pub fn open_video(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
// The session asked for a composited pointer; say so loudly if the backend that actually opened
|
// The session asked for a composited pointer; say so loudly if the backend that actually opened
|
||||||
// cannot deliver one. `cursor_blend` was a REQUEST with no answer for most of this crate's life
|
// cannot deliver one. Since the negotiation became caps-aware ([`cursor_blend_capable`] gates
|
||||||
// (`let _ = cursor_blend;` below), and the result was a stream with no mouse cursor and nothing
|
// the cursor channel, and the session plan keeps cursor sessions off the native-NV12/RGB-direct
|
||||||
// in the logs — confirmed on the VAAPI dmabuf path and the libav-NVENC CUDA path.
|
// shapes), no PLANNED path reaches this: capture negotiates embedded-cursor mode wherever the
|
||||||
//
|
// resolved backend can't blend. What remains reachable is the open-time divergence the plan
|
||||||
// A warning is deliberately all this does. `open_video` cannot re-plan capture, so refusing here
|
// cannot see — a Vulkan Video open failing back to VAAPI mid-`open_amd_intel`, and the
|
||||||
// would only trade a missing pointer for a dead session; the host owns `plan.cursor_blend` and is
|
// gamescope residual (gamescope has no embedded mode, so a never-blending backend there —
|
||||||
// the only layer that can fall back to capturer-side compositing. This makes the condition
|
// H.264→VAAPI, software — still streams cursorless). This is the backstop that keeps those
|
||||||
// visible and queryable (`EncoderCaps::blends_cursor`) so that decision can be made upstream.
|
// honest in the logs; `open_video` cannot re-plan capture, so a warning is deliberately all
|
||||||
|
// it does.
|
||||||
if cursor_blend && !inner.caps().blends_cursor {
|
if cursor_blend && !inner.caps().blends_cursor {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
backend,
|
backend,
|
||||||
@@ -607,27 +608,24 @@ fn open_video_backend(
|
|||||||
// NVIDIA → NVENC (direct SDK), AMD → AMF, Intel → QSV (both libavcodec), else → software
|
// NVIDIA → NVENC (direct SDK), AMD → AMF, Intel → QSV (both libavcodec), else → software
|
||||||
// H.264. `auto` (the default) resolves from the selected render adapter's vendor.
|
// H.264. `auto` (the default) resolves from the selected render adapter's vendor.
|
||||||
let backend = windows_resolved_backend();
|
let backend = windows_resolved_backend();
|
||||||
// With `auto` the backend is derived from the selected GPU, so this can only fire when an
|
// An explicit PUNKTFUNK_ENCODER pin contradicting the selected GPU's vendor was just
|
||||||
// explicit PUNKTFUNK_ENCODER contradicts the GPU the pipeline sits on (e.g. `nvenc` forced
|
// overridden by the adapter-derived backend (`resolve_windows_backend`) — honoring it
|
||||||
// while the web-console preference pins the Intel iGPU) — the open below will then fail on
|
// could only fail, and used to feed the reset ladder five futile rebuilds per connect
|
||||||
// a wrong-vendor device; say why up front instead of leaving an opaque encoder error.
|
// (an unrecoverable session + client reconnect loop on hybrid boxes). Warn per session
|
||||||
if let Some(sel) = pf_gpu::selected_gpu() {
|
// open so the stale pin gets removed from host.env.
|
||||||
let mismatched = match backend {
|
if windows_pinned_backend().is_some_and(|pin| pin != backend) {
|
||||||
WindowsBackend::Nvenc => sel.info.vendor_id != pf_gpu::VENDOR_NVIDIA,
|
|
||||||
WindowsBackend::Amf => sel.info.vendor_id != pf_gpu::VENDOR_AMD,
|
|
||||||
WindowsBackend::Qsv => sel.info.vendor_id != pf_gpu::VENDOR_INTEL,
|
|
||||||
WindowsBackend::Software => false,
|
|
||||||
};
|
|
||||||
if mismatched {
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
adapter = sel.info.name,
|
adapter = pf_gpu::selected_gpu()
|
||||||
?backend,
|
.map(|s| s.info.name)
|
||||||
"encoder backend does not match the selected GPU's vendor (explicit \
|
.as_deref()
|
||||||
PUNKTFUNK_ENCODER conflicting with the GPU preference?) — the encoder \
|
.unwrap_or("?"),
|
||||||
open will likely fail on this device"
|
pinned = %pf_host_config::config().encoder_pref,
|
||||||
|
using = ?backend,
|
||||||
|
"explicit PUNKTFUNK_ENCODER pin does not match the selected GPU's vendor — the \
|
||||||
|
pin is overridden (remove it from host.env, or point the GPU preference at the \
|
||||||
|
pinned vendor's adapter)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
match backend {
|
match backend {
|
||||||
WindowsBackend::Nvenc => {
|
WindowsBackend::Nvenc => {
|
||||||
// Hardware path: NVENC over D3D11. The DXGI capturer switches to its zero-copy
|
// Hardware path: NVENC over D3D11. The DXGI capturer switches to its zero-copy
|
||||||
@@ -989,6 +987,87 @@ pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the encode backend this session will resolve to composites [`CapturedFrame::cursor`]
|
||||||
|
/// ([`EncoderCaps::blends_cursor`]) — answered BEFORE capture opens, so the host plans cursor
|
||||||
|
/// delivery honestly instead of discovering a cursorless stream after the fact (the
|
||||||
|
/// `blends_cursor` audit finding): a blend-capable backend takes cursor-as-metadata capture
|
||||||
|
/// (pointer-free frames + host composite on demand — the cursor channel's contract); for
|
||||||
|
/// anything else the host must have the compositor EMBED the pointer. The sibling verdict of
|
||||||
|
/// [`linux_native_nv12_ok`], threaded into the same negotiation.
|
||||||
|
///
|
||||||
|
/// `cuda_planned` is the caller's prediction of a CUDA capture payload (NVIDIA + zero-copy — the
|
||||||
|
/// prediction `SessionPlan` already makes); `ten_bit` the negotiated depth. Both shift the
|
||||||
|
/// dispatch: a CPU payload keeps NVIDIA on libav NVENC (no blend), and a 10-bit HDR session
|
||||||
|
/// skips Vulkan Video for libav VAAPI's P010/Main10 wiring (no blend).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) -> bool {
|
||||||
|
// A negotiated PyroWave session routes to that backend before the pref is consulted
|
||||||
|
// (`open_video_backend_linux`), and its wavelet CSC composites the metadata cursor.
|
||||||
|
if codec == Codec::PyroWave {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let direct_nvenc = {
|
||||||
|
#[cfg(feature = "nvenc")]
|
||||||
|
{
|
||||||
|
nvenc_direct_enabled()
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "nvenc"))]
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let vulkan_csc = {
|
||||||
|
// The compute-CSC arm — the one that blends. Eligibility mirrors `open_amd_intel`;
|
||||||
|
// the device probe runs last (it opens a Vulkan instance, cached per GPU+codec).
|
||||||
|
#[cfg(feature = "vulkan-encode")]
|
||||||
|
{
|
||||||
|
matches!(codec, Codec::H265 | Codec::Av1)
|
||||||
|
&& vulkan_encode_enabled()
|
||||||
|
&& vulkan_encode_available(codec)
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "vulkan-encode"))]
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let backend = resolve_linux_backend(
|
||||||
|
pf_host_config::config().encoder_pref.as_str(),
|
||||||
|
linux_auto_is_vaapi,
|
||||||
|
cuda_planned,
|
||||||
|
);
|
||||||
|
cursor_blend_capable_for(backend, cuda_planned, ten_bit, direct_nvenc, vulkan_csc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dispatch-mirroring core of [`cursor_blend_capable`], device-free for the unit tests.
|
||||||
|
/// `direct_nvenc` = the direct-SDK NVENC path is compiled in and enabled; `vulkan_csc` = the
|
||||||
|
/// Vulkan Video compute-CSC arm (the one that blends) is compiled in, enabled, and
|
||||||
|
/// device-supported for the session's codec.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn cursor_blend_capable_for(
|
||||||
|
backend: Option<LinuxBackend>,
|
||||||
|
cuda_planned: bool,
|
||||||
|
ten_bit: bool,
|
||||||
|
direct_nvenc: bool,
|
||||||
|
vulkan_csc: bool,
|
||||||
|
) -> bool {
|
||||||
|
match backend {
|
||||||
|
// The wavelet CSC composites the metadata cursor (`linux/pyrowave.rs`).
|
||||||
|
Some(LinuxBackend::Pyrowave) => true,
|
||||||
|
// Only the direct-SDK arm blends (VkSlotBlend), and it only takes CUDA payloads —
|
||||||
|
// a CPU-payload session stays on libav NVENC, which cannot blend.
|
||||||
|
Some(LinuxBackend::Nvenc) => cuda_planned && direct_nvenc,
|
||||||
|
// The Vulkan Video compute-CSC path blends; a 10-bit HDR session skips it for libav
|
||||||
|
// VAAPI (no blend). The session plan keeps a cursor-blend session off the native-NV12
|
||||||
|
// and RGB-direct shapes (`SessionPlan::output_format` / `VulkanVideoEncoder::open`),
|
||||||
|
// so CSC eligibility IS the answer.
|
||||||
|
Some(LinuxBackend::AmdIntel) | Some(LinuxBackend::Vulkan) => !ten_bit && vulkan_csc,
|
||||||
|
// CPU frames: the capturer composites the metadata cursor inline before the encoder
|
||||||
|
// runs, but the ENCODER blends nothing — the cursor channel's on-demand composite
|
||||||
|
// contract can't be honored. Report the encoder's truth.
|
||||||
|
Some(LinuxBackend::Software) | None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Can this GPU + driver actually open a Vulkan Video **encode** session for `codec`? Cached per
|
/// Can this GPU + driver actually open a Vulkan Video **encode** session for `codec`? Cached per
|
||||||
/// (selected GPU, codec) — the [`can_encode_10bit`] idiom, with the probe run outside the lock.
|
/// (selected GPU, codec) — the [`can_encode_10bit`] idiom, with the probe run outside the lock.
|
||||||
///
|
///
|
||||||
@@ -1387,6 +1466,23 @@ pub fn can_encode_10bit(_codec: Codec) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marker in an encoder error's `anyhow` context chain: the failure is a deterministic
|
||||||
|
/// consequence of the session's configuration, so an in-place rebuild retry can never succeed
|
||||||
|
/// (e.g. the backend binding to a wrong-vendor adapter — the same wall on every attempt). The
|
||||||
|
/// stream loop's reset ladder downcasts for this and ends the session immediately with the
|
||||||
|
/// carried cause instead of burning its rebuild budget first. Attach with
|
||||||
|
/// `anyhow::Error::new(TerminalEncoderError).context("the actual cause")`.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct TerminalEncoderError;
|
||||||
|
|
||||||
|
impl std::fmt::Display for TerminalEncoderError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("deterministic configuration error — an encoder rebuild cannot fix this")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for TerminalEncoderError {}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
// Windows backend selection (the analogue of the Linux nvidia_present / linux_zero_copy_is_vaapi
|
// Windows backend selection (the analogue of the Linux nvidia_present / linux_zero_copy_is_vaapi
|
||||||
// logic). NVIDIA → NVENC, AMD → AMF, Intel → QSV; `auto` (default) reads the vendor of the
|
// logic). NVIDIA → NVENC, AMD → AMF, Intel → QSV; `auto` (default) reads the vendor of the
|
||||||
@@ -1394,7 +1490,8 @@ pub fn can_encode_10bit(_codec: Codec) -> bool {
|
|||||||
// backend always matches the GPU the capture ring and virtual display sit on.
|
// backend always matches the GPU the capture ring and virtual display sit on.
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
// Un-gated (unlike everything else in this section): the pure reconciliation half below is
|
||||||
|
// platform-agnostic so its decision table is unit-tested on every CI leg, not just Windows.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum WindowsBackend {
|
pub enum WindowsBackend {
|
||||||
Nvenc,
|
Nvenc,
|
||||||
@@ -1411,24 +1508,77 @@ enum GpuVendor {
|
|||||||
Intel,
|
Intel,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the active Windows encode backend from `PUNKTFUNK_ENCODER` (`auto` → the selected
|
/// The PCI vendor a Windows hardware backend can open on (`None` for the vendor-agnostic
|
||||||
/// render adapter's vendor). Shared by [`open_video`] and the GameStream codec advertisement so
|
/// software encoder). Capture ring, virtual display, and encoder share ONE adapter, so a backend
|
||||||
/// both agree.
|
/// whose vendor differs from the selected GPU's can never open — the table the pin-vs-preference
|
||||||
|
/// reconciliation in [`resolve_windows_backend`] stands on.
|
||||||
|
pub fn windows_backend_vendor_id(backend: WindowsBackend) -> Option<u32> {
|
||||||
|
match backend {
|
||||||
|
WindowsBackend::Nvenc => Some(pf_gpu::VENDOR_NVIDIA),
|
||||||
|
WindowsBackend::Amf => Some(pf_gpu::VENDOR_AMD),
|
||||||
|
WindowsBackend::Qsv => Some(pf_gpu::VENDOR_INTEL),
|
||||||
|
WindowsBackend::Software => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure half of [`windows_resolved_backend`]: reconcile an explicit `PUNKTFUNK_ENCODER` pin with
|
||||||
|
/// the selected render adapter. A hardware pin whose vendor contradicts the selected GPU is
|
||||||
|
/// OVERRIDDEN by the adapter-derived backend (`derived`, evaluated lazily) — honoring it can only
|
||||||
|
/// produce a deterministically-failing session, observed on a hybrid box as a stale `qsv` pin vs.
|
||||||
|
/// a console NVIDIA preference: five futile in-place rebuilds, session end, client reconnect,
|
||||||
|
/// forever. The console preference is the newer, user-visible, per-box intent; the pin is usually
|
||||||
|
/// a stale provisioning artifact — [`open_video`] warns loudly whenever a pin loses. The software
|
||||||
|
/// pin has no vendor and is always honored; with no selected GPU there is nothing to reconcile
|
||||||
|
/// against, so a pin is trusted as-is.
|
||||||
|
pub fn resolve_windows_backend(
|
||||||
|
pinned: Option<WindowsBackend>,
|
||||||
|
selected_vendor_id: Option<u32>,
|
||||||
|
derived: impl FnOnce() -> WindowsBackend,
|
||||||
|
) -> WindowsBackend {
|
||||||
|
match (pinned, selected_vendor_id) {
|
||||||
|
(None, _) => derived(),
|
||||||
|
(Some(pin), Some(vendor)) => match windows_backend_vendor_id(pin) {
|
||||||
|
Some(required) if required != vendor => derived(),
|
||||||
|
_ => pin,
|
||||||
|
},
|
||||||
|
(Some(pin), None) => pin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The explicit `PUNKTFUNK_ENCODER` pin as a backend — `None` for `auto`, unset, and unknown
|
||||||
|
/// spellings (which all mean "derive from the selected adapter's vendor").
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn windows_resolved_backend() -> WindowsBackend {
|
fn windows_pinned_backend() -> Option<WindowsBackend> {
|
||||||
// Resolved ONCE in HostConfig (Goal-1) — was re-read from PUNKTFUNK_ENCODER on every call.
|
// Resolved ONCE in HostConfig (Goal-1) — was re-read from PUNKTFUNK_ENCODER on every call.
|
||||||
match pf_host_config::config().encoder_pref.as_str() {
|
match pf_host_config::config().encoder_pref.as_str() {
|
||||||
"nvenc" | "hw" | "nvidia" | "cuda" => WindowsBackend::Nvenc,
|
"nvenc" | "hw" | "nvidia" | "cuda" => Some(WindowsBackend::Nvenc),
|
||||||
"amf" | "amd" => WindowsBackend::Amf,
|
"amf" | "amd" => Some(WindowsBackend::Amf),
|
||||||
"qsv" | "intel" => WindowsBackend::Qsv,
|
"qsv" | "intel" => Some(WindowsBackend::Qsv),
|
||||||
"sw" | "software" | "openh264" => WindowsBackend::Software,
|
"sw" | "software" | "openh264" => Some(WindowsBackend::Software),
|
||||||
_ => match windows_gpu_vendor() {
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the active Windows encode backend from `PUNKTFUNK_ENCODER` (`auto` → the selected
|
||||||
|
/// render adapter's vendor; an explicit pin contradicting that vendor is overridden — see
|
||||||
|
/// [`resolve_windows_backend`]). Shared by [`open_video`] and the GameStream codec advertisement
|
||||||
|
/// so both agree.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn windows_resolved_backend() -> WindowsBackend {
|
||||||
|
let pinned = windows_pinned_backend();
|
||||||
|
// The selected vendor is only consulted to reconcile a pin — skipping the query keeps the
|
||||||
|
// common auto path at ONE inventory walk (the one inside `windows_gpu_vendor`).
|
||||||
|
let selected = if pinned.is_some() {
|
||||||
|
pf_gpu::selected_gpu().map(|s| s.info.vendor_id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
resolve_windows_backend(pinned, selected, || match windows_gpu_vendor() {
|
||||||
Some(GpuVendor::Nvidia) => WindowsBackend::Nvenc,
|
Some(GpuVendor::Nvidia) => WindowsBackend::Nvenc,
|
||||||
Some(GpuVendor::Amd) => WindowsBackend::Amf,
|
Some(GpuVendor::Amd) => WindowsBackend::Amf,
|
||||||
Some(GpuVendor::Intel) => WindowsBackend::Qsv,
|
Some(GpuVendor::Intel) => WindowsBackend::Qsv,
|
||||||
None => WindowsBackend::Software,
|
None => WindowsBackend::Software,
|
||||||
},
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if the session's resolved encode backend produces GPU-resident frames (so the capturer should
|
/// True if the session's resolved encode backend produces GPU-resident frames (so the capturer should
|
||||||
@@ -1727,6 +1877,63 @@ pub fn pyrowave_mode_fits_rdo(_width: u32, _height: u32, _chroma444: bool) -> bo
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// The pin-vs-adapter reconciliation table (`resolve_windows_backend`): an explicit
|
||||||
|
/// `PUNKTFUNK_ENCODER` pin whose vendor contradicts the selected GPU must be overridden by
|
||||||
|
/// the adapter-derived backend — never "pin + proceed", which fed the reset ladder a
|
||||||
|
/// deterministic failure (the hybrid-box `qsv` pin × console NVIDIA preference loop).
|
||||||
|
#[test]
|
||||||
|
fn encoder_pin_reconciles_against_the_selected_adapter() {
|
||||||
|
use WindowsBackend::*;
|
||||||
|
let derived = |b: WindowsBackend| move || b;
|
||||||
|
let unreachable = || -> WindowsBackend { panic!("derived must not be consulted") };
|
||||||
|
// The repro: pinned qsv, console-selected NVIDIA → NVENC (the un-pinned path's answer).
|
||||||
|
assert_eq!(
|
||||||
|
resolve_windows_backend(Some(Qsv), Some(pf_gpu::VENDOR_NVIDIA), derived(Nvenc)),
|
||||||
|
Nvenc
|
||||||
|
);
|
||||||
|
// A pin matching the selected vendor is honored without deriving.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_windows_backend(Some(Qsv), Some(pf_gpu::VENDOR_INTEL), unreachable),
|
||||||
|
Qsv
|
||||||
|
);
|
||||||
|
// No selected GPU → nothing to reconcile against; the pin is trusted as-is.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_windows_backend(Some(Nvenc), None, unreachable),
|
||||||
|
Nvenc
|
||||||
|
);
|
||||||
|
// The software pin has no vendor: honored on any adapter.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_windows_backend(Some(Software), Some(pf_gpu::VENDOR_NVIDIA), unreachable),
|
||||||
|
Software
|
||||||
|
);
|
||||||
|
// No pin (`auto`/unset) → always the adapter-derived backend.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_windows_backend(None, Some(pf_gpu::VENDOR_AMD), derived(Amf)),
|
||||||
|
Amf
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_windows_backend(None, None, derived(Software)),
|
||||||
|
Software
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The terminal-marker contract the stream loop's reset ladder relies on: a
|
||||||
|
/// [`TerminalEncoderError`] attached at the failure site must stay downcastable through the
|
||||||
|
/// context layers added on the way up (a `format!`/stringify on any layer would break it).
|
||||||
|
#[test]
|
||||||
|
fn terminal_encoder_error_survives_the_context_chain() {
|
||||||
|
use anyhow::Context as _;
|
||||||
|
let site: anyhow::Error = anyhow::Error::new(TerminalEncoderError)
|
||||||
|
.context("capture device's adapter is not an Intel VPL implementation");
|
||||||
|
let bubbled = Err::<(), _>(site)
|
||||||
|
.context("QSV lazy bring-up")
|
||||||
|
.context("encoder submit")
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(bubbled.downcast_ref::<TerminalEncoderError>().is_some());
|
||||||
|
// The operator-facing rendering leads with the actual cause, not the marker.
|
||||||
|
assert!(format!("{bubbled:#}").contains("not an Intel VPL implementation"));
|
||||||
|
}
|
||||||
|
|
||||||
/// The probed-capability → wire-bitfield mapping the native codec advertisement is built from.
|
/// The probed-capability → wire-bitfield mapping the native codec advertisement is built from.
|
||||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1754,6 +1961,72 @@ mod tests {
|
|||||||
assert_eq!(none.wire_mask(), None);
|
assert_eq!(none.wire_mask(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The cursor-blend capability mirror, arm by arm — the table the caps-aware negotiation
|
||||||
|
/// (cursor channel grant, metadata-vs-embedded capture) stands on. Each row names the arm's
|
||||||
|
/// blending stage or the reason there is none.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn cursor_blend_capability_mirrors_the_dispatch() {
|
||||||
|
use LinuxBackend::*;
|
||||||
|
// PyroWave: the wavelet CSC composites, always.
|
||||||
|
assert!(cursor_blend_capable_for(
|
||||||
|
Some(Pyrowave),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
));
|
||||||
|
// NVIDIA: only the direct-SDK arm blends (VkSlotBlend), and only for CUDA payloads.
|
||||||
|
assert!(cursor_blend_capable_for(
|
||||||
|
Some(Nvenc),
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
!cursor_blend_capable_for(Some(Nvenc), false, false, true, false),
|
||||||
|
"a CPU payload stays on libav NVENC, which cannot blend"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!cursor_blend_capable_for(Some(Nvenc), true, false, false, false),
|
||||||
|
"PUNKTFUNK_NVENC_DIRECT=0 (or a build without the feature) is the libav path"
|
||||||
|
);
|
||||||
|
// AMD/Intel: the Vulkan Video compute-CSC arm blends; VAAPI never does.
|
||||||
|
assert!(cursor_blend_capable_for(
|
||||||
|
Some(AmdIntel),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
!cursor_blend_capable_for(Some(AmdIntel), false, false, false, false),
|
||||||
|
"no eligible Vulkan CSC arm (H.264, PUNKTFUNK_VULKAN_ENCODE=0, unsupported \
|
||||||
|
device) resolves to libav VAAPI, which cannot blend"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!cursor_blend_capable_for(Some(AmdIntel), false, true, false, true),
|
||||||
|
"a 10-bit HDR session skips Vulkan Video for VAAPI's P010 wiring — no blend"
|
||||||
|
);
|
||||||
|
assert!(cursor_blend_capable_for(
|
||||||
|
Some(Vulkan),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
// Software / unknown pref: CPU frames; the encoder blends nothing.
|
||||||
|
assert!(!cursor_blend_capable_for(
|
||||||
|
Some(Software),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!cursor_blend_capable_for(None, false, false, true, true));
|
||||||
|
}
|
||||||
|
|
||||||
/// WP7.7 guard (the cheap half): every `Encoder` trait method must be explicitly forwarded by
|
/// WP7.7 guard (the cheap half): every `Encoder` trait method must be explicitly forwarded by
|
||||||
/// `TrackedEncoder`. A defaulted trait method that isn't forwarded silently no-ops through the
|
/// `TrackedEncoder`. A defaulted trait method that isn't forwarded silently no-ops through the
|
||||||
/// wrapper — the trap has bitten three times (`set_wire_chunking`'s §4.4 chunking probe,
|
/// wrapper — the trap has bitten three times (`set_wire_chunking`'s §4.4 chunking probe,
|
||||||
|
|||||||
@@ -34,6 +34,28 @@
|
|||||||
|
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Whether a `PUNKTFUNK_*` env var reads as ON, or `None` when it is unset — the host's
|
||||||
|
/// **explicit-off** grammar: `0` / `false` / `off` / `no` (trimmed, case-insensitive) are off and ANY
|
||||||
|
/// other value is on, so a presence-style `=1` keeps working. Every "default ON" knob below shares
|
||||||
|
/// it.
|
||||||
|
///
|
||||||
|
/// Exported because callers in other crates need the SAME grammar. A hand-rolled
|
||||||
|
/// `var(k).as_deref() != Ok("0")` accepts `"0 "` (trailing space, trivially produced by a systemd
|
||||||
|
/// drop-in or a shell heredoc) and `"false"` as ON — the bug class of ed525c4c, and the reason
|
||||||
|
/// `PUNKTFUNK_PIPEWIRE_NV12` in pf-capture now routes through here.
|
||||||
|
///
|
||||||
|
/// Note this is deliberately NOT the grammar `pf-zerocopy` uses for its own flags (truthy:
|
||||||
|
/// `1|true|yes|on`, everything else off) — see the module docs: independent features that share a
|
||||||
|
/// name prefix.
|
||||||
|
pub fn env_on(name: &str) -> Option<bool> {
|
||||||
|
std::env::var(name).ok().map(|s| {
|
||||||
|
!matches!(
|
||||||
|
s.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"0" | "false" | "off" | "no"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolved host configuration. Holds the genuinely-constant operator/dispatch knobs (see module docs for
|
/// Resolved host configuration. Holds the genuinely-constant operator/dispatch knobs (see module docs for
|
||||||
/// what is deliberately excluded). Fields read on only one platform are kept alive cross-platform by the
|
/// what is deliberately excluded). Fields read on only one platform are kept alive cross-platform by the
|
||||||
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
|
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
|
||||||
@@ -128,42 +150,16 @@ impl HostConfig {
|
|||||||
idd_depth: val("PUNKTFUNK_IDD_DEPTH")
|
idd_depth: val("PUNKTFUNK_IDD_DEPTH")
|
||||||
.and_then(|s| s.parse::<usize>().ok())
|
.and_then(|s| s.parse::<usize>().ok())
|
||||||
.unwrap_or(2),
|
.unwrap_or(2),
|
||||||
zerocopy: val("PUNKTFUNK_ZEROCOPY").map(|s| {
|
zerocopy: env_on("PUNKTFUNK_ZEROCOPY"),
|
||||||
!matches!(
|
|
||||||
s.trim().to_ascii_lowercase().as_str(),
|
|
||||||
"0" | "false" | "off" | "no"
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
// Default ON, explicit-off grammar (mirrors `four_four_four`: the client's HDR setting
|
// Default ON, explicit-off grammar (mirrors `four_four_four`: the client's HDR setting
|
||||||
// is the real per-session switch; the encode probe keeps incapable GPUs honest at 8-bit).
|
// is the real per-session switch; the encode probe keeps incapable GPUs honest at 8-bit).
|
||||||
ten_bit: val("PUNKTFUNK_10BIT")
|
ten_bit: env_on("PUNKTFUNK_10BIT").unwrap_or(true),
|
||||||
.map(|s| {
|
|
||||||
!matches!(
|
|
||||||
s.trim().to_ascii_lowercase().as_str(),
|
|
||||||
"0" | "false" | "off" | "no"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or(true),
|
|
||||||
// Default ON, explicit-off grammar (the client's own 4:4:4 setting — default OFF —
|
// Default ON, explicit-off grammar (the client's own 4:4:4 setting — default OFF —
|
||||||
// is the real switch; see the field doc).
|
// is the real switch; see the field doc).
|
||||||
four_four_four: val("PUNKTFUNK_444")
|
four_four_four: env_on("PUNKTFUNK_444").unwrap_or(true),
|
||||||
.map(|s| {
|
|
||||||
!matches!(
|
|
||||||
s.trim().to_ascii_lowercase().as_str(),
|
|
||||||
"0" | "false" | "off" | "no"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or(true),
|
|
||||||
// Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real
|
// Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real
|
||||||
// per-session switch; see the field doc).
|
// per-session switch; see the field doc).
|
||||||
chacha20: val("PUNKTFUNK_CHACHA20")
|
chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
|
||||||
.map(|s| {
|
|
||||||
!matches!(
|
|
||||||
s.trim().to_ascii_lowercase().as_str(),
|
|
||||||
"0" | "false" | "off" | "no"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or(true),
|
|
||||||
perf: flag("PUNKTFUNK_PERF"),
|
perf: flag("PUNKTFUNK_PERF"),
|
||||||
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
|
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
|
||||||
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||||
|
|||||||
@@ -159,6 +159,10 @@ struct GroupState {
|
|||||||
/// PnP instance ids of monitor devnodes the EXPERIMENTAL `pnp_disable_monitors` axis disabled at
|
/// 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.
|
/// the group's first isolate — last-member teardown re-enables them BEFORE the CCD restore.
|
||||||
pnp_disabled: Vec<String>,
|
pnp_disabled: Vec<String>,
|
||||||
|
/// Whether `ccd_saved` was captured by an EXCLUSIVE isolate (vs `Primary`, which also
|
||||||
|
/// snapshots but deliberately keeps the physical displays active) — gates the re-assert
|
||||||
|
/// watchdog, which must never "fix" a Primary group's lit panels. Cleared with the restore.
|
||||||
|
ccd_exclusive: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The manager's guarded state: the slot map + the (single) group record. One lock for both — every
|
/// The manager's guarded state: the slot map + the (single) group record. One lock for both — every
|
||||||
@@ -183,7 +187,8 @@ impl MgrInner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The single device-level watchdog pinger, running while ANY slot lives (any IOCTL bumps the driver
|
/// The single device-level watchdog pinger, running while ANY slot lives (any IOCTL bumps the driver
|
||||||
/// watchdog, so one thread serves N monitors).
|
/// watchdog, so one thread serves N monitors). Also reused as the stop+join pair for the
|
||||||
|
/// exclusive-topology re-assert watchdog (same lifecycle shape).
|
||||||
struct Pinger {
|
struct Pinger {
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
thread: JoinHandle<()>,
|
thread: JoinHandle<()>,
|
||||||
@@ -240,6 +245,10 @@ pub struct VirtualDisplayManager {
|
|||||||
idd_session_stops: Mutex<std::collections::HashMap<u32, Arc<AtomicBool>>>,
|
idd_session_stops: Mutex<std::collections::HashMap<u32, Arc<AtomicBool>>>,
|
||||||
/// The device-level watchdog [`Pinger`], running while any slot lives.
|
/// The device-level watchdog [`Pinger`], running while any slot lives.
|
||||||
pinger: Mutex<Option<Pinger>>,
|
pinger: Mutex<Option<Pinger>>,
|
||||||
|
/// The exclusive-topology re-assert watchdog (same stop+join shape as [`Pinger`]), running
|
||||||
|
/// while an EXCLUSIVE group isolate is live — a verified isolate is not durable on hybrid
|
||||||
|
/// boxes (see [`Self::ensure_exclusive_watch`]).
|
||||||
|
exclusive_watch: Mutex<Option<Pinger>>,
|
||||||
// The per-client stable monitor-id map is now the process-wide `super::identity::global()`
|
// The per-client stable monitor-id map is now the process-wide `super::identity::global()`
|
||||||
// (shared with the Linux KWin backend's per-slot naming — never same-process). A monitor CREATE
|
// (shared with the Linux KWin backend's per-slot naming — never same-process). A monitor CREATE
|
||||||
// resolves the client's id via `identity::resolve_slot`, so it keeps the same EDID serial + IddCx
|
// resolves the client's id via `identity::resolve_slot`, so it keeps the same EDID serial + IddCx
|
||||||
@@ -248,6 +257,19 @@ pub struct VirtualDisplayManager {
|
|||||||
|
|
||||||
static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new();
|
static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Bumped on every exclusive-topology EVICTION the re-assert watchdog performs. An eviction is a
|
||||||
|
/// real topology change, and the OS drives COMMIT_MODES on every live path for those — the IDD
|
||||||
|
/// display's swap-chain is recreated driver-side while the session's capture ring keeps waiting
|
||||||
|
/// on the old attachment, so frames stop silently (on-glass: panel evicted, stream frozen). The
|
||||||
|
/// manager can only announce; the SESSION owns the ring + encoder — stream loops sample this at
|
||||||
|
/// start and rebuild their capture attachment in place when it advances.
|
||||||
|
static TOPOLOGY_REASSERT_GEN: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
/// The current exclusive-eviction generation (see [`TOPOLOGY_REASSERT_GEN`]).
|
||||||
|
pub fn topology_reassert_gen() -> u64 {
|
||||||
|
TOPOLOGY_REASSERT_GEN.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Initialise the process-wide manager with `driver` (the chosen backend) and return it. Idempotent: the
|
/// Initialise the process-wide manager with `driver` (the chosen backend) and return it. Idempotent: the
|
||||||
/// first backend to call wins (the host runs one backend per process), so a later call ignores its driver.
|
/// first backend to call wins (the host runs one backend per process), so a later call ignores its driver.
|
||||||
pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayManager {
|
pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayManager {
|
||||||
@@ -262,6 +284,7 @@ pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayMa
|
|||||||
setup_lock: Mutex::new(()),
|
setup_lock: Mutex::new(()),
|
||||||
idd_session_stops: Mutex::new(std::collections::HashMap::new()),
|
idd_session_stops: Mutex::new(std::collections::HashMap::new()),
|
||||||
pinger: Mutex::new(None),
|
pinger: Mutex::new(None),
|
||||||
|
exclusive_watch: Mutex::new(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,6 +798,130 @@ impl VirtualDisplayManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start the exclusive-topology re-assert watchdog (idempotent). A verified
|
||||||
|
/// [`isolate_displays_ccd`] is NOT durable: the isolated topology is deliberately not saved to
|
||||||
|
/// the CCD database (teardown must restore the user's layout), so any later topology
|
||||||
|
/// re-resolution — our own post-isolate resize/HDR commit, the deactivated panel's own driver,
|
||||||
|
/// display-poller software — can bring the stored layout back. Field-proven on a hybrid
|
||||||
|
/// Intel+NVIDIA box (`.221`): seconds after the commit-time verify reported "sole active
|
||||||
|
/// desktop", CCD showed the internal panel ACTIVE again beside the virtual display, and the
|
||||||
|
/// host never noticed. That silently un-does exclusive mode: cursor + windows can land
|
||||||
|
/// off-stream, and the secure desktop / lock screen can land on the physical panel.
|
||||||
|
///
|
||||||
|
/// The watchdog re-queries every [`knobs::exclusive_reassert_ms`] and, when a non-managed
|
||||||
|
/// display crept back, evicts it via the full [`isolate_displays_ccd`] — the forced
|
||||||
|
/// re-commit is required (it restarts OS presentation to the virtual display), and the
|
||||||
|
/// swap-chain bounce it causes is healed by the SESSION's in-place capture re-attach, keyed
|
||||||
|
/// off [`topology_reassert_gen`]. Each cycle takes the state lock via `try_lock` —
|
||||||
|
/// teardown stops+joins this thread while HOLDING the state lock, so a blocking `lock()` here
|
||||||
|
/// would deadlock; a contended cycle just skips (the slot transition that owns the lock
|
||||||
|
/// re-isolates itself). The sleep is sliced so that stop+join is bounded by ~250 ms, not a
|
||||||
|
/// full cycle.
|
||||||
|
fn ensure_exclusive_watch(&'static self) {
|
||||||
|
let interval = Duration::from_millis(knobs::exclusive_reassert_ms());
|
||||||
|
if interval.is_zero() {
|
||||||
|
return; // knob 0 = disabled (the pre-watchdog behavior)
|
||||||
|
}
|
||||||
|
let mut guard = self.exclusive_watch.lock().unwrap();
|
||||||
|
if guard.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let stop_t = stop.clone();
|
||||||
|
let thread = thread::Builder::new()
|
||||||
|
.name("vdisplay-exclusive-watch".into())
|
||||||
|
.spawn(move || {
|
||||||
|
// Consecutive cycles that found (and evicted) a non-managed display — resets when a
|
||||||
|
// cycle comes up clean, so an actor that re-adds the panel every few minutes logs a
|
||||||
|
// WARN each time, while one fighting every cycle escalates once and then goes quiet.
|
||||||
|
let mut fighting = 0u32;
|
||||||
|
'watch: loop {
|
||||||
|
let mut slept = Duration::ZERO;
|
||||||
|
while slept < interval {
|
||||||
|
if stop_t.load(Ordering::Relaxed) {
|
||||||
|
break 'watch;
|
||||||
|
}
|
||||||
|
let slice = Duration::from_millis(250).min(interval - slept);
|
||||||
|
thread::sleep(slice);
|
||||||
|
slept += slice;
|
||||||
|
}
|
||||||
|
let Ok(inner) = vdm().state.try_lock() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if inner.group.ccd_saved.is_none() || !inner.group.ccd_exclusive {
|
||||||
|
continue; // no exclusive isolate live right now
|
||||||
|
}
|
||||||
|
let keep = inner.target_ids();
|
||||||
|
if keep.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// SAFETY: `count_other_active` runs the CCD QueryDisplayConfig FFI over a
|
||||||
|
// borrowed slice of `Copy` target ids (owned result), under the `state` lock
|
||||||
|
// (`inner` guard held to the end of this iteration).
|
||||||
|
let survivors = unsafe { count_other_active(&keep) }.unwrap_or(0);
|
||||||
|
if survivors == 0 {
|
||||||
|
if fighting > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
reasserts = fighting,
|
||||||
|
"exclusive topology stable again — no non-managed display active"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fighting = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fighting += 1;
|
||||||
|
match fighting {
|
||||||
|
1..=3 => tracing::warn!(
|
||||||
|
survivors,
|
||||||
|
round = fighting,
|
||||||
|
"exclusive topology lost — a non-managed display re-activated after \
|
||||||
|
the verified isolate (hybrid-GPU driver / display-poller software \
|
||||||
|
restoring the saved layout?); re-asserting the isolate"
|
||||||
|
),
|
||||||
|
4 => tracing::error!(
|
||||||
|
survivors,
|
||||||
|
"exclusive topology keeps being re-activated (4 consecutive \
|
||||||
|
re-asserts) — something on this host is fighting the isolate; \
|
||||||
|
continuing to re-assert every cycle, further rounds log at DEBUG"
|
||||||
|
),
|
||||||
|
_ => tracing::debug!(
|
||||||
|
survivors,
|
||||||
|
round = fighting,
|
||||||
|
"re-asserting exclusive topology"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
// SAFETY: `isolate_displays_ccd` drives the CCD query/apply FFI over a
|
||||||
|
// borrowed slice of `Copy` target ids, under the `state` lock — the sole
|
||||||
|
// topology mutator. The returned snapshot is discarded (the group restores
|
||||||
|
// the FIRST member's). The FULL isolate on purpose — its forced re-commit
|
||||||
|
// (SDC_FORCE_MODE_ENUMERATION → COMMIT_MODES → ASSIGN_SWAPCHAIN) is
|
||||||
|
// load-bearing here exactly as at first isolate: after the eviction's
|
||||||
|
// topology change the OS stops presenting to the virtual display until a
|
||||||
|
// forced mode re-commit restarts it (on-glass: a gentle supplied-config
|
||||||
|
// eviction left capture receiving ONE stashed frame and then nothing).
|
||||||
|
let _ = unsafe { isolate_displays_ccd(&keep) };
|
||||||
|
// That same forced re-commit hands the live IDD path a fresh swap-chain,
|
||||||
|
// orphaning the session's capture ring — announce it so the session rebuilds
|
||||||
|
// its capture attachment (same-mode ring recreate + driver re-attach + fresh
|
||||||
|
// encoder) instead of silently streaming a frozen frame. The two halves are
|
||||||
|
// a pair: eviction restarts presentation, the session re-attaches capture.
|
||||||
|
TOPOLOGY_REASSERT_GEN.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("spawn vdisplay-exclusive-watch");
|
||||||
|
*guard = Some(Pinger { stop, thread });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop + join the exclusive-topology watchdog (the group's exclusive isolate is being
|
||||||
|
/// restored). Safe under the state lock: the watchdog only ever `try_lock`s it, and its sliced
|
||||||
|
/// sleep bounds the join by ~250 ms.
|
||||||
|
fn stop_exclusive_watch(&self) {
|
||||||
|
if let Some(w) = self.exclusive_watch.lock().unwrap().take() {
|
||||||
|
w.stop.store(true, Ordering::Relaxed);
|
||||||
|
let _ = w.thread.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Arrange the live slots' desktop origins (design §6.2: the pure `vdisplay/layout.rs` engine —
|
/// Arrange the live slots' desktop origins (design §6.2: the pure `vdisplay/layout.rs` engine —
|
||||||
/// `auto-row` default, console `manual` pins win) and commit them in one CCD apply. No-ops for a
|
/// `auto-row` default, console `manual` pins win) and commit them in one CCD apply. No-ops for a
|
||||||
/// single member (it sits at the origin), so the single-display path issues no positioning at
|
/// single member (it sits at the origin), so the single-display path issues no positioning at
|
||||||
@@ -998,6 +1145,12 @@ impl VirtualDisplayManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The verified isolate above is not durable — watch and re-assert
|
||||||
|
// while the exclusive group lives (see `ensure_exclusive_watch`).
|
||||||
|
inner.group.ccd_exclusive = inner.group.ccd_saved.is_some();
|
||||||
|
if inner.group.ccd_exclusive {
|
||||||
|
self.ensure_exclusive_watch();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Grown set: re-isolate so the fresh member joins the composited set
|
// Grown set: re-isolate so the fresh member joins the composited set
|
||||||
// (its auto-activate may have lit nothing extra to deactivate, but the
|
// (its auto-activate may have lit nothing extra to deactivate, but the
|
||||||
@@ -1413,6 +1566,10 @@ impl VirtualDisplayManager {
|
|||||||
// (stopped FIRST, as the per-monitor pinger was), and the group's topology restore runs
|
// (stopped FIRST, as the per-monitor pinger was), and the group's topology restore runs
|
||||||
// — first-in captured it, last-out restores it (design §6.1).
|
// — first-in captured it, last-out restores it (design §6.1).
|
||||||
self.stop_pinger();
|
self.stop_pinger();
|
||||||
|
// The exclusive re-assert watchdog must be gone BEFORE the restore below — it would
|
||||||
|
// read the restored (multi-display) topology as "lost exclusivity" and re-fight it.
|
||||||
|
// (Belt-and-braces: its cycles also gate on `ccd_exclusive`, cleared below.)
|
||||||
|
self.stop_exclusive_watch();
|
||||||
// EXPERIMENTAL `pnp_disable_monitors` restore: re-enable the devnodes FIRST and let
|
// EXPERIMENTAL `pnp_disable_monitors` restore: re-enable the devnodes FIRST and let
|
||||||
// them re-arrive, so a CCD restore below re-activates paths whose monitors exist
|
// them re-arrive, so a CCD restore below re-activates paths whose monitors exist
|
||||||
// again (a disabled devnode would leave the restored path modeless/EDID-less).
|
// again (a disabled devnode would leave the restored path modeless/EDID-less).
|
||||||
@@ -1425,6 +1582,7 @@ impl VirtualDisplayManager {
|
|||||||
}
|
}
|
||||||
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero
|
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero
|
||||||
// displays.
|
// displays.
|
||||||
|
inner.group.ccd_exclusive = false;
|
||||||
if let Some(saved) = inner.group.ccd_saved.take() {
|
if let Some(saved) = inner.group.ccd_saved.take() {
|
||||||
restore_displays_ccd(&saved);
|
restore_displays_ccd(&saved);
|
||||||
// EXPERIMENTAL `ddc_power_off` wake: the restore re-activated the physical paths, and
|
// EXPERIMENTAL `ddc_power_off` wake: the restore re-activated the physical paths, and
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ pub(super) fn keep_alive_forever() -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cadence of the exclusive-topology re-assert watchdog (`PUNKTFUNK_EXCLUSIVE_REASSERT_MS`,
|
||||||
|
/// default 2000, `0` disables — the pre-watchdog behavior). Why it exists: a verified isolate is
|
||||||
|
/// not durable — see `VirtualDisplayManager::ensure_exclusive_watch` in the parent module.
|
||||||
|
pub(super) fn exclusive_reassert_ms() -> u64 {
|
||||||
|
std::env::var("PUNKTFUNK_EXCLUSIVE_REASSERT_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(2_000)
|
||||||
|
}
|
||||||
|
|
||||||
/// The effective display topology for a freshly-created monitor (never `Auto`): the console policy's
|
/// The effective display topology for a freshly-created monitor (never `Auto`): the console policy's
|
||||||
/// [`effective_topology`](crate::effective_topology) when configured, else the legacy
|
/// [`effective_topology`](crate::effective_topology) when configured, else the legacy
|
||||||
/// `PUNKTFUNK_NO_ISOLATE` env knob (`Extend`) / `Exclusive` (today's default). `Extend` leaves the IDD
|
/// `PUNKTFUNK_NO_ISOLATE` env knob (`Extend`) / `Exclusive` (today's default). `Extend` leaves the IDD
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ use windows::Win32::Devices::Display::{
|
|||||||
};
|
};
|
||||||
use windows::Win32::Foundation::POINTL;
|
use windows::Win32::Foundation::POINTL;
|
||||||
use windows::Win32::Graphics::Gdi::{
|
use windows::Win32::Graphics::Gdi::{
|
||||||
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
|
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_RESET, CDS_TEST, CDS_UPDATEREGISTRY,
|
||||||
DISP_CHANGE_FAILED, DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT,
|
DEVMODEW, DISP_CHANGE_FAILED, DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY,
|
||||||
DM_PELSWIDTH, ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE,
|
DM_PELSHEIGHT, DM_PELSWIDTH, ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE,
|
||||||
};
|
};
|
||||||
|
|
||||||
use punktfunk_core::Mode;
|
use punktfunk_core::Mode;
|
||||||
@@ -540,6 +540,50 @@ pub unsafe fn sdr_white_level_scale(target_id: u32) -> Option<f32> {
|
|||||||
/// mode the driver didn't advertise just leaves the default instead of erroring the session.
|
/// mode the driver didn't advertise just leaves the default instead of erroring the session.
|
||||||
// pub so vdisplay::pf_vdisplay can reuse this backend-neutral CCD/GDI mode-set helper
|
// pub so vdisplay::pf_vdisplay can reuse this backend-neutral CCD/GDI mode-set helper
|
||||||
// (a pf-vdisplay monitor's GDI name is a real OS device name, so it works unchanged).
|
// (a pf-vdisplay monitor's GDI name is a real OS device name, so it works unchanged).
|
||||||
|
/// Force a REAL mode-set at the output's CURRENT mode — `CDS_RESET` applies even when nothing
|
||||||
|
/// changed (a plain re-apply of the same mode is treated as a no-op by the OS). This is the
|
||||||
|
/// presentation-restart hammer for a virtual display DWM silently stopped composing to after an
|
||||||
|
/// exclusive-eviction topology commit: measured on-glass, the eviction's forced re-commit
|
||||||
|
/// reassigned the swap-chain and the ring re-attach delivered the driver's stashed frame, but the
|
||||||
|
/// source's new-frame rate stayed 0 forever — only a real mode-set (the lever bring-up's ADD path
|
||||||
|
/// relies on via [`set_active_mode`]) makes the OS present again. Same input-desktop retry as the
|
||||||
|
/// mode-set proper. Returns `true` when the reset applied.
|
||||||
|
pub fn force_mode_reset(gdi_name: &str) -> bool {
|
||||||
|
let wname: Vec<u16> = gdi_name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
let mut dm = DEVMODEW {
|
||||||
|
dmSize: size_of::<DEVMODEW>() as u16,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// SAFETY: `wname` is a live NUL-terminated UTF-16 device name and `&mut dm` a live DEVMODEW
|
||||||
|
// out-param with `dmSize` set; the synchronous query only reads the name and fills `dm`.
|
||||||
|
let ok =
|
||||||
|
unsafe { EnumDisplaySettingsW(PCWSTR(wname.as_ptr()), ENUM_CURRENT_SETTINGS, &mut dm) }
|
||||||
|
.as_bool();
|
||||||
|
if !ok {
|
||||||
|
tracing::warn!("{gdi_name}: force_mode_reset — no current mode to re-apply");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// SAFETY: same liveness as the query above; CDS_RESET re-applies the identical mode, the two
|
||||||
|
// trailing args are null, and the API only reads its inputs. The input-desktop retry mirrors
|
||||||
|
// `set_active_mode` (a CDS write off the input desktop is refused with DISP_CHANGE_FAILED).
|
||||||
|
let rc = crate::input_desktop::retry_on_input_desktop(
|
||||||
|
|rc| *rc == DISP_CHANGE_FAILED,
|
||||||
|
|| unsafe {
|
||||||
|
ChangeDisplaySettingsExW(PCWSTR(wname.as_ptr()), Some(&dm), None, CDS_RESET, None)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if rc != DISP_CHANGE_SUCCESSFUL {
|
||||||
|
tracing::warn!(
|
||||||
|
result = rc.0,
|
||||||
|
"{gdi_name}: force_mode_reset rejected ({})",
|
||||||
|
disp_change_reason(rc.0)
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tracing::info!("{gdi_name}: forced same-mode reset applied (presentation restart)");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_active_mode(gdi_name: &str, mode: Mode) {
|
pub fn set_active_mode(gdi_name: &str, mode: Mode) {
|
||||||
let wname: Vec<u16> = gdi_name.encode_utf16().chain(std::iter::once(0)).collect();
|
let wname: Vec<u16> = gdi_name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
|
||||||
@@ -1045,6 +1089,14 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
|||||||
Some(saved)
|
Some(saved)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// (A "gentle" eviction variant — deactivate the stray paths WITHOUT `SDC_FORCE_MODE_ENUMERATION`,
|
||||||
|
// kept paths supplied verbatim — was tried for the re-assert watchdog and REMOVED: on-glass it
|
||||||
|
// still bounced the live virtual display's swap-chain (a real topology change drives COMMIT_MODES
|
||||||
|
// on its own) AND additionally left the OS not presenting to the virtual display — capture
|
||||||
|
// received one stashed frame and then nothing. Eviction therefore always goes through
|
||||||
|
// [`isolate_displays_ccd`], whose forced re-commit restarts presentation, and the SESSION pairs it
|
||||||
|
// with an in-place capture re-attach; see the vdisplay manager's re-assert watchdog.)
|
||||||
|
|
||||||
/// Build the ESCALATED supplied config for [`isolate_displays_ccd`]: ONLY the paths still flagged
|
/// Build the ESCALATED supplied config for [`isolate_displays_ccd`]: ONLY the paths still flagged
|
||||||
/// ACTIVE (the keep set — the caller already cleared ACTIVE on every doomed path), with the mode
|
/// ACTIVE (the keep set — the caller already cleared ACTIVE on every doomed path), with the mode
|
||||||
/// table rebuilt to just the entries those paths reference (indexes remapped). Docs-wise the
|
/// table rebuilt to just the entries those paths reference (indexes remapped). Docs-wise the
|
||||||
|
|||||||
@@ -262,6 +262,15 @@ unsafe fn copy_async(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
|
|||||||
ck(cuMemcpy2DAsync_v2(copy, copy_stream()), what)
|
ck(cuMemcpy2DAsync_v2(copy, copy_stream()), what)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Block until everything enqueued on THIS THREAD's copy stream completed — the shared tail of
|
||||||
|
/// the multi-plane blocking copies (stream FIFO: one sync after the last enqueue covers every
|
||||||
|
/// plane, where the per-plane `copy_blocking` paid one exposed CPU wait EACH — and under a
|
||||||
|
/// game's GPU load each exposed wait eats scheduling latency). The shared context must be
|
||||||
|
/// current.
|
||||||
|
unsafe fn sync_copy_stream() -> Result<()> {
|
||||||
|
ck(cuStreamSynchronize(copy_stream()), "cuStreamSynchronize")
|
||||||
|
}
|
||||||
|
|
||||||
/// `copy_blocking` when `sync`, else `copy_async` — the shared tail of the public `copy_*_to_device`
|
/// `copy_blocking` when `sync`, else `copy_async` — the shared tail of the public `copy_*_to_device`
|
||||||
/// helpers, whose `sync: false` mode carries `copy_async`'s source-lifetime contract.
|
/// helpers, whose `sync: false` mode carries `copy_async`'s source-lifetime contract.
|
||||||
unsafe fn copy_issue(copy: &CUDA_MEMCPY2D, what: &str, sync: bool) -> Result<()> {
|
unsafe fn copy_issue(copy: &CUDA_MEMCPY2D, what: &str, sync: bool) -> Result<()> {
|
||||||
@@ -985,18 +994,25 @@ pub fn copy_nv12_to_device(
|
|||||||
Height: h / 2,
|
Height: h / 2,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
// SAFETY: two unsafe `copy_issue` device→device copies; the caller must have the shared
|
// SAFETY: two unsafe `copy_async` device→device enqueues + an optional stream sync; the
|
||||||
// context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each
|
// caller must have the shared context current (documented). `&y`/`&uv` are live local
|
||||||
// enqueue. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live
|
// `CUDA_MEMCPY2D`s outliving each enqueue. All four device pointers are valid:
|
||||||
// NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are
|
// `src.ptr`/`src_uv_ptr` come from a live NV12 `DeviceBuffer` (its `.uv` presence was
|
||||||
// the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy
|
// checked via `ok_or_else`), `y_dst`/`uv_dst` are the caller's live NVENC surface planes;
|
||||||
// `(w/2)*2`×`h/2`, each within its planes; `sync: false` shifts the source-lifetime obligation
|
// the luma copy is `w`×`h`, the chroma copy `(w/2)*2`×`h/2`, each within its planes. With
|
||||||
// to the caller (documented above). Wrappers → live table.
|
// `sync` the single trailing stream sync covers both enqueues (FIFO) before we return — the
|
||||||
|
// same completion guarantee as the old per-plane blocking copies at half the exposed waits;
|
||||||
|
// `sync: false` shifts the source-lifetime obligation to the caller (documented above).
|
||||||
|
// Wrappers → live table.
|
||||||
unsafe {
|
unsafe {
|
||||||
copy_issue(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)", sync)?;
|
copy_async(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?;
|
||||||
copy_issue(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)", sync)
|
copy_async(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)")?;
|
||||||
|
if sync {
|
||||||
|
sync_copy_stream()?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Copy our imported stacked-YUV444 [`DeviceBuffer`] into NVENC's three-plane CUDA surface
|
/// Copy our imported stacked-YUV444 [`DeviceBuffer`] into NVENC's three-plane CUDA surface
|
||||||
/// (`av_hwframe_get_buffer`'s `data[0..3]` + `linesize[0..3]` for a `yuv444p` frames context).
|
/// (`av_hwframe_get_buffer`'s `data[0..3]` + `linesize[0..3]` for a `yuv444p` frames context).
|
||||||
@@ -1024,13 +1040,19 @@ pub fn copy_yuv444_to_device(
|
|||||||
Height: h,
|
Height: h,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
// SAFETY: unsafe `copy_issue` device→device copy; the caller must have the shared
|
// SAFETY: unsafe `copy_async` device→device enqueue; the caller must have the shared
|
||||||
// context current (documented). `©` is a live local outliving the enqueue;
|
// context current (documented). `©` is a live local outliving the enqueue;
|
||||||
// `src.ptr + pitch·h·i` stays within the live 3·H-row stacked allocation (`yuv444`
|
// `src.ptr + pitch·h·i` stays within the live 3·H-row stacked allocation (`yuv444`
|
||||||
// checked above), `dst_ptr`/`dst_pitch` is the caller's live NVENC plane; `w`×`h` fits
|
// checked above), `dst_ptr`/`dst_pitch` is the caller's live NVENC plane; `w`×`h` fits
|
||||||
// both; `sync: false` shifts the source-lifetime obligation to the caller (documented
|
// both. Completion is the trailing stream sync below (`sync`) or the caller's
|
||||||
// above). Wrapper → live table.
|
// stream-ordering obligation (`sync: false`, documented above). Wrapper → live table.
|
||||||
unsafe { copy_issue(©, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)", sync)? };
|
unsafe { copy_async(©, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)")? };
|
||||||
|
}
|
||||||
|
if sync {
|
||||||
|
// SAFETY: one stream sync after the last enqueue covers all three planes (FIFO) — the
|
||||||
|
// same completion guarantee as the old per-plane blocking copies at a third of the
|
||||||
|
// exposed waits. Context current per the caller's contract. Wrapper → live table.
|
||||||
|
unsafe { sync_copy_stream()? };
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1169,6 +1191,101 @@ impl Drop for ExternalDmabuf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A Vulkan **timeline** semaphore imported as a CUDA external semaphore — the cross-API ordering
|
||||||
|
/// primitive for the stream-ordered cursor blend (`vkslot.rs`): CUDA [`signal`](Self::signal)s a
|
||||||
|
/// value on this thread's copy stream once the input copy is enqueued, the Vulkan blend waits for
|
||||||
|
/// and then advances the timeline on its queue, and CUDA [`wait`](Self::wait)s that advanced
|
||||||
|
/// value before the encode — all ordering on-device, no CPU sync anywhere. One imported handle
|
||||||
|
/// per [`VkSlotBlend`](super::vkslot::VkSlotBlend); values are monotonic for its lifetime.
|
||||||
|
pub struct ExternalSemaphore {
|
||||||
|
sem: CUexternalSemaphore,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: `CUexternalSemaphore` is an opaque driver handle with no thread affinity (the driver
|
||||||
|
// API allows use from any thread with the context current). It is uniquely owned here, used from
|
||||||
|
// the encode thread but moved with its `VkSlotBlend`, and destroyed exactly once in `Drop` —
|
||||||
|
// `Send` (not `Sync`) matches that single-thread-at-a-time use, like `ExternalDmabuf`.
|
||||||
|
unsafe impl Send for ExternalSemaphore {}
|
||||||
|
|
||||||
|
impl ExternalSemaphore {
|
||||||
|
/// Import a Vulkan timeline semaphore exported as an OPAQUE_FD (`vkGetSemaphoreFdKHR`). The
|
||||||
|
/// fd is handed over: the driver owns it on success, we close it on failure. The shared
|
||||||
|
/// context must be current.
|
||||||
|
pub fn import_owned_timeline_fd(fd: i32) -> Result<ExternalSemaphore> {
|
||||||
|
let mut desc = CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC {
|
||||||
|
type_: CU_EXTERNAL_SEMAPHORE_HANDLE_TYPE_TIMELINE_SEMAPHORE_FD,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
desc.handle[0] = fd as u32 as u64; // union member `int fd` (little-endian low bytes)
|
||||||
|
let mut sem: CUexternalSemaphore = std::ptr::null_mut();
|
||||||
|
// SAFETY: `cuImportExternalSemaphore` reads `&desc`, a live local `#[repr(C)]`
|
||||||
|
// `CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC` (layout-asserted below) outliving the synchronous
|
||||||
|
// call: `type_` is TIMELINE_SEMAPHORE_FD and `handle[0]` holds the fd in the union's
|
||||||
|
// `int fd` low bytes. `&mut sem` is a live null-init out-param the driver writes the
|
||||||
|
// imported handle into. Distinct locals → no aliasing. Wrapper → live table (caller holds
|
||||||
|
// the context current).
|
||||||
|
let r = unsafe { cuImportExternalSemaphore(&mut sem, &desc) };
|
||||||
|
if r != 0 {
|
||||||
|
// SAFETY: import failed (`r != 0`), so the driver did NOT take ownership of `fd`; we
|
||||||
|
// still own it and close it exactly once here. `libc::close` acts on the integer alone.
|
||||||
|
unsafe { libc::close(fd) };
|
||||||
|
bail!("cuImportExternalSemaphore failed ({r}) — timeline-semaphore fd export/import unsupported?");
|
||||||
|
}
|
||||||
|
Ok(ExternalSemaphore { sem })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enqueue a signal that sets the timeline to `value` once all prior work on THIS THREAD's
|
||||||
|
/// copy stream (the stream `copy_stream_handle` exposes) completes. No CPU wait.
|
||||||
|
pub fn signal(&self, value: u64) -> Result<()> {
|
||||||
|
let params = CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS {
|
||||||
|
value,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// SAFETY: `self.sem` is the live imported handle (this struct only exists after a
|
||||||
|
// successful import; destroyed only in `Drop`). `&self.sem`/`¶ms` are live locals the
|
||||||
|
// synchronous enqueue reads (count 1); the driver retains no pointer into Rust memory.
|
||||||
|
// The stream is this thread's live copy stream. Wrapper → live table (context current).
|
||||||
|
unsafe {
|
||||||
|
ck(
|
||||||
|
cuSignalExternalSemaphoresAsync(&self.sem, ¶ms, 1, copy_stream()),
|
||||||
|
"cuSignalExternalSemaphoresAsync",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enqueue a wait: work enqueued on THIS THREAD's copy stream after this call runs only once
|
||||||
|
/// the timeline reaches `value`. No CPU wait.
|
||||||
|
pub fn wait(&self, value: u64) -> Result<()> {
|
||||||
|
let params = CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS {
|
||||||
|
value,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// SAFETY: same contract as `signal` — live handle, live locals across the synchronous
|
||||||
|
// enqueue, this thread's live copy stream. Wrapper → live table (context current).
|
||||||
|
unsafe {
|
||||||
|
ck(
|
||||||
|
cuWaitExternalSemaphoresAsync(&self.sem, ¶ms, 1, copy_stream()),
|
||||||
|
"cuWaitExternalSemaphoresAsync",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ExternalSemaphore {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `self.sem` is the valid imported handle this struct exclusively owns, destroyed
|
||||||
|
// exactly once here. The shared context is made current first because drop may run off
|
||||||
|
// the import thread (`VkSlotBlend` teardown quiesces the GPU before dropping, so no
|
||||||
|
// enqueued signal/wait still references the semaphore). Result ignored (best-effort).
|
||||||
|
unsafe {
|
||||||
|
if let Some(c) = CONTEXT.get() {
|
||||||
|
let _ = cuCtxSetCurrent(c.0);
|
||||||
|
}
|
||||||
|
let _ = cuDestroyExternalSemaphore(self.sem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Copy a pitched span starting at `src_ptr` (e.g. an [`ExternalDmabuf`] mapping at the chunk
|
/// Copy a pitched span starting at `src_ptr` (e.g. an [`ExternalDmabuf`] mapping at the chunk
|
||||||
/// offset) into `dst`. The shared context must be current on this thread.
|
/// offset) into `dst`. The shared context must be current on this thread.
|
||||||
pub fn copy_pitched_to_buffer(
|
pub fn copy_pitched_to_buffer(
|
||||||
@@ -1243,3 +1360,31 @@ pub fn copy_pitched_nv12_to_buffer(
|
|||||||
copy_blocking(&uv, "cuMemcpy2DAsync_v2(ext->dev nv12 UV)")
|
copy_blocking(&uv, "cuMemcpy2DAsync_v2(ext->dev nv12 UV)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::mem::{offset_of, size_of};
|
||||||
|
|
||||||
|
/// The external-semaphore param structs are hand-flattened from cuda.h unions — assert the
|
||||||
|
/// layout against the C definitions so a transcription slip fails in CI, not in the driver.
|
||||||
|
#[test]
|
||||||
|
fn external_semaphore_struct_layouts_match_cuda_h() {
|
||||||
|
// CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC: type(4)+pad(4)+union(16)+flags(4)+reserved(64) = 96.
|
||||||
|
assert_eq!(size_of::<CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC>(), 96);
|
||||||
|
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC, handle), 8);
|
||||||
|
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC, flags), 24);
|
||||||
|
|
||||||
|
// CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS: params{fence(8)+nvSciSync(8)+keyedMutex(8)+
|
||||||
|
// reserved[12](48)} = 72, flags at 72, reserved[16] → 144 total.
|
||||||
|
assert_eq!(size_of::<CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS>(), 144);
|
||||||
|
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS, value), 0);
|
||||||
|
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS, flags), 72);
|
||||||
|
|
||||||
|
// CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS: params{fence(8)+nvSciSync(8)+keyedMutex(16,
|
||||||
|
// tail-padded)+reserved[10](40)} = 72, flags at 72, reserved[16] → 144 total.
|
||||||
|
assert_eq!(size_of::<CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS>(), 144);
|
||||||
|
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS, value), 0);
|
||||||
|
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS, flags), 72);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub type CUdeviceptr = u64;
|
|||||||
pub type CUgraphicsResource = *mut c_void;
|
pub type CUgraphicsResource = *mut c_void;
|
||||||
pub type CUarray = *mut c_void;
|
pub type CUarray = *mut c_void;
|
||||||
pub type CUexternalMemory = *mut c_void; // opaque CUextMemory_st*
|
pub type CUexternalMemory = *mut c_void; // opaque CUextMemory_st*
|
||||||
|
pub type CUexternalSemaphore = *mut c_void; // opaque CUextSemaphore_st*
|
||||||
|
|
||||||
/// `CUmemorytype` (cuda.h): HOST=1, DEVICE=2, ARRAY=3, UNIFIED=4.
|
/// `CUmemorytype` (cuda.h): HOST=1, DEVICE=2, ARRAY=3, UNIFIED=4.
|
||||||
pub const CU_MEMORYTYPE_DEVICE: c_uint = 2;
|
pub const CU_MEMORYTYPE_DEVICE: c_uint = 2;
|
||||||
@@ -84,6 +85,60 @@ pub struct CUDA_EXTERNAL_MEMORY_BUFFER_DESC {
|
|||||||
|
|
||||||
pub const CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD: c_uint = 1;
|
pub const CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD: c_uint = 1;
|
||||||
|
|
||||||
|
/// `CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC` (cuda.h, 64-bit layout). Same union-flattening as the
|
||||||
|
/// memory desc above: `handle` is a union whose largest member is the win32 two-pointer struct
|
||||||
|
/// (16 bytes, align 8); for the fd-carrying types only the first 4 bytes (the `int fd`) are read.
|
||||||
|
/// No `size` field — a semaphore has none.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC {
|
||||||
|
pub type_: c_uint, // CU_EXTERNAL_SEMAPHORE_HANDLE_TYPE_TIMELINE_SEMAPHORE_FD = 9
|
||||||
|
pub(crate) _pad: u32,
|
||||||
|
pub handle: [u64; 2], // union { int fd; {void*,void*} win32; const void* nvSciSyncObj }
|
||||||
|
pub flags: c_uint,
|
||||||
|
pub(crate) reserved: [c_uint; 16],
|
||||||
|
pub(crate) _pad2: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS` (cuda.h, 64-bit layout), flattened: `params` nests
|
||||||
|
/// `fence.value` (the only member we set — the timeline value to signal), the `nvSciSync` union,
|
||||||
|
/// `keyedMutex.key`, then 12 reserved words; `flags` + 16 reserved words follow. 144 bytes total
|
||||||
|
/// (layout-asserted in `super`'s tests).
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS {
|
||||||
|
pub value: u64, // params.fence.value — the timeline value this signal sets
|
||||||
|
pub(crate) _nv_sci_sync: u64,
|
||||||
|
pub(crate) _keyed_mutex_key: u64,
|
||||||
|
pub(crate) _params_reserved: [c_uint; 12],
|
||||||
|
pub flags: c_uint,
|
||||||
|
pub(crate) reserved: [c_uint; 16],
|
||||||
|
pub(crate) _pad: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS` (cuda.h, 64-bit layout), flattened like the signal
|
||||||
|
/// params. The C `keyedMutex` member is `{ u64 key; u32 timeoutMs; }` — size 16 with tail
|
||||||
|
/// padding, hence the explicit pad word before the 10 reserved words. 144 bytes total.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS {
|
||||||
|
pub value: u64, // params.fence.value — wait until the timeline reaches this value
|
||||||
|
pub(crate) _nv_sci_sync: u64,
|
||||||
|
pub(crate) _keyed_mutex_key: u64,
|
||||||
|
pub(crate) _keyed_mutex_timeout: c_uint,
|
||||||
|
pub(crate) _keyed_mutex_pad: u32,
|
||||||
|
pub(crate) _params_reserved: [c_uint; 10],
|
||||||
|
pub flags: c_uint,
|
||||||
|
pub(crate) reserved: [c_uint; 16],
|
||||||
|
pub(crate) _pad: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CUexternalSemaphoreHandleType` (cuda.h): a Vulkan **timeline** semaphore exported as an
|
||||||
|
/// OPAQUE_FD (`vkGetSemaphoreFdKHR`). Needs driver ≥ 460 (CUDA 11.2) — far below the NVENC 12.1
|
||||||
|
/// floor this backend already requires, so import failure means "driver refused", not "too old
|
||||||
|
/// to try".
|
||||||
|
pub const CU_EXTERNAL_SEMAPHORE_HANDLE_TYPE_TIMELINE_SEMAPHORE_FD: c_uint = 9;
|
||||||
|
|
||||||
/// `CUipcMemHandle` (cuda.h): an opaque 64-byte struct identifying a device allocation across
|
/// `CUipcMemHandle` (cuda.h): an opaque 64-byte struct identifying a device allocation across
|
||||||
/// processes. Produced by `cuIpcGetMemHandle` in the exporting process, consumed by
|
/// processes. Produced by `cuIpcGetMemHandle` in the exporting process, consumed by
|
||||||
/// `cuIpcOpenMemHandle` in the importer — passed **by value**, matching the C
|
/// `cuIpcOpenMemHandle` in the importer — passed **by value**, matching the C
|
||||||
@@ -139,6 +194,23 @@ pub(crate) struct CudaApi {
|
|||||||
*const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
|
*const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
|
||||||
) -> CUresult,
|
) -> CUresult,
|
||||||
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
|
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
|
||||||
|
cuImportExternalSemaphore: unsafe extern "C" fn(
|
||||||
|
*mut CUexternalSemaphore,
|
||||||
|
*const CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC,
|
||||||
|
) -> CUresult,
|
||||||
|
cuDestroyExternalSemaphore: unsafe extern "C" fn(CUexternalSemaphore) -> CUresult,
|
||||||
|
cuSignalExternalSemaphoresAsync: unsafe extern "C" fn(
|
||||||
|
*const CUexternalSemaphore,
|
||||||
|
*const CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS,
|
||||||
|
c_uint,
|
||||||
|
CUstream,
|
||||||
|
) -> CUresult,
|
||||||
|
cuWaitExternalSemaphoresAsync: unsafe extern "C" fn(
|
||||||
|
*const CUexternalSemaphore,
|
||||||
|
*const CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS,
|
||||||
|
c_uint,
|
||||||
|
CUstream,
|
||||||
|
) -> CUresult,
|
||||||
cuIpcGetMemHandle: unsafe extern "C" fn(*mut CUipcMemHandle, CUdeviceptr) -> CUresult,
|
cuIpcGetMemHandle: unsafe extern "C" fn(*mut CUipcMemHandle, CUdeviceptr) -> CUresult,
|
||||||
cuIpcOpenMemHandle: unsafe extern "C" fn(*mut CUdeviceptr, CUipcMemHandle, c_uint) -> CUresult,
|
cuIpcOpenMemHandle: unsafe extern "C" fn(*mut CUdeviceptr, CUipcMemHandle, c_uint) -> CUresult,
|
||||||
cuIpcCloseMemHandle: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
|
cuIpcCloseMemHandle: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
|
||||||
@@ -206,6 +278,14 @@ pub(crate) fn cuda_api() -> Option<&'static CudaApi> {
|
|||||||
.get(b"cuExternalMemoryGetMappedBuffer\0")
|
.get(b"cuExternalMemoryGetMappedBuffer\0")
|
||||||
.ok()?,
|
.ok()?,
|
||||||
cuDestroyExternalMemory: *lib.get(b"cuDestroyExternalMemory\0").ok()?,
|
cuDestroyExternalMemory: *lib.get(b"cuDestroyExternalMemory\0").ok()?,
|
||||||
|
// External-semaphore interop (the stream-ordered cursor blend): all four are
|
||||||
|
// CUDA 10.0 entry points, far older than anything else this table requires.
|
||||||
|
cuImportExternalSemaphore: *lib.get(b"cuImportExternalSemaphore\0").ok()?,
|
||||||
|
cuDestroyExternalSemaphore: *lib.get(b"cuDestroyExternalSemaphore\0").ok()?,
|
||||||
|
cuSignalExternalSemaphoresAsync: *lib
|
||||||
|
.get(b"cuSignalExternalSemaphoresAsync\0")
|
||||||
|
.ok()?,
|
||||||
|
cuWaitExternalSemaphoresAsync: *lib.get(b"cuWaitExternalSemaphoresAsync\0").ok()?,
|
||||||
cuIpcGetMemHandle: *lib.get(b"cuIpcGetMemHandle\0").ok()?,
|
cuIpcGetMemHandle: *lib.get(b"cuIpcGetMemHandle\0").ok()?,
|
||||||
// CUDA 11 renamed the entry point (per-thread-stream ABI split); every modern
|
// CUDA 11 renamed the entry point (per-thread-stream ABI split); every modern
|
||||||
// driver exports `_v2`, but accept the unsuffixed one too (same signature).
|
// driver exports `_v2`, but accept the unsuffixed one too (same signature).
|
||||||
@@ -381,6 +461,43 @@ pub(crate) unsafe fn cuDestroyExternalMemory(ext_mem: CUexternalMemory) -> CUres
|
|||||||
None => CU_ERROR_NOT_LOADED,
|
None => CU_ERROR_NOT_LOADED,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pub(crate) unsafe fn cuImportExternalSemaphore(
|
||||||
|
ext_sem_out: *mut CUexternalSemaphore,
|
||||||
|
sem_handle_desc: *const CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuImportExternalSemaphore)(ext_sem_out, sem_handle_desc),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuDestroyExternalSemaphore(ext_sem: CUexternalSemaphore) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuDestroyExternalSemaphore)(ext_sem),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuSignalExternalSemaphoresAsync(
|
||||||
|
ext_sems: *const CUexternalSemaphore,
|
||||||
|
params: *const CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS,
|
||||||
|
count: c_uint,
|
||||||
|
stream: CUstream,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuSignalExternalSemaphoresAsync)(ext_sems, params, count, stream),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuWaitExternalSemaphoresAsync(
|
||||||
|
ext_sems: *const CUexternalSemaphore,
|
||||||
|
params: *const CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS,
|
||||||
|
count: c_uint,
|
||||||
|
stream: CUstream,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuWaitExternalSemaphoresAsync)(ext_sems, params, count, stream),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
pub(crate) unsafe fn cuIpcGetMemHandle(handle: *mut CUipcMemHandle, dptr: CUdeviceptr) -> CUresult {
|
pub(crate) unsafe fn cuIpcGetMemHandle(handle: *mut CUipcMemHandle, dptr: CUdeviceptr) -> CUresult {
|
||||||
match cuda_api() {
|
match cuda_api() {
|
||||||
Some(a) => (a.cuIpcGetMemHandle)(handle, dptr),
|
Some(a) => (a.cuIpcGetMemHandle)(handle, dptr),
|
||||||
|
|||||||
@@ -36,17 +36,6 @@ fn flag(name: &str) -> bool {
|
|||||||
flag_opt(name).unwrap_or(false)
|
flag_opt(name).unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-shot downgrade latch: a VAAPI-passthrough capture whose dmabuf-only offer never negotiated
|
|
||||||
/// (the compositor can't allocate a LINEAR BGRx dmabuf) flips this, so the encode loop's pipeline
|
|
||||||
/// rebuild lands on the CPU offer instead of failing the same negotiation forever. Only consulted
|
|
||||||
/// when `PUNKTFUNK_ZEROCOPY` is unset — an explicit `=1` keeps forcing the dmabuf offer.
|
|
||||||
static VAAPI_DMABUF_FAILED: AtomicBool = AtomicBool::new(false);
|
|
||||||
|
|
||||||
/// Record that the VAAPI LINEAR-dmabuf offer failed negotiation (see [`VAAPI_DMABUF_FAILED`]).
|
|
||||||
pub fn note_vaapi_dmabuf_failed() {
|
|
||||||
VAAPI_DMABUF_FAILED.store(true, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True when `PUNKTFUNK_ZEROCOPY` is explicitly truthy — the operator forced the dmabuf offer, so
|
/// True when `PUNKTFUNK_ZEROCOPY` is explicitly truthy — the operator forced the dmabuf offer, so
|
||||||
/// a failed negotiation keeps erroring loudly instead of silently downgrading to the CPU path.
|
/// a failed negotiation keeps erroring loudly instead of silently downgrading to the CPU path.
|
||||||
pub fn vaapi_dmabuf_forced() -> bool {
|
pub fn vaapi_dmabuf_forced() -> bool {
|
||||||
@@ -64,11 +53,16 @@ pub fn vaapi_dmabuf_forced() -> bool {
|
|||||||
/// capture when no importer/importable modifier is available and latches the import off after
|
/// capture when no importer/importable modifier is available and latches the import off after
|
||||||
/// repeated worker deaths. `PUNKTFUNK_ZEROCOPY=0` opts out; `PUNKTFUNK_FORCE_SHM` forces the
|
/// repeated worker deaths. `PUNKTFUNK_ZEROCOPY=0` opts out; `PUNKTFUNK_FORCE_SHM` forces the
|
||||||
/// race-free SHM path.
|
/// race-free SHM path.
|
||||||
|
///
|
||||||
|
/// This is the GLOBAL switch and nothing but the env var moves it. It used to fall back to
|
||||||
|
/// `!VAAPI_DMABUF_FAILED`, a latch a capture-side dmabuf *negotiation* timeout set — which made one
|
||||||
|
/// failed LINEAR-dmabuf negotiation disable zero-copy for EVERY later session on the host,
|
||||||
|
/// including the NVENC EGL→CUDA path that shares none of the failing machinery (and, because the
|
||||||
|
/// raw-passthrough offer is also taken for PyroWave sessions on any vendor, one PyroWave timeout on
|
||||||
|
/// an NVIDIA box did it). That downgrade now uses the correctly-scoped
|
||||||
|
/// [`note_raw_dmabuf_negotiation_failed`], which gates only the raw-passthrough offer.
|
||||||
pub fn enabled() -> bool {
|
pub fn enabled() -> bool {
|
||||||
match flag_opt("PUNKTFUNK_ZEROCOPY") {
|
flag_opt("PUNKTFUNK_ZEROCOPY").unwrap_or(true)
|
||||||
Some(v) => v,
|
|
||||||
None => !VAAPI_DMABUF_FAILED.load(Ordering::Relaxed),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the tiled-GL zero-copy path converts to NV12 on the GPU and feeds NVENC native YUV —
|
/// Whether the tiled-GL zero-copy path converts to NV12 on the GPU and feeds NVENC native YUV —
|
||||||
@@ -268,6 +262,27 @@ pub fn note_raw_dmabuf_import_ok() {
|
|||||||
RAW_DMABUF_FAILURE_STREAK.store(0, Ordering::Relaxed);
|
RAW_DMABUF_FAILURE_STREAK.store(0, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Latch the raw-dmabuf passthrough off because its dmabuf-only *offer never negotiated* — the
|
||||||
|
/// CAPTURE-side counterpart to [`note_raw_dmabuf_import_failure`]'s encoder-side streak. One
|
||||||
|
/// timeout is conclusive for this offer (a compositor that cannot allocate the requested
|
||||||
|
/// LINEAR/modifier BGRx dmabuf refuses it identically on every retry), so there is no streak to
|
||||||
|
/// count: the next capture skips the passthrough and negotiates SHM/CPU instead of re-running the
|
||||||
|
/// same 10 s timeout on every reconnect.
|
||||||
|
///
|
||||||
|
/// Scoped deliberately. This used to be `note_vaapi_dmabuf_failed`, which fed [`enabled`] and so
|
||||||
|
/// disabled ALL zero-copy host-wide — see [`enabled`]. `RAW_DMABUF_DISABLED` gates only the
|
||||||
|
/// raw-passthrough decision, so the EGL→CUDA importer that a later NVENC session builds is
|
||||||
|
/// untouched.
|
||||||
|
pub fn note_raw_dmabuf_negotiation_failed() {
|
||||||
|
if !RAW_DMABUF_DISABLED.swap(true, Ordering::Relaxed) {
|
||||||
|
tracing::warn!(
|
||||||
|
"zero-copy raw-dmabuf passthrough disabled for this host process: the compositor never \
|
||||||
|
accepted the dmabuf-only capture offer, so later captures negotiate the CPU path \
|
||||||
|
instead of repeating that timeout (the EGL→CUDA import path is NOT affected)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// True once repeated encoder import failures latched the raw-dmabuf passthrough off (see
|
/// True once repeated encoder import failures latched the raw-dmabuf passthrough off (see
|
||||||
/// [`note_raw_dmabuf_import_failure`]).
|
/// [`note_raw_dmabuf_import_failure`]).
|
||||||
pub fn raw_dmabuf_import_disabled() -> bool {
|
pub fn raw_dmabuf_import_disabled() -> bool {
|
||||||
|
|||||||
@@ -12,11 +12,24 @@
|
|||||||
//!
|
//!
|
||||||
//! The direct-SDK NVENC encoder allocates its input ring through [`VkSlotBlend::alloc_slot`]
|
//! The direct-SDK NVENC encoder allocates its input ring through [`VkSlotBlend::alloc_slot`]
|
||||||
//! instead of `cuMemAllocPitch`: same contiguous layouts (`InputSurface` docs), but the memory is
|
//! instead of `cuMemAllocPitch`: same contiguous layouts (`InputSurface` docs), but the memory is
|
||||||
//! Vulkan external memory both APIs address. Per cursor-bearing frame the encoder CPU-syncs its
|
//! Vulkan external memory both APIs address.
|
||||||
//! CUDA copy, then [`VkSlotBlend::blend`] dispatches the compute blend over the cursor's
|
//!
|
||||||
//! rectangle and fence-waits — the same coherence ceremony [`super::vulkan::VkBridge`] ships for
|
//! Cursor-bearing frames blend one of two ways:
|
||||||
//! its CSC (fence-ordered cross-API access on NVIDIA, no queue-family transfer needed). Frames
|
//!
|
||||||
//! without a cursor never touch Vulkan, keeping the stream-ordered fast path intact.
|
//! * **Stream-ordered** ([`blend_ref_ordered`](VkSlotBlend::blend_ref_ordered), the fast path
|
||||||
|
//! when the driver exports a timeline semaphore to CUDA): the encoder enqueues its CUDA copy
|
||||||
|
//! with no CPU sync, CUDA signals the shared timeline on the copy stream, the blend submission
|
||||||
|
//! waits for and then advances it on the Vulkan queue, and CUDA waits the advanced value
|
||||||
|
//! before the encode — the whole copy→blend→encode chain orders on-device, so a visible
|
||||||
|
//! cursor costs the submit path nothing. (Before this, EVERY cursor frame took the CPU-synced
|
||||||
|
//! path below; under gamescope — which composites the pointer into every frame — that
|
||||||
|
//! serialized submit behind the game's GPU load and capped a 120 fps session near 80.)
|
||||||
|
//! * **CPU-synced** ([`blend_ref`](VkSlotBlend::blend_ref), the fallback when timeline bring-up
|
||||||
|
//! failed): the encoder blocks on its CUDA copy, the blend fence-waits — the same coherence
|
||||||
|
//! ceremony [`super::vulkan::VkBridge`] ships for its CSC (fence-ordered cross-API access on
|
||||||
|
//! NVIDIA, no queue-family transfer needed).
|
||||||
|
//!
|
||||||
|
//! Frames without a cursor never touch Vulkan at all.
|
||||||
//!
|
//!
|
||||||
//! Falls back cleanly: if bring-up fails the encoder allocates plain CUDA surfaces and composite
|
//! Falls back cleanly: if bring-up fails the encoder allocates plain CUDA surfaces and composite
|
||||||
//! mode degrades to no cursor (warned once) — never a failed session.
|
//! mode degrades to no cursor (warned once) — never a failed session.
|
||||||
@@ -87,12 +100,36 @@ pub struct VkSlotRef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One allocated slot's backing objects, freed together in reverse order (CUDA mapping first).
|
/// One allocated slot's backing objects, freed together in reverse order (CUDA mapping first).
|
||||||
|
/// Each slot carries its own command buffer + descriptor set so ordered blends can be in flight
|
||||||
|
/// on several slots at once (the shared-single-set design raced the next recording against a
|
||||||
|
/// still-executing submission); the set's bindings are written once here — the slot buffer never
|
||||||
|
/// changes and the cursor staging buffer is shared.
|
||||||
struct SlotAlloc {
|
struct SlotAlloc {
|
||||||
buffer: vk::Buffer,
|
buffer: vk::Buffer,
|
||||||
memory: vk::DeviceMemory,
|
memory: vk::DeviceMemory,
|
||||||
/// CUDA's import of the exported OPAQUE_FD — must drop BEFORE the Vulkan memory is freed.
|
/// CUDA's import of the exported OPAQUE_FD — must drop BEFORE the Vulkan memory is freed.
|
||||||
cuda: cuda::ExternalDmabuf,
|
cuda: cuda::ExternalDmabuf,
|
||||||
size: u64,
|
cmd: vk::CommandBuffer,
|
||||||
|
desc: vk::DescriptorSet,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cross-API ordering state for stream-ordered blends: one Vulkan timeline semaphore,
|
||||||
|
/// exported as an OPAQUE_FD and imported into CUDA. `None` when the driver lacks timeline
|
||||||
|
/// semaphores or the export/import failed — blends then stay CPU-synced ([`VkSlotBlend::blend_ref`]).
|
||||||
|
struct Timeline {
|
||||||
|
sem: vk::Semaphore,
|
||||||
|
/// `vkWaitSemaphoresKHR` — the CPU-side quiesce for cursor-bitmap uploads and teardown
|
||||||
|
/// (the 1.1 device gets the entry point from `VK_KHR_timeline_semaphore`).
|
||||||
|
ts: ash::khr::timeline_semaphore::Device,
|
||||||
|
/// CUDA's import; its `signal`/`wait` enqueue on the encode thread's copy stream.
|
||||||
|
cuda: cuda::ExternalSemaphore,
|
||||||
|
/// Last timeline value handed out (monotonic for the device's lifetime, never reused —
|
||||||
|
/// each ordered blend consumes two: copy-done, then blend-done).
|
||||||
|
ticket: u64,
|
||||||
|
/// Last blend-done value an ACCEPTED `vkQueueSubmit` will signal — what upload/teardown
|
||||||
|
/// quiesce on. Only advanced on submit success: a value from a failed submit would never
|
||||||
|
/// be signaled and a quiesce on it would time out.
|
||||||
|
last_blend: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 28-byte push-constant block matching `cursor_blend.comp`'s `Push`.
|
/// 28-byte push-constant block matching `cursor_blend.comp`'s `Push`.
|
||||||
@@ -114,14 +151,12 @@ pub struct VkSlotBlend {
|
|||||||
ext_fd: ash::khr::external_memory_fd::Device,
|
ext_fd: ash::khr::external_memory_fd::Device,
|
||||||
queue: vk::Queue,
|
queue: vk::Queue,
|
||||||
cmd_pool: vk::CommandPool,
|
cmd_pool: vk::CommandPool,
|
||||||
cmd: vk::CommandBuffer,
|
|
||||||
fence: vk::Fence,
|
fence: vk::Fence,
|
||||||
mem_props: vk::PhysicalDeviceMemoryProperties,
|
mem_props: vk::PhysicalDeviceMemoryProperties,
|
||||||
shader: vk::ShaderModule,
|
shader: vk::ShaderModule,
|
||||||
desc_layout: vk::DescriptorSetLayout,
|
desc_layout: vk::DescriptorSetLayout,
|
||||||
pipe_layout: vk::PipelineLayout,
|
pipe_layout: vk::PipelineLayout,
|
||||||
desc_pool: vk::DescriptorPool,
|
desc_pool: vk::DescriptorPool,
|
||||||
desc_set: vk::DescriptorSet,
|
|
||||||
/// One pipeline per [`SlotFormat`], indexed by `mode()` (spec constant).
|
/// One pipeline per [`SlotFormat`], indexed by `mode()` (spec constant).
|
||||||
pipelines: [vk::Pipeline; 3],
|
pipelines: [vk::Pipeline; 3],
|
||||||
/// Host-visible cursor bitmap staging (CURSOR_MAX²·4, tight rows), persistently mapped.
|
/// Host-visible cursor bitmap staging (CURSOR_MAX²·4, tight rows), persistently mapped.
|
||||||
@@ -129,6 +164,8 @@ pub struct VkSlotBlend {
|
|||||||
cur_mem: vk::DeviceMemory,
|
cur_mem: vk::DeviceMemory,
|
||||||
cur_map: *mut u8,
|
cur_map: *mut u8,
|
||||||
slots: Vec<SlotAlloc>,
|
slots: Vec<SlotAlloc>,
|
||||||
|
/// Stream-ordered blend support (`None` = CPU-synced blends only). See [`Timeline`].
|
||||||
|
timeline: Option<Timeline>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: raw Vulkan handles + a persistently-mapped pointer, all uniquely owned by this struct
|
// SAFETY: raw Vulkan handles + a persistently-mapped pointer, all uniquely owned by this struct
|
||||||
@@ -182,14 +219,44 @@ impl VkSlotBlend {
|
|||||||
let qci = [vk::DeviceQueueCreateInfo::default()
|
let qci = [vk::DeviceQueueCreateInfo::default()
|
||||||
.queue_family_index(qf)
|
.queue_family_index(qf)
|
||||||
.queue_priorities(&prio)];
|
.queue_priorities(&prio)];
|
||||||
let exts = [ash::khr::external_memory_fd::NAME.as_ptr()];
|
// Timeline-semaphore export to CUDA (the stream-ordered blend) is optional: probe the
|
||||||
let device = match instance.create_device(
|
// device extensions + feature bit and enable them only where present, so a driver
|
||||||
phys,
|
// without them still gets a working (CPU-synced) blend device. NVIDIA has shipped
|
||||||
&vk::DeviceCreateInfo::default()
|
// both extensions since ~2019; the probe is for exotic/legacy stacks.
|
||||||
|
let want_timeline = {
|
||||||
|
let have_exts = instance
|
||||||
|
.enumerate_device_extension_properties(phys)
|
||||||
|
.map(|props| {
|
||||||
|
let has = |name: &std::ffi::CStr| {
|
||||||
|
props
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.extension_name_as_c_str().is_ok_and(|n| n == name))
|
||||||
|
};
|
||||||
|
has(ash::khr::timeline_semaphore::NAME)
|
||||||
|
&& has(ash::khr::external_semaphore_fd::NAME)
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
have_exts && {
|
||||||
|
let mut tl = vk::PhysicalDeviceTimelineSemaphoreFeatures::default();
|
||||||
|
let mut f2 = vk::PhysicalDeviceFeatures2::default().push_next(&mut tl);
|
||||||
|
instance.get_physical_device_features2(phys, &mut f2);
|
||||||
|
tl.timeline_semaphore == vk::TRUE
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut exts = vec![ash::khr::external_memory_fd::NAME.as_ptr()];
|
||||||
|
if want_timeline {
|
||||||
|
exts.push(ash::khr::timeline_semaphore::NAME.as_ptr());
|
||||||
|
exts.push(ash::khr::external_semaphore_fd::NAME.as_ptr());
|
||||||
|
}
|
||||||
|
let mut tl_enable =
|
||||||
|
vk::PhysicalDeviceTimelineSemaphoreFeatures::default().timeline_semaphore(true);
|
||||||
|
let mut dci = vk::DeviceCreateInfo::default()
|
||||||
.queue_create_infos(&qci)
|
.queue_create_infos(&qci)
|
||||||
.enabled_extension_names(&exts),
|
.enabled_extension_names(&exts);
|
||||||
None,
|
if want_timeline {
|
||||||
) {
|
dci = dci.push_next(&mut tl_enable);
|
||||||
|
}
|
||||||
|
let device = match instance.create_device(phys, &dci, None) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
instance.destroy_instance(None);
|
instance.destroy_instance(None);
|
||||||
@@ -207,30 +274,93 @@ impl VkSlotBlend {
|
|||||||
ext_fd,
|
ext_fd,
|
||||||
queue,
|
queue,
|
||||||
cmd_pool: vk::CommandPool::null(),
|
cmd_pool: vk::CommandPool::null(),
|
||||||
cmd: vk::CommandBuffer::null(),
|
|
||||||
fence: vk::Fence::null(),
|
fence: vk::Fence::null(),
|
||||||
mem_props,
|
mem_props,
|
||||||
shader: vk::ShaderModule::null(),
|
shader: vk::ShaderModule::null(),
|
||||||
desc_layout: vk::DescriptorSetLayout::null(),
|
desc_layout: vk::DescriptorSetLayout::null(),
|
||||||
pipe_layout: vk::PipelineLayout::null(),
|
pipe_layout: vk::PipelineLayout::null(),
|
||||||
desc_pool: vk::DescriptorPool::null(),
|
desc_pool: vk::DescriptorPool::null(),
|
||||||
desc_set: vk::DescriptorSet::null(),
|
|
||||||
pipelines: [vk::Pipeline::null(); 3],
|
pipelines: [vk::Pipeline::null(); 3],
|
||||||
cur_buf: vk::Buffer::null(),
|
cur_buf: vk::Buffer::null(),
|
||||||
cur_mem: vk::DeviceMemory::null(),
|
cur_mem: vk::DeviceMemory::null(),
|
||||||
cur_map: std::ptr::null_mut(),
|
cur_map: std::ptr::null_mut(),
|
||||||
slots: Vec::new(),
|
slots: Vec::new(),
|
||||||
|
timeline: None,
|
||||||
};
|
};
|
||||||
me.init_objects(qf).inspect_err(|_| {
|
me.init_objects(qf).inspect_err(|_| {
|
||||||
// `Drop` runs the same teardown and tolerates the nulls left by a partial init.
|
// `Drop` runs the same teardown and tolerates the nulls left by a partial init.
|
||||||
})?;
|
})?;
|
||||||
|
if want_timeline {
|
||||||
|
// Non-fatal: any failure (export refused, CUDA import refused) leaves
|
||||||
|
// `timeline` None and blends CPU-synced — never a failed bring-up.
|
||||||
|
if let Err(e) = me.init_timeline() {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
"cursor blend timeline export unavailable — blends stay CPU-synced"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
stream_ordered = me.timeline.is_some(),
|
||||||
"Vulkan slot blend ready (exportable NVENC inputs + SPIR-V cursor blend)"
|
"Vulkan slot blend ready (exportable NVENC inputs + SPIR-V cursor blend)"
|
||||||
);
|
);
|
||||||
Ok(me)
|
Ok(me)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bring up the [`Timeline`]: create an exportable timeline semaphore, export its OPAQUE_FD,
|
||||||
|
/// import it into CUDA. Only called when the device was created with the timeline extensions.
|
||||||
|
fn init_timeline(&mut self) -> Result<()> {
|
||||||
|
// SAFETY: ash calls on the live `self.device` with builder infos from locals outliving
|
||||||
|
// each synchronous call. The semaphore is destroyed on every failure path after its
|
||||||
|
// creation; the exported fd is owned by `import_owned_timeline_fd` (the driver takes it
|
||||||
|
// on success, it closes it on failure). The shared CUDA context is current: the encoder
|
||||||
|
// brings this device up from its encode thread with the context established.
|
||||||
|
unsafe {
|
||||||
|
let mut type_ci = vk::SemaphoreTypeCreateInfo::default()
|
||||||
|
.semaphore_type(vk::SemaphoreType::TIMELINE)
|
||||||
|
.initial_value(0);
|
||||||
|
let mut export = vk::ExportSemaphoreCreateInfo::default()
|
||||||
|
.handle_types(vk::ExternalSemaphoreHandleTypeFlags::OPAQUE_FD);
|
||||||
|
let sem = self
|
||||||
|
.device
|
||||||
|
.create_semaphore(
|
||||||
|
&vk::SemaphoreCreateInfo::default()
|
||||||
|
.push_next(&mut type_ci)
|
||||||
|
.push_next(&mut export),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.context("create timeline semaphore")?;
|
||||||
|
let sem_fd = ash::khr::external_semaphore_fd::Device::new(&self.instance, &self.device);
|
||||||
|
let fd = match sem_fd.get_semaphore_fd(
|
||||||
|
&vk::SemaphoreGetFdInfoKHR::default()
|
||||||
|
.semaphore(sem)
|
||||||
|
.handle_type(vk::ExternalSemaphoreHandleTypeFlags::OPAQUE_FD),
|
||||||
|
) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
self.device.destroy_semaphore(sem, None);
|
||||||
|
return Err(e).context("vkGetSemaphoreFdKHR(timeline)");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let cuda_sem = match cuda::ExternalSemaphore::import_owned_timeline_fd(fd) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
self.device.destroy_semaphore(sem, None);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.timeline = Some(Timeline {
|
||||||
|
sem,
|
||||||
|
ts: ash::khr::timeline_semaphore::Device::new(&self.instance, &self.device),
|
||||||
|
cuda: cuda_sem,
|
||||||
|
ticket: 0,
|
||||||
|
last_blend: 0,
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The non-device objects: command machinery, cursor staging, descriptor + pipelines.
|
/// The non-device objects: command machinery, cursor staging, descriptor + pipelines.
|
||||||
fn init_objects(&mut self, qf: u32) -> Result<()> {
|
fn init_objects(&mut self, qf: u32) -> Result<()> {
|
||||||
// SAFETY: same contract as `new` — ash calls on the live `self.device` with builder infos
|
// SAFETY: same contract as `new` — ash calls on the live `self.device` with builder infos
|
||||||
@@ -246,14 +376,6 @@ impl VkSlotBlend {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.context("create command pool")?;
|
.context("create command pool")?;
|
||||||
self.cmd = d
|
|
||||||
.allocate_command_buffers(
|
|
||||||
&vk::CommandBufferAllocateInfo::default()
|
|
||||||
.command_pool(self.cmd_pool)
|
|
||||||
.level(vk::CommandBufferLevel::PRIMARY)
|
|
||||||
.command_buffer_count(1),
|
|
||||||
)
|
|
||||||
.context("allocate command buffer")?[0];
|
|
||||||
self.fence = d
|
self.fence = d
|
||||||
.create_fence(&vk::FenceCreateInfo::default(), None)
|
.create_fence(&vk::FenceCreateInfo::default(), None)
|
||||||
.context("create fence")?;
|
.context("create fence")?;
|
||||||
@@ -320,25 +442,21 @@ impl VkSlotBlend {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.context("create pipeline layout")?;
|
.context("create pipeline layout")?;
|
||||||
|
// Per-slot sets (one per ring slot, written once at `alloc_slot`; freed by
|
||||||
|
// `free_slots` on ring rebuilds, hence FREE_DESCRIPTOR_SET). 64 is comfortably
|
||||||
|
// above any ring depth (the encoder's POOL is 8).
|
||||||
let pool_sizes = [vk::DescriptorPoolSize::default()
|
let pool_sizes = [vk::DescriptorPoolSize::default()
|
||||||
.ty(vk::DescriptorType::STORAGE_BUFFER)
|
.ty(vk::DescriptorType::STORAGE_BUFFER)
|
||||||
.descriptor_count(2)];
|
.descriptor_count(128)];
|
||||||
self.desc_pool = d
|
self.desc_pool = d
|
||||||
.create_descriptor_pool(
|
.create_descriptor_pool(
|
||||||
&vk::DescriptorPoolCreateInfo::default()
|
&vk::DescriptorPoolCreateInfo::default()
|
||||||
.max_sets(1)
|
.flags(vk::DescriptorPoolCreateFlags::FREE_DESCRIPTOR_SET)
|
||||||
|
.max_sets(64)
|
||||||
.pool_sizes(&pool_sizes),
|
.pool_sizes(&pool_sizes),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.context("create descriptor pool")?;
|
.context("create descriptor pool")?;
|
||||||
let dls = [self.desc_layout];
|
|
||||||
self.desc_set = d
|
|
||||||
.allocate_descriptor_sets(
|
|
||||||
&vk::DescriptorSetAllocateInfo::default()
|
|
||||||
.descriptor_pool(self.desc_pool)
|
|
||||||
.set_layouts(&dls),
|
|
||||||
)
|
|
||||||
.context("allocate descriptor set")?[0];
|
|
||||||
|
|
||||||
// The shader + one pipeline per MODE (spec constant 0).
|
// The shader + one pipeline per MODE (spec constant 0).
|
||||||
if CURSOR_SPV.len() % 4 != 0 {
|
if CURSOR_SPV.len() % 4 != 0 {
|
||||||
@@ -465,6 +583,59 @@ impl VkSlotBlend {
|
|||||||
return Err(e).context("cuImportExternalMemory(slot OPAQUE_FD)");
|
return Err(e).context("cuImportExternalMemory(slot OPAQUE_FD)");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// The slot's own descriptor set + command buffer (see `SlotAlloc`). Bindings are
|
||||||
|
// written once here: binding 0 is this slot's buffer (immutable for its lifetime),
|
||||||
|
// binding 1 the shared cursor staging buffer.
|
||||||
|
let dls = [self.desc_layout];
|
||||||
|
let desc = match d.allocate_descriptor_sets(
|
||||||
|
&vk::DescriptorSetAllocateInfo::default()
|
||||||
|
.descriptor_pool(self.desc_pool)
|
||||||
|
.set_layouts(&dls),
|
||||||
|
) {
|
||||||
|
Ok(s) => s[0],
|
||||||
|
Err(e) => {
|
||||||
|
drop(ext); // CUDA's view of the memory goes first
|
||||||
|
d.free_memory(memory, None);
|
||||||
|
d.destroy_buffer(buffer, None);
|
||||||
|
return Err(e).context("allocate slot descriptor set");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let surf_info = [vk::DescriptorBufferInfo::default()
|
||||||
|
.buffer(buffer)
|
||||||
|
.offset(0)
|
||||||
|
.range(size)];
|
||||||
|
let cur_info = [vk::DescriptorBufferInfo::default()
|
||||||
|
.buffer(self.cur_buf)
|
||||||
|
.offset(0)
|
||||||
|
.range((CURSOR_MAX * CURSOR_MAX * 4) as u64)];
|
||||||
|
let writes = [
|
||||||
|
vk::WriteDescriptorSet::default()
|
||||||
|
.dst_set(desc)
|
||||||
|
.dst_binding(0)
|
||||||
|
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||||
|
.buffer_info(&surf_info),
|
||||||
|
vk::WriteDescriptorSet::default()
|
||||||
|
.dst_set(desc)
|
||||||
|
.dst_binding(1)
|
||||||
|
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||||
|
.buffer_info(&cur_info),
|
||||||
|
];
|
||||||
|
d.update_descriptor_sets(&writes, &[]);
|
||||||
|
let cmd = match d.allocate_command_buffers(
|
||||||
|
&vk::CommandBufferAllocateInfo::default()
|
||||||
|
.command_pool(self.cmd_pool)
|
||||||
|
.level(vk::CommandBufferLevel::PRIMARY)
|
||||||
|
.command_buffer_count(1),
|
||||||
|
) {
|
||||||
|
Ok(c) => c[0],
|
||||||
|
Err(e) => {
|
||||||
|
let _ = d.free_descriptor_sets(self.desc_pool, &[desc]);
|
||||||
|
drop(ext); // CUDA's view of the memory goes first
|
||||||
|
d.free_memory(memory, None);
|
||||||
|
d.destroy_buffer(buffer, None);
|
||||||
|
return Err(e).context("allocate slot command buffer");
|
||||||
|
}
|
||||||
|
};
|
||||||
let r = VkSlotRef {
|
let r = VkSlotRef {
|
||||||
ptr: ext.ptr,
|
ptr: ext.ptr,
|
||||||
pitch: pitch as usize,
|
pitch: pitch as usize,
|
||||||
@@ -475,7 +646,8 @@ impl VkSlotBlend {
|
|||||||
buffer,
|
buffer,
|
||||||
memory,
|
memory,
|
||||||
cuda: ext,
|
cuda: ext,
|
||||||
size,
|
cmd,
|
||||||
|
desc,
|
||||||
});
|
});
|
||||||
Ok(r)
|
Ok(r)
|
||||||
}
|
}
|
||||||
@@ -485,12 +657,26 @@ impl VkSlotBlend {
|
|||||||
/// first (field order in [`SlotAlloc`] frees `cuda` via its own `Drop` before we free the VK
|
/// first (field order in [`SlotAlloc`] frees `cuda` via its own `Drop` before we free the VK
|
||||||
/// objects explicitly here).
|
/// objects explicitly here).
|
||||||
pub fn free_slots(&mut self) {
|
pub fn free_slots(&mut self) {
|
||||||
|
// Ordered blends return with their work still on the queue — quiesce before freeing the
|
||||||
|
// buffers/sets it references. `device_wait_idle` (not a timeline wait) so a submission
|
||||||
|
// whose CUDA copy-done signal never fired is still covered; this tiny device idles in
|
||||||
|
// microseconds outside that pathological case. CPU-synced blends need nothing (they
|
||||||
|
// fence-wait before returning), so a `None` timeline skips it.
|
||||||
|
if self.timeline.is_some() && !self.slots.is_empty() {
|
||||||
|
// SAFETY: single-threaded owner; no other thread touches this device or its queue.
|
||||||
|
unsafe {
|
||||||
|
let _ = self.device.device_wait_idle();
|
||||||
|
}
|
||||||
|
}
|
||||||
for s in self.slots.drain(..) {
|
for s in self.slots.drain(..) {
|
||||||
drop(s.cuda); // CUDA's view of the memory goes first
|
drop(s.cuda); // CUDA's view of the memory goes first
|
||||||
// SAFETY: `buffer`/`memory` were created in `alloc_slot`, are uniquely owned by the
|
// SAFETY: `buffer`/`memory`/`cmd`/`desc` were created in `alloc_slot`, are uniquely
|
||||||
// drained `SlotAlloc`, and are destroyed exactly once here. No queue work can be
|
// owned by the drained `SlotAlloc`, and are destroyed exactly once here. No queue
|
||||||
// in flight: every `blend` fence-waits before returning.
|
// work is in flight: CPU-synced blends fence-wait before returning, and ordered
|
||||||
|
// blends were quiesced by the `device_wait_idle` above.
|
||||||
unsafe {
|
unsafe {
|
||||||
|
let _ = self.device.free_descriptor_sets(self.desc_pool, &[s.desc]);
|
||||||
|
self.device.free_command_buffers(self.cmd_pool, &[s.cmd]);
|
||||||
self.device.destroy_buffer(s.buffer, None);
|
self.device.destroy_buffer(s.buffer, None);
|
||||||
self.device.free_memory(s.memory, None);
|
self.device.free_memory(s.memory, None);
|
||||||
}
|
}
|
||||||
@@ -498,21 +684,188 @@ impl VkSlotBlend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Upload the cursor RGBA (`cw*ch*4`, tight rows) into the mapped staging buffer. Call only
|
/// Upload the cursor RGBA (`cw*ch*4`, tight rows) into the mapped staging buffer. Call only
|
||||||
/// when the bitmap changes; position moves are push constants.
|
/// when the bitmap changes; position moves are push constants. Quiesces any in-flight
|
||||||
|
/// ordered blend first (bitmap changes are rare — pointer-shape flips — so the occasional
|
||||||
|
/// bounded CPU wait here is the trade that keeps the per-frame path sync-free).
|
||||||
pub fn upload_cursor(&mut self, rgba: &[u8], cw: u32, ch: u32) {
|
pub fn upload_cursor(&mut self, rgba: &[u8], cw: u32, ch: u32) {
|
||||||
|
if let Some(t) = &self.timeline {
|
||||||
|
if t.last_blend > 0 {
|
||||||
|
let sems = [t.sem];
|
||||||
|
let values = [t.last_blend];
|
||||||
|
// SAFETY: `t.sem` is the live timeline semaphore; the wait info's arrays are
|
||||||
|
// locals outliving the synchronous call. `last_blend` only holds values an
|
||||||
|
// accepted submit will signal, so the wait terminates (the timeout is the
|
||||||
|
// backstop for a wedged queue — then we proceed and risk one torn cursor
|
||||||
|
// bitmap, never UB: the staging buffer stays alive regardless).
|
||||||
|
let r = unsafe {
|
||||||
|
t.ts.wait_semaphores(
|
||||||
|
&vk::SemaphoreWaitInfo::default()
|
||||||
|
.semaphores(&sems)
|
||||||
|
.values(&values),
|
||||||
|
1_000_000_000,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if let Err(e) = r {
|
||||||
|
tracing::warn!(
|
||||||
|
error = ?e,
|
||||||
|
"cursor upload quiesce failed — proceeding (a torn cursor bitmap for \
|
||||||
|
one frame at worst)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let cw = cw.min(CURSOR_MAX);
|
let cw = cw.min(CURSOR_MAX);
|
||||||
let ch = ch.min(CURSOR_MAX);
|
let ch = ch.min(CURSOR_MAX);
|
||||||
let len = (cw * ch * 4) as usize;
|
let len = (cw * ch * 4) as usize;
|
||||||
let len = len.min(rgba.len());
|
let len = len.min(rgba.len());
|
||||||
// SAFETY: `cur_map` is the live persistent mapping of the CURSOR_MAX²·4 host-coherent
|
// SAFETY: `cur_map` is the live persistent mapping of the CURSOR_MAX²·4 host-coherent
|
||||||
// allocation (created in `init_objects`, unmapped only in `Drop`); `len` is clamped to
|
// allocation (created in `init_objects`, unmapped only in `Drop`); `len` is clamped to
|
||||||
// both the source slice and the buffer capacity. No blend is in flight (every `blend`
|
// both the source slice and the buffer capacity. No blend reads race this host write:
|
||||||
// fence-waits before returning), so no GPU read races this host write.
|
// CPU-synced blends fence-wait before returning, and ordered blends were quiesced via
|
||||||
|
// the timeline wait above.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::ptr::copy_nonoverlapping(rgba.as_ptr(), self.cur_map, len);
|
std::ptr::copy_nonoverlapping(rgba.as_ptr(), self.cur_map, len);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stream-ordered blends available: the timeline semaphore was exported to CUDA at bring-up,
|
||||||
|
/// so [`blend_ref_ordered`](Self::blend_ref_ordered) can order copy→blend→encode on-device.
|
||||||
|
pub fn ordered_ready(&self) -> bool {
|
||||||
|
self.timeline.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last timeline value handed out (0 = no ordered blend submitted yet) — test observability
|
||||||
|
/// for "did the ordered path actually run".
|
||||||
|
pub fn ordered_ticket(&self) -> u64 {
|
||||||
|
self.timeline.as_ref().map_or(0, |t| t.ticket)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push constants + dispatch geometry for one blend (must match cursor_blend.comp): ARGB =
|
||||||
|
/// per cursor px; NV12/YUV444 = per word-aligned 4-px span × (2-row blocks | rows). `None`
|
||||||
|
/// when the clamped cursor rect is empty (nothing to dispatch).
|
||||||
|
fn blend_geometry(
|
||||||
|
slot: &VkSlotRef,
|
||||||
|
fmt: SlotFormat,
|
||||||
|
surf_w: u32,
|
||||||
|
cw: u32,
|
||||||
|
ch: u32,
|
||||||
|
ox: i32,
|
||||||
|
oy: i32,
|
||||||
|
) -> Option<(Push, u32, u32)> {
|
||||||
|
let cw = cw.min(CURSOR_MAX);
|
||||||
|
let ch = ch.min(CURSOR_MAX);
|
||||||
|
if cw == 0 || ch == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let push = Push {
|
||||||
|
pitch: slot.pitch as u32,
|
||||||
|
surf_w,
|
||||||
|
surf_h: slot.height,
|
||||||
|
cur_w: cw,
|
||||||
|
cur_h: ch,
|
||||||
|
ox,
|
||||||
|
oy,
|
||||||
|
};
|
||||||
|
let (gx, gy) = match fmt {
|
||||||
|
SlotFormat::Argb => (cw.div_ceil(8), ch.div_ceil(8)),
|
||||||
|
_ => {
|
||||||
|
let x0 = (ox >> 2) << 2;
|
||||||
|
let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32;
|
||||||
|
let rows = match fmt {
|
||||||
|
SlotFormat::Nv12 => ch.div_ceil(2),
|
||||||
|
_ => ch,
|
||||||
|
};
|
||||||
|
(spans.div_ceil(8), rows.div_ceil(8))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some((push, gx, gy))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the blend (barriers + bind + push constants + dispatch) into `id`'s own command
|
||||||
|
/// buffer and return it, ready for submission.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// The slot's previous submission must have completed: sync blends fence-wait before
|
||||||
|
/// returning; ordered blends rely on the encoder's ring-reuse discipline (a slot is reused
|
||||||
|
/// only after its encode — GPU-ordered after its blend — was polled).
|
||||||
|
unsafe fn record_blend(
|
||||||
|
&self,
|
||||||
|
id: usize,
|
||||||
|
fmt: SlotFormat,
|
||||||
|
push: &Push,
|
||||||
|
gx: u32,
|
||||||
|
gy: u32,
|
||||||
|
) -> Result<vk::CommandBuffer> {
|
||||||
|
let alloc = self
|
||||||
|
.slots
|
||||||
|
.get(id)
|
||||||
|
.ok_or_else(|| anyhow!("bad slot id {id}"))?;
|
||||||
|
let d = &self.device;
|
||||||
|
let cmd = alloc.cmd;
|
||||||
|
d.begin_command_buffer(
|
||||||
|
cmd,
|
||||||
|
&vk::CommandBufferBeginInfo::default()
|
||||||
|
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
|
||||||
|
)
|
||||||
|
.context("begin blend cmd")?;
|
||||||
|
// CUDA wrote the frame into this memory outside Vulkan's view — make it visible to
|
||||||
|
// the shader (external-memory coherence ceremony; NVIDIA honors this with the
|
||||||
|
// fence/semaphore ordering alone, the barrier is the spec-shaped belt-and-braces).
|
||||||
|
let acquire = [vk::MemoryBarrier::default()
|
||||||
|
.src_access_mask(vk::AccessFlags::MEMORY_WRITE)
|
||||||
|
.dst_access_mask(vk::AccessFlags::SHADER_READ | vk::AccessFlags::SHADER_WRITE)];
|
||||||
|
d.cmd_pipeline_barrier(
|
||||||
|
cmd,
|
||||||
|
vk::PipelineStageFlags::TOP_OF_PIPE,
|
||||||
|
vk::PipelineStageFlags::COMPUTE_SHADER,
|
||||||
|
vk::DependencyFlags::empty(),
|
||||||
|
&acquire,
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
d.cmd_bind_pipeline(
|
||||||
|
cmd,
|
||||||
|
vk::PipelineBindPoint::COMPUTE,
|
||||||
|
self.pipelines[fmt.mode() as usize],
|
||||||
|
);
|
||||||
|
d.cmd_bind_descriptor_sets(
|
||||||
|
cmd,
|
||||||
|
vk::PipelineBindPoint::COMPUTE,
|
||||||
|
self.pipe_layout,
|
||||||
|
0,
|
||||||
|
&[alloc.desc],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
let bytes = std::slice::from_raw_parts(
|
||||||
|
(push as *const Push) as *const u8,
|
||||||
|
std::mem::size_of::<Push>(),
|
||||||
|
);
|
||||||
|
d.cmd_push_constants(
|
||||||
|
cmd,
|
||||||
|
self.pipe_layout,
|
||||||
|
vk::ShaderStageFlags::COMPUTE,
|
||||||
|
0,
|
||||||
|
bytes,
|
||||||
|
);
|
||||||
|
d.cmd_dispatch(cmd, gx.max(1), gy.max(1), 1);
|
||||||
|
// Release the shader's writes so the downstream CUDA/NVENC reads (fence- or
|
||||||
|
// semaphore-ordered) see them.
|
||||||
|
let release = [vk::MemoryBarrier::default()
|
||||||
|
.src_access_mask(vk::AccessFlags::SHADER_WRITE)
|
||||||
|
.dst_access_mask(vk::AccessFlags::MEMORY_READ)];
|
||||||
|
d.cmd_pipeline_barrier(
|
||||||
|
cmd,
|
||||||
|
vk::PipelineStageFlags::COMPUTE_SHADER,
|
||||||
|
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
|
||||||
|
vk::DependencyFlags::empty(),
|
||||||
|
&release,
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
d.end_command_buffer(cmd).context("end blend cmd")?;
|
||||||
|
Ok(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
/// Blend the uploaded cursor into `slot` at `(ox, oy)`: record, submit, fence-wait. The
|
/// Blend the uploaded cursor into `slot` at `(ox, oy)`: record, submit, fence-wait. The
|
||||||
/// caller has CPU-synced its CUDA frame copy first; the fence wait makes the shader's writes
|
/// caller has CPU-synced its CUDA frame copy first; the fence wait makes the shader's writes
|
||||||
/// visible to the subsequent NVENC encode (the `VkBridge` precedent: fence-ordered cross-API
|
/// visible to the subsequent NVENC encode (the `VkBridge` precedent: fence-ordered cross-API
|
||||||
@@ -528,129 +881,20 @@ impl VkSlotBlend {
|
|||||||
ox: i32,
|
ox: i32,
|
||||||
oy: i32,
|
oy: i32,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let alloc = self
|
let Some((push, gx, gy)) = Self::blend_geometry(slot, fmt, surf_w, cw, ch, ox, oy) else {
|
||||||
.slots
|
|
||||||
.get(slot.id)
|
|
||||||
.ok_or_else(|| anyhow!("bad slot id {}", slot.id))?;
|
|
||||||
let cw = cw.min(CURSOR_MAX);
|
|
||||||
let ch = ch.min(CURSOR_MAX);
|
|
||||||
if cw == 0 || ch == 0 {
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
|
||||||
let push = Push {
|
|
||||||
pitch: slot.pitch as u32,
|
|
||||||
surf_w,
|
|
||||||
surf_h: slot.height,
|
|
||||||
cur_w: cw,
|
|
||||||
cur_h: ch,
|
|
||||||
ox,
|
|
||||||
oy,
|
|
||||||
};
|
};
|
||||||
// Dispatch geometry (must match cursor_blend.comp): ARGB = per cursor px; NV12/YUV444 =
|
// SAFETY: single-threaded record/submit/wait on handles this struct owns. The slot's
|
||||||
// per word-aligned 4-px span × (2-row blocks | rows).
|
// previous submission completed (`record_blend`'s contract: this path fence-waits every
|
||||||
let (gx, gy) = match fmt {
|
// blend, and an earlier ORDERED blend on this slot finished before the encoder reused it
|
||||||
SlotFormat::Argb => (cw.div_ceil(8), ch.div_ceil(8)),
|
// — ring-reuse discipline). Submit-info arrays are locals outliving the synchronous
|
||||||
_ => {
|
// calls. The dispatch's shader accesses stay in-bounds by the shader's own guards
|
||||||
let x0 = (ox >> 2) << 2;
|
// (surfW/surfH/curW/curH from `push`) against the slot allocation sized in `alloc_slot`
|
||||||
let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32;
|
// for exactly that geometry.
|
||||||
let rows = match fmt {
|
|
||||||
SlotFormat::Nv12 => ch.div_ceil(2),
|
|
||||||
_ => ch,
|
|
||||||
};
|
|
||||||
(spans.div_ceil(8), rows.div_ceil(8))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// SAFETY: single-threaded record/submit/wait on handles this struct owns. The descriptor
|
|
||||||
// update is safe because no prior submission is in flight (every blend fence-waits and
|
|
||||||
// the fence is reset before reuse). Buffer infos and barrier structs are locals outliving
|
|
||||||
// their synchronous calls. The dispatch's shader accesses stay in-bounds by the shader's
|
|
||||||
// own guards (surfW/surfH/curW/curH from `push`) against the slot allocation sized in
|
|
||||||
// `alloc_slot` for exactly that geometry.
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let d = &self.device;
|
let d = &self.device;
|
||||||
let surf_info = [vk::DescriptorBufferInfo::default()
|
let cmd = self.record_blend(slot.id, fmt, &push, gx, gy)?;
|
||||||
.buffer(alloc.buffer)
|
let cmds = [cmd];
|
||||||
.offset(0)
|
|
||||||
.range(alloc.size)];
|
|
||||||
let cur_info = [vk::DescriptorBufferInfo::default()
|
|
||||||
.buffer(self.cur_buf)
|
|
||||||
.offset(0)
|
|
||||||
.range((CURSOR_MAX * CURSOR_MAX * 4) as u64)];
|
|
||||||
let writes = [
|
|
||||||
vk::WriteDescriptorSet::default()
|
|
||||||
.dst_set(self.desc_set)
|
|
||||||
.dst_binding(0)
|
|
||||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
|
||||||
.buffer_info(&surf_info),
|
|
||||||
vk::WriteDescriptorSet::default()
|
|
||||||
.dst_set(self.desc_set)
|
|
||||||
.dst_binding(1)
|
|
||||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
|
||||||
.buffer_info(&cur_info),
|
|
||||||
];
|
|
||||||
d.update_descriptor_sets(&writes, &[]);
|
|
||||||
|
|
||||||
d.begin_command_buffer(
|
|
||||||
self.cmd,
|
|
||||||
&vk::CommandBufferBeginInfo::default()
|
|
||||||
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
|
|
||||||
)
|
|
||||||
.context("begin blend cmd")?;
|
|
||||||
// CUDA wrote the frame into this memory outside Vulkan's view — make it visible to
|
|
||||||
// the shader (external-memory coherence ceremony; NVIDIA honors this with the fence
|
|
||||||
// ordering alone, the barrier is the spec-shaped belt-and-braces).
|
|
||||||
let acquire = [vk::MemoryBarrier::default()
|
|
||||||
.src_access_mask(vk::AccessFlags::MEMORY_WRITE)
|
|
||||||
.dst_access_mask(vk::AccessFlags::SHADER_READ | vk::AccessFlags::SHADER_WRITE)];
|
|
||||||
d.cmd_pipeline_barrier(
|
|
||||||
self.cmd,
|
|
||||||
vk::PipelineStageFlags::TOP_OF_PIPE,
|
|
||||||
vk::PipelineStageFlags::COMPUTE_SHADER,
|
|
||||||
vk::DependencyFlags::empty(),
|
|
||||||
&acquire,
|
|
||||||
&[],
|
|
||||||
&[],
|
|
||||||
);
|
|
||||||
d.cmd_bind_pipeline(
|
|
||||||
self.cmd,
|
|
||||||
vk::PipelineBindPoint::COMPUTE,
|
|
||||||
self.pipelines[fmt.mode() as usize],
|
|
||||||
);
|
|
||||||
d.cmd_bind_descriptor_sets(
|
|
||||||
self.cmd,
|
|
||||||
vk::PipelineBindPoint::COMPUTE,
|
|
||||||
self.pipe_layout,
|
|
||||||
0,
|
|
||||||
&[self.desc_set],
|
|
||||||
&[],
|
|
||||||
);
|
|
||||||
let bytes = std::slice::from_raw_parts(
|
|
||||||
(&push as *const Push) as *const u8,
|
|
||||||
std::mem::size_of::<Push>(),
|
|
||||||
);
|
|
||||||
d.cmd_push_constants(
|
|
||||||
self.cmd,
|
|
||||||
self.pipe_layout,
|
|
||||||
vk::ShaderStageFlags::COMPUTE,
|
|
||||||
0,
|
|
||||||
bytes,
|
|
||||||
);
|
|
||||||
d.cmd_dispatch(self.cmd, gx.max(1), gy.max(1), 1);
|
|
||||||
// Release the shader's writes so the post-fence CUDA/NVENC reads see them.
|
|
||||||
let release = [vk::MemoryBarrier::default()
|
|
||||||
.src_access_mask(vk::AccessFlags::SHADER_WRITE)
|
|
||||||
.dst_access_mask(vk::AccessFlags::MEMORY_READ)];
|
|
||||||
d.cmd_pipeline_barrier(
|
|
||||||
self.cmd,
|
|
||||||
vk::PipelineStageFlags::COMPUTE_SHADER,
|
|
||||||
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
|
|
||||||
vk::DependencyFlags::empty(),
|
|
||||||
&release,
|
|
||||||
&[],
|
|
||||||
&[],
|
|
||||||
);
|
|
||||||
d.end_command_buffer(self.cmd).context("end blend cmd")?;
|
|
||||||
let cmds = [self.cmd];
|
|
||||||
let submit = [vk::SubmitInfo::default().command_buffers(&cmds)];
|
let submit = [vk::SubmitInfo::default().command_buffers(&cmds)];
|
||||||
d.queue_submit(self.queue, &submit, self.fence)
|
d.queue_submit(self.queue, &submit, self.fence)
|
||||||
.context("submit blend")?;
|
.context("submit blend")?;
|
||||||
@@ -660,11 +904,121 @@ impl VkSlotBlend {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stream-ordered blend — no CPU sync anywhere (the fix for cursor frames serializing the
|
||||||
|
/// NVENC submit path). The caller has ENQUEUED (not synced) its CUDA frame copy on this
|
||||||
|
/// thread's copy stream; this method then
|
||||||
|
/// 1. enqueues a CUDA signal of `copy_done` on that stream (fires once the copy completes),
|
||||||
|
/// 2. submits the blend waiting `copy_done` and signaling `blend_done` on the Vulkan queue,
|
||||||
|
/// 3. enqueues a CUDA wait of `blend_done` — so later stream work (the encode, via the
|
||||||
|
/// session's IO-stream binding) orders after the blend's writes.
|
||||||
|
///
|
||||||
|
/// Timeline values are fresh per call and never reused. Failure leaves no dangling waiter:
|
||||||
|
/// the CUDA wait is enqueued only after the submit was accepted, and an orphaned CUDA
|
||||||
|
/// signal is legal (timeline signals may skip values — a later, larger signal satisfies
|
||||||
|
/// any waiter).
|
||||||
|
#[allow(clippy::too_many_arguments)] // same unpacked kernel args as `blend_ref`
|
||||||
|
pub fn blend_ref_ordered(
|
||||||
|
&mut self,
|
||||||
|
slot: &VkSlotRef,
|
||||||
|
fmt: SlotFormat,
|
||||||
|
surf_w: u32,
|
||||||
|
cw: u32,
|
||||||
|
ch: u32,
|
||||||
|
ox: i32,
|
||||||
|
oy: i32,
|
||||||
|
) -> Result<()> {
|
||||||
|
let Some((push, gx, gy)) = Self::blend_geometry(slot, fmt, surf_w, cw, ch, ox, oy) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let (copy_done, blend_done, sem) = {
|
||||||
|
let t = self
|
||||||
|
.timeline
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| anyhow!("ordered blend without timeline support"))?;
|
||||||
|
t.ticket += 2;
|
||||||
|
(t.ticket - 1, t.ticket, t.sem)
|
||||||
|
};
|
||||||
|
// Signal FIRST: if this fails nothing was submitted and the fresh values are simply
|
||||||
|
// skipped. (The reverse order could wedge the queue — a submitted blend waiting on a
|
||||||
|
// copy-done signal that never got enqueued.)
|
||||||
|
self.timeline
|
||||||
|
.as_ref()
|
||||||
|
.expect("checked above")
|
||||||
|
.cuda
|
||||||
|
.signal(copy_done)
|
||||||
|
.context("cuSignalExternalSemaphoresAsync (copy done)")?;
|
||||||
|
// SAFETY: single-threaded record/submit on handles this struct owns. The slot's previous
|
||||||
|
// submission completed (`record_blend`'s contract — ring-reuse discipline, see above).
|
||||||
|
// All submit-info arrays and the timeline-submit chain are locals outliving the
|
||||||
|
// synchronous `queue_submit`. No fence: completion is observed through the timeline
|
||||||
|
// (the encode polls it via the CUDA wait below; teardown quiesces via
|
||||||
|
// `device_wait_idle`).
|
||||||
|
unsafe {
|
||||||
|
let cmd = self.record_blend(slot.id, fmt, &push, gx, gy)?;
|
||||||
|
let cmds = [cmd];
|
||||||
|
let wait_sems = [sem];
|
||||||
|
let wait_vals = [copy_done];
|
||||||
|
let wait_stages = [vk::PipelineStageFlags::COMPUTE_SHADER];
|
||||||
|
let sig_sems = [sem];
|
||||||
|
let sig_vals = [blend_done];
|
||||||
|
let mut tsi = vk::TimelineSemaphoreSubmitInfo::default()
|
||||||
|
.wait_semaphore_values(&wait_vals)
|
||||||
|
.signal_semaphore_values(&sig_vals);
|
||||||
|
let submit = [vk::SubmitInfo::default()
|
||||||
|
.command_buffers(&cmds)
|
||||||
|
.wait_semaphores(&wait_sems)
|
||||||
|
.wait_dst_stage_mask(&wait_stages)
|
||||||
|
.signal_semaphores(&sig_sems)
|
||||||
|
.push_next(&mut tsi)];
|
||||||
|
self.device
|
||||||
|
.queue_submit(self.queue, &submit, vk::Fence::null())
|
||||||
|
.context("submit ordered blend")?;
|
||||||
|
}
|
||||||
|
let t = self.timeline.as_mut().expect("checked above");
|
||||||
|
// Only now: `blend_done` WILL be signaled, so quiesces may rely on it.
|
||||||
|
t.last_blend = blend_done;
|
||||||
|
if let Err(e) = t.cuda.wait(blend_done) {
|
||||||
|
// The blend is in flight but the encode would no longer order after it — restore
|
||||||
|
// the ordering with a bounded CPU wait (the CPU-synced path's cost, once). The
|
||||||
|
// frame still composites correctly.
|
||||||
|
let sems = [t.sem];
|
||||||
|
let values = [blend_done];
|
||||||
|
// SAFETY: live timeline semaphore; wait-info arrays are locals outliving the
|
||||||
|
// synchronous call; `blend_done` was accepted for signaling just above, so the
|
||||||
|
// wait terminates (timeout backstops a wedged queue).
|
||||||
|
let r = unsafe {
|
||||||
|
t.ts.wait_semaphores(
|
||||||
|
&vk::SemaphoreWaitInfo::default()
|
||||||
|
.semaphores(&sems)
|
||||||
|
.values(&values),
|
||||||
|
1_000_000_000,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
tracing::warn!(
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
cpu_wait = ?r,
|
||||||
|
"ordered cursor blend: CUDA-side wait enqueue failed — ordering restored with \
|
||||||
|
a CPU wait this frame"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for VkSlotBlend {
|
impl Drop for VkSlotBlend {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.free_slots();
|
self.free_slots();
|
||||||
|
if let Some(t) = self.timeline.take() {
|
||||||
|
drop(t.cuda); // CUDA's import of the semaphore goes first (mirrors the slot order)
|
||||||
|
// SAFETY: `sem` was created in `init_timeline`, is uniquely owned, and is destroyed
|
||||||
|
// exactly once here. No submission still references it: ordered blends only exist
|
||||||
|
// alongside allocated slots, and `free_slots` just quiesced (device_wait_idle) any
|
||||||
|
// in-flight work before this point.
|
||||||
|
unsafe {
|
||||||
|
self.device.destroy_semaphore(t.sem, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
// SAFETY: every handle below was created in `new`/`init_objects` (or is null from a
|
// SAFETY: every handle below was created in `new`/`init_objects` (or is null from a
|
||||||
// partial init — Vulkan destroy/free calls are defined no-ops on null handles) and is
|
// partial init — Vulkan destroy/free calls are defined no-ops on null handles) and is
|
||||||
// uniquely owned; each is destroyed exactly once here, pipelines/layouts/pools before the
|
// uniquely owned; each is destroyed exactly once here, pipelines/layouts/pools before the
|
||||||
|
|||||||
@@ -36,8 +36,13 @@ pub(crate) const HOST_TIMING_QUEUE: usize = 512;
|
|||||||
pub(crate) const CLIP_EVENT_QUEUE: usize = 32;
|
pub(crate) const CLIP_EVENT_QUEUE: usize = 32;
|
||||||
|
|
||||||
/// Cursor-shape plane depth (control-stream [`crate::quic::CursorShape`], one per pointer-bitmap
|
/// Cursor-shape plane depth (control-stream [`crate::quic::CursorShape`], one per pointer-bitmap
|
||||||
/// change — human-paced). Overflow drops the newest (try_send); the next shape change or a
|
/// change — human-paced, but bursty: crossing a toolbar flips arrow/I-beam/hand/resize several
|
||||||
/// serial mismatch against `0xD0` state heals it visually within a shape-change period.
|
/// times a second, and every flip mints a fresh serial and a fresh bitmap). Overflow drops the
|
||||||
|
/// newest (try_send) and the host does NOT re-send it — it only sends on a serial CHANGE — so the
|
||||||
|
/// dropped serial stays un-backed until the pointer changes shape again. Embedders must therefore
|
||||||
|
/// hold their last worn shape when `hostCursors[serial]` misses rather than hiding the pointer
|
||||||
|
/// (the Apple client's `lastWornShape`); healing is bounded by the next shape change, not by this
|
||||||
|
/// ring.
|
||||||
pub(crate) const CURSOR_SHAPE_QUEUE: usize = 8;
|
pub(crate) const CURSOR_SHAPE_QUEUE: usize = 8;
|
||||||
|
|
||||||
/// Cursor-state plane depth (`0xD0`, one datagram per captured frame). Latest-wins state — the
|
/// Cursor-state plane depth (`0xD0`, one datagram per captured frame). Latest-wins state — the
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub use pf_capture::{
|
|||||||
capturer_supports_444, capturer_supports_hdr, Capturer, FastSyntheticCapturer,
|
capturer_supports_444, capturer_supports_hdr, Capturer, FastSyntheticCapturer,
|
||||||
SyntheticCapturer,
|
SyntheticCapturer,
|
||||||
};
|
};
|
||||||
// `crate::capture::dxgi::{install_gpu_pref_hook, hdr_p010_selftest}` (main.rs subcommands) and
|
// `crate::capture::dxgi::{install_gpu_pref_hook, hdr_p010_selftest_at}` (main.rs subcommands) and
|
||||||
// `crate::capture::synthetic_nv12` resolve through pf-capture's Windows modules.
|
// `crate::capture::synthetic_nv12` resolve through pf-capture's Windows modules.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub use pf_capture::{dxgi, synthetic_nv12};
|
pub use pf_capture::{dxgi, synthetic_nv12};
|
||||||
@@ -66,8 +66,14 @@ fn zero_copy_policy(
|
|||||||
/// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal. `want_hdr`
|
/// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal. `want_hdr`
|
||||||
/// offers the GNOME 50+ 10-bit PQ/BT.2020 formats (pass it only when the session negotiated HDR
|
/// offers the GNOME 50+ 10-bit PQ/BT.2020 formats (pass it only when the session negotiated HDR
|
||||||
/// AND the mirrored monitor is in HDR mode — see [`pf_capture::gnome_hdr_monitor_active`]).
|
/// AND the mirrored monitor is in HDR mode — see [`pf_capture::gnome_hdr_monitor_active`]).
|
||||||
|
/// `want_metadata_cursor` asks for cursor-as-metadata — pass it only when the session's encode
|
||||||
|
/// backend composites `CapturedFrame::cursor` (`encode::cursor_blend_capable`); otherwise the
|
||||||
|
/// portal embeds the pointer, so no backend × cursor-mode combination streams cursorless.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn open_portal_monitor(want_hdr: bool) -> Result<Box<dyn Capturer>> {
|
pub fn open_portal_monitor(
|
||||||
|
want_hdr: bool,
|
||||||
|
want_metadata_cursor: bool,
|
||||||
|
) -> Result<Box<dyn Capturer>> {
|
||||||
// On RemoteDesktop-capable desktops (KWin/GNOME) anchor ScreenCast to a RemoteDesktop
|
// On RemoteDesktop-capable desktops (KWin/GNOME) anchor ScreenCast to a RemoteDesktop
|
||||||
// session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal,
|
// session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal,
|
||||||
// so use a plain ScreenCast session there.
|
// so use a plain ScreenCast session there.
|
||||||
@@ -76,11 +82,19 @@ pub fn open_portal_monitor(want_hdr: bool) -> Result<Box<dyn Capturer>> {
|
|||||||
// passthrough is virtual-output-only; the global encoder-pref lever still applies inside.
|
// passthrough is virtual-output-only; the global encoder-pref lever still applies inside.
|
||||||
// Native NV12 stays off too: the mirror path doesn't resolve the codec here, and the desktop
|
// Native NV12 stays off too: the mirror path doesn't resolve the codec here, and the desktop
|
||||||
// compositors it mirrors (GNOME/KWin) don't produce NV12 anyway.
|
// compositors it mirrors (GNOME/KWin) don't produce NV12 anyway.
|
||||||
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy(false, false))
|
pf_capture::open_portal_monitor(
|
||||||
|
anchored,
|
||||||
|
want_hdr,
|
||||||
|
want_metadata_cursor,
|
||||||
|
zero_copy_policy(false, false),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
pub fn open_portal_monitor(_want_hdr: bool) -> Result<Box<dyn Capturer>> {
|
pub fn open_portal_monitor(
|
||||||
|
_want_hdr: bool,
|
||||||
|
_want_metadata_cursor: bool,
|
||||||
|
) -> Result<Box<dyn Capturer>> {
|
||||||
anyhow::bail!("portal capture requires Linux (xdg-desktop-portal + PipeWire)")
|
anyhow::bail!("portal capture requires Linux (xdg-desktop-portal + PipeWire)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,33 @@ pub struct StreamRef {
|
|||||||
pub plane: Plane,
|
pub plane: Plane,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A launched game, as the `game.*` events see it.
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||||
|
pub struct GameRefPayload {
|
||||||
|
/// Store-qualified library id (`steam:570`). Absent for an operator-typed GameStream
|
||||||
|
/// `apps.json` command, which has no library entry behind it.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub app: Option<String>,
|
||||||
|
/// Display title.
|
||||||
|
pub title: String,
|
||||||
|
/// Which store surfaced it (`steam`, `heroic`, `custom`, …), when known.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub store: Option<String>,
|
||||||
|
/// Client-supplied device name of the session that launched it; may be empty.
|
||||||
|
pub client: String,
|
||||||
|
pub plane: Plane,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a launched game is no longer running.
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum GameEndReason {
|
||||||
|
/// The player quit it (or it crashed) — the host did not ask.
|
||||||
|
Exited,
|
||||||
|
/// The host ended it, per the session⇄game lifetime policy.
|
||||||
|
Terminated,
|
||||||
|
}
|
||||||
|
|
||||||
/// A device in the pairing flow.
|
/// A device in the pairing flow.
|
||||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||||
pub struct DeviceRef {
|
pub struct DeviceRef {
|
||||||
@@ -140,6 +167,17 @@ pub enum EventKind {
|
|||||||
StreamStarted { stream: StreamRef },
|
StreamStarted { stream: StreamRef },
|
||||||
#[serde(rename = "stream.stopped")]
|
#[serde(rename = "stream.stopped")]
|
||||||
StreamStopped { stream: StreamRef },
|
StreamStopped { stream: StreamRef },
|
||||||
|
/// A launched game was confirmed running — fires once per launch, after the host has actually
|
||||||
|
/// seen the game's process (not merely spawned its launcher).
|
||||||
|
#[serde(rename = "game.running")]
|
||||||
|
GameRunning { game: GameRefPayload },
|
||||||
|
/// A launched game is gone. `reason` distinguishes the player quitting from the host ending it
|
||||||
|
/// per the lifetime policy.
|
||||||
|
#[serde(rename = "game.exited")]
|
||||||
|
GameExited {
|
||||||
|
game: GameRefPayload,
|
||||||
|
reason: GameEndReason,
|
||||||
|
},
|
||||||
#[serde(rename = "pairing.pending")]
|
#[serde(rename = "pairing.pending")]
|
||||||
PairingPending { device: DeviceRef },
|
PairingPending { device: DeviceRef },
|
||||||
#[serde(rename = "pairing.completed")]
|
#[serde(rename = "pairing.completed")]
|
||||||
@@ -196,6 +234,8 @@ impl EventKind {
|
|||||||
EventKind::SessionEnded { .. } => "session.ended",
|
EventKind::SessionEnded { .. } => "session.ended",
|
||||||
EventKind::StreamStarted { .. } => "stream.started",
|
EventKind::StreamStarted { .. } => "stream.started",
|
||||||
EventKind::StreamStopped { .. } => "stream.stopped",
|
EventKind::StreamStopped { .. } => "stream.stopped",
|
||||||
|
EventKind::GameRunning { .. } => "game.running",
|
||||||
|
EventKind::GameExited { .. } => "game.exited",
|
||||||
EventKind::PairingPending { .. } => "pairing.pending",
|
EventKind::PairingPending { .. } => "pairing.pending",
|
||||||
EventKind::PairingCompleted { .. } => "pairing.completed",
|
EventKind::PairingCompleted { .. } => "pairing.completed",
|
||||||
EventKind::PairingDenied { .. } => "pairing.denied",
|
EventKind::PairingDenied { .. } => "pairing.denied",
|
||||||
@@ -224,6 +264,9 @@ impl EventKind {
|
|||||||
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
|
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
|
||||||
Some(&stream.client)
|
Some(&stream.client)
|
||||||
}
|
}
|
||||||
|
EventKind::GameRunning { game } | EventKind::GameExited { game, .. } => {
|
||||||
|
Some(&game.client)
|
||||||
|
}
|
||||||
EventKind::PairingPending { device }
|
EventKind::PairingPending { device }
|
||||||
| EventKind::PairingCompleted { device }
|
| EventKind::PairingCompleted { device }
|
||||||
| EventKind::PairingDenied { device } => Some(&device.name),
|
| EventKind::PairingDenied { device } => Some(&device.name),
|
||||||
@@ -251,6 +294,9 @@ impl EventKind {
|
|||||||
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
|
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
|
||||||
Some(stream.plane)
|
Some(stream.plane)
|
||||||
}
|
}
|
||||||
|
EventKind::GameRunning { game } | EventKind::GameExited { game, .. } => {
|
||||||
|
Some(game.plane)
|
||||||
|
}
|
||||||
EventKind::PairingPending { device }
|
EventKind::PairingPending { device }
|
||||||
| EventKind::PairingCompleted { device }
|
| EventKind::PairingCompleted { device }
|
||||||
| EventKind::PairingDenied { device } => Some(device.plane),
|
| EventKind::PairingDenied { device } => Some(device.plane),
|
||||||
@@ -264,6 +310,11 @@ impl EventKind {
|
|||||||
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
|
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
|
||||||
stream.app.as_deref()
|
stream.app.as_deref()
|
||||||
}
|
}
|
||||||
|
// A `game.*` event without a library id came from an operator-typed command; its title
|
||||||
|
// is the only handle a hook filter has, so fall back to it rather than matching nothing.
|
||||||
|
EventKind::GameRunning { game } | EventKind::GameExited { game, .. } => {
|
||||||
|
game.app.as_deref().or(Some(&game.title))
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -522,6 +573,84 @@ mod tests {
|
|||||||
serde_json::to_string(&ev).unwrap(),
|
serde_json::to_string(&ev).unwrap(),
|
||||||
r#"{"seq":3,"ts_ms":1700000000000,"schema":1,"kind":"plugins.changed","id":"rom-manager"}"#
|
r#"{"seq":3,"ts_ms":1700000000000,"schema":1,"kind":"plugins.changed","id":"rom-manager"}"#
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let ev = HostEvent {
|
||||||
|
seq: 5,
|
||||||
|
ts_ms: 1_700_000_000_000,
|
||||||
|
schema: 1,
|
||||||
|
kind: EventKind::GameRunning {
|
||||||
|
game: GameRefPayload {
|
||||||
|
app: Some("steam:570".into()),
|
||||||
|
title: "Dota 2".into(),
|
||||||
|
store: Some("steam".into()),
|
||||||
|
client: "Living Room TV".into(),
|
||||||
|
plane: Plane::Native,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&ev).unwrap(),
|
||||||
|
r#"{"seq":5,"ts_ms":1700000000000,"schema":1,"kind":"game.running","game":{"app":"steam:570","title":"Dota 2","store":"steam","client":"Living Room TV","plane":"native"}}"#
|
||||||
|
);
|
||||||
|
|
||||||
|
// A game the host ended itself, and one with no library entry behind it (an operator-typed
|
||||||
|
// GameStream command) — the optional ids are omitted, not nulled.
|
||||||
|
let ev = HostEvent {
|
||||||
|
seq: 6,
|
||||||
|
ts_ms: 1_700_000_000_000,
|
||||||
|
schema: 1,
|
||||||
|
kind: EventKind::GameExited {
|
||||||
|
game: GameRefPayload {
|
||||||
|
app: None,
|
||||||
|
title: "Big Picture".into(),
|
||||||
|
store: None,
|
||||||
|
client: String::new(),
|
||||||
|
plane: Plane::Gamestream,
|
||||||
|
},
|
||||||
|
reason: GameEndReason::Terminated,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&ev).unwrap(),
|
||||||
|
r#"{"seq":6,"ts_ms":1700000000000,"schema":1,"kind":"game.exited","game":{"title":"Big Picture","client":"","plane":"gamestream"},"reason":"terminated"}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `game.*` events must be reachable by the same hook/SSE filters as every other kind — a
|
||||||
|
/// filterable event nobody can select is not a feature.
|
||||||
|
#[test]
|
||||||
|
fn game_events_are_filterable() {
|
||||||
|
let running = EventKind::GameRunning {
|
||||||
|
game: GameRefPayload {
|
||||||
|
app: Some("steam:570".into()),
|
||||||
|
title: "Dota 2".into(),
|
||||||
|
store: Some("steam".into()),
|
||||||
|
client: "Deck".into(),
|
||||||
|
plane: Plane::Native,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(running.name(), "game.running");
|
||||||
|
assert!(kind_matches("game.*", running.name()));
|
||||||
|
assert!(kind_matches("game.running", running.name()));
|
||||||
|
assert!(!kind_matches("gamestream.*", running.name()));
|
||||||
|
assert_eq!(running.client_name(), Some("Deck"));
|
||||||
|
assert_eq!(running.plane(), Some(Plane::Native));
|
||||||
|
assert_eq!(running.app(), Some("steam:570"));
|
||||||
|
|
||||||
|
// With no library id, the title is the filterable handle — otherwise an `apps.json` launch
|
||||||
|
// would be unmatchable by app.
|
||||||
|
let exited = EventKind::GameExited {
|
||||||
|
game: GameRefPayload {
|
||||||
|
app: None,
|
||||||
|
title: "Big Picture".into(),
|
||||||
|
store: None,
|
||||||
|
client: String::new(),
|
||||||
|
plane: Plane::Gamestream,
|
||||||
|
},
|
||||||
|
reason: GameEndReason::Exited,
|
||||||
|
};
|
||||||
|
assert_eq!(exited.app(), Some("Big Picture"));
|
||||||
|
assert_eq!(exited.fingerprint(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,10 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
|||||||
// One-shot latch for the HDR-mode control message (0x010e); re-armed on Disconnect.
|
// One-shot latch for the HDR-mode control message (0x010e); re-armed on Disconnect.
|
||||||
let mut hdr_sent = false;
|
let mut hdr_sent = false;
|
||||||
let mut peer: Option<PeerID> = None;
|
let mut peer: Option<PeerID> = None;
|
||||||
|
// The session's GCM key, remembered from the last tick it was live. Ending a session
|
||||||
|
// clears `launch` — and the key lives there — so without this copy the one message that
|
||||||
|
// has to go out *because* the session ended could no longer be sealed.
|
||||||
|
let mut last_key: Option<[u8; 16]> = None;
|
||||||
loop {
|
loop {
|
||||||
loop {
|
loop {
|
||||||
match host.service() {
|
match host.service() {
|
||||||
@@ -154,6 +158,56 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A session can also end from the HOST side: the launched game exited, the operator
|
||||||
|
// stopped it, or `/cancel` arrived on another connection. Tearing the media threads
|
||||||
|
// down is *silence*, not a signal — Moonlight holds this control stream for the
|
||||||
|
// whole session, so with no word from us it sits on its last frame until its own
|
||||||
|
// timeout eventually fires. The client froze instead of ending (on-glass, .173).
|
||||||
|
//
|
||||||
|
// Disconnecting the peer is how it learns. Moonlight treats a control-stream
|
||||||
|
// disconnect as the session being over and returns to its app list — the same place
|
||||||
|
// it lands when the user quits, which is exactly where "the game exited" should
|
||||||
|
// leave them.
|
||||||
|
//
|
||||||
|
// Merely dropping the peer is not enough either: the client reports that as a
|
||||||
|
// connection error (`-1` on glass, .173), because an ENet disconnect on its own is
|
||||||
|
// indistinguishable from the host falling over. The protocol has a word for this —
|
||||||
|
// a TERMINATION control message — so send that first and let the disconnect follow.
|
||||||
|
//
|
||||||
|
// `end_session` clears `launch`, so a tracked peer with no launch behind it *is*
|
||||||
|
// the ended session. Clearing `peer` first makes this fire once; the real
|
||||||
|
// `Disconnect` event that follows then takes the non-session-peer branch, which is
|
||||||
|
// why this arm repeats that branch's per-connection cleanup.
|
||||||
|
if let Some(pid) = peer {
|
||||||
|
let ended = state
|
||||||
|
.launch
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.is_none();
|
||||||
|
if ended {
|
||||||
|
// Only sealable once the client's scheme is known; without it, fall through
|
||||||
|
// to the bare disconnect rather than send a packet it can't read.
|
||||||
|
if let (Some(scheme), Some(key)) = (detected, last_key) {
|
||||||
|
let pt = termination_plaintext();
|
||||||
|
let wire = encrypt_control(&key, &scheme, host_seq, &pt);
|
||||||
|
host_seq = host_seq.wrapping_add(1);
|
||||||
|
if let Err(e) = host.peer_mut(pid).send(0, &Packet::reliable(&wire[..]))
|
||||||
|
{
|
||||||
|
tracing::warn!(error = ?e, "control: termination send failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!("control: the session ended — telling the client");
|
||||||
|
// `disconnect_later` flushes the queued termination first; a plain
|
||||||
|
// `disconnect` would race it off the wire and land us back at "-1".
|
||||||
|
host.peer_mut(pid).disconnect_later(0);
|
||||||
|
peer = None;
|
||||||
|
detected = None;
|
||||||
|
decrypt_fails = 0;
|
||||||
|
hdr_sent = false;
|
||||||
|
pads = GamepadManager::new();
|
||||||
|
pointer = super::pen::GsPointer::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
// Service the pads' force-feedback protocol every tick (games block inside
|
// Service the pads' force-feedback protocol every tick (games block inside
|
||||||
// EVIOCSFF until answered) and relay mixed rumble levels to the client.
|
// EVIOCSFF until answered) and relay mixed rumble levels to the client.
|
||||||
//
|
//
|
||||||
@@ -171,6 +225,10 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
|||||||
// use the native punktfunk/1 plane (correct per-direction nonces + seq-as-AAD).
|
// use the native punktfunk/1 plane (correct per-direction nonces + seq-as-AAD).
|
||||||
if let (Some(pid), Some(scheme)) = (peer, detected) {
|
if let (Some(pid), Some(scheme)) = (peer, detected) {
|
||||||
let key = state.launch.lock().unwrap().map(|s| s.gcm_key);
|
let key = state.launch.lock().unwrap().map(|s| s.gcm_key);
|
||||||
|
// Remember it for the teardown message (see `last_key`).
|
||||||
|
if key.is_some() {
|
||||||
|
last_key = key;
|
||||||
|
}
|
||||||
if let Some(key) = key {
|
if let Some(key) = key {
|
||||||
let mut out: Vec<Vec<u8>> = Vec::new();
|
let mut out: Vec<Vec<u8>> = Vec::new();
|
||||||
// One-shot HDR-mode signal (type 0x010e / Sunshine `IDX_HDR_MODE`) once the
|
// One-shot HDR-mode signal (type 0x010e / Sunshine `IDX_HDR_MODE`) once the
|
||||||
@@ -547,6 +605,42 @@ fn hdr_mode_plaintext(enabled: bool, m: &HdrMeta) -> Vec<u8> {
|
|||||||
pt
|
pt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the host→client TERMINATION control plaintext — "the session is over, on purpose".
|
||||||
|
///
|
||||||
|
/// Without it, ending a session host-side is invisible on the wire: the media threads simply stop,
|
||||||
|
/// which the client cannot tell from a host that fell over, so it either sits on its last frame or
|
||||||
|
/// (once we disconnect the peer) reports a connection error. This is the message that makes it a
|
||||||
|
/// clean end, and the client returns to its app list.
|
||||||
|
///
|
||||||
|
/// Verified against moonlight-common-c `ControlStream.c` (master, 2026-07-26):
|
||||||
|
///
|
||||||
|
/// * **Type `0x0109`**, from `packetTypesGen7Enc[IDX_TERMINATION]`. Which table the client reads is
|
||||||
|
/// decided by ONE thing — the version we advertise:
|
||||||
|
/// `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1, 431)`, and
|
||||||
|
/// [`super::APP_VERSION`] is exactly `7.1.431`. So every client is on the encrypted table, where
|
||||||
|
/// termination is `0x0109`; the plain table's `0x0100` would be ignored.
|
||||||
|
///
|
||||||
|
/// This is *not* the same axis as [`NonceKind`], which only describes how the GCM nonce is built.
|
||||||
|
/// Deriving the type from the nonce scheme looks reasonable and is wrong — it sent `0x0100` to a
|
||||||
|
/// client reading the encrypted table, which ignored it silently and reported the session as `-1`
|
||||||
|
/// (on glass, .173). The HDR message could never have caught this: `0x010e` in both tables.
|
||||||
|
/// * **Payload** is a big-endian `u32` reason (the ≥6-byte "extended" branch; the short branch is a
|
||||||
|
/// little-endian `u16`, which is GFE's older shape).
|
||||||
|
/// * **`0x80030023`** is `NVST_DISCONN_SERVER_TERMINATED_CLOSED`, which the client maps to
|
||||||
|
/// `ML_ERROR_GRACEFUL_TERMINATION` *provided it has seen a frame* — true for any real session,
|
||||||
|
/// and the reason this reads as "the app quit" rather than an error.
|
||||||
|
fn termination_plaintext() -> Vec<u8> {
|
||||||
|
/// `packetTypesGen7Enc[IDX_TERMINATION]` — see the version gate above.
|
||||||
|
const TERMINATION: u16 = 0x0109;
|
||||||
|
/// `NVST_DISCONN_SERVER_TERMINATED_CLOSED` — a deliberate, graceful host-side end.
|
||||||
|
const GRACEFUL: u32 = 0x8003_0023;
|
||||||
|
let mut pt = Vec::with_capacity(8);
|
||||||
|
pt.extend_from_slice(&TERMINATION.to_le_bytes());
|
||||||
|
pt.extend_from_slice(&4u16.to_le_bytes()); // length = the reason that follows
|
||||||
|
pt.extend_from_slice(&GRACEFUL.to_be_bytes()); // big-endian: the extended branch
|
||||||
|
pt
|
||||||
|
}
|
||||||
|
|
||||||
/// Seal a host→client control message, mirroring the client's `detected` scheme with the
|
/// Seal a host→client control message, mirroring the client's `detected` scheme with the
|
||||||
/// direction flipped: V2 nonces use marker `H?` (host-originated) instead of `C?`; legacy
|
/// direction flipped: V2 nonces use marker `H?` (host-originated) instead of `C?`; legacy
|
||||||
/// nonces keep their construction with our own independent `seq` counter. Wire layout matches
|
/// nonces keep their construction with our own independent `seq` counter. Wire layout matches
|
||||||
@@ -648,6 +742,41 @@ mod tests {
|
|||||||
assert_eq!(decode_rfi_range(&rfi_msg(9, 4)), None); // last < first
|
assert_eq!(decode_rfi_range(&rfi_msg(9, 4)), None); // last < first
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The termination plaintext is what turns "the host went quiet" into "the app quit" on the
|
||||||
|
/// client. Getting the type from the wrong table, or the reason's byte order wrong, is silently
|
||||||
|
/// ignored by the client — which reads on glass as a frozen stream or a `-1` error, not as a
|
||||||
|
/// failed test. Pinned against moonlight-common-c `ControlStream.c`.
|
||||||
|
#[test]
|
||||||
|
fn termination_plaintext_wire_layout() {
|
||||||
|
let pt = super::termination_plaintext();
|
||||||
|
assert_eq!(pt.len(), 8);
|
||||||
|
// The ENCRYPTED table's entry — which is the one every client reads, see below.
|
||||||
|
assert_eq!(&pt[0..2], &0x0109u16.to_le_bytes());
|
||||||
|
assert_eq!(&pt[2..4], &4u16.to_le_bytes());
|
||||||
|
// The reason is BIG-endian: the client's >=6-byte "extended" branch.
|
||||||
|
assert_eq!(&pt[4..8], &0x8003_0023u32.to_be_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The termination type above is only correct because of the version we advertise:
|
||||||
|
/// moonlight-common-c sets `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1, 431)`, and that
|
||||||
|
/// alone decides whether the client reads `packetTypesGen7Enc` (termination `0x0109`) or
|
||||||
|
/// `packetTypesGen7` (`0x0100`). Drop below that version and the message silently stops being
|
||||||
|
/// understood — the stream would end as an error again, with nothing failing here to say why.
|
||||||
|
#[test]
|
||||||
|
fn advertised_version_keeps_the_client_on_the_encrypted_packet_table() {
|
||||||
|
let q: Vec<i32> = super::super::APP_VERSION
|
||||||
|
.split('.')
|
||||||
|
.map(|p| p.parse().unwrap_or(0))
|
||||||
|
.collect();
|
||||||
|
let at_least = q[0] > 7 || (q[0] == 7 && (q[1] > 1 || (q[1] == 1 && q[2] >= 431)));
|
||||||
|
assert!(
|
||||||
|
at_least,
|
||||||
|
"APP_VERSION {} is below 7.1.431, so clients read the PLAIN packet table and \
|
||||||
|
termination must become 0x0100",
|
||||||
|
super::super::APP_VERSION
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The HDR-mode plaintext must match what moonlight-common-c parses: `[u16 type=0x010e]
|
/// The HDR-mode plaintext must match what moonlight-common-c parses: `[u16 type=0x010e]
|
||||||
/// [u16 length=27][u8 enable][SS_HDR_METADATA]`, 31 bytes, all little-endian, primaries R,G,B.
|
/// [u16 length=27][u8 enable][SS_HDR_METADATA]`, 31 bytes, all little-endian, primaries R,G,B.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -156,6 +156,15 @@ pub struct AppState {
|
|||||||
pub audio_params: std::sync::Mutex<audio::AudioParams>,
|
pub audio_params: std::sync::Mutex<audio::AudioParams>,
|
||||||
/// True while the video stream thread is running (also its keep-running flag).
|
/// True while the video stream thread is running (also its keep-running flag).
|
||||||
pub streaming: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
pub streaming: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||||
|
/// Whether the current session is ending **deliberately** — the compat plane's answer to the
|
||||||
|
/// native plane's `QUIT_CODE` close, which RTSP has no equivalent of.
|
||||||
|
///
|
||||||
|
/// Set by the three things that mean "this is over": the client's `/cancel` (Moonlight's Quit
|
||||||
|
/// App), the management API's stop, and the launched game exiting. An ENet vanish or an
|
||||||
|
/// unreachable client leaves it clear — those are drops, and the difference decides whether the
|
||||||
|
/// virtual display skips its keep-alive linger and whether the end-game policy sees an intent or
|
||||||
|
/// a network blip. Cleared by `/launch`, which is where a session begins.
|
||||||
|
pub quit: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||||
/// True while the audio stream thread is running (also its keep-running flag).
|
/// True while the audio stream thread is running (also its keep-running flag).
|
||||||
pub audio_streaming: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
pub audio_streaming: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||||
/// Set by the control stream when the client requests an IDR / invalidates reference
|
/// Set by the control stream when the client requests an IDR / invalidates reference
|
||||||
@@ -222,6 +231,17 @@ impl AppState {
|
|||||||
was_streaming
|
was_streaming
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// End the session as a **decision** rather than a drop: mark it deliberate, then tear it down.
|
||||||
|
///
|
||||||
|
/// This is what a client's `/cancel`, the management stop, and a launched game's exit all use.
|
||||||
|
/// The flag is read by the virtual display's keep-alive lease (skip the linger — nobody is coming
|
||||||
|
/// back) and, at the video thread's teardown, by the end-game-on-session-end policy (which gives a
|
||||||
|
/// mere drop a reconnect window first). See [`AppState::quit`].
|
||||||
|
pub(crate) fn quit_session(&self, reason: &str) -> bool {
|
||||||
|
self.quit.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
self.end_session(reason)
|
||||||
|
}
|
||||||
|
|
||||||
/// Fresh control-plane state: no active session; the pairing allow-list is loaded from
|
/// Fresh control-plane state: no active session; the pairing allow-list is loaded from
|
||||||
/// disk (pairings persist across restarts). `stats` is the shared recorder handed to both the
|
/// disk (pairings persist across restarts). `stats` is the shared recorder handed to both the
|
||||||
/// mgmt API and the streaming loops.
|
/// mgmt API and the streaming loops.
|
||||||
@@ -239,6 +259,7 @@ impl AppState {
|
|||||||
stream: std::sync::Mutex::new(None),
|
stream: std::sync::Mutex::new(None),
|
||||||
audio_params: std::sync::Mutex::new(audio::AudioParams::default()),
|
audio_params: std::sync::Mutex::new(audio::AudioParams::default()),
|
||||||
streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
|
quit: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
audio_streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
audio_streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
force_idr: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
force_idr: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
rfi_range: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
rfi_range: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||||
@@ -529,6 +550,32 @@ mod session_tests {
|
|||||||
// Idempotent: a second end (e.g. `/cancel` racing the ENet Disconnect) is a no-op.
|
// Idempotent: a second end (e.g. `/cancel` racing the ENet Disconnect) is a no-op.
|
||||||
assert!(!state.end_session("test again"));
|
assert!(!state.end_session("test again"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The compat plane has no close code, so the difference between "the player stopped" and "the
|
||||||
|
/// client vanished" lives entirely in this flag — and it decides whether a display lingers and
|
||||||
|
/// whether an operator's end-game policy sees a decision or a network blip. A teardown that
|
||||||
|
/// forgets to set it silently downgrades a deliberate stop to a drop.
|
||||||
|
#[test]
|
||||||
|
fn quit_marks_a_teardown_deliberate_and_a_plain_end_does_not() {
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
let state = test_state();
|
||||||
|
assert!(
|
||||||
|
!state.quit.load(Ordering::SeqCst),
|
||||||
|
"a fresh session is undecided"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A drop (ENet vanish / unreachable client) must leave it clear.
|
||||||
|
state.streaming.store(true, Ordering::SeqCst);
|
||||||
|
state.end_session("client unreachable");
|
||||||
|
assert!(!state.quit.load(Ordering::SeqCst));
|
||||||
|
|
||||||
|
// `/cancel`, the management stop and a game exiting all go through `quit_session`.
|
||||||
|
state.streaming.store(true, Ordering::SeqCst);
|
||||||
|
assert!(state.quit_session("client /cancel"), "video was live");
|
||||||
|
assert!(state.quit.load(Ordering::SeqCst));
|
||||||
|
// …and it still performs the full teardown.
|
||||||
|
assert!(!state.streaming.load(Ordering::SeqCst));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, unix))]
|
#[cfg(all(test, unix))]
|
||||||
|
|||||||
@@ -201,6 +201,11 @@ async fn h_launch(
|
|||||||
session.height = h;
|
session.height = h;
|
||||||
session.fps = f;
|
session.fps = f;
|
||||||
}
|
}
|
||||||
|
// A new session starts undecided: whatever ended the last one says nothing about this
|
||||||
|
// one (see `AppState::quit`). The game this launch reclaims — if this client left one
|
||||||
|
// waiting out its reconnect window — is reprieved by the stream thread, which resolves
|
||||||
|
// the title anyway and so needs no second library scan here.
|
||||||
|
st.quit.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||||
*st.launch.lock().unwrap() = Some(session);
|
*st.launch.lock().unwrap() = Some(session);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
w = session.width,
|
w = session.width,
|
||||||
@@ -250,9 +255,12 @@ async fn h_cancel(
|
|||||||
tracing::warn!("cancel rejected — caller does not own the session");
|
tracing::warn!("cancel rejected — caller does not own the session");
|
||||||
return xml(error_xml());
|
return xml(error_xml());
|
||||||
}
|
}
|
||||||
// Quit semantics: the shared full teardown (launch cleared + both media threads stop on
|
// Quit semantics, and now literally so: `/cancel` is Moonlight's "Quit App" — a decision, not a
|
||||||
// their flags) — the virtual output/gamescope teardown follows via the capturer's RAII.
|
// drop. The shared full teardown (launch cleared + both media threads stop on their flags) runs
|
||||||
st.end_session("client /cancel");
|
// with the session's quit flag set, so the virtual display skips its keep-alive linger and the
|
||||||
|
// end-game-on-session-end policy treats this as the operator asking. The virtual
|
||||||
|
// output/gamescope teardown follows via the capturer's RAII.
|
||||||
|
st.quit_session("client /cancel");
|
||||||
xml("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\"><cancel>1</cancel></root>\n".to_string())
|
xml("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\"><cancel>1</cancel></root>\n".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -279,6 +279,20 @@ fn handle_request(req: &Request, state: &Arc<AppState>, peer: Option<SocketAddr>
|
|||||||
state.video_cap.clone(),
|
state.video_cap.clone(),
|
||||||
state.stats.clone(),
|
state.stats.clone(),
|
||||||
on_lost.clone(),
|
on_lost.clone(),
|
||||||
|
// The launched game's lifetime wiring. A game *exiting* is a deliberate end
|
||||||
|
// (the player finished) — the same distinction the native plane draws with
|
||||||
|
// its close code, and what the end-game-on-session-end policy keys off at
|
||||||
|
// teardown.
|
||||||
|
stream::GameLifetime {
|
||||||
|
quit: state.quit.clone(),
|
||||||
|
fingerprint: ls.owner_fp.map(hex::encode),
|
||||||
|
on_game_exit: {
|
||||||
|
let st = state.clone();
|
||||||
|
Arc::new(move || {
|
||||||
|
st.quit_session("game exited");
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Some(_) => tracing::info!("RTSP PLAY — stream already running"),
|
Some(_) => tracing::info!("RTSP PLAY — stream already running"),
|
||||||
@@ -450,6 +464,25 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
|
|||||||
);
|
);
|
||||||
hdr = false;
|
hdr = false;
|
||||||
}
|
}
|
||||||
|
// Second half of the same live gate: the process-wide HDR-capture latch. The colour-mode probe
|
||||||
|
// above only says the monitor is in BT.2100 RIGHT NOW — it says nothing about whether the
|
||||||
|
// portal will actually hand us the 10-bit PQ formats. Once a `want_hdr` negotiation has failed
|
||||||
|
// (the monitor left HDR mode between probe and negotiation, NVIDIA EGL not listing LINEAR for
|
||||||
|
// XR30, a pre-50 Mutter), `pf_capture::open_portal_monitor` permanently drops the HDR OFFER and
|
||||||
|
// captures SDR. Without this check we'd keep telling the client HDR while capturing and
|
||||||
|
// encoding SDR: it renders an SDR picture through PQ — washed out, wrong gamut, no error
|
||||||
|
// anywhere — and because the latch is sticky until host restart, every reconnect repeats it.
|
||||||
|
// Consulted here rather than folded into `host_hdr_capable` for the same reason as the probe
|
||||||
|
// above: that fn is the STATIC serverinfo capability, and this is a live per-session fact.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if hdr && pf_capture::hdr_capture_failed() {
|
||||||
|
tracing::warn!(
|
||||||
|
"client requested HDR and a monitor is in BT.2100 (HDR) colour mode, but an earlier \
|
||||||
|
HDR capture negotiation on this host failed — the capturer offers SDR only for the \
|
||||||
|
rest of the process lifetime, so streaming 8-bit SDR (restart the host to retry HDR)"
|
||||||
|
);
|
||||||
|
hdr = false;
|
||||||
|
}
|
||||||
// The client's requested CSC (moonlight-common-c SdpGenerator.c: `encoderCscMode =
|
// The client's requested CSC (moonlight-common-c SdpGenerator.c: `encoderCscMode =
|
||||||
// (colorspace << 1) | fullRange` — colorspace 0=Rec601, 1=Rec709, 2=Rec2020). Moonlight
|
// (colorspace << 1) | fullRange` — colorspace 0=Rec601, 1=Rec709, 2=Rec2020). Moonlight
|
||||||
// renderers configure their YUV→RGB from this REQUESTED value (not the bitstream VUI), so a
|
// renderers configure their YUV→RGB from this REQUESTED value (not the bitstream VUI), so a
|
||||||
|
|||||||
@@ -33,15 +33,34 @@ pub struct StreamConfig {
|
|||||||
pub hdr: bool,
|
pub hdr: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A pooled capturer plus the two PipeWire-negotiation-time properties reuse must match on —
|
||||||
|
/// its HDR-ness and its metadata-cursor mode; a mismatch on either needs a fresh screencast
|
||||||
|
/// session (see `AppState::video_cap`).
|
||||||
|
pub type PooledCapturer = (Box<dyn Capturer>, bool, bool);
|
||||||
|
|
||||||
/// Slot for the persistent screen capturer, shared with the control plane and reused across
|
/// Slot for the persistent screen capturer, shared with the control plane and reused across
|
||||||
/// streams so a reconnect doesn't open a second (conflicting) screencast session. The `bool` is
|
/// streams so a reconnect doesn't open a second (conflicting) screencast session.
|
||||||
/// the pooled capturer's HDR-ness (see `AppState::video_cap`).
|
pub type CapturerSlot = Arc<std::sync::Mutex<Option<PooledCapturer>>>;
|
||||||
pub type CapturerSlot = Arc<std::sync::Mutex<Option<(Box<dyn Capturer>, bool)>>>;
|
|
||||||
|
|
||||||
/// A pending client reference-frame-invalidation range (lost `firstFrame..=lastFrame`), set by the
|
/// A pending client reference-frame-invalidation range (lost `firstFrame..=lastFrame`), set by the
|
||||||
/// control plane and drained by the video thread (see [`AppState::rfi_range`](super::AppState)).
|
/// control plane and drained by the video thread (see [`AppState::rfi_range`](super::AppState)).
|
||||||
pub type RfiSlot = Arc<std::sync::Mutex<Option<(i64, i64)>>>;
|
pub type RfiSlot = Arc<std::sync::Mutex<Option<(i64, i64)>>>;
|
||||||
|
|
||||||
|
/// What the stream thread needs to give the launched game a lifetime
|
||||||
|
/// (design/session-game-lifetime.md). Bundled because the three only exist together — the control
|
||||||
|
/// plane builds them from the live `AppState` at RTSP PLAY, and the stream thread is where they are
|
||||||
|
/// spent.
|
||||||
|
pub struct GameLifetime {
|
||||||
|
/// The session's deliberate-quit flag ([`super::AppState::quit`]), read when the stream ends: a
|
||||||
|
/// decision may end the game, a drop gets a reconnect window first.
|
||||||
|
pub quit: Arc<AtomicBool>,
|
||||||
|
/// Hex cert fingerprint of the paired client that owns the launch, so only it can reclaim its own
|
||||||
|
/// game. `None` when the peer cert couldn't be read.
|
||||||
|
pub fingerprint: Option<String>,
|
||||||
|
/// Ends the whole session, deliberately — the action for "the launched game exited".
|
||||||
|
pub on_game_exit: super::OnSessionLost,
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn the video stream thread (idempotent via `running`). Stops when `running` clears.
|
/// Spawn the video stream thread (idempotent via `running`). Stops when `running` clears.
|
||||||
/// `force_idr` is set by the control stream on a client recovery request; `video_cap` holds
|
/// `force_idr` is set by the control stream on a client recovery request; `video_cap` holds
|
||||||
/// the persistent capturer the thread borrows for the stream's duration.
|
/// the persistent capturer the thread borrows for the stream's duration.
|
||||||
@@ -55,6 +74,7 @@ pub fn start(
|
|||||||
video_cap: CapturerSlot,
|
video_cap: CapturerSlot,
|
||||||
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||||
on_lost: super::OnSessionLost,
|
on_lost: super::OnSessionLost,
|
||||||
|
life: GameLifetime,
|
||||||
) {
|
) {
|
||||||
let _ = std::thread::Builder::new()
|
let _ = std::thread::Builder::new()
|
||||||
.name("punktfunk-video".into())
|
.name("punktfunk-video".into())
|
||||||
@@ -106,6 +126,7 @@ pub fn start(
|
|||||||
&video_cap,
|
&video_cap,
|
||||||
&stats,
|
&stats,
|
||||||
&on_lost,
|
&on_lost,
|
||||||
|
&life,
|
||||||
);
|
);
|
||||||
// A clean return is a stop (RTSP teardown / cancel / client unreachable) → `quit`;
|
// A clean return is a stop (RTSP teardown / cancel / client unreachable) → `quit`;
|
||||||
// an error return is `error`. The compat plane can't tell a user stop from an idle
|
// an error return is `error`. The compat plane can't tell a user stop from an idle
|
||||||
@@ -136,12 +157,14 @@ fn run(
|
|||||||
running: &Arc<AtomicBool>,
|
running: &Arc<AtomicBool>,
|
||||||
force_idr: &AtomicBool,
|
force_idr: &AtomicBool,
|
||||||
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>,
|
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>,
|
||||||
video_cap: &std::sync::Mutex<Option<(Box<dyn Capturer>, bool)>>,
|
video_cap: &std::sync::Mutex<Option<PooledCapturer>>,
|
||||||
// Shared stats recorder for the web-console capture/graph. Threaded into `stream_body` (the
|
// Shared stats recorder for the web-console capture/graph. Threaded into `stream_body` (the
|
||||||
// encode loop); per-frame sample emission is wired by a later pass.
|
// encode loop); per-frame sample emission is wired by a later pass.
|
||||||
stats: &Arc<crate::stats_recorder::StatsRecorder>,
|
stats: &Arc<crate::stats_recorder::StatsRecorder>,
|
||||||
// Whole-session teardown for the send thread's client-unreachable detection.
|
// Whole-session teardown for the send thread's client-unreachable detection.
|
||||||
on_lost: &super::OnSessionLost,
|
on_lost: &super::OnSessionLost,
|
||||||
|
// The launched game's lifetime wiring (quit flag, launch owner, game-exit teardown).
|
||||||
|
life: &GameLifetime,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// GameStream capture/encode thread: apply Windows session tuning (no-op off Windows).
|
// GameStream capture/encode thread: apply Windows session tuning (no-op off Windows).
|
||||||
pf_frame::session_tuning::on_hot_thread();
|
pf_frame::session_tuning::on_hot_thread();
|
||||||
@@ -181,6 +204,29 @@ fn run(
|
|||||||
// `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the
|
// `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the
|
||||||
// output is released when this capturer drops at stream end (RAII via its keepalive).
|
// output is released when this capturer drops at stream end (RAII via its keepalive).
|
||||||
if pf_host_config::config().video_source.as_deref() == Some("virtual") {
|
if pf_host_config::config().video_source.as_deref() == Some("virtual") {
|
||||||
|
// Reference point for adopting the launched game's processes: anything the host will call
|
||||||
|
// "this session's game" has to have started after this instant. Taken HERE — before the prep
|
||||||
|
// steps, before the source (a bare-spawn gamescope nests the game inside it), before the
|
||||||
|
// launch — because a reading taken later would reject the very process it is meant to find.
|
||||||
|
// Erring early can only ever include more of our own launch, never a copy from before it.
|
||||||
|
let launch_stamp = crate::gamelease::launch_clock();
|
||||||
|
// Everything the host knows about the title being launched, resolved in ONE library scan:
|
||||||
|
// what to run, what to call it, and how to recognize it once it is up.
|
||||||
|
let target = resolve_gs_app(app);
|
||||||
|
// Moonlight has no session resume, so a client coming back for a game it left behind does it
|
||||||
|
// by launching the title again. Reprieve that game before anything starts, so the copy the
|
||||||
|
// player is about to be handed isn't killed out from under them when the old window closes.
|
||||||
|
if let Some(t) = target.as_ref() {
|
||||||
|
let reprieved =
|
||||||
|
crate::gamelease::readopt(life.fingerprint.as_deref(), t.game.id.as_deref());
|
||||||
|
if reprieved > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
reprieved,
|
||||||
|
title = %t.game.title,
|
||||||
|
"gamestream: this client came back for its game — keeping it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Per-app prep steps (RFC §6): the entry's own `prep` plus a custom library title's,
|
// Per-app prep steps (RFC §6): the entry's own `prep` plus a custom library title's,
|
||||||
// run synchronously BEFORE the virtual output opens or anything launches (an HDR
|
// run synchronously BEFORE the virtual output opens or anything launches (an HDR
|
||||||
// toggle / sink switch must land first — and gamescope's nested launch happens inside
|
// toggle / sink switch must land first — and gamescope's nested launch happens inside
|
||||||
@@ -200,7 +246,8 @@ fn run(
|
|||||||
// exactly like the native plane), create a virtual output at the client mode, and capture it.
|
// exactly like the native plane), create a virtual output at the client mode, and capture it.
|
||||||
// Re-runnable: the encode loop calls it again on a mid-stream capture loss to FOLLOW a
|
// Re-runnable: the encode loop calls it again on a mid-stream capture loss to FOLLOW a
|
||||||
// Desktop<->Game switch.
|
// Desktop<->Game switch.
|
||||||
let (mut capturer, compositor) = open_gs_virtual_source(cfg, app)?;
|
let (mut capturer, compositor) =
|
||||||
|
open_gs_virtual_source(cfg, app, target.as_ref(), &life.quit)?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
?compositor,
|
?compositor,
|
||||||
app = ?app.map(|a| &a.title),
|
app = ?app.map(|a| &a.title),
|
||||||
@@ -217,39 +264,114 @@ fn run(
|
|||||||
// existing title, never inject a command). An apps.json entry instead carries an
|
// existing title, never inject a command). An apps.json entry instead carries an
|
||||||
// operator-typed `cmd`. Library id wins when both are set.
|
// operator-typed `cmd`. Library id wins when both are set.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
if let Some(t) = target.as_ref() {
|
||||||
if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) {
|
// A library title launches by its store-qualified id (the interactive-session spawner
|
||||||
if let Err(e) = crate::library::launch_gamestream_library(lib_id) {
|
// resolves the store's own recipe); an operator-typed command runs as itself.
|
||||||
tracing::warn!(library_id = lib_id, error = %e, "gamestream: could not launch library title");
|
let launched = match (t.game.id.as_deref(), t.command.as_deref()) {
|
||||||
}
|
(Some(id), _) => crate::library::launch_gamestream_library(id),
|
||||||
} else if let Some(cmd) = app
|
(None, Some(cmd)) => crate::library::launch_gamestream_command(cmd),
|
||||||
.and_then(|a| a.cmd.as_deref())
|
(None, None) => Ok(()),
|
||||||
.filter(|c| !c.trim().is_empty())
|
};
|
||||||
{
|
if let Err(e) = launched {
|
||||||
if let Err(e) = crate::library::launch_gamestream_command(cmd) {
|
tracing::warn!(title = %t.game.title, error = %e, "gamestream: could not launch app");
|
||||||
tracing::warn!(command = %cmd, error = %e, "gamestream: could not launch app");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Linux keeps the spawned child rather than dropping it: it is the primary liveness signal
|
||||||
|
// for a title whose store told us nothing else, and the handle the termination ladder
|
||||||
|
// signals. A gamescope bare spawn already nested the command (`set_launch_command` in the
|
||||||
|
// source open), so launching again would start it twice.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
if !crate::vdisplay::launch_is_nested(compositor) {
|
let spawned_launch = match target.as_ref().and_then(|t| t.command.as_deref()) {
|
||||||
if let Some(cmd) = crate::library::resolve_session_launch(
|
Some(_) if crate::vdisplay::launch_is_nested(compositor) => None,
|
||||||
app.and_then(|a| a.library_id.as_deref()),
|
Some(cmd) => match crate::library::launch_session_command(compositor, cmd) {
|
||||||
app.and_then(|a| a.cmd.as_deref()),
|
Ok(spawned) => Some(spawned),
|
||||||
) {
|
Err(e) => {
|
||||||
if let Err(e) = crate::library::launch_session_command(compositor, &cmd) {
|
|
||||||
tracing::warn!(command = %cmd, error = %e, "gamestream: could not launch app");
|
tracing::warn!(command = %cmd, error = %e, "gamestream: could not launch app");
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The launched game's lifetime, in both directions (design/session-game-lifetime.md) — the
|
||||||
|
// compat plane's half of what the native plane already does:
|
||||||
|
//
|
||||||
|
// * **its exit ends the session**, so Moonlight returns to its app list instead of leaving
|
||||||
|
// the player on a bare desktop or a hidden launcher.
|
||||||
|
// * **this session ending can end it** — never by default; only when the operator asked, and
|
||||||
|
// for a mere drop only after a reconnect window (the guard's drop). Moonlight can't resume
|
||||||
|
// a session, but the window still protects unsaved progress on a network blip, and a
|
||||||
|
// relaunch of the same title reclaims the game (above).
|
||||||
|
let _game_life = target.as_ref().map(|t| {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let nested = crate::vdisplay::launch_is_nested(compositor);
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let nested = false;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let child = spawned_launch.map(|s| (s.child, s.group_leader));
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let child = None;
|
||||||
|
|
||||||
|
let on_exit: crate::gamelease::OnExit = {
|
||||||
|
let on_game_exit = life.on_game_exit.clone();
|
||||||
|
Box::new(move || {
|
||||||
|
// Read the setting at fire time, so flipping it mid-session takes effect. The
|
||||||
|
// lease itself keeps running either way — the status surface still reports the
|
||||||
|
// game.
|
||||||
|
if !crate::session_settings::get().session_on_game_exit {
|
||||||
|
tracing::info!(
|
||||||
|
"the launched game exited, but ending the session on game exit is off — \
|
||||||
|
leaving the stream up"
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
tracing::info!("the launched game exited — ending the session");
|
||||||
|
// Deliberate: the player finished. The display skips its keep-alive linger and
|
||||||
|
// the launch state is cleared, so Moonlight's next `/launch` starts cleanly.
|
||||||
|
on_game_exit();
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let lease = crate::gamelease::open(
|
||||||
|
crate::gamelease::LeaseRequest {
|
||||||
|
game: t.game.clone(),
|
||||||
|
// RTSP carries no client device name, so the peer IP is the best label there is
|
||||||
|
// (the same one the stats capture uses).
|
||||||
|
client: client_label.clone(),
|
||||||
|
plane: crate::events::Plane::Gamestream,
|
||||||
|
spec: t.detect.clone(),
|
||||||
|
nested,
|
||||||
|
child,
|
||||||
|
launch_stamp,
|
||||||
|
},
|
||||||
|
on_exit,
|
||||||
|
);
|
||||||
|
// Declared first so it drops first: the console loses the live row before the policy
|
||||||
|
// below can replace it with a `grace` one, rather than briefly showing both.
|
||||||
|
let published = crate::session_status::publish_gamestream_game(lease.shared());
|
||||||
|
(
|
||||||
|
published,
|
||||||
|
crate::gamelease::SessionGuard::new(
|
||||||
|
lease,
|
||||||
|
life.quit.clone(),
|
||||||
|
life.fingerprint.clone(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
});
|
||||||
// Rebuild closure: re-open the source on a mid-stream capture loss, RE-DETECTING the live
|
// Rebuild closure: re-open the source on a mid-stream capture loss, RE-DETECTING the live
|
||||||
// compositor — so a Desktop<->Game switch (at the client's fixed mode) is FOLLOWED in place
|
// compositor — so a Desktop<->Game switch (at the client's fixed mode) is FOLLOWED in place
|
||||||
// without a Moonlight reconnect. (A resolution change can't be followed mid-stream on
|
// without a Moonlight reconnect. (A resolution change can't be followed mid-stream on
|
||||||
// GameStream — WxH is locked at ANNOUNCE — but a session toggle keeps the negotiated mode.)
|
// GameStream — WxH is locked at ANNOUNCE — but a session toggle keeps the negotiated mode.)
|
||||||
let rebuild = || open_gs_virtual_source(cfg, app).map(|(c, _)| c);
|
let rebuild =
|
||||||
|
|| open_gs_virtual_source(cfg, app, target.as_ref(), &life.quit).map(|(c, _)| c);
|
||||||
return stream_body(
|
return stream_body(
|
||||||
&mut capturer,
|
&mut capturer,
|
||||||
Some(&rebuild),
|
Some(&rebuild),
|
||||||
|
// The virtual-output source never selects cursor-as-metadata (`set_hw_cursor` is
|
||||||
|
// never called → the compositor EMBEDS the pointer where it can), so the encoder
|
||||||
|
// is handed nothing to composite. gamescope remains the pointerless residual —
|
||||||
|
// its capture carries no cursor either way (the native plane's XFixes source is
|
||||||
|
// not wired on this plane).
|
||||||
|
false,
|
||||||
&sock,
|
&sock,
|
||||||
cfg,
|
cfg,
|
||||||
running,
|
running,
|
||||||
@@ -266,13 +388,34 @@ fn run(
|
|||||||
// pooled capturer's HDR-ness matching this stream's negotiated `cfg.hdr` — the depth is a
|
// pooled capturer's HDR-ness matching this stream's negotiated `cfg.hdr` — the depth is a
|
||||||
// PipeWire-negotiation-time property of the screencast session, so an HDR↔SDR change needs a
|
// PipeWire-negotiation-time property of the screencast session, so an HDR↔SDR change needs a
|
||||||
// fresh session (same pattern as the audio capturer's channel-count gate).
|
// fresh session (same pattern as the audio capturer's channel-count gate).
|
||||||
|
// Cursor-as-metadata only where the encode backend this session resolves to composites
|
||||||
|
// `frame.cursor` (the caps-aware negotiation — mirror of the native plane's); otherwise ask
|
||||||
|
// the portal to EMBED the pointer so no backend × cursor-mode combination streams
|
||||||
|
// cursorless. Synthetic frames carry no pointer either way.
|
||||||
|
let metadata_cursor = {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
// Same CUDA-payload prediction SessionPlan/`handshake::cursor_forward` make:
|
||||||
|
// the NVIDIA resolution plus the zero-copy master switch.
|
||||||
|
let cuda_planned =
|
||||||
|
!crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
|
||||||
|
crate::encode::cursor_blend_capable(cfg.codec, cuda_planned, cfg.hdr)
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
false
|
||||||
|
};
|
||||||
let pooled = match video_cap.lock().unwrap().take() {
|
let pooled = match video_cap.lock().unwrap().take() {
|
||||||
Some((c, was_hdr)) if was_hdr == cfg.hdr => Some(c),
|
Some((c, was_hdr, was_meta)) if was_hdr == cfg.hdr && was_meta == metadata_cursor => {
|
||||||
Some((c, was_hdr)) => {
|
Some(c)
|
||||||
|
}
|
||||||
|
Some((c, was_hdr, was_meta)) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
was_hdr,
|
was_hdr,
|
||||||
want_hdr = cfg.hdr,
|
want_hdr = cfg.hdr,
|
||||||
"video source: pooled capturer depth mismatch — opening a fresh screencast session"
|
was_metadata_cursor = was_meta,
|
||||||
|
want_metadata_cursor = metadata_cursor,
|
||||||
|
"video source: pooled capturer depth/cursor-mode mismatch — opening a fresh \
|
||||||
|
screencast session"
|
||||||
);
|
);
|
||||||
drop(c);
|
drop(c);
|
||||||
None
|
None
|
||||||
@@ -285,8 +428,13 @@ fn run(
|
|||||||
c
|
c
|
||||||
}
|
}
|
||||||
None if pf_host_config::config().video_source.as_deref() == Some("portal") => {
|
None if pf_host_config::config().video_source.as_deref() == Some("portal") => {
|
||||||
tracing::info!(hdr = cfg.hdr, "video source: portal desktop capture");
|
tracing::info!(
|
||||||
capture::open_portal_monitor(cfg.hdr).context("open portal capturer")?
|
hdr = cfg.hdr,
|
||||||
|
metadata_cursor,
|
||||||
|
"video source: portal desktop capture"
|
||||||
|
);
|
||||||
|
capture::open_portal_monitor(cfg.hdr, metadata_cursor)
|
||||||
|
.context("open portal capturer")?
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
tracing::info!("video source: synthetic test pattern");
|
tracing::info!("video source: synthetic test pattern");
|
||||||
@@ -298,6 +446,7 @@ fn run(
|
|||||||
let result = stream_body(
|
let result = stream_body(
|
||||||
&mut capturer,
|
&mut capturer,
|
||||||
None,
|
None,
|
||||||
|
metadata_cursor,
|
||||||
&sock,
|
&sock,
|
||||||
cfg,
|
cfg,
|
||||||
running,
|
running,
|
||||||
@@ -308,10 +457,85 @@ fn run(
|
|||||||
on_lost,
|
on_lost,
|
||||||
);
|
);
|
||||||
capturer.set_active(false);
|
capturer.set_active(false);
|
||||||
*video_cap.lock().unwrap() = Some((capturer, cfg.hdr));
|
// Re-pool ONLY a capturer that can still produce frames. Every terminal state of the portal
|
||||||
|
// backend is sticky (`Capturer::is_alive`): a dead zerocopy-import worker, an exited PipeWire
|
||||||
|
// thread, or a compositor that went away all make the NEXT stream fail at exactly the same
|
||||||
|
// point — and this path has no rebuild closure (unlike the virtual-output path above), so a
|
||||||
|
// re-admitted dead capturer wedged GameStream portal video permanently, at 10 s per reconnect
|
||||||
|
// attempt. Dropping it instead costs one fresh screencast session on the next connect. Note
|
||||||
|
// `result` may already be `Err` here, which is itself that signal. (`metadata_cursor` rides
|
||||||
|
// along as the second reuse key, beside HDR-ness — see `PooledCapturer`.)
|
||||||
|
if result.is_ok() && capturer.is_alive() {
|
||||||
|
*video_cap.lock().unwrap() = Some((capturer, cfg.hdr, metadata_cursor));
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
stream_failed = result.is_err(),
|
||||||
|
capturer_alive = capturer.is_alive(),
|
||||||
|
"video source: retiring the pooled capturer — the next stream opens a fresh screencast \
|
||||||
|
session"
|
||||||
|
);
|
||||||
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the compat plane resolved about the app a client launched: identity for the lease, the status
|
||||||
|
/// surface and the `game.*` events; the signals that recognize the running game; and the command to
|
||||||
|
/// run it.
|
||||||
|
struct GsApp {
|
||||||
|
game: crate::gamelease::GameRef,
|
||||||
|
detect: crate::library::DetectSpec,
|
||||||
|
/// The resolved shell command. `Some` on Linux, which runs it itself; `None` for a Windows
|
||||||
|
/// library title, which launches by id through the interactive-session spawner instead.
|
||||||
|
command: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a `/launch`ed catalog entry against the host's **own** library — the client sends only an
|
||||||
|
/// appid, and everything the session does with the title afterwards comes from what the host knows
|
||||||
|
/// about it.
|
||||||
|
///
|
||||||
|
/// A library pick carries its store's detect signals. An operator-typed `apps.json` command has no
|
||||||
|
/// library entry behind it, so its title is the whole identity and its own first token — when that is
|
||||||
|
/// an absolute executable — the only signal there is; the host spawns it directly anyway, so the child
|
||||||
|
/// is the primary tracking either way. `None` = nothing to launch (Desktop, or an unresolvable entry).
|
||||||
|
fn resolve_gs_app(app: Option<&super::apps::AppEntry>) -> Option<GsApp> {
|
||||||
|
let app = app?;
|
||||||
|
if let Some(id) = app.library_id.as_deref() {
|
||||||
|
match crate::library::resolve_launch(id) {
|
||||||
|
Some(t) => {
|
||||||
|
return Some(GsApp {
|
||||||
|
game: t.game,
|
||||||
|
detect: t.detect,
|
||||||
|
command: t.command,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Same fallback (and same warning) the plain command resolution has always had, so a
|
||||||
|
// client picking a stale title sees why nothing started.
|
||||||
|
None => tracing::warn!(
|
||||||
|
launch_id = id,
|
||||||
|
"requested launch id not in this host's library (or no launch recipe) — ignoring"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cmd = app
|
||||||
|
.cmd
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|c| !c.is_empty())?;
|
||||||
|
Some(GsApp {
|
||||||
|
game: crate::gamelease::GameRef {
|
||||||
|
id: None,
|
||||||
|
store: None,
|
||||||
|
title: if app.title.trim().is_empty() {
|
||||||
|
cmd.to_string()
|
||||||
|
} else {
|
||||||
|
app.title.clone()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
detect: crate::library::spec_from_command(cmd),
|
||||||
|
command: Some(cmd.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the virtual-display video source for a GameStream session: pick the LIVE compositor + normalize
|
/// Open the virtual-display video source for a GameStream session: pick the LIVE compositor + normalize
|
||||||
/// the session env (apply_session_env/apply_input_env — gamescope ATTACH/resize, KWin/Mutter
|
/// the session env (apply_session_env/apply_input_env — gamescope ATTACH/resize, KWin/Mutter
|
||||||
/// retargeting) exactly like the native plane (native.rs resolve_compositor), create a virtual
|
/// retargeting) exactly like the native plane (native.rs resolve_compositor), create a virtual
|
||||||
@@ -323,6 +547,12 @@ fn run(
|
|||||||
fn open_gs_virtual_source(
|
fn open_gs_virtual_source(
|
||||||
cfg: StreamConfig,
|
cfg: StreamConfig,
|
||||||
app: Option<&super::apps::AppEntry>,
|
app: Option<&super::apps::AppEntry>,
|
||||||
|
// The resolved title (see [`resolve_gs_app`]) — its command is what a bare-spawn gamescope nests
|
||||||
|
// and what decides whether this session is a dedicated game session at all. Resolved once by the
|
||||||
|
// caller, so a mid-stream rebuild can't re-resolve to something different.
|
||||||
|
launch: Option<&GsApp>,
|
||||||
|
// The session's deliberate-quit flag, handed to the display's keep-alive lease.
|
||||||
|
quit: &Arc<AtomicBool>,
|
||||||
) -> Result<(Box<dyn Capturer>, crate::vdisplay::Compositor)> {
|
) -> Result<(Box<dyn Capturer>, crate::vdisplay::Compositor)> {
|
||||||
let compositor = if let Some(c) = app.and_then(|a| a.compositor) {
|
let compositor = if let Some(c) = app.and_then(|a| a.compositor) {
|
||||||
c
|
c
|
||||||
@@ -349,11 +579,7 @@ fn open_gs_virtual_source(
|
|||||||
// id / apps.json command), under `game_session=dedicated` with gamescope available, gets its
|
// id / apps.json command), under `game_session=dedicated` with gamescope available, gets its
|
||||||
// own headless gamescope spawn at the client mode — same routing as the native plane. Gate on
|
// own headless gamescope spawn at the client mode — same routing as the native plane. Gate on
|
||||||
// the resolved command so an unresolvable entry falls back to auto routing (review #9).
|
// the resolved command so an unresolvable entry falls back to auto routing (review #9).
|
||||||
let has_launch = crate::library::resolve_session_launch(
|
let has_launch = launch.and_then(|t| t.command.as_deref()).is_some();
|
||||||
app.and_then(|a| a.library_id.as_deref()),
|
|
||||||
app.and_then(|a| a.cmd.as_deref()),
|
|
||||||
)
|
|
||||||
.is_some();
|
|
||||||
if crate::vdisplay::wants_dedicated_game_session(has_launch) {
|
if crate::vdisplay::wants_dedicated_game_session(has_launch) {
|
||||||
crate::vdisplay::apply_input_env(crate::vdisplay::Compositor::Gamescope, true);
|
crate::vdisplay::apply_input_env(crate::vdisplay::Compositor::Gamescope, true);
|
||||||
crate::vdisplay::Compositor::Gamescope
|
crate::vdisplay::Compositor::Gamescope
|
||||||
@@ -369,17 +595,12 @@ fn open_gs_virtual_source(
|
|||||||
};
|
};
|
||||||
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
|
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
|
||||||
// Carry the resolved launch command on the backend instance (per-session) rather than a
|
// Carry the resolved launch command on the backend instance (per-session) rather than a
|
||||||
// process-global env var, so concurrent sessions can't stomp each other's launch target. On
|
// process-global env var, so concurrent sessions can't stomp each other's launch target. It is
|
||||||
// Linux resolve a library-id selection to its command too, so gamescope's bare spawn nests a
|
// the RESOLVED command, so gamescope's bare spawn nests a library title exactly like an
|
||||||
// library title exactly like an apps.json command (it previously nested only `cmd`, silently
|
// apps.json command (it previously nested only `cmd`, silently dropping library picks). Off
|
||||||
// dropping library picks).
|
// Linux this is a no-op backend-side, and a library title resolves to no command at all — the
|
||||||
#[cfg(target_os = "linux")]
|
// interactive-session spawner launches it by id instead.
|
||||||
vd.set_launch_command(crate::library::resolve_session_launch(
|
vd.set_launch_command(launch.and_then(|t| t.command.clone()));
|
||||||
app.and_then(|a| a.library_id.as_deref()),
|
|
||||||
app.and_then(|a| a.cmd.as_deref()),
|
|
||||||
));
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
vd.set_launch_command(app.and_then(|a| a.cmd.clone()));
|
|
||||||
// Serialize with the punktfunk/1 plane's IDD-push setup dance (Goal-1 §2.5). A GameStream
|
// Serialize with the punktfunk/1 plane's IDD-push setup dance (Goal-1 §2.5). A GameStream
|
||||||
// connect used to skip it entirely, so it could ADD/reconfigure the shared monitor while a
|
// connect used to skip it entirely, so it could ADD/reconfigure the shared monitor while a
|
||||||
// native session was mid-build (and vice versa), and its sealed-channel delivery would replace
|
// native session was mid-build (and vice versa), and its sealed-channel delivery would replace
|
||||||
@@ -407,10 +628,11 @@ fn open_gs_virtual_source(
|
|||||||
height: cfg.height,
|
height: cfg.height,
|
||||||
refresh_hz: cfg.fps,
|
refresh_hz: cfg.fps,
|
||||||
},
|
},
|
||||||
// GameStream's deliberate quit is the Moonlight "Quit App" (nvhttp `h_cancel`), not a QUIC
|
// GameStream's deliberate quit is the Moonlight "Quit App" (nvhttp `h_cancel`), the
|
||||||
// close code — wiring it to skip-linger is a follow-up, so this path keeps normal keep-alive
|
// management stop, or the launched game exiting — not a QUIC close code. All three set the
|
||||||
// (a fresh, never-set flag).
|
// session's quit flag ([`super::AppState::quit`]), so the display skips its keep-alive linger
|
||||||
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
// for a stop the way it does on the native plane, and only a real drop lingers.
|
||||||
|
quit.clone(),
|
||||||
None, // fresh session — no display superseded
|
None, // fresh session — no display superseded
|
||||||
)
|
)
|
||||||
.context("create virtual output at client resolution")?;
|
.context("create virtual output at client resolution")?;
|
||||||
@@ -420,7 +642,7 @@ fn open_gs_virtual_source(
|
|||||||
// capturer follows the display). No-op on Linux: virtual-output capture is SDR-only upstream
|
// capturer follows the display). No-op on Linux: virtual-output capture is SDR-only upstream
|
||||||
// (Mutter RecordVirtual), and `host_hdr_capable` therefore keeps `cfg.hdr` false for this
|
// (Mutter RecordVirtual), and `host_hdr_capable` therefore keeps `cfg.hdr` false for this
|
||||||
// source — the Linux HDR path is the portal monitor mirror (`video_source=portal`).
|
// source — the Linux HDR path is the portal monitor mirror (`video_source=portal`).
|
||||||
let capturer = capture::capture_virtual_output(
|
let mut capturer = capture::capture_virtual_output(
|
||||||
vout,
|
vout,
|
||||||
capture::OutputFormat::resolve(cfg.hdr, crate::encode::resolved_backend_is_gpu()),
|
capture::OutputFormat::resolve(cfg.hdr, crate::encode::resolved_backend_is_gpu()),
|
||||||
crate::session_plan::CaptureBackend::resolve(),
|
crate::session_plan::CaptureBackend::resolve(),
|
||||||
@@ -646,6 +868,10 @@ fn stream_body(
|
|||||||
// Re-open the video source on capture loss (virtual-display path → follow a Desktop<->Game switch);
|
// Re-open the video source on capture loss (virtual-display path → follow a Desktop<->Game switch);
|
||||||
// `None` for the portal/synthetic source, which has nothing to re-detect (propagate the error).
|
// `None` for the portal/synthetic source, which has nothing to re-detect (propagate the error).
|
||||||
rebuild: Option<&dyn Fn() -> Result<Box<dyn Capturer>>>,
|
rebuild: Option<&dyn Fn() -> Result<Box<dyn Capturer>>>,
|
||||||
|
// The capture hands the encoder cursor bitmaps to composite (cursor-as-metadata negotiated
|
||||||
|
// because the resolved backend blends — see the callers). `false` = the pointer is embedded
|
||||||
|
// in the pixels (or absent), so the encoder is asked to composite nothing.
|
||||||
|
cursor_blend: bool,
|
||||||
sock: &UdpSocket,
|
sock: &UdpSocket,
|
||||||
cfg: StreamConfig,
|
cfg: StreamConfig,
|
||||||
running: &Arc<AtomicBool>,
|
running: &Arc<AtomicBool>,
|
||||||
@@ -681,9 +907,9 @@ fn stream_body(
|
|||||||
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
|
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
|
||||||
// Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only.
|
// Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only.
|
||||||
encode::ChromaFormat::Yuv420,
|
encode::ChromaFormat::Yuv420,
|
||||||
// Desktop monitor capture negotiates cursor-as-metadata where available — the encoder
|
// True only when THIS session's capture negotiated cursor-as-metadata — which the
|
||||||
// may be handed cursor bitmaps to composite.
|
// callers grant only where the resolved backend composites (`cursor_blend_capable`).
|
||||||
true,
|
cursor_blend,
|
||||||
)
|
)
|
||||||
.context("open video encoder for stream")?;
|
.context("open video encoder for stream")?;
|
||||||
// Tell the encoder how deep the capturer lets it pipeline. Without this an in-place backend
|
// Tell the encoder how deep the capturer lets it pipeline. Without this an in-place backend
|
||||||
@@ -855,7 +1081,7 @@ fn stream_body(
|
|||||||
frame.is_cuda(),
|
frame.is_cuda(),
|
||||||
gs_bit_depth(frame.format),
|
gs_bit_depth(frame.format),
|
||||||
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
|
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
|
||||||
true, // metadata-cursor capture — see the first open
|
cursor_blend, // same capture cursor mode — see the first open
|
||||||
)
|
)
|
||||||
.context("reopen encoder after rebuild")?;
|
.context("reopen encoder after rebuild")?;
|
||||||
// A rebuilt encoder starts unconfigured — same reason as the first open above.
|
// A rebuilt encoder starts unconfigured — same reason as the first open above.
|
||||||
@@ -1171,6 +1397,49 @@ fn stream_body(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn entry(title: &str, cmd: Option<&str>) -> super::super::apps::AppEntry {
|
||||||
|
super::super::apps::AppEntry {
|
||||||
|
id: 1,
|
||||||
|
title: title.to_string(),
|
||||||
|
compositor: None,
|
||||||
|
cmd: cmd.map(str::to_string),
|
||||||
|
library_id: None,
|
||||||
|
prep: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the compat plane decides to track. The negative cases are the load-bearing ones: a
|
||||||
|
/// Desktop entry launches nothing, so it must take no lease at all — the feature has to stay
|
||||||
|
/// completely inert for a plain desktop stream.
|
||||||
|
#[test]
|
||||||
|
fn only_an_entry_that_launches_something_gets_tracked() {
|
||||||
|
// Nothing selected (no `/launch` app) → nothing to track.
|
||||||
|
assert!(resolve_gs_app(None).is_none());
|
||||||
|
// Desktop: a title with no command and no library id.
|
||||||
|
assert!(resolve_gs_app(Some(&entry("Desktop", None))).is_none());
|
||||||
|
// A blank command is the same as none.
|
||||||
|
assert!(resolve_gs_app(Some(&entry("Blank", Some(" ")))).is_none());
|
||||||
|
|
||||||
|
// An operator-typed apps.json command: no library entry behind it, so the title is the whole
|
||||||
|
// identity and there is no store-qualified id for the console to match box art on.
|
||||||
|
let t = resolve_gs_app(Some(&entry(
|
||||||
|
"Steam Big Picture",
|
||||||
|
Some(" steam -gamepadui "),
|
||||||
|
)))
|
||||||
|
.expect("a command entry is tracked");
|
||||||
|
assert_eq!(t.command.as_deref(), Some("steam -gamepadui"));
|
||||||
|
assert_eq!(t.game.id, None);
|
||||||
|
assert_eq!(t.game.store, None);
|
||||||
|
assert_eq!(t.game.title, "Steam Big Picture");
|
||||||
|
// `steam` is a PATH lookup, not an absolute executable, so nothing is asserted about the
|
||||||
|
// process — the host's own child is what tracks it (see `library::spec_from_command`).
|
||||||
|
assert!(t.detect.is_empty());
|
||||||
|
|
||||||
|
// A titleless entry still shows up as something a human can read.
|
||||||
|
let t = resolve_gs_app(Some(&entry("", Some("/opt/game/run")))).expect("tracked");
|
||||||
|
assert_eq!(t.game.title, "/opt/game/run");
|
||||||
|
}
|
||||||
|
|
||||||
/// End-to-end check of the send thread: batches pushed on the channel arrive, complete and
|
/// End-to-end check of the send thread: batches pushed on the channel arrive, complete and
|
||||||
/// byte-identical, at a peer socket via the paced sendmmsg path.
|
/// byte-identical, at a peer socket via the paced sendmmsg path.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub(crate) use utoipa::ToSchema;
|
|||||||
|
|
||||||
mod art;
|
mod art;
|
||||||
mod custom;
|
mod custom;
|
||||||
|
mod detect;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod epic;
|
mod epic;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -38,6 +39,7 @@ mod xbox;
|
|||||||
|
|
||||||
pub use art::*;
|
pub use art::*;
|
||||||
pub use custom::*;
|
pub use custom::*;
|
||||||
|
pub use detect::*;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub use epic::*;
|
pub use epic::*;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -83,6 +85,57 @@ pub struct LaunchSpec {
|
|||||||
pub value: String,
|
pub value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Descriptive metadata for a title — everything a richer library UI (details pane, platform
|
||||||
|
/// filter, couch-pick badges) renders beyond the poster. Every field is optional and defaults to
|
||||||
|
/// absent, so pre-metadata catalogs, providers, and clients keep working unchanged. The struct is
|
||||||
|
/// `#[serde(flatten)]`-ed into [`GameEntry`] / the custom-store shapes: one definition, a flat
|
||||||
|
/// wire shape everywhere.
|
||||||
|
///
|
||||||
|
/// Values are free-form display strings, not enums — emulation sources (RomM, EmuDeck, Playnite)
|
||||||
|
/// each have their own vocabulary and the host has no business normalizing it.
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct GameMeta {
|
||||||
|
/// The system the title runs on — `"PS2"`, `"Xbox 360"`, `"SNES"`, … Installed-store
|
||||||
|
/// scanners stamp `"PC"`; `GET /library?platform=` filters on it (case-insensitive).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[schema(example = "PS2")]
|
||||||
|
pub platform: Option<String>,
|
||||||
|
/// Short blurb for a details pane.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub developer: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub publisher: Option<String>,
|
||||||
|
/// Year of first release — the granularity metadata sources reliably agree on.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[schema(example = 2001)]
|
||||||
|
pub release_year: Option<u16>,
|
||||||
|
/// Genre taxonomy from the metadata source (`"RPG"`, `"Platformer"`, …).
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub genres: Vec<String>,
|
||||||
|
/// Free-form organizational labels (`"co-op"`, `"kids"`, `"finished"`, …).
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
/// Release region — emulation-relevant (`"NTSC-U"`, `"PAL"`, `"NTSC-J"`).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub region: Option<String>,
|
||||||
|
/// Maximum simultaneous (local) players.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub players: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameMeta {
|
||||||
|
/// The one field an installed-store scanner can assert about its own titles: they run on this
|
||||||
|
/// host, i.e. on a PC. Everything else stays absent (the launchers' local files don't carry it).
|
||||||
|
pub(crate) fn pc() -> Self {
|
||||||
|
GameMeta {
|
||||||
|
platform: Some("PC".into()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One title in the unified library, regardless of which store it came from.
|
/// One title in the unified library, regardless of which store it came from.
|
||||||
#[derive(Clone, Debug, Serialize, ToSchema)]
|
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||||
pub struct GameEntry {
|
pub struct GameEntry {
|
||||||
@@ -102,6 +155,18 @@ pub struct GameEntry {
|
|||||||
/// console uses it for attribution; `GET /library?provider=` filters on it.
|
/// console uses it for attribution; `GET /library?provider=` filters on it.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub provider: Option<String>,
|
pub provider: Option<String>,
|
||||||
|
/// How to recognize this title's process(es) once it is running ([`DetectSpec`]) — filled in by
|
||||||
|
/// each provider from paths it already read while scanning.
|
||||||
|
///
|
||||||
|
/// **Host-internal: never serialized.** It names local filesystem paths, so it stays out of both
|
||||||
|
/// the catalog JSON the client renders and the OpenAPI schema; it rides here only so the
|
||||||
|
/// providers that already hold this data don't have to be re-scanned.
|
||||||
|
#[serde(skip)]
|
||||||
|
#[schema(ignore)]
|
||||||
|
pub detect: DetectSpec,
|
||||||
|
/// Descriptive metadata, flattened — see [`GameMeta`].
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub meta: GameMeta,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A store that contributes titles to the library. The trait is the extension point for future
|
/// A store that contributes titles to the library. The trait is the extension point for future
|
||||||
|
|||||||
@@ -28,6 +28,18 @@ pub struct CustomEntry {
|
|||||||
/// host-assigned `id` stays stable across reconciles. Present iff `provider` is.
|
/// host-assigned `id` stays stable across reconciles. Present iff `provider` is.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub external_id: Option<String>,
|
pub external_id: Option<String>,
|
||||||
|
/// How to recognize this title's process once it is running (design §9) — the one thing a
|
||||||
|
/// provider knows that the host cannot work out for itself.
|
||||||
|
///
|
||||||
|
/// Optional: without it the entry is still tracked by the child the host spawns for it, which
|
||||||
|
/// covers every command that stays in the foreground. It earns its keep for a command that hands
|
||||||
|
/// off and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where
|
||||||
|
/// the host would otherwise lose the game the moment the shim returns.
|
||||||
|
#[serde(default, skip_serializing_if = "DetectHint::is_empty")]
|
||||||
|
pub detect: DetectHint,
|
||||||
|
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`].
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub meta: GameMeta,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body to create or replace a custom entry (no `id` — the host owns it).
|
/// Request body to create or replace a custom entry (no `id` — the host owns it).
|
||||||
@@ -41,6 +53,13 @@ pub struct CustomInput {
|
|||||||
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
|
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prep: Vec<crate::hooks::PrepCmd>,
|
pub prep: Vec<crate::hooks::PrepCmd>,
|
||||||
|
/// How to recognize this title's process — see [`CustomEntry::detect`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub detect: DetectHint,
|
||||||
|
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced
|
||||||
|
/// wholesale on update, like `art`: an edit must round-trip every field it wants kept.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub meta: GameMeta,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the
|
/// One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the
|
||||||
@@ -57,10 +76,30 @@ pub struct ProviderEntryInput {
|
|||||||
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
|
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prep: Vec<crate::hooks::PrepCmd>,
|
pub prep: Vec<crate::hooks::PrepCmd>,
|
||||||
|
/// How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its
|
||||||
|
/// titles' install directories (Playnite does) should send them: it is what lets a game launched
|
||||||
|
/// through the provider's own client still end its session when the player quits.
|
||||||
|
#[serde(default)]
|
||||||
|
pub detect: DetectHint,
|
||||||
|
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`].
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub meta: GameMeta,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<CustomEntry> for GameEntry {
|
impl From<CustomEntry> for GameEntry {
|
||||||
fn from(c: CustomEntry) -> Self {
|
fn from(c: CustomEntry) -> Self {
|
||||||
|
// A custom/provider entry is spawned by the host itself, so its own child process is the
|
||||||
|
// primary lifetime signal; the spec is the fallback for a command that hands off and exits (a
|
||||||
|
// launcher script, a `flatpak run`). An absolute exe in the command line is all that can be
|
||||||
|
// *inferred*; anything sharper has to be stated, which is what `detect` is for (design §9).
|
||||||
|
// The inferred exe wins where both exist — it is derived from the very command being run.
|
||||||
|
let detect = c
|
||||||
|
.launch
|
||||||
|
.as_ref()
|
||||||
|
.filter(|l| l.kind == "command")
|
||||||
|
.map(|l| crate::library::spec_from_command(&l.value))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.or_hint(&c.detect);
|
||||||
GameEntry {
|
GameEntry {
|
||||||
id: format!("custom:{}", c.id),
|
id: format!("custom:{}", c.id),
|
||||||
store: "custom".into(),
|
store: "custom".into(),
|
||||||
@@ -68,6 +107,8 @@ impl From<CustomEntry> for GameEntry {
|
|||||||
art: c.art,
|
art: c.art,
|
||||||
launch: c.launch,
|
launch: c.launch,
|
||||||
provider: c.provider,
|
provider: c.provider,
|
||||||
|
detect,
|
||||||
|
meta: c.meta,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +198,8 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
|||||||
prep: input.prep,
|
prep: input.prep,
|
||||||
provider: None,
|
provider: None,
|
||||||
external_id: None,
|
external_id: None,
|
||||||
|
detect: input.detect,
|
||||||
|
meta: input.meta,
|
||||||
};
|
};
|
||||||
entries.push(entry.clone());
|
entries.push(entry.clone());
|
||||||
save_custom(&entries)?;
|
save_custom(&entries)?;
|
||||||
@@ -178,6 +221,8 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<Custo
|
|||||||
slot.art = input.art;
|
slot.art = input.art;
|
||||||
slot.launch = input.launch;
|
slot.launch = input.launch;
|
||||||
slot.prep = input.prep;
|
slot.prep = input.prep;
|
||||||
|
slot.detect = input.detect;
|
||||||
|
slot.meta = input.meta;
|
||||||
let updated = slot.clone();
|
let updated = slot.clone();
|
||||||
save_custom(&entries)?;
|
save_custom(&entries)?;
|
||||||
emit_changed("manual");
|
emit_changed("manual");
|
||||||
@@ -272,6 +317,8 @@ fn reconcile_entries(
|
|||||||
prep: input.prep,
|
prep: input.prep,
|
||||||
provider: Some(provider.to_string()),
|
provider: Some(provider.to_string()),
|
||||||
external_id: Some(input.external_id),
|
external_id: Some(input.external_id),
|
||||||
|
detect: input.detect,
|
||||||
|
meta: input.meta,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile).
|
// `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile).
|
||||||
@@ -349,6 +396,8 @@ mod tests {
|
|||||||
prep: Vec::new(),
|
prep: Vec::new(),
|
||||||
provider: None,
|
provider: None,
|
||||||
external_id: None,
|
external_id: None,
|
||||||
|
detect: DetectHint::default(),
|
||||||
|
meta: GameMeta::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +408,8 @@ mod tests {
|
|||||||
art: Artwork::default(),
|
art: Artwork::default(),
|
||||||
launch: None,
|
launch: None,
|
||||||
prep: Vec::new(),
|
prep: Vec::new(),
|
||||||
|
detect: DetectHint::default(),
|
||||||
|
meta: GameMeta::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,8 +423,43 @@ mod tests {
|
|||||||
let mut e = manual("def456", "Synced");
|
let mut e = manual("def456", "Synced");
|
||||||
e.provider = Some("romm".into());
|
e.provider = Some("romm".into());
|
||||||
e.external_id = Some("rom-1".into());
|
e.external_id = Some("rom-1".into());
|
||||||
|
e.meta.platform = Some("PS2".into());
|
||||||
let g: GameEntry = e.into();
|
let g: GameEntry = e.into();
|
||||||
assert_eq!(g.provider.as_deref(), Some("romm"));
|
assert_eq!(g.provider.as_deref(), Some("romm"));
|
||||||
|
assert_eq!(g.meta.platform.as_deref(), Some("PS2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting —
|
||||||
|
/// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a
|
||||||
|
/// pre-metadata `library.json` / payload still parses (all-optional).
|
||||||
|
#[test]
|
||||||
|
fn meta_is_flat_and_optional_on_the_wire() {
|
||||||
|
let mut e = manual("abc123", "Shadow of the Colossus");
|
||||||
|
e.meta = GameMeta {
|
||||||
|
platform: Some("PS2".into()),
|
||||||
|
release_year: Some(2005),
|
||||||
|
genres: vec!["Adventure".into()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let v = serde_json::to_value(&e).unwrap();
|
||||||
|
assert_eq!(v["platform"], "PS2");
|
||||||
|
assert_eq!(v["release_year"], 2005);
|
||||||
|
assert_eq!(v["genres"][0], "Adventure");
|
||||||
|
assert!(v.get("meta").is_none(), "flattened, not nested");
|
||||||
|
assert!(v.get("description").is_none(), "absent fields are omitted");
|
||||||
|
assert!(v.get("tags").is_none(), "empty lists are omitted");
|
||||||
|
|
||||||
|
// A pre-metadata entry (what an existing library.json holds) still deserializes.
|
||||||
|
let old = r#"{"id":"abc","title":"Old"}"#;
|
||||||
|
let e: CustomEntry = serde_json::from_str(old).unwrap();
|
||||||
|
assert!(e.meta.platform.is_none());
|
||||||
|
|
||||||
|
// And a provider payload can carry the fields flat, next to `title` (RFC §8 shape).
|
||||||
|
let payload = r#"{"external_id":"rom-1","title":"OoT","platform":"N64",
|
||||||
|
"region":"NTSC-U","players":1,"tags":["kids"]}"#;
|
||||||
|
let p: ProviderEntryInput = serde_json::from_str(payload).unwrap();
|
||||||
|
assert_eq!(p.meta.platform.as_deref(), Some("N64"));
|
||||||
|
assert_eq!(p.meta.players, Some(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The RFC §8 contract in one walk: add keeps ids stable across re-syncs, updates flow,
|
/// The RFC §8 contract in one walk: add keeps ids stable across re-syncs, updates flow,
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
//! How to **recognize** a launched title's running process(es) — the read-side counterpart to
|
||||||
|
//! [`super::launch`], which only knows how to *start* one.
|
||||||
|
//!
|
||||||
|
//! A launch tells the host what to run; it does not tell it what "the game" looks like once the
|
||||||
|
//! launcher has handed off. Every store here therefore contributes whatever identifying signals it
|
||||||
|
//! already has on disk (design §4): a Steam appid, an install directory, a concrete executable, an
|
||||||
|
//! environment marker. [`crate::procscan`] turns those into live pids, and
|
||||||
|
//! [`crate::gamelease`] turns pids into a lifetime.
|
||||||
|
//!
|
||||||
|
//! The signals are a **union**, not a ladder: any process matching any signal belongs to the game.
|
||||||
|
//! That is what lets one recipe (`install_dir`) cover the stores that expose nothing else, while a
|
||||||
|
//! store with something sharper (Steam's launch reaper) still gets the precise answer.
|
||||||
|
//!
|
||||||
|
//! `DetectSpec` is **host-internal and never crosses the wire** — it names local filesystem paths,
|
||||||
|
//! which no client has any business seeing. It rides on [`super::GameEntry`] as a `#[serde(skip)]`
|
||||||
|
//! field purely so the providers that already read these paths during their scan don't have to be
|
||||||
|
//! walked a second time.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// An environment variable a launcher stamps onto the game's process, identifying it.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct EnvMarker {
|
||||||
|
/// The variable name (e.g. `HEROIC_GAME_ID`).
|
||||||
|
pub key: String,
|
||||||
|
/// The exact value to require, when the launcher's value identifies *this* title. `None` matches
|
||||||
|
/// the key's mere presence — only safe for launchers that run one game at a time.
|
||||||
|
pub value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The signals that identify a launched title's process(es). Every field is optional and
|
||||||
|
/// independent; an all-`None` spec means "this title can't be tracked" (the lease degrades to
|
||||||
|
/// [`crate::gamelease::LeaseKind::Untracked`] and both lifetime behaviors stay inert for it).
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct DetectSpec {
|
||||||
|
/// Steam appid, for titles Steam itself installed (never for non-Steam shortcuts, whose reaper
|
||||||
|
/// appid semantics differ — those carry an [`exe`](Self::exe) instead). On Linux this is the
|
||||||
|
/// sharpest signal available: Steam wraps every launch — native or Proton — in
|
||||||
|
/// `reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's.
|
||||||
|
pub steam_appid: Option<u32>,
|
||||||
|
/// A launcher-stamped environment marker.
|
||||||
|
pub env_marker: Option<EnvMarker>,
|
||||||
|
/// The game's own executable, when a store resolves one exactly.
|
||||||
|
pub exe: Option<PathBuf>,
|
||||||
|
/// The game's install directory — the universal recipe. A process whose image path (or, for
|
||||||
|
/// Proton/Wine, whose command line) sits under this directory is part of the game.
|
||||||
|
pub install_dir: Option<PathBuf>,
|
||||||
|
/// The game's executable **file name** (`Hades.exe`, `retroarch`), matched case-insensitively
|
||||||
|
/// against a process's image name and nothing else.
|
||||||
|
///
|
||||||
|
/// The weakest signal here, and the only one an operator supplies by hand
|
||||||
|
/// ([`super::DetectHint`]): a bare name says nothing about *which* copy is running. It is offered
|
||||||
|
/// because the entries that need it — an emulator launched through a front-end, a game whose
|
||||||
|
/// launcher relocates it — often expose nothing sharper, and because "started after this launch"
|
||||||
|
/// still bounds it: a copy the player already had open is never adopted.
|
||||||
|
pub process_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DetectSpec {
|
||||||
|
/// A spec with no signals at all — nothing to track.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.steam_appid.is_none()
|
||||||
|
&& self.env_marker.is_none()
|
||||||
|
&& self.exe.is_none()
|
||||||
|
&& self.install_dir.is_none()
|
||||||
|
&& self.process_name.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Just a Steam appid (the manifest path; art/shortcut scanning fills the rest).
|
||||||
|
pub fn steam(appid: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
steam_appid: Some(appid),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Just an install directory — the universal recipe most stores land on.
|
||||||
|
pub fn dir(dir: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
install_dir: Some(dir.into()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Just a concrete executable.
|
||||||
|
pub fn exe(exe: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
exe: Some(exe.into()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an install directory to an existing spec (Steam pairs one with its appid so the Windows
|
||||||
|
/// matcher, which has no reaper, still has something to go on).
|
||||||
|
pub fn with_dir(mut self, dir: impl Into<PathBuf>) -> Self {
|
||||||
|
self.install_dir = Some(dir.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an environment marker to an existing spec.
|
||||||
|
pub fn with_env(mut self, key: impl Into<String>, value: Option<String>) -> Self {
|
||||||
|
self.env_marker = Some(EnvMarker {
|
||||||
|
key: key.into(),
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill in whatever this spec doesn't already know from an operator/provider hint.
|
||||||
|
///
|
||||||
|
/// The host's own findings win: a hint is a fallback for a title the scanners couldn't pin down,
|
||||||
|
/// never a way to redirect the matcher away from what the store actually reported.
|
||||||
|
pub fn or_hint(mut self, hint: &DetectHint) -> Self {
|
||||||
|
let from = Self::from(hint);
|
||||||
|
self.install_dir = self.install_dir.or(from.install_dir);
|
||||||
|
self.exe = self.exe.or(from.exe);
|
||||||
|
self.process_name = self.process_name.or(from.process_name);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an operator (or a provider plugin) can tell the host about recognizing a title — the wire
|
||||||
|
/// half of [`DetectSpec`], and the only part of it that is ever accepted from outside.
|
||||||
|
///
|
||||||
|
/// Deliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment
|
||||||
|
/// marker) are things the host discovers for itself and would be meaningless — or dangerous — to take
|
||||||
|
/// on someone's word. What is left is what a provider genuinely knows and the host cannot guess: where
|
||||||
|
/// the title is installed, which executable is the game, what the process is called. All three are
|
||||||
|
/// optional; supplying none is the same as supplying no hint at all.
|
||||||
|
///
|
||||||
|
/// Never returned by the catalog API — see the module docs on why detect data does not cross the wire
|
||||||
|
/// outbound.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct DetectHint {
|
||||||
|
/// Where the title is installed. Any process running from under this directory is part of the
|
||||||
|
/// game — the universal recipe, and the one worth supplying if you supply only one.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub install_dir: Option<String>,
|
||||||
|
/// The game's own executable, as an absolute path.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub exe: Option<String>,
|
||||||
|
/// The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three
|
||||||
|
/// — see [`DetectSpec::process_name`].
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub process_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DetectHint {
|
||||||
|
/// Whether the hint says anything at all (all-empty is treated as absent).
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.trimmed().is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hint with blank fields dropped, or `None` if nothing is left. Console text inputs and
|
||||||
|
/// hand-written plugin payloads both produce `""` for "not set", and an empty install dir would
|
||||||
|
/// otherwise match *every* process on the box.
|
||||||
|
fn trimmed(&self) -> Option<(Option<&str>, Option<&str>, Option<&str>)> {
|
||||||
|
fn f(s: &Option<String>) -> Option<&str> {
|
||||||
|
s.as_deref().map(str::trim).filter(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
let (dir, exe, name) = (f(&self.install_dir), f(&self.exe), f(&self.process_name));
|
||||||
|
(dir.is_some() || exe.is_some() || name.is_some()).then_some((dir, exe, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A provider's hint becomes a spec — the one inbound path into [`DetectSpec`].
|
||||||
|
impl From<&DetectHint> for DetectSpec {
|
||||||
|
fn from(h: &DetectHint) -> Self {
|
||||||
|
let Some((install_dir, exe, process_name)) = h.trimmed() else {
|
||||||
|
return Self::default();
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
install_dir: install_dir.map(PathBuf::from),
|
||||||
|
exe: exe.map(PathBuf::from),
|
||||||
|
process_name: process_name.map(str::to_string),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a detect spec from an operator-typed shell command (the custom store's `command` kind, and
|
||||||
|
/// the provider-plugin entries that reuse it): if the command's first token is an absolute path to an
|
||||||
|
/// existing file, that's the game's executable.
|
||||||
|
///
|
||||||
|
/// Deliberately conservative — a bare `dolphin-emu --batch` yields nothing (a PATH lookup would guess
|
||||||
|
/// at which of several installs the launcher will pick, and a wrong exe is worse than none: the lease
|
||||||
|
/// would call a running game exited). Custom entries are spawned by the host anyway, so their primary
|
||||||
|
/// tracking is the child process itself; this is only the fallback for a command that shims out.
|
||||||
|
pub fn spec_from_command(cmd: &str) -> DetectSpec {
|
||||||
|
let Some(first) = shell_first_token(cmd) else {
|
||||||
|
return DetectSpec::default();
|
||||||
|
};
|
||||||
|
let p = Path::new(&first);
|
||||||
|
if p.is_absolute() && p.is_file() {
|
||||||
|
DetectSpec::exe(p)
|
||||||
|
} else {
|
||||||
|
DetectSpec::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first token of a shell command, honoring single/double quotes around it (`"/opt/My Game/run"`).
|
||||||
|
/// Not a shell parser — just enough to recover a quoted absolute path, which is the only case that
|
||||||
|
/// matters here.
|
||||||
|
fn shell_first_token(cmd: &str) -> Option<String> {
|
||||||
|
let cmd = cmd.trim_start();
|
||||||
|
let mut chars = cmd.chars();
|
||||||
|
match chars.next()? {
|
||||||
|
q @ ('"' | '\'') => {
|
||||||
|
let rest: String = chars.collect();
|
||||||
|
let end = rest.find(q)?;
|
||||||
|
Some(rest[..end].to_string())
|
||||||
|
}
|
||||||
|
_ => cmd.split_whitespace().next().map(str::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_spec_is_untrackable() {
|
||||||
|
assert!(DetectSpec::default().is_empty());
|
||||||
|
assert!(!DetectSpec::steam(570).is_empty());
|
||||||
|
assert!(!DetectSpec::dir("/games/x").is_empty());
|
||||||
|
assert!(!DetectSpec::exe("/games/x/run").is_empty());
|
||||||
|
assert!(!DetectSpec::default()
|
||||||
|
.with_env("HEROIC_GAME_ID", Some("abc".into()))
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builders_compose() {
|
||||||
|
let s = DetectSpec::steam(570).with_dir("/games/dota");
|
||||||
|
assert_eq!(s.steam_appid, Some(570));
|
||||||
|
assert_eq!(s.install_dir.as_deref(), Some(Path::new("/games/dota")));
|
||||||
|
let h = DetectSpec::dir("/games/quail").with_env("HEROIC_GAME_ID", Some("Quail".into()));
|
||||||
|
assert_eq!(
|
||||||
|
h.env_marker,
|
||||||
|
Some(EnvMarker {
|
||||||
|
key: "HEROIC_GAME_ID".into(),
|
||||||
|
value: Some("Quail".into())
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_spec_only_trusts_an_absolute_existing_file() {
|
||||||
|
// A PATH-relative command is not guessed at.
|
||||||
|
assert!(spec_from_command("dolphin-emu --batch").is_empty());
|
||||||
|
// An absolute path that doesn't exist is not asserted either.
|
||||||
|
assert!(spec_from_command("/nope/not/here --x").is_empty());
|
||||||
|
// A real absolute file (this test binary) is picked up, quoted or bare.
|
||||||
|
let me = std::env::current_exe().expect("current exe");
|
||||||
|
let bare = format!("{} --flag", me.display());
|
||||||
|
assert_eq!(spec_from_command(&bare).exe.as_deref(), Some(me.as_path()));
|
||||||
|
let quoted = format!("\"{}\" --flag", me.display());
|
||||||
|
assert_eq!(
|
||||||
|
spec_from_command("ed).exe.as_deref(),
|
||||||
|
Some(me.as_path())
|
||||||
|
);
|
||||||
|
assert!(spec_from_command(" ").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hint is operator/plugin input, so the blank-field case is the norm, not an edge: a console
|
||||||
|
/// form and a hand-written plugin payload both send `""` for "not set". An empty install dir that
|
||||||
|
/// reached the matcher would prefix-match every process on the box — and this feature can end
|
||||||
|
/// processes.
|
||||||
|
#[test]
|
||||||
|
fn a_blank_hint_says_nothing() {
|
||||||
|
assert!(DetectHint::default().is_empty());
|
||||||
|
let blank = DetectHint {
|
||||||
|
install_dir: Some("".into()),
|
||||||
|
exe: Some(" ".into()),
|
||||||
|
process_name: Some("\t".into()),
|
||||||
|
};
|
||||||
|
assert!(blank.is_empty());
|
||||||
|
assert!(DetectSpec::from(&blank).is_empty(), "nothing to match on");
|
||||||
|
|
||||||
|
let hint = DetectHint {
|
||||||
|
install_dir: Some(" /games/quail ".into()),
|
||||||
|
exe: None,
|
||||||
|
process_name: Some("quail".into()),
|
||||||
|
};
|
||||||
|
assert!(!hint.is_empty());
|
||||||
|
let spec = DetectSpec::from(&hint);
|
||||||
|
assert_eq!(spec.install_dir.as_deref(), Some(Path::new("/games/quail")));
|
||||||
|
assert_eq!(spec.process_name.as_deref(), Some("quail"));
|
||||||
|
assert_eq!(spec.exe, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host's own findings outrank a hint. A provider that guessed wrong (or a stale export)
|
||||||
|
/// must not be able to point the matcher — and therefore the termination ladder — at something
|
||||||
|
/// other than what the store itself reported.
|
||||||
|
#[test]
|
||||||
|
fn a_hint_only_fills_gaps() {
|
||||||
|
let found = DetectSpec::dir("/games/real");
|
||||||
|
let hint = DetectHint {
|
||||||
|
install_dir: Some("/games/wrong".into()),
|
||||||
|
exe: Some("/games/real/run".into()),
|
||||||
|
process_name: None,
|
||||||
|
};
|
||||||
|
let merged = found.or_hint(&hint);
|
||||||
|
assert_eq!(
|
||||||
|
merged.install_dir.as_deref(),
|
||||||
|
Some(Path::new("/games/real")),
|
||||||
|
"the store's own answer stands"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
merged.exe.as_deref(),
|
||||||
|
Some(Path::new("/games/real/run")),
|
||||||
|
"but a field the store had nothing for is filled in"
|
||||||
|
);
|
||||||
|
// Nothing found + nothing hinted stays untrackable.
|
||||||
|
assert!(DetectSpec::default()
|
||||||
|
.or_hint(&DetectHint::default())
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_token_handles_quotes_and_spaces() {
|
||||||
|
assert_eq!(
|
||||||
|
shell_first_token("\"/opt/My Game/run\" -w").as_deref(),
|
||||||
|
Some("/opt/My Game/run")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
shell_first_token("'/opt/My Game/run'").as_deref(),
|
||||||
|
Some("/opt/My Game/run")
|
||||||
|
);
|
||||||
|
assert_eq!(shell_first_token(" plain --x").as_deref(), Some("plain"));
|
||||||
|
assert_eq!(shell_first_token("").as_deref(), None);
|
||||||
|
// An unterminated quote yields nothing rather than a bogus token.
|
||||||
|
assert_eq!(shell_first_token("\"/opt/oops").as_deref(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,8 +88,19 @@ fn epic_entry(
|
|||||||
} else {
|
} else {
|
||||||
app_name.clone()
|
app_name.clone()
|
||||||
};
|
};
|
||||||
|
// Detect signals: the manifest's own `LaunchExecutable` (relative to the install dir) is exact
|
||||||
|
// when present; the install dir covers the rest (Epic hands off to its launcher, so the host
|
||||||
|
// never owns the game's process).
|
||||||
|
let detect = match s("LaunchExecutable")
|
||||||
|
.map(|rel| Path::new(install).join(rel))
|
||||||
|
.filter(|p| p.is_file())
|
||||||
|
{
|
||||||
|
Some(exe) => DetectSpec::exe(exe).with_dir(install),
|
||||||
|
None => DetectSpec::dir(install),
|
||||||
|
};
|
||||||
Some(GameEntry {
|
Some(GameEntry {
|
||||||
provider: None,
|
provider: None,
|
||||||
|
meta: GameMeta::pc(),
|
||||||
id: format!("epic:{app_name}"),
|
id: format!("epic:{app_name}"),
|
||||||
store: "epic".into(),
|
store: "epic".into(),
|
||||||
title,
|
title,
|
||||||
@@ -98,6 +109,7 @@ fn epic_entry(
|
|||||||
kind: "epic".into(),
|
kind: "epic".into(),
|
||||||
value,
|
value,
|
||||||
}),
|
}),
|
||||||
|
detect,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,12 @@ fn gog_games() -> Vec<GameEntry> {
|
|||||||
// Art (public api.gog.com) is resolved off the hot path by the background warmer; read
|
// Art (public api.gog.com) is resolved off the hot path by the background warmer; read
|
||||||
// whatever it has cached (title-only until warmed).
|
// whatever it has cached (title-only until warmed).
|
||||||
let art = cached_art(&id).unwrap_or_default();
|
let art = cached_art(&id).unwrap_or_default();
|
||||||
|
// GOG launches the game's exe directly (no Galaxy), so the host owns the process and the
|
||||||
|
// spec is only the fallback for a stub launcher that hands off; both signals are exact here.
|
||||||
|
let detect = DetectSpec::exe(&exe).with_dir(&path);
|
||||||
out.push(GameEntry {
|
out.push(GameEntry {
|
||||||
provider: None,
|
provider: None,
|
||||||
|
meta: GameMeta::pc(),
|
||||||
id,
|
id,
|
||||||
store: "gog".into(),
|
store: "gog".into(),
|
||||||
title,
|
title,
|
||||||
@@ -62,6 +66,7 @@ fn gog_games() -> Vec<GameEntry> {
|
|||||||
kind: "gog".into(),
|
kind: "gog".into(),
|
||||||
value: format!("{exe}\t{args}\t{workdir}"),
|
value: format!("{exe}\t{args}\t{workdir}"),
|
||||||
}),
|
}),
|
||||||
|
detect,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
|
|||||||
@@ -72,14 +72,16 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
|||||||
{
|
{
|
||||||
continue; // the cache also lists owned-but-not-installed titles
|
continue; // the cache also lists owned-but-not-installed titles
|
||||||
}
|
}
|
||||||
let install_ok = g
|
// The install dir doubles as this title's detect signal (Heroic hands off to
|
||||||
|
// legendary/gogdl/nile, so the host never sees the game's own process any other way).
|
||||||
|
let install_path = g
|
||||||
.get("install")
|
.get("install")
|
||||||
.and_then(|i| i.get("install_path"))
|
.and_then(|i| i.get("install_path"))
|
||||||
.and_then(|p| p.as_str())
|
.and_then(|p| p.as_str())
|
||||||
.is_some_and(|p| Path::new(p).is_dir());
|
.filter(|p| Path::new(p).is_dir());
|
||||||
if !install_ok {
|
let Some(install_path) = install_path else {
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
let Some(app_name) = g
|
let Some(app_name) = g
|
||||||
.get("app_name")
|
.get("app_name")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -107,6 +109,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
|||||||
};
|
};
|
||||||
games.push(GameEntry {
|
games.push(GameEntry {
|
||||||
provider: None,
|
provider: None,
|
||||||
|
meta: GameMeta::pc(),
|
||||||
id: format!("heroic:{runner}:{app_name}"),
|
id: format!("heroic:{runner}:{app_name}"),
|
||||||
store: "heroic".into(),
|
store: "heroic".into(),
|
||||||
title,
|
title,
|
||||||
@@ -115,6 +118,11 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
|||||||
kind: "heroic".into(),
|
kind: "heroic".into(),
|
||||||
value: format!("{runner}:{app_name}"),
|
value: format!("{runner}:{app_name}"),
|
||||||
}),
|
}),
|
||||||
|
// The install dir is the reliable signal. `HEROIC_APP_NAME` is also stamped on the game's
|
||||||
|
// env by Heroic's launch path; it is carried as a second, cheap signal (a union — if a
|
||||||
|
// Heroic version doesn't set it, the install dir still matches).
|
||||||
|
detect: DetectSpec::dir(install_path)
|
||||||
|
.with_env("HEROIC_APP_NAME", Some(app_name.to_string())),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(games)
|
Ok(games)
|
||||||
|
|||||||
@@ -8,27 +8,71 @@ use super::*;
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use super::{epic::epic_launch_uri, gog::gog_spawn};
|
use super::{epic::epic_launch_uri, gog::gog_spawn};
|
||||||
|
|
||||||
/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`) to the shell
|
/// Everything a session needs about the title it is launching, resolved in **one** library scan:
|
||||||
/// command the host should run for it — looked up in the host's OWN library so a client can only
|
/// what to run, what to call it, and how to recognize it once it is running.
|
||||||
/// pick an existing title, never inject a command. `None` = unknown id, no launch recipe, or a
|
|
||||||
/// malformed Steam appid.
|
|
||||||
///
|
///
|
||||||
/// **Linux only**: the resolved command is run nested inside the per-session gamescope. On Windows
|
/// Enumerating the library touches every installed store's on-disk metadata, so the launch path
|
||||||
/// there is no gamescope to nest into; the host launches a title into the interactive user session
|
/// resolves this once at handshake time and threads it into the data plane rather than looking the
|
||||||
/// via [`launch_title`] instead.
|
/// same id up again per use.
|
||||||
|
pub struct LaunchTarget {
|
||||||
|
/// Identity for the status surface and the `game.*` events.
|
||||||
|
pub game: crate::gamelease::GameRef,
|
||||||
|
/// How to recognize the running game ([`DetectSpec`]); empty when the store offers nothing.
|
||||||
|
pub detect: DetectSpec,
|
||||||
|
/// The resolved shell command. `Some` on Linux (where the host runs it); `None` on Windows,
|
||||||
|
/// which launches by library id through the interactive-session spawner instead.
|
||||||
|
pub command: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`, or carried on a
|
||||||
|
/// GameStream catalog entry) against the host's **own** library — so a client can only pick an
|
||||||
|
/// existing title, never inject a command. `None` = unknown id, or — on Linux — a title with no
|
||||||
|
/// runnable recipe.
|
||||||
|
///
|
||||||
|
/// This is the single lookup, shared by both planes: the client sends only an id, and everything the
|
||||||
|
/// session does with the title afterwards comes from what the host itself knows about it.
|
||||||
|
///
|
||||||
|
/// **Linux**: the resolved command is run by the host (nested into a per-session gamescope, or
|
||||||
|
/// spawned into the live session). **Windows** has no gamescope to nest into and resolves the
|
||||||
|
/// concrete process at launch time instead, via [`launch_title`].
|
||||||
|
pub fn resolve_launch(id: &str) -> Option<LaunchTarget> {
|
||||||
|
let entry = all_games().into_iter().find(|g| g.id == id)?;
|
||||||
|
let game = crate::gamelease::GameRef {
|
||||||
|
id: Some(entry.id.clone()),
|
||||||
|
store: Some(entry.store.clone()),
|
||||||
|
title: entry.title.clone(),
|
||||||
|
};
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
// Linux runs the command itself, so a title without one has nothing to launch — same answer
|
||||||
|
// (and same warning path) as before this resolution existed.
|
||||||
|
let command = entry.launch.as_ref().and_then(command_for)?;
|
||||||
|
Some(LaunchTarget {
|
||||||
|
game,
|
||||||
|
detect: entry.detect,
|
||||||
|
command: Some(command),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
// Windows resolves the concrete process at launch time (`launch_title`), which is also where
|
||||||
|
// a missing recipe is reported — so an entry with no Windows recipe still yields a target and
|
||||||
|
// the existing warning fires there.
|
||||||
|
Some(LaunchTarget {
|
||||||
|
game,
|
||||||
|
detect: entry.detect,
|
||||||
|
command: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of
|
||||||
|
/// [`resolve_launch`], split out so the appid-validation can be tested without a Steam install).
|
||||||
///
|
///
|
||||||
/// - `steam_appid` → `steam steam://rungameid/<appid>` (appid validated as digits).
|
/// - `steam_appid` → `steam steam://rungameid/<appid>` (appid validated as digits).
|
||||||
/// - `command` → the stored command verbatim. This string comes from the host's own custom store
|
/// - `command` → the stored command verbatim. This string comes from the host's own custom store
|
||||||
/// (added by the host operator via the admin UI), never from the client, so it is trusted.
|
/// (added by the host operator via the admin UI), never from the client, so it is trusted.
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
pub fn launch_command(id: &str) -> Option<String> {
|
|
||||||
let spec = all_games().into_iter().find(|g| g.id == id)?.launch?;
|
|
||||||
command_for(&spec)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of
|
|
||||||
/// [`launch_command`], split out so the appid-validation can be tested without a Steam install).
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
fn command_for(spec: &LaunchSpec) -> Option<String> {
|
fn command_for(spec: &LaunchSpec) -> Option<String> {
|
||||||
match spec.kind.as_str() {
|
match spec.kind.as_str() {
|
||||||
"steam_appid" => valid_steam_appid(&spec.value)
|
"steam_appid" => valid_steam_appid(&spec.value)
|
||||||
@@ -47,7 +91,7 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Windows: launch a store-qualified library id into the **interactive user session** — the Windows
|
/// Windows: launch a store-qualified library id into the **interactive user session** — the Windows
|
||||||
/// analogue of the Linux gamescope-nested [`launch_command`]. The id is resolved against the host's
|
/// analogue of the Linux gamescope-nested [`resolve_launch`]. The id is resolved against the host's
|
||||||
/// OWN library (the client never sends a command), mapped to a concrete process by
|
/// OWN library (the client never sends a command), mapped to a concrete process by
|
||||||
/// [`windows_launch_for`], and spawned via [`crate::interactive::spawn_in_active_session`].
|
/// [`windows_launch_for`], and spawned via [`crate::interactive::spawn_in_active_session`].
|
||||||
///
|
///
|
||||||
@@ -166,16 +210,31 @@ pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
|
|||||||
/// on the `AppEntry`, resolved from the numeric Moonlight appid) into the interactive Windows user
|
/// on the `AppEntry`, resolved from the numeric Moonlight appid) into the interactive Windows user
|
||||||
/// session ([`launch_title`]). The id is resolved against the host's OWN library, so a client can
|
/// session ([`launch_title`]). The id is resolved against the host's OWN library, so a client can
|
||||||
/// only ever pick an existing title — never inject a command. Linux resolves the id via
|
/// only ever pick an existing title — never inject a command. Linux resolves the id via
|
||||||
/// [`launch_command`] and goes through [`launch_session_command`] instead.
|
/// [`resolve_launch`] and goes through [`launch_session_command`] instead.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn launch_gamestream_library(id: &str) -> Result<()> {
|
pub fn launch_gamestream_library(id: &str) -> Result<()> {
|
||||||
launch_title(id)
|
launch_title(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The child a session launch produced.
|
||||||
|
///
|
||||||
|
/// Handed back to the caller so the game's lifetime can be tracked
|
||||||
|
/// (design/session-game-lifetime.md) instead of the process being forgotten the moment it starts.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub struct SpawnedLaunch {
|
||||||
|
pub child: std::process::Child,
|
||||||
|
/// Whether the child leads its own process group — true for the plain session spawn in [`launch_session_command`], which
|
||||||
|
/// deliberately creates one so the whole wrapper tree can be signalled as a unit. False for the
|
||||||
|
/// gamescope-session spawn, which shares the host's group (see
|
||||||
|
/// [`crate::gamelease::OwnedChild::group_leader`]: a non-leader must never be signalled by
|
||||||
|
/// negative pid).
|
||||||
|
pub group_leader: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Launch a resolved shell command into the **live Linux session** for the session's compositor —
|
/// Launch a resolved shell command into the **live Linux session** for the session's compositor —
|
||||||
/// the one launch entry point shared by the native (punktfunk/1) and GameStream planes, called
|
/// the one launch entry point shared by the native (punktfunk/1) and GameStream planes, called
|
||||||
/// AFTER capture is up so the app renders onto the streamed output. The command is host-resolved
|
/// AFTER capture is up so the app renders onto the streamed output. The command is host-resolved
|
||||||
/// (a library id via [`launch_command`], or an operator-typed apps.json/custom command) — never a
|
/// (a library id via [`resolve_launch`], or an operator-typed apps.json/custom command) — never a
|
||||||
/// client-sent string. Best-effort by contract: a failure leaves the user on the (streamed)
|
/// client-sent string. Best-effort by contract: a failure leaves the user on the (streamed)
|
||||||
/// desktop/session rather than tearing the stream down.
|
/// desktop/session rather than tearing the stream down.
|
||||||
///
|
///
|
||||||
@@ -190,18 +249,29 @@ pub fn launch_gamestream_library(id: &str) -> Result<()> {
|
|||||||
/// * **gamescope (bare spawn)** — not routed here: the command was nested into the fresh gamescope
|
/// * **gamescope (bare spawn)** — not routed here: the command was nested into the fresh gamescope
|
||||||
/// via `set_launch_command` (the caller gates on `vdisplay::launch_is_nested`).
|
/// via `set_launch_command` (the caller gates on `vdisplay::launch_is_nested`).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn launch_session_command(compositor: crate::vdisplay::Compositor, cmd: &str) -> Result<()> {
|
pub fn launch_session_command(
|
||||||
|
compositor: crate::vdisplay::Compositor,
|
||||||
|
cmd: &str,
|
||||||
|
) -> Result<SpawnedLaunch> {
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
let cmd = cmd.trim();
|
let cmd = cmd.trim();
|
||||||
anyhow::ensure!(!cmd.is_empty(), "empty command");
|
anyhow::ensure!(!cmd.is_empty(), "empty command");
|
||||||
let child = match compositor {
|
let (child, group_leader) = match compositor {
|
||||||
crate::vdisplay::Compositor::Gamescope => {
|
crate::vdisplay::Compositor::Gamescope => {
|
||||||
crate::vdisplay::launch_into_gamescope_session(cmd)?
|
(crate::vdisplay::launch_into_gamescope_session(cmd)?, false)
|
||||||
}
|
}
|
||||||
_ => std::process::Command::new("sh")
|
_ => (
|
||||||
|
std::process::Command::new("sh")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg(cmd)
|
.arg(cmd)
|
||||||
|
// Its own process group, so ending this game later signals the shell *and* the game
|
||||||
|
// it exec'd or forked — the whole tree the host started — and nothing else. Also
|
||||||
|
// detaches it from any signal the host's own group receives.
|
||||||
|
.process_group(0)
|
||||||
.spawn()
|
.spawn()
|
||||||
.context("spawn launch command")?,
|
.context("spawn launch command")?,
|
||||||
|
true,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
command = %cmd,
|
command = %cmd,
|
||||||
@@ -209,27 +279,10 @@ pub fn launch_session_command(compositor: crate::vdisplay::Compositor, cmd: &str
|
|||||||
compositor = compositor.id(),
|
compositor = compositor.id(),
|
||||||
"launched app into the live session"
|
"launched app into the live session"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(SpawnedLaunch {
|
||||||
}
|
child,
|
||||||
|
group_leader,
|
||||||
/// Resolve the launch command for a session app selection on Linux: a store-qualified library id
|
})
|
||||||
/// (from either plane) wins, else the operator-typed command. `None` = nothing to launch (or an
|
|
||||||
/// unknown/recipe-less id — warned, so a client picking a stale title sees why nothing started).
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn resolve_session_launch(library_id: Option<&str>, command: Option<&str>) -> Option<String> {
|
|
||||||
if let Some(id) = library_id {
|
|
||||||
match launch_command(id) {
|
|
||||||
Some(cmd) => return Some(cmd),
|
|
||||||
None => tracing::warn!(
|
|
||||||
launch_id = id,
|
|
||||||
"requested launch id not in this host's library (or no launch recipe) — ignoring"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
command
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|c| !c.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -55,22 +55,36 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
|
|||||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
|
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
|
||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
let mut stmt = conn.prepare(
|
// `directory` (the game's install dir — our detect signal) is not load-bearing for the library, so
|
||||||
"SELECT id, slug, name FROM games \
|
// a pga.db schema without it must not cost the whole Lutris store: try the richer query first and
|
||||||
|
// fall back to the historical one on any prepare error.
|
||||||
|
const SELECT_WITH_DIR: &str = "SELECT id, slug, name, directory FROM games \
|
||||||
WHERE installed = 1 AND name IS NOT NULL AND name <> '' \
|
WHERE installed = 1 AND name IS NOT NULL AND name <> '' \
|
||||||
ORDER BY name COLLATE NOCASE",
|
ORDER BY name COLLATE NOCASE";
|
||||||
)?;
|
const SELECT_PLAIN: &str = "SELECT id, slug, name, NULL FROM games \
|
||||||
|
WHERE installed = 1 AND name IS NOT NULL AND name <> '' \
|
||||||
|
ORDER BY name COLLATE NOCASE";
|
||||||
|
let mut stmt = match conn.prepare(SELECT_WITH_DIR) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "lutris pga.db has no `directory` column — listing without \
|
||||||
|
install dirs (game-exit detection unavailable for Lutris titles)");
|
||||||
|
conn.prepare(SELECT_PLAIN)?
|
||||||
|
}
|
||||||
|
};
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
Ok((
|
Ok((
|
||||||
row.get::<_, i64>(0)?,
|
row.get::<_, i64>(0)?,
|
||||||
row.get::<_, Option<String>>(1)?,
|
row.get::<_, Option<String>>(1)?,
|
||||||
row.get::<_, String>(2)?,
|
row.get::<_, String>(2)?,
|
||||||
|
row.get::<_, Option<String>>(3)?,
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let mut games = Vec::new();
|
let mut games = Vec::new();
|
||||||
for (id, slug, name) in rows.flatten() {
|
for (id, slug, name, directory) in rows.flatten() {
|
||||||
games.push(GameEntry {
|
games.push(GameEntry {
|
||||||
provider: None,
|
provider: None,
|
||||||
|
meta: GameMeta::pc(),
|
||||||
id: format!("lutris:{id}"),
|
id: format!("lutris:{id}"),
|
||||||
store: "lutris".into(),
|
store: "lutris".into(),
|
||||||
title: name,
|
title: name,
|
||||||
@@ -79,6 +93,12 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
|
|||||||
kind: "lutris_id".into(),
|
kind: "lutris_id".into(),
|
||||||
value: id.to_string(),
|
value: id.to_string(),
|
||||||
}),
|
}),
|
||||||
|
// Lutris stamps no per-game env marker we can rely on, so the install dir is the whole
|
||||||
|
// recipe; a game with none (an emulator entry pointing at a bare ROM) stays untracked.
|
||||||
|
detect: directory
|
||||||
|
.filter(|d| !d.trim().is_empty())
|
||||||
|
.map(DetectSpec::dir)
|
||||||
|
.unwrap_or_default(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(games)
|
Ok(games)
|
||||||
|
|||||||
@@ -17,25 +17,33 @@ impl LibraryProvider for SteamProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn list(&self) -> Vec<GameEntry> {
|
fn list(&self) -> Vec<GameEntry> {
|
||||||
let mut by_appid: std::collections::BTreeMap<u32, String> = Default::default();
|
let mut by_appid: std::collections::BTreeMap<u32, Installed> = Default::default();
|
||||||
for steamapps in steam_library_dirs() {
|
for steamapps in steam_library_dirs() {
|
||||||
for (appid, name) in scan_manifests(&steamapps) {
|
for app in scan_manifests(&steamapps) {
|
||||||
by_appid.entry(appid).or_insert(name); // first library wins; dedups shared appids
|
// First library wins; dedups appids present in several libraries.
|
||||||
|
by_appid.entry(app.appid).or_insert(app);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut games: Vec<GameEntry> = by_appid
|
let mut games: Vec<GameEntry> = by_appid
|
||||||
.into_iter()
|
.into_values()
|
||||||
.filter(|(appid, name)| !is_steam_tool(*appid, name))
|
.filter(|app| !is_steam_tool(app.appid, &app.name))
|
||||||
.map(|(appid, title)| GameEntry {
|
.map(|app| GameEntry {
|
||||||
provider: None,
|
provider: None,
|
||||||
id: format!("steam:{appid}"),
|
meta: GameMeta::pc(),
|
||||||
|
id: format!("steam:{}", app.appid),
|
||||||
store: "steam".into(),
|
store: "steam".into(),
|
||||||
title,
|
art: steam_art(app.appid),
|
||||||
art: steam_art(appid),
|
|
||||||
launch: Some(LaunchSpec {
|
launch: Some(LaunchSpec {
|
||||||
kind: "steam_appid".into(),
|
kind: "steam_appid".into(),
|
||||||
value: appid.to_string(),
|
value: app.appid.to_string(),
|
||||||
}),
|
}),
|
||||||
|
// The appid alone is authoritative on Linux (Steam's launch reaper); the install dir
|
||||||
|
// is what the Windows matcher — which has no reaper to watch — keys off instead.
|
||||||
|
detect: match app.install_dir {
|
||||||
|
Some(dir) => DetectSpec::steam(app.appid).with_dir(dir),
|
||||||
|
None => DetectSpec::steam(app.appid),
|
||||||
|
},
|
||||||
|
title: app.name,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
// Non-Steam shortcuts have no `appmanifest` — [`scan_manifests`] can't see them, so the
|
// Non-Steam shortcuts have no `appmanifest` — [`scan_manifests`] can't see them, so the
|
||||||
@@ -274,8 +282,17 @@ fn vdf_value<'a>(line: &'a str, key: &str) -> Option<&'a str> {
|
|||||||
Some(&after[..after.find('"')?])
|
Some(&after[..after.find('"')?])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan a `steamapps` dir for `appmanifest_*.acf` files → (appid, name) of installed titles.
|
/// One installed Steam title, as read from its `appmanifest_<appid>.acf`.
|
||||||
fn scan_manifests(steamapps: &Path) -> Vec<(u32, String)> {
|
struct Installed {
|
||||||
|
appid: u32,
|
||||||
|
name: String,
|
||||||
|
/// `<steamapps>/common/<installdir>`, when the manifest names one and it exists on disk — the
|
||||||
|
/// game's own files, used to recognize its processes ([`DetectSpec::install_dir`]).
|
||||||
|
install_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan a `steamapps` dir for `appmanifest_*.acf` files → the installed titles it describes.
|
||||||
|
fn scan_manifests(steamapps: &Path) -> Vec<Installed> {
|
||||||
let Ok(rd) = std::fs::read_dir(steamapps) else {
|
let Ok(rd) = std::fs::read_dir(steamapps) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
@@ -290,7 +307,17 @@ fn scan_manifests(steamapps: &Path) -> Vec<(u32, String)> {
|
|||||||
let appid = text.lines().find_map(|l| vdf_value(l.trim(), "appid"));
|
let appid = text.lines().find_map(|l| vdf_value(l.trim(), "appid"));
|
||||||
let name = text.lines().find_map(|l| vdf_value(l.trim(), "name"));
|
let name = text.lines().find_map(|l| vdf_value(l.trim(), "name"));
|
||||||
if let (Some(Ok(appid)), Some(name)) = (appid.map(str::parse::<u32>), name) {
|
if let (Some(Ok(appid)), Some(name)) = (appid.map(str::parse::<u32>), name) {
|
||||||
out.push((appid, name.to_string()));
|
// `installdir` is a bare folder name relative to this library's `common/`.
|
||||||
|
let install_dir = text
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| vdf_value(l.trim(), "installdir"))
|
||||||
|
.map(|d| steamapps.join("common").join(d))
|
||||||
|
.filter(|p| p.is_dir());
|
||||||
|
out.push(Installed {
|
||||||
|
appid,
|
||||||
|
name: name.to_string(),
|
||||||
|
install_dir,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,6 +345,9 @@ struct Shortcut {
|
|||||||
appid: u32,
|
appid: u32,
|
||||||
/// Display name (`AppName`).
|
/// Display name (`AppName`).
|
||||||
name: String,
|
name: String,
|
||||||
|
/// The shortcut's target (`Exe`), as stored — Steam quotes it. This *is* the game (a shortcut
|
||||||
|
/// points straight at it, with no launcher in between), so it doubles as the detect signal.
|
||||||
|
exe: String,
|
||||||
/// Whether Steam has this shortcut hidden from the library (`IsHidden`) — we honor that.
|
/// Whether Steam has this shortcut hidden from the library (`IsHidden`) — we honor that.
|
||||||
hidden: bool,
|
hidden: bool,
|
||||||
}
|
}
|
||||||
@@ -353,6 +383,7 @@ fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
|
|||||||
}
|
}
|
||||||
Some(GameEntry {
|
Some(GameEntry {
|
||||||
provider: None,
|
provider: None,
|
||||||
|
meta: GameMeta::pc(),
|
||||||
id: format!("steam:{}", sc.appid),
|
id: format!("steam:{}", sc.appid),
|
||||||
store: "steam".into(),
|
store: "steam".into(),
|
||||||
title: sc.name,
|
title: sc.name,
|
||||||
@@ -361,9 +392,22 @@ fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
|
|||||||
kind: "steam_appid".into(),
|
kind: "steam_appid".into(),
|
||||||
value: shortcut_gameid(sc.appid).to_string(),
|
value: shortcut_gameid(sc.appid).to_string(),
|
||||||
}),
|
}),
|
||||||
|
detect: shortcut_detect(&sc.exe),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect signals for a non-Steam shortcut: its `Exe` target is the game itself, so the executable
|
||||||
|
/// (and its folder, which catches a launcher script that execs a sibling binary) identifies it. Steam
|
||||||
|
/// stores the target quoted and may include trailing arguments; only an existing absolute path is
|
||||||
|
/// asserted — a guess would be worse than no tracking at all.
|
||||||
|
fn shortcut_detect(exe: &str) -> DetectSpec {
|
||||||
|
let mut spec = crate::library::spec_from_command(exe);
|
||||||
|
if let Some(dir) = spec.exe.as_deref().and_then(Path::parent) {
|
||||||
|
spec.install_dir = Some(dir.to_path_buf());
|
||||||
|
}
|
||||||
|
spec
|
||||||
|
}
|
||||||
|
|
||||||
/// Every `userdata/<id>/config/shortcuts.vdf` under each Steam root — one file per Steam account
|
/// Every `userdata/<id>/config/shortcuts.vdf` under each Steam root — one file per Steam account
|
||||||
/// that has signed in on this host.
|
/// that has signed in on this host.
|
||||||
fn shortcuts_files() -> Vec<PathBuf> {
|
fn shortcuts_files() -> Vec<PathBuf> {
|
||||||
@@ -489,6 +533,7 @@ fn parse_one_shortcut(buf: &[u8], pos: &mut usize) -> Option<Shortcut> {
|
|||||||
Some(Shortcut {
|
Some(Shortcut {
|
||||||
appid,
|
appid,
|
||||||
name,
|
name,
|
||||||
|
exe,
|
||||||
hidden,
|
hidden,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -704,6 +749,7 @@ mod tests {
|
|||||||
let sc = Shortcut {
|
let sc = Shortcut {
|
||||||
appid: 2_456_789_012,
|
appid: 2_456_789_012,
|
||||||
name: "My Emulator".into(),
|
name: "My Emulator".into(),
|
||||||
|
exe: "\"/opt/emu/run.sh\"".into(),
|
||||||
hidden: false,
|
hidden: false,
|
||||||
};
|
};
|
||||||
let entry = shortcut_entry(sc).unwrap();
|
let entry = shortcut_entry(sc).unwrap();
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ fn xbox_games() -> Vec<GameEntry> {
|
|||||||
let art = cached_art(&id).unwrap_or_default();
|
let art = cached_art(&id).unwrap_or_default();
|
||||||
games.push(GameEntry {
|
games.push(GameEntry {
|
||||||
provider: None,
|
provider: None,
|
||||||
|
meta: GameMeta::pc(),
|
||||||
id,
|
id,
|
||||||
store: "xbox".into(),
|
store: "xbox".into(),
|
||||||
title,
|
title,
|
||||||
@@ -78,6 +79,9 @@ fn xbox_games() -> Vec<GameEntry> {
|
|||||||
kind: "aumid".into(),
|
kind: "aumid".into(),
|
||||||
value: format!("{pfn}!{app_id}"),
|
value: format!("{pfn}!{app_id}"),
|
||||||
}),
|
}),
|
||||||
|
// AUMID activation goes through the shell, so the host never owns the process: the
|
||||||
|
// title's `Content` dir (which holds the game's binaries) is the detect signal.
|
||||||
|
detect: DetectSpec::dir(title_dir.join("Content")),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ mod encode {
|
|||||||
pub(crate) use pf_encode::*;
|
pub(crate) use pf_encode::*;
|
||||||
}
|
}
|
||||||
mod events;
|
mod events;
|
||||||
|
// The lifetime of a launched game: whether it is running, when it exits (which can end the session),
|
||||||
|
// and how to end it (which a session ending can ask for) — design/session-game-lifetime.md.
|
||||||
|
mod gamelease;
|
||||||
|
// The Win32 half of ending a game: WM_CLOSE onto the interactive desktop, then TerminateProcess.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "windows/game_term.rs"]
|
||||||
|
mod game_term;
|
||||||
mod gamestream;
|
mod gamestream;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "linux/gpuclocks.rs"]
|
#[path = "linux/gpuclocks.rs"]
|
||||||
@@ -66,11 +73,17 @@ mod native;
|
|||||||
mod native_pairing;
|
mod native_pairing;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
mod plugins;
|
mod plugins;
|
||||||
|
// Finding a launched game's processes from its store's detect signals — the read side of the
|
||||||
|
// session⇄game lifetime binding (design/session-game-lifetime.md §4). Per-OS matchers inside; on a
|
||||||
|
// platform with neither (macOS, which has no launch path either) the module is an empty shell.
|
||||||
|
mod procscan;
|
||||||
mod send_pacing;
|
mod send_pacing;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "windows/service.rs"]
|
#[path = "windows/service.rs"]
|
||||||
mod service;
|
mod service;
|
||||||
mod session_plan;
|
mod session_plan;
|
||||||
|
// Operator policy for the session⇄game lifetime binding (`session-settings.json`).
|
||||||
|
mod session_settings;
|
||||||
mod session_status;
|
mod session_status;
|
||||||
mod sleep_inhibit;
|
mod sleep_inhibit;
|
||||||
mod spike;
|
mod spike;
|
||||||
@@ -338,7 +351,15 @@ fn real_main() -> Result<()> {
|
|||||||
// adapter, which may not be the one that encodes).
|
// adapter, which may not be the one that encodes).
|
||||||
let mut size = (64u32, 64u32);
|
let mut size = (64u32, 64u32);
|
||||||
let mut vendor = None;
|
let mut vendor = None;
|
||||||
for a in args.iter().skip(2) {
|
// `args` starts AT the subcommand (main builds it with `env::args().skip(1)`), so the
|
||||||
|
// optional arguments begin at index 1 — cf. `args.get(1)` in the `service` arm. This
|
||||||
|
// read `skip(2)` and so silently swallowed the first optional argument: the documented
|
||||||
|
// `hdr-p010-selftest 1920x1080 nvidia` picked up the vendor but ran at the 64x64
|
||||||
|
// DEFAULT, and a size-only invocation parsed nothing at all and still printed PASS.
|
||||||
|
// The size is the whole point of the flag — 1080 is not 16-aligned and takes a
|
||||||
|
// different driver path — so the self-test was passing on the one geometry that
|
||||||
|
// exercises the least.
|
||||||
|
for a in args.iter().skip(1) {
|
||||||
match a.as_str() {
|
match a.as_str() {
|
||||||
"intel" => vendor = Some(0x8086),
|
"intel" => vendor = Some(0x8086),
|
||||||
"nvidia" => vendor = Some(0x10de),
|
"nvidia" => vendor = Some(0x10de),
|
||||||
|
|||||||
@@ -214,6 +214,11 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
.routes(routes!(native::deny_pending_device))
|
.routes(routes!(native::deny_pending_device))
|
||||||
.routes(routes!(session::stop_session))
|
.routes(routes!(session::stop_session))
|
||||||
.routes(routes!(session::request_idr))
|
.routes(routes!(session::request_idr))
|
||||||
|
.routes(routes!(
|
||||||
|
session::get_session_settings,
|
||||||
|
session::set_session_settings
|
||||||
|
))
|
||||||
|
.routes(routes!(session::end_game))
|
||||||
.routes(routes!(library::get_library))
|
.routes(routes!(library::get_library))
|
||||||
.routes(routes!(library::list_library_scanners))
|
.routes(routes!(library::list_library_scanners))
|
||||||
.routes(routes!(library::set_library_scanner))
|
.routes(routes!(library::set_library_scanner))
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ pub(crate) struct GpuState {
|
|||||||
/// `PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is
|
/// `PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is
|
||||||
/// `auto`; a manual preference overrides it.
|
/// `auto`; a manual preference overrides it.
|
||||||
env_override: Option<String>,
|
env_override: Option<String>,
|
||||||
|
/// `PUNKTFUNK_ENCODER` (the host.env encoder pin), when set to something other than `auto`
|
||||||
|
/// (e.g. `qsv`, `nvenc`, `amf`, `software`). A pin whose vendor contradicts the selected
|
||||||
|
/// GPU is overridden at session open — the adapter wins — so the console can warn that the
|
||||||
|
/// pin is stale rather than letting the selection look broken.
|
||||||
|
encoder_pin: Option<String>,
|
||||||
/// The GPU the next session will use.
|
/// The GPU the next session will use.
|
||||||
selected: Option<ApiSelectedGpu>,
|
selected: Option<ApiSelectedGpu>,
|
||||||
/// The GPU live sessions use right now (absent while nothing is streaming).
|
/// The GPU live sessions use right now (absent while nothing is streaming).
|
||||||
@@ -143,11 +148,36 @@ pub(crate) fn gpu_state() -> GpuState {
|
|||||||
.render_adapter
|
.render_adapter
|
||||||
.clone()
|
.clone()
|
||||||
.filter(|s| !s.is_empty()),
|
.filter(|s| !s.is_empty()),
|
||||||
|
encoder_pin: encoder_pin_of(&pf_host_config::config().encoder_pref),
|
||||||
selected,
|
selected,
|
||||||
active,
|
active,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `PUNKTFUNK_ENCODER` value worth surfacing to the console: `None` for unset/empty and for
|
||||||
|
/// an explicit `auto` (both mean "derive from the selected adapter" — nothing is pinned).
|
||||||
|
fn encoder_pin_of(pref: &str) -> Option<String> {
|
||||||
|
match pref {
|
||||||
|
"" | "auto" => None,
|
||||||
|
pin => Some(pin.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Only a real pin surfaces to the console — the `auto` spellings pin nothing, and showing
|
||||||
|
/// them would put a permanent scary note under every default install's GPU card.
|
||||||
|
#[test]
|
||||||
|
fn encoder_pin_surfaces_only_real_pins() {
|
||||||
|
assert_eq!(encoder_pin_of(""), None);
|
||||||
|
assert_eq!(encoder_pin_of("auto"), None);
|
||||||
|
assert_eq!(encoder_pin_of("qsv"), Some("qsv".into()));
|
||||||
|
assert_eq!(encoder_pin_of("software"), Some("software".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// GPU inventory and selection
|
/// GPU inventory and selection
|
||||||
///
|
///
|
||||||
/// Lists the host's hardware GPUs, the persisted auto/manual preference, the GPU the next session
|
/// Lists the host's hardware GPUs, the persisted auto/manual preference, the GPU the next session
|
||||||
|
|||||||
@@ -112,6 +112,38 @@ pub(crate) struct RuntimeStatus {
|
|||||||
/// The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's
|
/// The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's
|
||||||
/// mode/codec/bitrate. `null` when nothing is streaming.
|
/// mode/codec/bitrate. `null` when nothing is streaming.
|
||||||
stream: Option<StreamInfo>,
|
stream: Option<StreamInfo>,
|
||||||
|
/// Every launched game the host is tracking: one row per live session that launched a title, plus
|
||||||
|
/// any game whose session has ended and which is waiting out its reconnect window before being
|
||||||
|
/// ended (`state: "grace"`). Empty when nothing was launched — a plain desktop stream has no game.
|
||||||
|
games: Vec<ActiveGame>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One launched game, for the console's running-game card.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct ActiveGame {
|
||||||
|
/// The session streaming it; `null` for a game waiting out its reconnect window.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
session_id: Option<u64>,
|
||||||
|
/// Client-supplied device name of the session that launched it; may be empty.
|
||||||
|
client: String,
|
||||||
|
/// Store-qualified library id (`steam:570`) — the key the console matches against `GET /library`
|
||||||
|
/// to show box art. Absent for an operator-typed GameStream command.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
app_id: Option<String>,
|
||||||
|
/// Display title.
|
||||||
|
title: String,
|
||||||
|
/// Which store surfaced it (`steam`, `heroic`, `custom`, …), when known.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
store: Option<String>,
|
||||||
|
/// `native` or `gamestream`.
|
||||||
|
plane: crate::events::Plane,
|
||||||
|
/// `launching` (launched, not seen running yet), `running`, `exited`, or `grace` (its session is
|
||||||
|
/// gone and it will be ended when the reconnect window closes).
|
||||||
|
#[schema(example = "running")]
|
||||||
|
state: String,
|
||||||
|
/// Seconds until this game is ended — only present on a `grace` row.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
grace_remaining_s: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Client-requested launch parameters (key material is never exposed here).
|
/// Client-requested launch parameters (key material is never exposed here).
|
||||||
@@ -175,6 +207,13 @@ pub(crate) struct LocalSummary {
|
|||||||
/// the tray/console surface them so the clash is visible before pairing silently fails.
|
/// the tray/console surface them so the clash is visible before pairing silently fails.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
conflicts: Vec<String>,
|
conflicts: Vec<String>,
|
||||||
|
/// Launched games the host is tracking, as compact labels (`Hades`, `Hades (closing in 4:12)`).
|
||||||
|
///
|
||||||
|
/// The countdown form is the one that matters: it means the game's client is gone and the host
|
||||||
|
/// will end the game when the window closes — something a user at the machine should be able to
|
||||||
|
/// see (and stop) without opening the console. Empty when nothing was launched.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
games: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Liveness probe
|
/// Liveness probe
|
||||||
@@ -373,6 +412,19 @@ pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<Runtime
|
|||||||
active_sessions: native.len() as u32 + u32::from(gs_video),
|
active_sessions: native.len() as u32 + u32::from(gs_video),
|
||||||
session,
|
session,
|
||||||
stream,
|
stream,
|
||||||
|
games: crate::session_status::games()
|
||||||
|
.into_iter()
|
||||||
|
.map(|g| ActiveGame {
|
||||||
|
session_id: g.session_id,
|
||||||
|
client: g.client,
|
||||||
|
app_id: g.app_id,
|
||||||
|
title: g.title,
|
||||||
|
store: g.store,
|
||||||
|
plane: g.plane,
|
||||||
|
state: g.state.to_string(),
|
||||||
|
grace_remaining_s: g.grace_remaining_s,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,5 +496,12 @@ pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<
|
|||||||
// Cached at `serve` startup (empty when nothing was detected / never scanned) — no per-poll
|
// Cached at `serve` startup (empty when nothing was detected / never scanned) — no per-poll
|
||||||
// process enumeration.
|
// process enumeration.
|
||||||
conflicts: crate::detect::summary_labels(crate::detect::snapshot()),
|
conflicts: crate::detect::summary_labels(crate::detect::snapshot()),
|
||||||
|
games: crate::session_status::games()
|
||||||
|
.into_iter()
|
||||||
|
.map(|g| match g.grace_remaining_s {
|
||||||
|
Some(left) => format!("{} (closing in {}:{:02})", g.title, left / 60, left % 60),
|
||||||
|
None => g.title,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use axum::http::header;
|
|||||||
pub(crate) struct LibraryQuery {
|
pub(crate) struct LibraryQuery {
|
||||||
/// Only entries owned by this external provider (RFC §8).
|
/// Only entries owned by this external provider (RFC §8).
|
||||||
provider: Option<String>,
|
provider: Option<String>,
|
||||||
|
/// Only entries on this platform (case-insensitive).
|
||||||
|
platform: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List the game library
|
/// List the game library
|
||||||
@@ -15,7 +17,8 @@ pub(crate) struct LibraryQuery {
|
|||||||
/// Every installed-store title (Steam, read from the host's local files — no Steam API key)
|
/// Every installed-store title (Steam, read from the host's local files — no Steam API key)
|
||||||
/// merged with the user's custom entries, sorted by title. Artwork fields are URLs the client
|
/// merged with the user's custom entries, sorted by title. Artwork fields are URLs the client
|
||||||
/// fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
|
/// fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
|
||||||
/// entries a given external provider owns.
|
/// entries a given external provider owns; `?platform=` to one platform (case-insensitive —
|
||||||
|
/// installed-store titles are `PC`, custom/provider entries carry whatever was authored).
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/library",
|
path = "/library",
|
||||||
@@ -23,6 +26,7 @@ pub(crate) struct LibraryQuery {
|
|||||||
operation_id = "getLibrary",
|
operation_id = "getLibrary",
|
||||||
params(
|
params(
|
||||||
("provider" = Option<String>, Query, description = "Only entries owned by this external provider"),
|
("provider" = Option<String>, Query, description = "Only entries owned by this external provider"),
|
||||||
|
("platform" = Option<String>, Query, description = "Only entries on this platform (case-insensitive, e.g. `PS2`)"),
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
|
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
|
||||||
@@ -36,6 +40,14 @@ pub(crate) async fn get_library(
|
|||||||
if let Some(provider) = q.provider.filter(|p| !p.is_empty()) {
|
if let Some(provider) = q.provider.filter(|p| !p.is_empty()) {
|
||||||
games.retain(|g| g.provider.as_deref() == Some(provider.as_str()));
|
games.retain(|g| g.provider.as_deref() == Some(provider.as_str()));
|
||||||
}
|
}
|
||||||
|
if let Some(platform) = q.platform.filter(|p| !p.is_empty()) {
|
||||||
|
games.retain(|g| {
|
||||||
|
g.meta
|
||||||
|
.platform
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|p| p.eq_ignore_ascii_case(&platform))
|
||||||
|
});
|
||||||
|
}
|
||||||
// Rewrite provider entries' local-file art into host art-proxy URLs so a client fetches covers
|
// Rewrite provider entries' local-file art into host art-proxy URLs so a client fetches covers
|
||||||
// from the host (a provider like Playnite stores on-host paths; the payload stays tiny at any
|
// from the host (a provider like Playnite stores on-host paths; the payload stays tiny at any
|
||||||
// library size, and the client never sees an unreachable `C:\…`).
|
// library size, and the client never sees an unreachable `C:\…`).
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ use std::sync::atomic::Ordering;
|
|||||||
///
|
///
|
||||||
/// Kicks the connected client: stops the video/audio stream threads and clears the launch
|
/// Kicks the connected client: stops the video/audio stream threads and clears the launch
|
||||||
/// state. Idempotent — succeeds even when nothing is streaming.
|
/// state. Idempotent — succeeds even when nothing is streaming.
|
||||||
|
///
|
||||||
|
/// Counts as a **deliberate** stop, exactly like a client pressing Stop: the display skips its
|
||||||
|
/// keep-alive linger, and the end-game-on-session-end policy (if the operator enabled one) applies.
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
delete,
|
delete,
|
||||||
path = "/session",
|
path = "/session",
|
||||||
@@ -19,11 +22,11 @@ use std::sync::atomic::Ordering;
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub(crate) async fn stop_session(State(st): State<Arc<MgmtState>>) -> StatusCode {
|
pub(crate) async fn stop_session(State(st): State<Arc<MgmtState>>) -> StatusCode {
|
||||||
let was_streaming = st.app.end_session("management API stop");
|
let was_streaming = st.app.quit_session("management API stop");
|
||||||
// Native plane: the GameStream teardown above doesn't reach it (it runs its own loops off the shared
|
// Native plane: the GameStream teardown above doesn't reach it (it runs its own loops off the shared
|
||||||
// session registry), so signal every live native session to tear down too.
|
// session registry), so signal every live native session to tear down too.
|
||||||
let native = crate::session_status::count();
|
let native = crate::session_status::count();
|
||||||
crate::session_status::stop_all();
|
crate::session_status::stop_all_quit();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
was_streaming,
|
was_streaming,
|
||||||
native_sessions = native,
|
native_sessions = native,
|
||||||
@@ -32,6 +35,126 @@ pub(crate) async fn stop_session(State(st): State<Arc<MgmtState>>) -> StatusCode
|
|||||||
StatusCode::NO_CONTENT
|
StatusCode::NO_CONTENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// End a launched game
|
||||||
|
///
|
||||||
|
/// Ends a game whose session has already gone and which is waiting out its reconnect window — the
|
||||||
|
/// console's "End now" for a game the host is about to close anyway. `app_id` picks one title; omit it
|
||||||
|
/// to end every waiting game.
|
||||||
|
///
|
||||||
|
/// This does **not** touch a game whose session is still live: ending that is session management
|
||||||
|
/// (`DELETE /session`), and how the game is treated then follows the operator's policy.
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/game/end",
|
||||||
|
tag = "session",
|
||||||
|
operation_id = "endGame",
|
||||||
|
request_body = EndGameRequest,
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "How many waiting games were ended", body = EndGameResult),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
(status = CONFLICT, description = "No game is waiting to be ended", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn end_game(ApiJson(req): ApiJson<EndGameRequest>) -> Response {
|
||||||
|
let ended = crate::gamelease::end_pending(req.app_id.as_deref());
|
||||||
|
if ended == 0 {
|
||||||
|
return api_error(StatusCode::CONFLICT, "no game is waiting to be ended");
|
||||||
|
}
|
||||||
|
tracing::info!(app_id = ?req.app_id, ended, "management API: game ended");
|
||||||
|
Json(EndGameResult { ended }).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for `endGame`.
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub(crate) struct EndGameRequest {
|
||||||
|
/// Store-qualified library id (`steam:570`) to end; omit to end every waiting game.
|
||||||
|
#[serde(default)]
|
||||||
|
pub app_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of an `endGame`.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct EndGameResult {
|
||||||
|
/// How many waiting games were ended.
|
||||||
|
ended: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session⇄game lifetime settings, plus which axes this build acts on.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct SessionSettingsState {
|
||||||
|
/// The stored settings (or the built-in defaults when this host has never been configured).
|
||||||
|
settings: crate::session_settings::SessionSettings,
|
||||||
|
/// Whether an operator has ever saved these settings (`false` ⇒ `settings` are the defaults).
|
||||||
|
configured: bool,
|
||||||
|
/// Which fields this build actually enforces. Empty on a platform with no launch path (macOS),
|
||||||
|
/// so the console can say so instead of offering a switch that does nothing.
|
||||||
|
enforced: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_settings_state() -> SessionSettingsState {
|
||||||
|
let store = crate::session_settings::store();
|
||||||
|
SessionSettingsState {
|
||||||
|
settings: store.get(),
|
||||||
|
configured: store.configured(),
|
||||||
|
enforced: crate::session_settings::enforced(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session⇄game lifetime settings
|
||||||
|
///
|
||||||
|
/// Whether a launched game's exit ends the streaming session, and whether a session ending ends the
|
||||||
|
/// game (with the reconnect window that protects a dropped client's unsaved progress). See
|
||||||
|
/// `design/session-game-lifetime.md`.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/session/settings",
|
||||||
|
tag = "session",
|
||||||
|
operation_id = "getSessionSettings",
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Stored settings + which axes this build enforces", body = SessionSettingsState),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn get_session_settings() -> Json<SessionSettingsState> {
|
||||||
|
Json(session_settings_state())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the session⇄game lifetime settings
|
||||||
|
///
|
||||||
|
/// Persists the settings (clamped) and applies them from the next decision — including to a session
|
||||||
|
/// that is already streaming, since the policy is read when a session ends rather than when it starts.
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/session/settings",
|
||||||
|
tag = "session",
|
||||||
|
operation_id = "setSessionSettings",
|
||||||
|
request_body = crate::session_settings::SessionSettings,
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Settings stored; the new state", body = SessionSettingsState),
|
||||||
|
(status = BAD_REQUEST, description = "Malformed settings body", body = ApiError),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "Settings could not be persisted", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn set_session_settings(
|
||||||
|
ApiJson(settings): ApiJson<crate::session_settings::SessionSettings>,
|
||||||
|
) -> Response {
|
||||||
|
if let Err(e) = crate::session_settings::store().set(settings) {
|
||||||
|
return api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("persist session settings: {e:#}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let state = session_settings_state();
|
||||||
|
tracing::info!(
|
||||||
|
game_on_session_end = state.settings.game_on_session_end.as_str(),
|
||||||
|
session_on_game_exit = state.settings.session_on_game_exit,
|
||||||
|
grace_s = state.settings.disconnect_grace_seconds,
|
||||||
|
"management API: session⇄game lifetime settings updated"
|
||||||
|
);
|
||||||
|
Json(state).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
/// Force a keyframe
|
/// Force a keyframe
|
||||||
///
|
///
|
||||||
/// Asks the encoder for an IDR frame on the active video stream (what a client requests
|
/// Asks the encoder for an IDR frame on the active video stream (what a client requests
|
||||||
|
|||||||
@@ -306,17 +306,20 @@ fn fake_native_session(
|
|||||||
fps: u32,
|
fps: u32,
|
||||||
) -> crate::session_status::LiveSessionGuard {
|
) -> crate::session_status::LiveSessionGuard {
|
||||||
let packed = ((width as u64) << 32) | ((height as u64) << 16) | fps as u64;
|
let packed = ((width as u64) << 32) | ((height as u64) << 16) | fps as u64;
|
||||||
crate::session_status::register(
|
crate::session_status::register(crate::session_status::Registration {
|
||||||
Arc::new(std::sync::atomic::AtomicU64::new(packed)),
|
mode: Arc::new(std::sync::atomic::AtomicU64::new(packed)),
|
||||||
Arc::new(std::sync::atomic::AtomicU32::new(20_000)),
|
bitrate_kbps: Arc::new(std::sync::atomic::AtomicU32::new(20_000)),
|
||||||
Codec::H265,
|
codec: Codec::H265,
|
||||||
Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
stop: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
quit: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
"test-client".into(),
|
force_idr: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
false,
|
client: "test-client".into(),
|
||||||
Arc::new(std::sync::atomic::AtomicU32::new(0)),
|
hdr: false,
|
||||||
Arc::new(std::sync::atomic::AtomicU32::new(0)),
|
ttff_ms: Arc::new(std::sync::atomic::AtomicU32::new(0)),
|
||||||
)
|
last_resize_ms: Arc::new(std::sync::atomic::AtomicU32::new(0)),
|
||||||
|
// No launch: a desktop stream, which must show no game row.
|
||||||
|
game: None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A native (punktfunk/1) session — the DEFAULT plane — must read as streaming in the tray's
|
/// A native (punktfunk/1) session — the DEFAULT plane — must read as streaming in the tray's
|
||||||
@@ -1234,6 +1237,13 @@ async fn gpu_endpoints_list_and_validate() {
|
|||||||
assert_eq!(s, StatusCode::OK);
|
assert_eq!(s, StatusCode::OK);
|
||||||
assert!(b["gpus"].is_array());
|
assert!(b["gpus"].is_array());
|
||||||
assert!(b["mode"].is_string());
|
assert!(b["mode"].is_string());
|
||||||
|
// The host.env encoder pin is part of the schema (null when nothing is pinned) — the
|
||||||
|
// console warns off it when a pin contradicts the selected GPU (the pin is overridden at
|
||||||
|
// session open, and without this field the selection would just look broken).
|
||||||
|
assert!(
|
||||||
|
b.as_object().unwrap().contains_key("encoder_pin"),
|
||||||
|
"listGpus must carry encoder_pin"
|
||||||
|
);
|
||||||
|
|
||||||
// Unknown mode → 400.
|
// Unknown mode → 400.
|
||||||
let (s, _) = send(
|
let (s, _) = send(
|
||||||
|
|||||||
@@ -1012,9 +1012,10 @@ async fn serve_session(
|
|||||||
// just never fires then.
|
// just never fires then.
|
||||||
let (cursor_shape_tx, cursor_shape_rx) =
|
let (cursor_shape_tx, cursor_shape_rx) =
|
||||||
tokio::sync::mpsc::unbounded_channel::<punktfunk_core::quic::CursorShape>();
|
tokio::sync::mpsc::unbounded_channel::<punktfunk_core::quic::CursorShape>();
|
||||||
// Negotiated cursor forwarding: MUST match the HOST_CAP_CURSOR bit the Welcome advertised
|
// Negotiated cursor forwarding: the HOST_CAP_CURSOR bit the Welcome advertised, read back
|
||||||
// (handshake::cursor_forward is the single predicate both read).
|
// rather than recomputed (`handshake::cursor_forward` computed it once, with the encoder
|
||||||
let cursor_forward = handshake::cursor_forward(hello.client_caps, compositor);
|
// blend-capability gate — re-running it here could drift, and would re-probe).
|
||||||
|
let cursor_forward = welcome.host_caps & punktfunk_core::quic::HOST_CAP_CURSOR != 0;
|
||||||
// Who renders the pointer RIGHT NOW (client `CursorRenderMode`, flipped live by the mouse-
|
// Who renders the pointer RIGHT NOW (client `CursorRenderMode`, flipped live by the mouse-
|
||||||
// model chord): `true` = client draws (exclude + forward), `false` = host composites (the
|
// model chord): `true` = client draws (exclude + forward), `false` = host composites (the
|
||||||
// capture model). Starts true — the pre-message behavior for cap sessions. Control task
|
// capture model). Starts true — the pre-message behavior for cap sessions. Control task
|
||||||
@@ -1302,14 +1303,23 @@ async fn serve_session(
|
|||||||
// to its shell command HERE against the host's own library — a client can only ever pick an
|
// to its shell command HERE against the host's own library — a client can only ever pick an
|
||||||
// existing title, never send a command — and the data plane runs it per-backend (nested into a
|
// existing title, never send a command — and the data plane runs it per-backend (nested into a
|
||||||
// bare-spawn gamescope, or spawned into the live session once capture is up).
|
// bare-spawn gamescope, or spawned into the live session once capture is up).
|
||||||
#[cfg(target_os = "windows")]
|
// ONE library lookup for the whole session: enumerating the installed stores touches every
|
||||||
let launch_for_dp = hello.launch.clone();
|
// launcher's on-disk metadata, and the data plane needs three things out of it — what to run, what
|
||||||
#[cfg(not(target_os = "windows"))]
|
// to call the title, and how to recognize its process once a launcher has handed off
|
||||||
let launch_for_dp = hello.launch.as_deref().and_then(|id| {
|
// (design/session-game-lifetime.md §4).
|
||||||
match crate::library::launch_command(id) {
|
let launch_target =
|
||||||
Some(cmd) => {
|
hello
|
||||||
tracing::info!(launch_id = id, command = %cmd, "resolved library launch for this session");
|
.launch
|
||||||
Some(cmd)
|
.as_deref()
|
||||||
|
.and_then(|id| match crate::library::resolve_launch(id) {
|
||||||
|
Some(t) => {
|
||||||
|
tracing::info!(
|
||||||
|
launch_id = id,
|
||||||
|
title = %t.game.title,
|
||||||
|
command = t.command.as_deref().unwrap_or("-"),
|
||||||
|
"resolved library launch for this session"
|
||||||
|
);
|
||||||
|
Some(t)
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -1318,8 +1328,18 @@ async fn serve_session(
|
|||||||
);
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let launch_for_dp = launch_target.as_ref().and(hello.launch.clone());
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let launch_for_dp = launch_target.as_ref().and_then(|t| t.command.clone());
|
||||||
|
// A client reconnecting inside its game's reconnect window takes the game back: nothing is ended,
|
||||||
|
// and this session adopts it. Matched on (this client, this title) so it can only ever reclaim its
|
||||||
|
// own game.
|
||||||
|
if let Some(target) = launch_target.as_ref() {
|
||||||
|
let fp = punktfunk_core::quic::endpoint::peer_fingerprint(&conn).map(hex::encode);
|
||||||
|
crate::gamelease::readopt(fp.as_deref(), target.game.id.as_deref());
|
||||||
|
}
|
||||||
// Per-title prep steps (RFC §6) for a launched CUSTOM library title: run synchronously
|
// Per-title prep steps (RFC §6) for a launched CUSTOM library title: run synchronously
|
||||||
// before the data plane starts (so before the display opens and the title spawns); the
|
// before the data plane starts (so before the display opens and the title spawns); the
|
||||||
// guard's drop — any serve_session exit — runs the undos in reverse, best-effort.
|
// guard's drop — any serve_session exit — runs the undos in reverse, best-effort.
|
||||||
@@ -1465,6 +1485,7 @@ async fn serve_session(
|
|||||||
stats: stats_dp,
|
stats: stats_dp,
|
||||||
client_label,
|
client_label,
|
||||||
launch: launch_for_dp,
|
launch: launch_for_dp,
|
||||||
|
launch_target,
|
||||||
client_hdr,
|
client_hdr,
|
||||||
bringup: bringup_dp,
|
bringup: bringup_dp,
|
||||||
resize_ms: resize_ms_dp,
|
resize_ms: resize_ms_dp,
|
||||||
|
|||||||
@@ -12,31 +12,46 @@ use super::*;
|
|||||||
/// the client asked ([`CLIENT_CAP_CURSOR`](punktfunk_core::quic::CLIENT_CAP_CURSOR)) AND the
|
/// the client asked ([`CLIENT_CAP_CURSOR`](punktfunk_core::quic::CLIENT_CAP_CURSOR)) AND the
|
||||||
/// capture path can deliver cursor metadata separately from the frame — the Linux portal
|
/// capture path can deliver cursor metadata separately from the frame — the Linux portal
|
||||||
/// `SPA_META_Cursor` path (not gamescope, whose capture paints no cursor at all), or Windows
|
/// `SPA_META_Cursor` path (not gamescope, whose capture paints no cursor at all), or Windows
|
||||||
/// with a proto-v5 pf-vdisplay driver (the IddCx hardware-cursor channel, M2c). THE single
|
/// with a proto-v5 pf-vdisplay driver (the IddCx hardware-cursor channel, M2c) — AND, on
|
||||||
/// predicate: the Welcome's `HOST_CAP_CURSOR` bit and the session's forwarding/blend-off
|
/// Linux, the encode backend this session resolves to can composite the pointer on demand
|
||||||
/// wiring both read it, so they can never disagree.
|
/// (`encode::cursor_blend_capable`): the channel's capture-mouse flip (`CursorRenderMode`,
|
||||||
|
/// `client_draws = false`) makes the HOST draw the pointer, and on Linux the encoder is that
|
||||||
|
/// compositing stage — granting the channel over a backend that can't blend (libav
|
||||||
|
/// VAAPI/NVENC, software) shipped a cursorless stream on every capture-mode flip. Denied, the
|
||||||
|
/// session keeps the pre-channel path: the compositor EMBEDS the pointer and the client never
|
||||||
|
/// draws — never cursorless, never doubled. THE single predicate: the Welcome's
|
||||||
|
/// `HOST_CAP_CURSOR` bit is computed from it, and the session wiring reads that bit back.
|
||||||
pub(super) fn cursor_forward(
|
pub(super) fn cursor_forward(
|
||||||
client_caps: u8,
|
client_caps: u8,
|
||||||
compositor: Option<crate::vdisplay::Compositor>,
|
compositor: Option<crate::vdisplay::Compositor>,
|
||||||
|
codec: crate::encode::Codec,
|
||||||
|
bit_depth: u8,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if client_caps & punktfunk_core::quic::CLIENT_CAP_CURSOR == 0 {
|
if client_caps & punktfunk_core::quic::CLIENT_CAP_CURSOR == 0 {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
|
// CUDA-payload prediction — the same one `SessionPlan` makes: the NVIDIA resolution
|
||||||
|
// plus the zero-copy master switch. It decides direct-SDK NVENC (blends) vs libav
|
||||||
|
// NVENC (doesn't) inside the capability mirror.
|
||||||
|
let cuda_planned = !crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
|
||||||
compositor.is_some_and(|c| c != crate::vdisplay::Compositor::Gamescope)
|
compositor.is_some_and(|c| c != crate::vdisplay::Compositor::Gamescope)
|
||||||
|
&& crate::encode::cursor_blend_capable(codec, cuda_planned, bit_depth == 10)
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
// Windows (M2c): the pf-vdisplay driver must speak the v5 hardware-cursor channel —
|
// Windows (M2c): the pf-vdisplay driver must speak the v5 hardware-cursor channel —
|
||||||
// DWM composites the pointer into the IDD frame otherwise, and forwarding a second
|
// DWM composites the pointer into the IDD frame otherwise, and forwarding a second
|
||||||
// copy would double it. The probe latches by opening the control device once.
|
// copy would double it. The probe latches by opening the control device once. The
|
||||||
let _ = compositor;
|
// encoder is deliberately NOT consulted: the IDD capturer itself composites on the
|
||||||
|
// capture-mouse flip (`set_cursor_forward`), so no Windows encode backend blends.
|
||||||
|
let _ = (compositor, codec, bit_depth);
|
||||||
crate::vdisplay::manager::hw_cursor_capable()
|
crate::vdisplay::manager::hw_cursor_capable()
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
{
|
{
|
||||||
let _ = compositor;
|
let _ = (compositor, codec, bit_depth);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,12 +205,14 @@ pub(super) async fn negotiate(
|
|||||||
// (gamescope available) gets its own headless gamescope spawn at the client mode. Gate on
|
// (gamescope available) gets its own headless gamescope spawn at the client mode. Gate on
|
||||||
// whether the launch id actually RESOLVES to a command in the host's library — an unknown
|
// whether the launch id actually RESOLVES to a command in the host's library — an unknown
|
||||||
// id must fall back to normal auto routing, not a blank "sleep infinity" gamescope
|
// id must fall back to normal auto routing, not a blank "sleep infinity" gamescope
|
||||||
// (review #9). (dedicated is Linux-only; the resolver is the non-Windows launch_command.)
|
// (review #9). (dedicated is Linux-only, and only there does `resolve_launch` carry a
|
||||||
|
// command — on Windows the concrete process is resolved at launch time instead.)
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
let has_resolvable_launch = hello
|
let has_resolvable_launch = hello
|
||||||
.launch
|
.launch
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(crate::library::launch_command)
|
.and_then(crate::library::resolve_launch)
|
||||||
|
.and_then(|t| t.command)
|
||||||
.is_some();
|
.is_some();
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let has_resolvable_launch = false;
|
let has_resolvable_launch = false;
|
||||||
@@ -252,6 +269,12 @@ pub(super) async fn negotiate(
|
|||||||
// *monitor* streams only — the GameStream portal-mirror path uses that; see
|
// *monitor* streams only — the GameStream portal-mirror path uses that; see
|
||||||
// `gamestream::host_hdr_capable`), so a Linux native session honestly stays 8-bit SDR even
|
// `gamestream::host_hdr_capable`), so a Linux native session honestly stays 8-bit SDR even
|
||||||
// though `can_encode_10bit` now probes true on a Main10-capable GPU.
|
// though `can_encode_10bit` now probes true on a Main10-capable GPU.
|
||||||
|
//
|
||||||
|
// That `false` is also why this plane needs no equivalent of the GameStream path's
|
||||||
|
// `pf_capture::hdr_capture_failed()` check (rtsp.rs): that latch is a fact about the PORTAL
|
||||||
|
// capturer's HDR offer, and the native plane captures a virtual output instead — it never
|
||||||
|
// reaches the portal path, and on Linux it never negotiates 10-bit at all. Revisit both halves
|
||||||
|
// together if Mutter ever gains HDR for RecordVirtual streams.
|
||||||
let capture_supports_hdr = crate::capture::capturer_supports_hdr();
|
let capture_supports_hdr = crate::capture::capturer_supports_hdr();
|
||||||
// The GPU probe may open a tiny encoder on first use, so run it off the reactor like the
|
// The GPU probe may open a tiny encoder on first use, so run it off the reactor like the
|
||||||
// 4:4:4 probe below (blocking probes → spawn_blocking), short-circuited behind the cheap
|
// 4:4:4 probe below (blocking probes → spawn_blocking), short-circuited behind the cheap
|
||||||
@@ -291,9 +314,14 @@ pub(super) async fn negotiate(
|
|||||||
let host_wants_444 = pf_host_config::config().four_four_four;
|
let host_wants_444 = pf_host_config::config().four_four_four;
|
||||||
let client_supports_444 = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0;
|
let client_supports_444 = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0;
|
||||||
// The active capturer must be able to deliver a full-chroma (RGB) source — the honest-downgrade
|
// The active capturer must be able to deliver a full-chroma (RGB) source — the honest-downgrade
|
||||||
// gate. Linux's portal capturer can; the Windows IDD-push path delivers subsampled NV12/P010
|
// gate. Linux's portal capturer always can (`capturer_supports_444` returns `true`
|
||||||
// today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host
|
// unconditionally). On WINDOWS the IDD-push path CAN too — for an SDR 4:4:4 session it passes
|
||||||
// negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only
|
// the BGRA ring slot straight through, skipping the NV12 VideoConverter — but only a backend
|
||||||
|
// that ingests RGB and CSCs it to 4:4:4 itself can consume that, so the Windows arm forwards
|
||||||
|
// `resolved_backend_ingests_rgb_444()` (today: direct-NVENC only; AMF can't 4:4:4 at all and
|
||||||
|
// the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR display still downgrades to 4:2:0
|
||||||
|
// at capture time — there is no 10-bit full-chroma source — and the encoder's caps cross-check
|
||||||
|
// reports that truth. (Replaces the old `single_process` gate — single-process is now the only
|
||||||
// topology, and 4:4:4 routed to DDA, which was removed.)
|
// topology, and 4:4:4 routed to DDA, which was removed.)
|
||||||
// PyroWave does its own RGB→YCbCr CSC and its capture mode always delivers a full-chroma
|
// PyroWave does its own RGB→YCbCr CSC and its capture mode always delivers a full-chroma
|
||||||
// (RGB/BGRA) source on both OSes — the capturer gate is inherently satisfied; the real
|
// (RGB/BGRA) source on both OSes — the capturer gate is inherently satisfied; the real
|
||||||
@@ -488,9 +516,10 @@ pub(super) async fn negotiate(
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
// Cursor channel granted (client asked + this capture path can deliver cursor
|
// Cursor channel granted (client asked + this capture path can deliver cursor
|
||||||
// metadata out of the frame) — the client turns its local renderer on ONLY when
|
// metadata out of the frame + the resolved encoder can composite on the
|
||||||
// it sees this bit, and serve_session wires forwarding from the same predicate.
|
// capture-mouse flip) — the client turns its local renderer on ONLY when it sees
|
||||||
| if cursor_forward(hello.client_caps, compositor) {
|
// this bit, and serve_session wires forwarding by reading the bit back.
|
||||||
|
| if cursor_forward(hello.client_caps, compositor, codec, bit_depth) {
|
||||||
punktfunk_core::quic::HOST_CAP_CURSOR
|
punktfunk_core::quic::HOST_CAP_CURSOR
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
@@ -536,9 +565,9 @@ pub(super) async fn negotiate(
|
|||||||
let (ctx_tx, ctx_rx) = std::sync::mpsc::sync_channel::<SessionContext>(1);
|
let (ctx_tx, ctx_rx) = std::sync::mpsc::sync_channel::<SessionContext>(1);
|
||||||
let client_identity = endpoint::peer_fingerprint(conn);
|
let client_identity = endpoint::peer_fingerprint(conn);
|
||||||
let client_hdr = hello.display_hdr;
|
let client_hdr = hello.display_hdr;
|
||||||
// Same predicate the Welcome's HOST_CAP_CURSOR bit used — the prepared display and
|
// The bit the Welcome just advertised — read back rather than recomputed, so the
|
||||||
// the session wiring must agree with what we just advertised.
|
// prepared display and the session wiring cannot disagree with it.
|
||||||
let cursor_fw = cursor_forward(hello.client_caps, Some(comp));
|
let cursor_fw = welcome.host_caps & punktfunk_core::quic::HOST_CAP_CURSOR != 0;
|
||||||
let (mode, shard_payload) = (hello.mode, welcome.shard_payload);
|
let (mode, shard_payload) = (hello.mode, welcome.shard_payload);
|
||||||
let trace = bringup.clone();
|
let trace = bringup.clone();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
|
|||||||
@@ -988,6 +988,10 @@ pub(super) struct SessionContext {
|
|||||||
/// command already resolved against the host's own library — nested into gamescope's bare spawn
|
/// command already resolved against the host's own library — nested into gamescope's bare spawn
|
||||||
/// via `set_launch_command`, or spawned into the live session once capture is up.
|
/// via `set_launch_command`, or spawned into the live session once capture is up.
|
||||||
pub(super) launch: Option<String>,
|
pub(super) launch: Option<String>,
|
||||||
|
/// Identity + detection metadata for the launched title, resolved once at handshake time
|
||||||
|
/// alongside `launch`. `None` when nothing was launched. Drives the game's lifetime — its exit
|
||||||
|
/// can end this session, and this session ending can end it (design/session-game-lifetime.md).
|
||||||
|
pub(super) launch_target: Option<crate::library::LaunchTarget>,
|
||||||
/// The client display's HDR colour volume (`Hello::display_hdr`; `None` = older client / SDR).
|
/// The client display's HDR colour volume (`Hello::display_hdr`; `None` = older client / SDR).
|
||||||
/// Threaded into the vdisplay backend before `create` (→ the pf-vdisplay EDID's CTA HDR block,
|
/// Threaded into the vdisplay backend before `create` (→ the pf-vdisplay EDID's CTA HDR block,
|
||||||
/// so host apps tone-map to the client's real panel) and preferred over the generic baseline
|
/// so host apps tone-map to the client's real panel) and preferred over the generic baseline
|
||||||
@@ -1022,8 +1026,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// pointer compositor-EMBEDDED (`vd.set_hw_cursor(false)` → no cursor metadata, nothing to
|
// pointer compositor-EMBEDDED (`vd.set_hw_cursor(false)` → no cursor metadata, nothing to
|
||||||
// blend), keeping the zero-cost pre-channel path. gamescope is the exception (Phase C):
|
// blend), keeping the zero-cost pre-channel path. gamescope is the exception (Phase C):
|
||||||
// it can't embed the pointer, so the host ALWAYS composites the XFixes-sourced cursor —
|
// it can't embed the pointer, so the host ALWAYS composites the XFixes-sourced cursor —
|
||||||
// the blend must be built for every gamescope session.
|
// the blend must be built for every gamescope session. (`cursor_forward` is already
|
||||||
ctx.compositor == pf_vdisplay::Compositor::Gamescope || ctx.cursor_forward,
|
// blend-gated: `handshake::cursor_forward` grants the channel only where
|
||||||
|
// `encode::cursor_blend_capable` says the resolved backend composites.)
|
||||||
|
crate::session_plan::cursor_blend_for(
|
||||||
|
ctx.cursor_forward,
|
||||||
|
ctx.compositor == pf_vdisplay::Compositor::Gamescope,
|
||||||
|
),
|
||||||
ctx.cursor_forward,
|
ctx.cursor_forward,
|
||||||
);
|
);
|
||||||
// gamescope: the XFixes cursor source feeds the always-on composite (Phase C). Set after
|
// gamescope: the XFixes cursor source feeds the always-on composite (Phase C). Set after
|
||||||
@@ -1072,10 +1081,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
stats,
|
stats,
|
||||||
client_label,
|
client_label,
|
||||||
launch,
|
launch,
|
||||||
|
launch_target,
|
||||||
client_hdr,
|
client_hdr,
|
||||||
bringup,
|
bringup,
|
||||||
resize_ms,
|
resize_ms,
|
||||||
} = ctx;
|
} = ctx;
|
||||||
|
// Reference point for adopting the launched game's processes: anything the host will call "this
|
||||||
|
// session's game" has to have started after this instant. Taken HERE, before the display (and
|
||||||
|
// therefore before a bare-spawn gamescope's nested child) exists, because a reading taken after
|
||||||
|
// the launch would reject the very process it is meant to find. Erring early is the safe
|
||||||
|
// direction: it can only ever include more of our own launch, never a copy from before it.
|
||||||
|
let launch_stamp = crate::gamelease::launch_clock();
|
||||||
// Streamed-AU wire mode: the client's cap AND the host escape hatch (`PUNKTFUNK_STREAMED_AU=0`
|
// Streamed-AU wire mode: the client's cap AND the host escape hatch (`PUNKTFUNK_STREAMED_AU=0`
|
||||||
// reverts to whole-AU sends without touching the encoder's slicing knobs). The third gate —
|
// reverts to whole-AU sends without touching the encoder's slicing knobs). The third gate —
|
||||||
// whether the ENCODER actually chunks — is dynamic (`supports_chunked_poll`, per AU).
|
// whether the ENCODER actually chunks — is dynamic (`supports_chunked_poll`, per AU).
|
||||||
@@ -1206,40 +1222,58 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
if let Some(cmd) = launch.as_deref() {
|
let spawned_launch = match launch.as_deref() {
|
||||||
if crate::vdisplay::launch_is_nested(compositor) {
|
Some(cmd) if crate::vdisplay::launch_is_nested(compositor) => {
|
||||||
tracing::info!(command = %cmd, "launch nested into the per-session gamescope");
|
tracing::info!(command = %cmd, "launch nested into the per-session gamescope");
|
||||||
} else if let Err(e) = crate::library::launch_session_command(compositor, cmd) {
|
None
|
||||||
|
}
|
||||||
|
Some(cmd) => match crate::library::launch_session_command(compositor, cmd) {
|
||||||
|
Ok(spawned) => Some(spawned),
|
||||||
|
Err(e) => {
|
||||||
tracing::warn!(command = %cmd, error = %e, "could not launch requested title into the session");
|
tracing::warn!(command = %cmd, error = %e, "could not launch requested title into the session");
|
||||||
|
None
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||||
let _ = &launch;
|
let _ = &launch;
|
||||||
|
|
||||||
// Dedicated Steam launch: end the stream cleanly when the LAUNCHED GAME exits. The node-death
|
// The launched game's lifetime, in both directions (design/session-game-lifetime.md):
|
||||||
// check in the capture-loss branch below can't see this for Steam — the nested `steam` is the
|
//
|
||||||
// resident client and stays up after a game quits, so gamescope (and its node) never dies and the
|
// * **its exit ends this session** — so a client returns to its library instead of sitting on a
|
||||||
// stream would sit on a hidden Steam session forever. Watch the game process directly (Steam's
|
// hidden launcher or a bare desktop. This generalizes what used to be a Steam-and-gamescope-only
|
||||||
// `SteamLaunch AppId=<id>` reaper, whose lifetime == the game's) and close with APP_EXITED when
|
// watch: the game is now recognized from whatever its store told us (appid, install dir, exe,
|
||||||
// it's gone, so a launcher client returns to its library. Non-Steam nested launches keep the
|
// env marker), which covers every compositor and every store. The node-death check in the
|
||||||
// node-death path (gamescope's child IS the game). Cancelled via `stop` when the session ends for
|
// capture-loss branch below stays as the backstop for a nested launch we can't otherwise see.
|
||||||
// another reason first; the thread self-terminates, so we don't join it.
|
// * **this session ending can end it** — never by default; only when the operator asked, and for
|
||||||
|
// a mere disconnect only after a reconnect window (`_game_life`'s drop, below).
|
||||||
|
let game_lease = launch_target.as_ref().map(|target| {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let _game_watch = launch
|
let nested = crate::vdisplay::launch_is_nested(compositor);
|
||||||
.as_deref()
|
#[cfg(not(target_os = "linux"))]
|
||||||
.filter(|_| crate::vdisplay::launch_is_nested(compositor))
|
let nested = false;
|
||||||
.and_then(crate::vdisplay::steam_appid_from_launch)
|
#[cfg(target_os = "linux")]
|
||||||
.map(|appid| {
|
let child = spawned_launch.map(|s| (s.child, s.group_leader));
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let child = None;
|
||||||
|
|
||||||
|
let on_exit: crate::gamelease::OnExit = {
|
||||||
let conn = conn.clone();
|
let conn = conn.clone();
|
||||||
let stop = stop.clone();
|
let stop = stop.clone();
|
||||||
let quit = quit.clone();
|
let quit = quit.clone();
|
||||||
std::thread::Builder::new()
|
Box::new(move || {
|
||||||
.name("pf1-gamewatch".into())
|
// Read the setting at fire time, so flipping it mid-session takes effect. The lease
|
||||||
.spawn(move || {
|
// itself keeps running either way — the status surface still reports the game.
|
||||||
if crate::vdisplay::watch_steam_game_exit(appid, &stop) {
|
if !crate::session_settings::get().session_on_game_exit {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
appid,
|
"the launched game exited, but ending the session on game exit is off — \
|
||||||
"dedicated Steam game exited — ending the session cleanly (APP_EXITED)"
|
leaving the stream up"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
"the launched game exited — ending the session cleanly (APP_EXITED)"
|
||||||
);
|
);
|
||||||
// Close FIRST so APP_EXITED is the winning close code (quinn keeps the first
|
// Close FIRST so APP_EXITED is the winning close code (quinn keeps the first
|
||||||
// application close), then set the flags: `quit` skips the display lease's
|
// application close), then set the flags: `quit` skips the display lease's
|
||||||
@@ -1250,9 +1284,32 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
);
|
);
|
||||||
quit.store(true, Ordering::SeqCst);
|
quit.store(true, Ordering::SeqCst);
|
||||||
stop.store(true, Ordering::SeqCst);
|
stop.store(true, Ordering::SeqCst);
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.ok()
|
};
|
||||||
|
crate::gamelease::open(
|
||||||
|
crate::gamelease::LeaseRequest {
|
||||||
|
game: target.game.clone(),
|
||||||
|
client: client_label.clone(),
|
||||||
|
plane: crate::events::Plane::Native,
|
||||||
|
spec: target.detect.clone(),
|
||||||
|
nested,
|
||||||
|
child,
|
||||||
|
launch_stamp,
|
||||||
|
},
|
||||||
|
on_exit,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let game_shared = game_lease.as_ref().map(|l| l.shared());
|
||||||
|
// Declared here so it drops *after* the live-session registration below (reverse declaration
|
||||||
|
// order): `session.ended` fires first, then the game policy runs — the order an operator reading
|
||||||
|
// the log expects. The fingerprint is what lets a reconnecting client reclaim its own game and
|
||||||
|
// nothing else.
|
||||||
|
let _game_life = game_lease.map(|lease| {
|
||||||
|
crate::gamelease::SessionGuard::new(
|
||||||
|
lease,
|
||||||
|
quit.clone(),
|
||||||
|
endpoint::peer_fingerprint(&conn).map(hex::encode),
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
let perf = pf_host_config::config().perf;
|
let perf = pf_host_config::config().perf;
|
||||||
@@ -1324,17 +1381,19 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// (`GET /status`) shows the native stream — resolution/fps/codec/bitrate resolve live from the
|
// (`GET /status`) shows the native stream — resolution/fps/codec/bitrate resolve live from the
|
||||||
// same handles a mid-stream mode switch / adaptive-bitrate change updates. The guard clears the
|
// same handles a mid-stream mode switch / adaptive-bitrate change updates. The guard clears the
|
||||||
// entry when this loop exits (return / `?` / panic), so the Dashboard tracks the session's life.
|
// entry when this loop exits (return / `?` / panic), so the Dashboard tracks the session's life.
|
||||||
let _live_session = crate::session_status::register(
|
let _live_session = crate::session_status::register(crate::session_status::Registration {
|
||||||
live_mode.clone(),
|
mode: live_mode.clone(),
|
||||||
live_bitrate.clone(),
|
bitrate_kbps: live_bitrate.clone(),
|
||||||
plan.codec,
|
codec: plan.codec,
|
||||||
stop.clone(),
|
stop: stop.clone(),
|
||||||
force_idr.clone(),
|
quit: quit.clone(),
|
||||||
client_label,
|
force_idr: force_idr.clone(),
|
||||||
plan.hdr,
|
client: client_label,
|
||||||
bringup.total_slot(),
|
hdr: plan.hdr,
|
||||||
resize_ms.clone(),
|
ttff_ms: bringup.total_slot(),
|
||||||
);
|
last_resize_ms: resize_ms.clone(),
|
||||||
|
game: game_shared,
|
||||||
|
});
|
||||||
|
|
||||||
// Mid-stream session-switch watcher (opt-in via PUNKTFUNK_SESSION_WATCH; never under an explicit
|
// Mid-stream session-switch watcher (opt-in via PUNKTFUNK_SESSION_WATCH; never under an explicit
|
||||||
// PUNKTFUNK_COMPOSITOR pin). It self-baselines and signals the loop below to swap the backend in
|
// PUNKTFUNK_COMPOSITOR pin). It self-baselines and signals the loop below to swap the backend in
|
||||||
@@ -1372,6 +1431,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
let mut cur_mode = mode;
|
let mut cur_mode = mode;
|
||||||
const MAX_CAPTURE_REBUILDS: u32 = 5;
|
const MAX_CAPTURE_REBUILDS: u32 = 5;
|
||||||
let mut capture_rebuilds: u32 = 0;
|
let mut capture_rebuilds: u32 = 0;
|
||||||
|
// Exclusive-topology eviction generation last seen (Windows IDD-push; see the recovery block
|
||||||
|
// in the loop): the vdisplay watchdog bumps it on every eviction, each of which drives
|
||||||
|
// COMMIT_MODES on the live IDD path and orphans this pipeline's capture ring.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let mut seen_reassert_gen = crate::vdisplay::manager::topology_reassert_gen();
|
||||||
// Encode-stall watchdog: AMF/QSV (and async NVENC) poll non-blocking, so a wedged driver
|
// Encode-stall watchdog: AMF/QSV (and async NVENC) poll non-blocking, so a wedged driver
|
||||||
// shows up as poll() returning None forever while submits keep succeeding — `inflight` grows,
|
// shows up as poll() returning None forever while submits keep succeeding — `inflight` grows,
|
||||||
// no AU ever reaches the send thread, and the client freezes on the last frame with nothing
|
// no AU ever reaches the send thread, and the client freezes on the last frame with nothing
|
||||||
@@ -1600,6 +1664,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
plan,
|
plan,
|
||||||
&quit,
|
&quit,
|
||||||
resize_trace.as_ref(),
|
resize_trace.as_ref(),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
let fast_done = false;
|
let fast_done = false;
|
||||||
@@ -1696,6 +1761,55 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
resize_trace.finish("pipeline_rebuilt");
|
resize_trace.finish("pipeline_rebuilt");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Exclusive-topology eviction recovery (Windows IDD-push): the vdisplay watchdog just
|
||||||
|
// evicted a display that crept back into the "exclusive" desktop, via the full isolate —
|
||||||
|
// its forced re-commit restarts OS presentation to the virtual display (a gentle
|
||||||
|
// supplied-config eviction left capture one stashed frame and then nothing, on-glass),
|
||||||
|
// but it also hands the live IDD path a fresh swap-chain while this pipeline's ring
|
||||||
|
// keeps waiting on the old attachment; with an unchanged descriptor the poller's
|
||||||
|
// two-strike debounce never trips, so frames would just stop. Rebuild the capture
|
||||||
|
// attachment in place at the CURRENT mode (same-mode ring recreate + driver re-attach +
|
||||||
|
// fresh encoder — the resize fast path's cost). If even that fails, end the session with
|
||||||
|
// a clear error: the client's reconnect rebuilds from scratch, which beats streaming a
|
||||||
|
// frozen image forever.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
if plan.capture == crate::session_plan::CaptureBackend::IddPush {
|
||||||
|
let reassert_gen = crate::vdisplay::manager::topology_reassert_gen();
|
||||||
|
if reassert_gen != seen_reassert_gen {
|
||||||
|
seen_reassert_gen = reassert_gen;
|
||||||
|
tracing::info!(
|
||||||
|
"exclusive-topology eviction bounced the virtual display's modes — rebuilding \
|
||||||
|
the capture attachment in place at the current mode"
|
||||||
|
);
|
||||||
|
let trace = crate::bringup::Trace::start("reassert-recover", resize_ms.clone());
|
||||||
|
if try_inplace_resize(
|
||||||
|
&mut vd,
|
||||||
|
&mut capturer,
|
||||||
|
&mut enc,
|
||||||
|
&mut frame,
|
||||||
|
&mut interval,
|
||||||
|
cur_mode,
|
||||||
|
bitrate_kbps,
|
||||||
|
bit_depth,
|
||||||
|
plan,
|
||||||
|
&quit,
|
||||||
|
trace.as_ref(),
|
||||||
|
true,
|
||||||
|
) {
|
||||||
|
// The owed AUs died with the old encoder — same bookkeeping as a resize.
|
||||||
|
inflight.clear();
|
||||||
|
last_au_at = std::time::Instant::now();
|
||||||
|
encoder_resets = 0;
|
||||||
|
last_forced_idr = Some(std::time::Instant::now());
|
||||||
|
trace.finish("pipeline_rebuilt");
|
||||||
|
} else {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"exclusive-topology eviction recovery failed — ending the session for a \
|
||||||
|
clean reconnect (a fresh bring-up re-attaches capture)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Adaptive bitrate: drain to the NEWEST requested rate (the client's controller may step
|
// Adaptive bitrate: drain to the NEWEST requested rate (the client's controller may step
|
||||||
// several times while we stream) and retarget the ENCODER ONLY — the mode didn't change,
|
// several times while we stream) and retarget the ENCODER ONLY — the mode didn't change,
|
||||||
// so capture and the virtual output are untouched. Preferred lever: an IN-PLACE
|
// so capture and the virtual output are untouched. Preferred lever: an IN-PLACE
|
||||||
@@ -1997,8 +2111,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// write-only variable under `-D warnings` (the `let _ = &launch` idiom above).
|
// write-only variable under `-D warnings` (the `let _ = &launch` idiom above).
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
let _ = &cur_node_id;
|
let _ = &cur_node_id;
|
||||||
|
// Backstop for a nested launch the lease can't recognize (no detect signals): a
|
||||||
|
// bare-spawn gamescope exits with its child, so its node staying gone means the game
|
||||||
|
// quit. Honors the same operator setting as the lease's own exit path — with
|
||||||
|
// end-session-on-game-exit off, a lost capture is just a rebuild.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
if launch.is_some()
|
if launch.is_some()
|
||||||
|
&& crate::session_settings::get().session_on_game_exit
|
||||||
&& crate::vdisplay::launch_is_nested(compositor)
|
&& crate::vdisplay::launch_is_nested(compositor)
|
||||||
&& crate::vdisplay::dedicated_game_exited(cur_node_id)
|
&& crate::vdisplay::dedicated_game_exited(cur_node_id)
|
||||||
{
|
{
|
||||||
@@ -2098,8 +2217,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// capture-mode channel); a switch AWAY restores the prior
|
// capture-mode channel); a switch AWAY restores the prior
|
||||||
// gating. `plan` is `Copy` — this is the value the rebuild
|
// gating. `plan` is `Copy` — this is the value the rebuild
|
||||||
// (and its `build_pipeline` attach) reads.
|
// (and its `build_pipeline` attach) reads.
|
||||||
plan.cursor_blend = plan.cursor_forward
|
plan.cursor_blend = crate::session_plan::cursor_blend_for(
|
||||||
|| c == crate::vdisplay::Compositor::Gamescope;
|
plan.cursor_forward,
|
||||||
|
c == crate::vdisplay::Compositor::Gamescope,
|
||||||
|
);
|
||||||
plan.gamescope_cursor =
|
plan.gamescope_cursor =
|
||||||
c == crate::vdisplay::Compositor::Gamescope;
|
c == crate::vdisplay::Compositor::Gamescope;
|
||||||
gamescope_composite =
|
gamescope_composite =
|
||||||
@@ -2322,6 +2443,19 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// assign next. The RFI backends pin their frame numbering to it.
|
// assign next. The RFI backends pin their frame numbering to it.
|
||||||
let wire_index = au_seq.wrapping_add(inflight.len() as u32);
|
let wire_index = au_seq.wrapping_add(inflight.len() as u32);
|
||||||
if let Err(e) = enc.submit_indexed(&frame, wire_index) {
|
if let Err(e) = enc.submit_indexed(&frame, wire_index) {
|
||||||
|
// A typed-terminal error is a deterministic configuration failure — the identical
|
||||||
|
// wall on every attempt, so rebuilds can't help. End the session at once with the
|
||||||
|
// carried cause (observed: a stale PUNKTFUNK_ENCODER pin vs. the selected adapter
|
||||||
|
// burned all 5 rebuilds per connect while the client reconnected forever).
|
||||||
|
if e.downcast_ref::<crate::encode::TerminalEncoderError>()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
"encoder failed with a deterministic configuration error — ending the video \
|
||||||
|
session without rebuild attempts (see the error for the remedy)");
|
||||||
|
return Err(e).context("encoder submit");
|
||||||
|
}
|
||||||
// The input half of an encode stall: once the driver stops draining AUs, libavcodec's
|
// The input half of an encode stall: once the driver stops draining AUs, libavcodec's
|
||||||
// one-frame buffer fills and avcodec_send_frame starts failing (EAGAIN) — the same
|
// one-frame buffer fills and avcodec_send_frame starts failing (EAGAIN) — the same
|
||||||
// wedge the watchdog below catches, seen from submit. Rebuild the encoder in place
|
// wedge the watchdog below catches, seen from submit. Rebuild the encoder in place
|
||||||
@@ -2839,6 +2973,10 @@ fn try_inplace_resize(
|
|||||||
plan: crate::session_plan::SessionPlan,
|
plan: crate::session_plan::SessionPlan,
|
||||||
quit: &Arc<AtomicBool>,
|
quit: &Arc<AtomicBool>,
|
||||||
trace: &crate::bringup::Trace,
|
trace: &crate::bringup::Trace,
|
||||||
|
// Same-mode swap-chain recovery (the exclusive re-assert bounced the IDD's modes): recreate
|
||||||
|
// the ring even though the size is unchanged — `resize_output`'s same-size fast path would
|
||||||
|
// no-op exactly the case being recovered.
|
||||||
|
recover_ring: bool,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let Some(cur_target) = capturer.capture_target_id() else {
|
let Some(cur_target) = capturer.capture_target_id() else {
|
||||||
return false; // not an IDD-push capturer — nothing to reuse
|
return false; // not an IDD-push capturer — nothing to reuse
|
||||||
@@ -2869,7 +3007,12 @@ fn try_inplace_resize(
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if !capturer.resize_output(new_mode.width, new_mode.height) {
|
let ring_ok = if recover_ring {
|
||||||
|
capturer.recreate_ring_in_place()
|
||||||
|
} else {
|
||||||
|
capturer.resize_output(new_mode.width, new_mode.height)
|
||||||
|
};
|
||||||
|
if !ring_ok {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
trace.mark("ring_recreated");
|
trace.mark("ring_recreated");
|
||||||
@@ -2896,6 +3039,38 @@ fn try_inplace_resize(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Liveness gate for the eviction recovery: the driver re-delivers its STASH on re-attach, so
|
||||||
|
// the first frame proves only the ring — not that the OS resumed presenting (measured: the
|
||||||
|
// stash arrives in ~50 ms, then new_fps=0 forever). Require a SECOND, newer present — the
|
||||||
|
// forced mode reset just triggered a full redraw, so a live display produces one promptly —
|
||||||
|
// before declaring recovery; a stash-only re-attach must FAIL so the caller ends the session
|
||||||
|
// cleanly (a reconnect's fresh bring-up always recovers) instead of streaming a frozen frame.
|
||||||
|
let new_frame = if recover_ring {
|
||||||
|
let first_pts = new_frame.pts_ns;
|
||||||
|
let live_deadline = std::time::Instant::now() + std::time::Duration::from_millis(1500);
|
||||||
|
loop {
|
||||||
|
match capturer.try_latest() {
|
||||||
|
Ok(Some(f)) if f.pts_ns != first_pts => break f,
|
||||||
|
Ok(_) => {
|
||||||
|
if std::time::Instant::now() >= live_deadline {
|
||||||
|
tracing::warn!(
|
||||||
|
"eviction recovery: ring re-attached but only the stashed frame \
|
||||||
|
arrived — the OS is not presenting; failing the in-place recovery"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"),
|
||||||
|
"eviction recovery: capture failed while waiting for a live frame");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
new_frame
|
||||||
|
};
|
||||||
trace.mark("first_new_frame");
|
trace.mark("first_new_frame");
|
||||||
// Fresh encoder at the delivered size — the one component that can't follow a resolution
|
// Fresh encoder at the delivered size — the one component that can't follow a resolution
|
||||||
// change in place today (P2.4 stays unimplemented: `open_video` is ms-scale, measured).
|
// change in place today (P2.4 stays unimplemented: `open_video` is ms-scale, measured).
|
||||||
@@ -2987,7 +3162,10 @@ pub(super) fn prepare_display(
|
|||||||
// non-gamescope sessions get the pointer compositor-EMBEDDED, nothing to blend; the
|
// non-gamescope sessions get the pointer compositor-EMBEDDED, nothing to blend; the
|
||||||
// mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per tick for channel
|
// mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per tick for channel
|
||||||
// sessions). gamescope (Phase C) can't embed → always composites the XFixes cursor.
|
// sessions). gamescope (Phase C) can't embed → always composites the XFixes cursor.
|
||||||
compositor == pf_vdisplay::Compositor::Gamescope || cursor_forward,
|
crate::session_plan::cursor_blend_for(
|
||||||
|
cursor_forward,
|
||||||
|
compositor == pf_vdisplay::Compositor::Gamescope,
|
||||||
|
),
|
||||||
cursor_forward,
|
cursor_forward,
|
||||||
);
|
);
|
||||||
plan.gamescope_cursor = compositor == pf_vdisplay::Compositor::Gamescope;
|
plan.gamescope_cursor = compositor == pf_vdisplay::Compositor::Gamescope;
|
||||||
@@ -3248,20 +3426,23 @@ fn build_pipeline(
|
|||||||
let mut capturer =
|
let mut capturer =
|
||||||
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
||||||
.context("capture virtual output")?;
|
.context("capture virtual output")?;
|
||||||
// gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer gamescope's
|
// gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer a way to
|
||||||
// nested Xwayland — it reads the pointer over X11 (XFixes shape + QueryPointer position) and
|
// reach gamescope's nested Xwaylands — it reads the pointer over X11 (XFixes shape +
|
||||||
// feeds `cursor()`, which the encode loop composites. A failed discovery/connect leaves the
|
// QueryPointer position) and feeds `cursor()`, which the encode loop composites.
|
||||||
// stream cursorless (today's behaviour); non-gamescope plans skip this entirely.
|
// Non-gamescope plans skip this entirely.
|
||||||
|
//
|
||||||
|
// A PROVIDER, not the discovered list: gamescope creates the game's Xwayland when the game
|
||||||
|
// launches and advertises only the FIRST in any child's environ, so a list captured here misses
|
||||||
|
// it — and the cursor source would then blank the pointer for the whole game session (it asks
|
||||||
|
// the connected display "are you drawing the pointer?" and gets "no"). The source re-runs this
|
||||||
|
// every couple of seconds, so a stream that starts before the game converges, and a display
|
||||||
|
// that dies is retried. Same one-way-edge shape as the Windows channel senders: the closure
|
||||||
|
// wraps the host's discovery, and pf-capture never reaches back into pf-vdisplay.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
if plan.gamescope_cursor {
|
if plan.gamescope_cursor {
|
||||||
let targets = pf_vdisplay::gamescope_xwayland_cursor_targets();
|
capturer.attach_gamescope_cursor(std::sync::Arc::new(
|
||||||
if targets.is_empty() {
|
pf_vdisplay::gamescope_xwayland_cursor_targets,
|
||||||
tracing::warn!(
|
));
|
||||||
"gamescope cursor: no nested Xwayland discovered — no in-video pointer this session"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
capturer.attach_gamescope_cursor(targets);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(t) = trace {
|
if let Some(t) = trace {
|
||||||
t.mark("capture_attached");
|
t.mark("capture_attached");
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//! Finding a launched game's processes, from the signals its store gave us
|
||||||
|
//! ([`crate::library::DetectSpec`]).
|
||||||
|
//!
|
||||||
|
//! Read-only by construction: the host enumerates processes and reads metadata it already has
|
||||||
|
//! permission to see. No ptrace, no injection, no handles held open. On Linux it looks only at
|
||||||
|
//! processes owned by its **own uid**; on Windows it runs as SYSTEM and therefore *can* see
|
||||||
|
//! everything, which makes the two rules below the load-bearing part rather than an afterthought.
|
||||||
|
//!
|
||||||
|
//! ### The two rules
|
||||||
|
//!
|
||||||
|
//! 1. **Never adopt a process that predates the launch.** A player may already have the game open
|
||||||
|
//! when a session starts; treating that instance as "this session's game" would let a session end
|
||||||
|
//! kill a process it never started. Candidates are filtered by start time against
|
||||||
|
//! [`launch_stamp`], taken before anything spawns.
|
||||||
|
//! 2. **Never trust a bare pid.** Pids are recycled — aggressively so on Windows — and a lease can
|
||||||
|
//! outlive its game by a grace window, so every remembered process carries its start time and is
|
||||||
|
//! re-verified against it ([`Scanner::alive`]) before it is counted as running, or signalled.
|
||||||
|
//!
|
||||||
|
//! ### Per-OS implementations
|
||||||
|
//!
|
||||||
|
//! The two have nothing in common beyond that contract, so they are separate modules presenting the
|
||||||
|
//! same [`Scanner`] surface: `/proc` on Linux, a Toolhelp snapshot on Windows. Everything above the
|
||||||
|
//! scanner ([`crate::gamelease`]) is platform-neutral.
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub use linux::Scanner;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod windows;
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub use windows::Scanner;
|
||||||
|
|
||||||
|
/// A process the matcher adopted: its pid plus a start stamp that pins that pid to *this* process,
|
||||||
|
/// so a recycled pid can never be mistaken for it.
|
||||||
|
///
|
||||||
|
/// `start` is opaque and only ever compared for equality against a later read of the same pid — its
|
||||||
|
/// units differ per platform (clock ticks since boot on Linux, a creation `FILETIME` on Windows).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ProcRef {
|
||||||
|
pub pid: u32,
|
||||||
|
pub start: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tolerance on the "started after the launch" test, in seconds.
|
||||||
|
///
|
||||||
|
/// The launch stamp is taken just before the spawn, but start times are quantized (~10 ms on Linux)
|
||||||
|
/// and a launcher can race ahead of the host's own bookkeeping, so an exact comparison would
|
||||||
|
/// occasionally reject the real game. Two seconds is far below the time any launcher takes to bring a
|
||||||
|
/// game up, so it cannot let a *pre-existing* instance through.
|
||||||
|
pub const START_SLACK_SECS: f64 = 2.0;
|
||||||
|
|
||||||
|
/// The reference instant for adopting a launch's processes, in **seconds on the platform's
|
||||||
|
/// process-start timeline** — seconds since boot on Linux, seconds since the Windows epoch on
|
||||||
|
/// Windows. Only ever compared against a process's own start time on the same platform, never
|
||||||
|
/// interpreted as a wall clock or persisted.
|
||||||
|
///
|
||||||
|
/// Call it **before** anything spawns; see [`crate::gamelease::LeaseRequest::launch_stamp`]. `None`
|
||||||
|
/// when the platform has no matcher (macOS) or the clock could not be read, which disables the
|
||||||
|
/// start-time filter rather than rejecting everything.
|
||||||
|
pub fn launch_stamp() -> Option<f64> {
|
||||||
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
|
{
|
||||||
|
Scanner::system().now_stamp()
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "linux", windows)))]
|
||||||
|
{
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An out-of-band opinion on whether a spec's game is still running, independent of the process scan.
|
||||||
|
///
|
||||||
|
/// Consulted **only to veto** declaring a game gone — never to declare it running, and never as the
|
||||||
|
/// primary signal. `Some(true)` = something else believes it is up, so hold off; `Some(false)` = that
|
||||||
|
/// something agrees it is gone; `None` = no opinion available, which is the common case.
|
||||||
|
///
|
||||||
|
/// Linux has none by design: Steam's launch reaper is both the sharpest signal and already a *process*,
|
||||||
|
/// so it is covered by the scan itself. Windows has no reaper, which is exactly where a second opinion
|
||||||
|
/// earns its keep.
|
||||||
|
pub fn running_hint(spec: &crate::library::DetectSpec) -> Option<bool> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
spec.steam_appid.and_then(windows::steam_running_hint)
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = spec;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
//! The Linux matcher: `/proc`.
|
||||||
|
//!
|
||||||
|
//! Contract, rules, and the shared vocabulary live in [`super`]; this module is only the reading of
|
||||||
|
//! `/proc` — and the parsing that gets wrong more often than it looks (`stat`'s `comm` field can
|
||||||
|
//! contain spaces *and* parentheses, so fields must be counted from the last `)`).
|
||||||
|
|
||||||
|
use super::{ProcRef, START_SLACK_SECS};
|
||||||
|
use crate::library::DetectSpec;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Largest `cmdline`/`environ` blob we will read from a single process. Both are bounded by `ARG_MAX`
|
||||||
|
/// in practice; the cap exists so a pathological process can't make the host allocate without bound
|
||||||
|
/// during a once-a-second scan.
|
||||||
|
const MAX_PROC_BLOB: u64 = 512 * 1024;
|
||||||
|
|
||||||
|
/// A `/proc` reader. Parameterized on its root, uid filter, and clock rate so the matching logic can
|
||||||
|
/// be unit-tested against a fixture tree instead of the live system.
|
||||||
|
pub struct Scanner {
|
||||||
|
root: PathBuf,
|
||||||
|
/// Only consider processes owned by this uid. `None` (tests only) considers all.
|
||||||
|
uid: Option<u32>,
|
||||||
|
ticks_per_sec: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scanner {
|
||||||
|
/// The live system: `/proc`, this host process's own uid, the kernel's clock rate.
|
||||||
|
pub fn system() -> Self {
|
||||||
|
// SAFETY: a parameterless POSIX call that always succeeds and touches no memory.
|
||||||
|
let uid = unsafe { libc::getuid() };
|
||||||
|
// SAFETY: `sysconf` reads a static system limit by name; no memory of ours is involved, and a
|
||||||
|
// non-positive answer (which the caller below handles) is its documented failure signal.
|
||||||
|
let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
|
||||||
|
Self {
|
||||||
|
root: PathBuf::from("/proc"),
|
||||||
|
uid: Some(uid),
|
||||||
|
// A non-positive sysconf answer would poison every start-time comparison; fall back to
|
||||||
|
// the universal Linux value rather than divide by nonsense.
|
||||||
|
ticks_per_sec: if ticks > 0 { ticks as f64 } else { 100.0 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seconds since boot — the Linux process-start timeline (see [`super::launch_stamp`]). `None`
|
||||||
|
/// when `/proc/uptime` can't be read, which disables start-time filtering rather than rejecting
|
||||||
|
/// every candidate.
|
||||||
|
pub fn now_stamp(&self) -> Option<f64> {
|
||||||
|
let text = std::fs::read_to_string(self.root.join("uptime")).ok()?;
|
||||||
|
text.split_whitespace().next()?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every process matching any of `spec`'s signals, restricted to those that started at or after
|
||||||
|
/// `min_start` (seconds since boot; `None` disables the filter).
|
||||||
|
///
|
||||||
|
/// The signals are a union: a Steam appid, an env marker, an exact executable, or an install
|
||||||
|
/// directory each independently qualify a process. That is deliberate — the store with the
|
||||||
|
/// sharpest signal wins, but a store that only knows its install directory is still covered.
|
||||||
|
pub fn find(&self, spec: &DetectSpec, min_start: Option<f64>) -> Vec<ProcRef> {
|
||||||
|
if spec.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
// Resolve symlinks in the install dir once per scan: a game reached through a symlinked
|
||||||
|
// library folder would otherwise never prefix-match its own canonical image path.
|
||||||
|
let dir = spec
|
||||||
|
.install_dir
|
||||||
|
.as_deref()
|
||||||
|
.map(|d| d.canonicalize().unwrap_or_else(|_| d.to_path_buf()));
|
||||||
|
let exe = spec
|
||||||
|
.exe
|
||||||
|
.as_deref()
|
||||||
|
.map(|e| e.canonicalize().unwrap_or_else(|_| e.to_path_buf()));
|
||||||
|
let steam_tok = spec.steam_appid.map(|id| format!("AppId={id}"));
|
||||||
|
|
||||||
|
let Ok(entries) = std::fs::read_dir(&self.root) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for e in entries.flatten() {
|
||||||
|
let name = e.file_name();
|
||||||
|
let Some(pid) = name.to_str().and_then(|s| s.parse::<u32>().ok()) else {
|
||||||
|
continue; // not a pid dir
|
||||||
|
};
|
||||||
|
let dir_path = e.path();
|
||||||
|
if let Some(uid) = self.uid {
|
||||||
|
let owned = std::fs::metadata(&dir_path)
|
||||||
|
.map(|m| {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
m.uid() == uid
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !owned {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(start_ticks) = self.start_ticks(&dir_path) else {
|
||||||
|
continue; // vanished mid-scan, or an unparseable stat
|
||||||
|
};
|
||||||
|
if let Some(min) = min_start {
|
||||||
|
let started = start_ticks as f64 / self.ticks_per_sec;
|
||||||
|
if started + START_SLACK_SECS < min {
|
||||||
|
continue; // predates this launch — never ours (rule 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.matches(
|
||||||
|
&dir_path,
|
||||||
|
spec,
|
||||||
|
steam_tok.as_deref(),
|
||||||
|
exe.as_deref(),
|
||||||
|
dir.as_deref(),
|
||||||
|
) {
|
||||||
|
out.push(ProcRef {
|
||||||
|
pid,
|
||||||
|
start: start_ticks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of `procs` are still the same live processes — pid present **and** start time unchanged,
|
||||||
|
/// so a recycled pid is never reported alive (rule 2).
|
||||||
|
pub fn alive(&self, procs: &[ProcRef]) -> Vec<ProcRef> {
|
||||||
|
procs
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|p| self.start_ticks(&self.root.join(p.pid.to_string())) == Some(p.start))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this process match any of the spec's signals?
|
||||||
|
fn matches(
|
||||||
|
&self,
|
||||||
|
dir_path: &Path,
|
||||||
|
spec: &DetectSpec,
|
||||||
|
steam_tok: Option<&str>,
|
||||||
|
exe: Option<&Path>,
|
||||||
|
install_dir: Option<&Path>,
|
||||||
|
) -> bool {
|
||||||
|
// The process's own image, resolved through /proc/<pid>/exe. Absent for a kernel thread or a
|
||||||
|
// process whose binary was replaced.
|
||||||
|
let image = std::fs::read_link(dir_path.join("exe")).ok();
|
||||||
|
if let Some(want) = exe {
|
||||||
|
if image.as_deref() == Some(want) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(dir) = install_dir {
|
||||||
|
if image.as_deref().is_some_and(|i| i.starts_with(dir)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A bare executable name, the operator-supplied fallback. Case-insensitive because it is typed
|
||||||
|
// by hand and the cost of a case mismatch (the game is never recognized) far outweighs the
|
||||||
|
// cost of a case collision (two differently-cased binaries, both started since this launch).
|
||||||
|
if let Some(want) = spec.process_name.as_deref() {
|
||||||
|
let named = image
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|i| i.file_name())
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.is_some_and(|n| n.eq_ignore_ascii_case(want));
|
||||||
|
if named {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The command line covers what the image can't: a Proton/Wine title's image is the *runtime*
|
||||||
|
// (`…/proton`, `wine64-preloader`), and the game only appears as an argument; Steam's launch
|
||||||
|
// reaper is likewise identified by its argv, not its binary.
|
||||||
|
let cmdline = read_capped(&dir_path.join("cmdline"));
|
||||||
|
if let Some(cmdline) = cmdline.as_deref() {
|
||||||
|
if let Some(tok) = steam_tok {
|
||||||
|
// Both tokens together, exact-matched, so `AppId=57` never satisfies appid 570 and
|
||||||
|
// Steam's own (non-reaper) helper steps aren't mistaken for the game.
|
||||||
|
let mut launch = false;
|
||||||
|
let mut appid = false;
|
||||||
|
for arg in cmdline.split(|&b| b == 0) {
|
||||||
|
if arg == b"SteamLaunch" {
|
||||||
|
launch = true;
|
||||||
|
} else if arg == tok.as_bytes() {
|
||||||
|
appid = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if launch && appid {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(dir) = install_dir {
|
||||||
|
// Require a path separator after the directory, so an install dir of `/games/x` is
|
||||||
|
// not satisfied by an unrelated `/games/xyz/…` argument. (The image-path check above
|
||||||
|
// needs no such care — `Path::starts_with` already compares whole components.)
|
||||||
|
let needle = dir.as_os_str().as_encoded_bytes();
|
||||||
|
let under_dir = |arg: &[u8]| {
|
||||||
|
arg.strip_prefix(needle)
|
||||||
|
.is_some_and(|rest| rest.first() == Some(&b'/'))
|
||||||
|
};
|
||||||
|
if cmdline.split(|&b| b == 0).any(under_dir) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(want) = exe {
|
||||||
|
let needle = want.as_os_str().as_encoded_bytes();
|
||||||
|
if cmdline.split(|&b| b == 0).any(|arg| arg == needle) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Env markers last: reading another process's environment is the most invasive of these
|
||||||
|
// reads, so it only happens for a spec that actually asked for one. The contents are matched
|
||||||
|
// and discarded — never logged.
|
||||||
|
if let Some(marker) = &spec.env_marker {
|
||||||
|
if let Some(env) = read_capped(&dir_path.join("environ")) {
|
||||||
|
let want: Vec<u8> = match &marker.value {
|
||||||
|
Some(v) => format!("{}={v}", marker.key).into_bytes(),
|
||||||
|
None => format!("{}=", marker.key).into_bytes(),
|
||||||
|
};
|
||||||
|
let hit = env.split(|&b| b == 0).any(|kv| match marker.value {
|
||||||
|
// An exact `KEY=VALUE` entry.
|
||||||
|
Some(_) => kv == want.as_slice(),
|
||||||
|
// Presence of the key with any value.
|
||||||
|
None => kv.starts_with(want.as_slice()),
|
||||||
|
});
|
||||||
|
if hit {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/proc/<pid>/stat` field 22 (`starttime`). The `comm` field is parenthesized and may itself
|
||||||
|
/// contain spaces and parentheses, so fields are counted from the **last** `)`: the token right
|
||||||
|
/// after it is field 3 (`state`), which puts `starttime` at index 19 of the remainder.
|
||||||
|
fn start_ticks(&self, dir_path: &Path) -> Option<u64> {
|
||||||
|
let stat = std::fs::read_to_string(dir_path.join("stat")).ok()?;
|
||||||
|
let tail = &stat[stat.rfind(')')? + 1..];
|
||||||
|
tail.split_whitespace().nth(19)?.parse().ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a `/proc` blob with a hard size cap (see [`MAX_PROC_BLOB`]). `None` when the process vanished
|
||||||
|
/// or the file is unreadable — both routine during a scan.
|
||||||
|
fn read_capped(path: &Path) -> Option<Vec<u8>> {
|
||||||
|
use std::io::Read;
|
||||||
|
let file = std::fs::File::open(path).ok()?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
file.take(MAX_PROC_BLOB).read_to_end(&mut buf).ok()?;
|
||||||
|
Some(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::library::DetectSpec;
|
||||||
|
|
||||||
|
/// Build a fake `/proc` tree. Each process gets a `stat` (with a deliberately nasty `comm`), and
|
||||||
|
/// optionally a `cmdline`, an `environ`, and an `exe` symlink.
|
||||||
|
struct FakeProc {
|
||||||
|
pid: u32,
|
||||||
|
start: u64,
|
||||||
|
exe: Option<PathBuf>,
|
||||||
|
cmdline: Vec<&'static str>,
|
||||||
|
environ: Vec<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeProc {
|
||||||
|
fn new(pid: u32, start: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
pid,
|
||||||
|
start,
|
||||||
|
exe: None,
|
||||||
|
cmdline: Vec::new(),
|
||||||
|
environ: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn exe(mut self, p: impl Into<PathBuf>) -> Self {
|
||||||
|
self.exe = Some(p.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn cmdline(mut self, args: &[&'static str]) -> Self {
|
||||||
|
self.cmdline = args.to_vec();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn environ(mut self, kvs: &[&'static str]) -> Self {
|
||||||
|
self.environ = kvs.to_vec();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fake_proc_root(uptime: f64, procs: &[FakeProc]) -> tempfile::TempDir {
|
||||||
|
let td = tempfile::tempdir().expect("tempdir");
|
||||||
|
std::fs::write(td.path().join("uptime"), format!("{uptime} 1000.0\n")).unwrap();
|
||||||
|
// Non-pid entries must be skipped, not parsed.
|
||||||
|
std::fs::create_dir_all(td.path().join("self")).unwrap();
|
||||||
|
std::fs::write(td.path().join("cmdline"), b"").unwrap();
|
||||||
|
for p in procs {
|
||||||
|
let dir = td.path().join(p.pid.to_string());
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
// `stat` is `pid (comm) state ppid … starttime …`, with starttime as field 22. Counting
|
||||||
|
// from the last ')', index 0 is `state` (field 3), so starttime lands at index 19. The
|
||||||
|
// comm is hostile on purpose — a space and a close-paren inside the parens, which is
|
||||||
|
// exactly what naive field-splitting gets wrong.
|
||||||
|
let mut tail = vec!["0".to_string(); 20];
|
||||||
|
tail[0] = "S".to_string(); // field 3
|
||||||
|
tail[19] = p.start.to_string(); // field 22
|
||||||
|
std::fs::write(
|
||||||
|
dir.join("stat"),
|
||||||
|
format!("{} (evil ) name) {}\n", p.pid, tail.join(" ")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
if !p.cmdline.is_empty() {
|
||||||
|
let mut blob = Vec::new();
|
||||||
|
for a in &p.cmdline {
|
||||||
|
blob.extend_from_slice(a.as_bytes());
|
||||||
|
blob.push(0);
|
||||||
|
}
|
||||||
|
std::fs::write(dir.join("cmdline"), blob).unwrap();
|
||||||
|
}
|
||||||
|
if !p.environ.is_empty() {
|
||||||
|
let mut blob = Vec::new();
|
||||||
|
for a in &p.environ {
|
||||||
|
blob.extend_from_slice(a.as_bytes());
|
||||||
|
blob.push(0);
|
||||||
|
}
|
||||||
|
std::fs::write(dir.join("environ"), blob).unwrap();
|
||||||
|
}
|
||||||
|
if let Some(exe) = &p.exe {
|
||||||
|
std::os::unix::fs::symlink(exe, dir.join("exe")).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scanner(root: &Path) -> Scanner {
|
||||||
|
Scanner {
|
||||||
|
root: root.to_path_buf(),
|
||||||
|
uid: None, // the fixture's owner is the test user; don't couple the test to it
|
||||||
|
ticks_per_sec: 100.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pids(mut v: Vec<ProcRef>) -> Vec<u32> {
|
||||||
|
v.sort_by_key(|p| p.pid);
|
||||||
|
v.into_iter().map(|p| p.pid).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_spec_matches_nothing() {
|
||||||
|
let td = fake_proc_root(100.0, &[FakeProc::new(1, 100).exe("/games/x/run")]);
|
||||||
|
let s = scanner(td.path());
|
||||||
|
assert!(s.find(&DetectSpec::default(), None).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_exact_exe_and_install_dir() {
|
||||||
|
let td = fake_proc_root(
|
||||||
|
1000.0,
|
||||||
|
&[
|
||||||
|
FakeProc::new(10, 50_000).exe("/games/hades/Hades"),
|
||||||
|
FakeProc::new(11, 50_000).exe("/games/hades/tools/crash.bin"),
|
||||||
|
FakeProc::new(12, 50_000).exe("/usr/bin/firefox"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let s = scanner(td.path());
|
||||||
|
// Exact exe → only that process.
|
||||||
|
assert_eq!(
|
||||||
|
pids(s.find(&DetectSpec::exe("/games/hades/Hades"), None)),
|
||||||
|
vec![10]
|
||||||
|
);
|
||||||
|
// Install dir → every process running out of the game's tree, but nothing outside it.
|
||||||
|
assert_eq!(
|
||||||
|
pids(s.find(&DetectSpec::dir("/games/hades"), None)),
|
||||||
|
vec![10, 11]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The operator-supplied fallback ([`DetectSpec::process_name`]): the image's own file name and
|
||||||
|
/// nothing else — never a directory that happens to be called the same, and never a process whose
|
||||||
|
/// path merely contains the name.
|
||||||
|
#[test]
|
||||||
|
fn matches_a_bare_process_name_against_the_image_name_only() {
|
||||||
|
let td = fake_proc_root(
|
||||||
|
1000.0,
|
||||||
|
&[
|
||||||
|
FakeProc::new(20, 50_000).exe("/opt/retroarch/bin/RetroArch"),
|
||||||
|
FakeProc::new(21, 50_000).exe("/usr/bin/retroarch-assets-helper"),
|
||||||
|
FakeProc::new(22, 50_000).exe("/home/p/retroarch/launcher.sh"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let s = scanner(td.path());
|
||||||
|
let spec = DetectSpec {
|
||||||
|
process_name: Some("retroarch".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// Case-insensitive (the name is typed by hand), but a whole-name match: neither the
|
||||||
|
// longer-named helper nor the script living in a `retroarch/` directory qualifies.
|
||||||
|
assert_eq!(pids(s.find(&spec, None)), vec![20]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_dir_does_not_match_a_sibling_with_the_same_prefix() {
|
||||||
|
// `/games/x` must not adopt a process running out of `/games/xyz` — a real hazard when one
|
||||||
|
// library folder's name is a prefix of another's.
|
||||||
|
let td = fake_proc_root(
|
||||||
|
1000.0,
|
||||||
|
&[
|
||||||
|
FakeProc::new(90, 50_000).exe("/games/xyz/other"),
|
||||||
|
FakeProc::new(91, 50_000).cmdline(&["wrapper", "/games/xyz/other"]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let s = scanner(td.path());
|
||||||
|
assert!(s.find(&DetectSpec::dir("/games/x"), None).is_empty());
|
||||||
|
// The genuine directory still matches, by image path and by argument.
|
||||||
|
assert_eq!(
|
||||||
|
pids(s.find(&DetectSpec::dir("/games/xyz"), None)),
|
||||||
|
vec![90, 91]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_proton_style_game_only_in_the_cmdline() {
|
||||||
|
// The image is the Proton runtime; the game appears only as an argument.
|
||||||
|
let td = fake_proc_root(
|
||||||
|
1000.0,
|
||||||
|
&[FakeProc::new(20, 50_000)
|
||||||
|
.exe("/steam/runtime/proton")
|
||||||
|
.cmdline(&["proton", "waitforexitandrun", "/games/elden/eldenring.exe"])],
|
||||||
|
);
|
||||||
|
let s = scanner(td.path());
|
||||||
|
assert_eq!(
|
||||||
|
pids(s.find(&DetectSpec::dir("/games/elden"), None)),
|
||||||
|
vec![20]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_steam_reaper_exactly() {
|
||||||
|
let td = fake_proc_root(
|
||||||
|
1000.0,
|
||||||
|
&[
|
||||||
|
FakeProc::new(30, 50_000).cmdline(&[
|
||||||
|
"reaper",
|
||||||
|
"SteamLaunch",
|
||||||
|
"AppId=570",
|
||||||
|
"--",
|
||||||
|
"dota",
|
||||||
|
]),
|
||||||
|
// A different appid that shares a prefix must NOT match (AppId=57 vs 570).
|
||||||
|
FakeProc::new(31, 50_000).cmdline(&[
|
||||||
|
"reaper",
|
||||||
|
"SteamLaunch",
|
||||||
|
"AppId=57",
|
||||||
|
"--",
|
||||||
|
"other",
|
||||||
|
]),
|
||||||
|
// `SteamLaunch` without the appid, and the appid without `SteamLaunch`, are both no.
|
||||||
|
FakeProc::new(32, 50_000).cmdline(&["reaper", "SteamLaunch", "--", "shader"]),
|
||||||
|
FakeProc::new(33, 50_000).cmdline(&["something", "AppId=570"]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let s = scanner(td.path());
|
||||||
|
assert_eq!(pids(s.find(&DetectSpec::steam(570), None)), vec![30]);
|
||||||
|
assert_eq!(pids(s.find(&DetectSpec::steam(57), None)), vec![31]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_env_marker_by_exact_value_or_presence() {
|
||||||
|
let td = fake_proc_root(
|
||||||
|
1000.0,
|
||||||
|
&[
|
||||||
|
FakeProc::new(40, 50_000).environ(&["HOME=/home/u", "HEROIC_APP_NAME=Quail"]),
|
||||||
|
FakeProc::new(41, 50_000).environ(&["HEROIC_APP_NAME=OtherGame"]),
|
||||||
|
FakeProc::new(42, 50_000).environ(&["HOME=/home/u"]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let s = scanner(td.path());
|
||||||
|
let exact = DetectSpec::default().with_env("HEROIC_APP_NAME", Some("Quail".to_string()));
|
||||||
|
assert_eq!(pids(s.find(&exact, None)), vec![40]);
|
||||||
|
// Presence-only matches any value, but still nothing that lacks the key.
|
||||||
|
let any = DetectSpec::default().with_env("HEROIC_APP_NAME", None);
|
||||||
|
assert_eq!(pids(s.find(&any, None)), vec![40, 41]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn never_adopts_a_process_that_predates_the_launch() {
|
||||||
|
// Uptime 1000 s; the launch happened at t=900. pid 50 started at t=500 (a copy the player
|
||||||
|
// already had open), pid 51 at t=950 (ours).
|
||||||
|
let td = fake_proc_root(
|
||||||
|
1000.0,
|
||||||
|
&[
|
||||||
|
FakeProc::new(50, 50_000).exe("/games/hades/Hades"),
|
||||||
|
FakeProc::new(51, 95_000).exe("/games/hades/Hades"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let s = scanner(td.path());
|
||||||
|
let spec = DetectSpec::dir("/games/hades");
|
||||||
|
assert_eq!(pids(s.find(&spec, Some(900.0))), vec![51]);
|
||||||
|
// Without the filter both are visible — proving the filter is what excluded it.
|
||||||
|
assert_eq!(pids(s.find(&spec, None)), vec![50, 51]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_slack_tolerates_tick_granularity() {
|
||||||
|
// Started a hair (0.5 s) BEFORE the recorded launch instant: still ours.
|
||||||
|
let td = fake_proc_root(1000.0, &[FakeProc::new(60, 89_950).exe("/games/x/run")]);
|
||||||
|
let s = scanner(td.path());
|
||||||
|
assert_eq!(
|
||||||
|
pids(s.find(&DetectSpec::dir("/games/x"), Some(900.0))),
|
||||||
|
vec![60]
|
||||||
|
);
|
||||||
|
// A full minute early is not.
|
||||||
|
assert!(s.find(&DetectSpec::dir("/games/x"), Some(960.0)).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alive_rejects_a_recycled_pid() {
|
||||||
|
let td = fake_proc_root(1000.0, &[FakeProc::new(70, 50_000).exe("/games/x/run")]);
|
||||||
|
let s = scanner(td.path());
|
||||||
|
let same = ProcRef {
|
||||||
|
pid: 70,
|
||||||
|
start: 50_000,
|
||||||
|
};
|
||||||
|
let recycled = ProcRef {
|
||||||
|
pid: 70,
|
||||||
|
start: 99_999,
|
||||||
|
};
|
||||||
|
let gone = ProcRef {
|
||||||
|
pid: 71,
|
||||||
|
start: 50_000,
|
||||||
|
};
|
||||||
|
assert_eq!(s.alive(&[same]), vec![same]);
|
||||||
|
assert!(s.alive(&[recycled]).is_empty());
|
||||||
|
assert!(s.alive(&[gone]).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything above runs against a fixture tree, which proves the parsing but not that the
|
||||||
|
/// parsing describes this kernel. This one scans the **real** `/proc` for a process it just
|
||||||
|
/// started — the only version of this test that would notice `/proc/<pid>/stat` field ordering,
|
||||||
|
/// the uid check, or the uptime clock being wrong on a real box.
|
||||||
|
#[test]
|
||||||
|
fn finds_a_real_process_it_just_started() {
|
||||||
|
// A real game's *binary* lives under its install dir, which is what makes the install-dir
|
||||||
|
// recipe work. So the stand-in must too: copy a long-running binary into the directory and run
|
||||||
|
// it from there. (A wrapper script that `exec`s something outside the directory would leave no
|
||||||
|
// trace of the directory in either the image path or the command line — worth knowing, and the
|
||||||
|
// reason a copied binary is the honest fixture here.)
|
||||||
|
let td = tempfile::tempdir().expect("tempdir");
|
||||||
|
let game = td.path().join("game");
|
||||||
|
std::fs::copy("/bin/sleep", &game).expect("copy a stand-in game binary");
|
||||||
|
let s = Scanner::system();
|
||||||
|
let before = s.now_stamp().expect("real /proc/uptime is readable");
|
||||||
|
|
||||||
|
let mut child = std::process::Command::new(&game)
|
||||||
|
.arg("20")
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn the fake game");
|
||||||
|
// Give it a moment to be visible in /proc.
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||||
|
|
||||||
|
let found = s.find(&DetectSpec::dir(td.path()), Some(before));
|
||||||
|
assert!(
|
||||||
|
found.iter().any(|p| p.pid == child.id()),
|
||||||
|
"scanning the real /proc did not find pid {} under {}; found {found:?}",
|
||||||
|
child.id(),
|
||||||
|
td.path().display()
|
||||||
|
);
|
||||||
|
// The same process is still alive when re-verified by (pid, start time).
|
||||||
|
assert!(!s.alive(&found).is_empty());
|
||||||
|
// The exact-exe recipe finds it too, on the same live process.
|
||||||
|
assert!(s
|
||||||
|
.find(&DetectSpec::exe(&game), Some(before))
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.pid == child.id()));
|
||||||
|
|
||||||
|
// A launch reference AFTER this process started must exclude it — the rule that keeps a
|
||||||
|
// pre-existing copy of a game from being adopted, checked against a real start time.
|
||||||
|
let after = s.now_stamp().unwrap() + 60.0;
|
||||||
|
assert!(s.find(&DetectSpec::dir(td.path()), Some(after)).is_empty());
|
||||||
|
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
// Once it is gone and reaped, neither the scan nor the liveness re-check sees it.
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||||
|
assert!(s.find(&DetectSpec::dir(td.path()), Some(before)).is_empty());
|
||||||
|
assert!(s.alive(&found).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_uptime_and_hostile_comm() {
|
||||||
|
let td = fake_proc_root(4321.5, &[FakeProc::new(80, 1234)]);
|
||||||
|
let s = scanner(td.path());
|
||||||
|
assert_eq!(s.now_stamp(), Some(4321.5));
|
||||||
|
// The comm field contains a space and a ')' — start time must still parse.
|
||||||
|
assert_eq!(s.start_ticks(&td.path().join("80")), Some(1234));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
//! The Windows matcher: a Toolhelp snapshot plus each process's full image path.
|
||||||
|
//!
|
||||||
|
//! Contract and rules live in [`super`]. Two differences from the Linux side shape everything here:
|
||||||
|
//!
|
||||||
|
//! * **The host is SYSTEM**, so it can open essentially any process. That makes rule 1 (never adopt a
|
||||||
|
//! process that predates the launch) load-bearing rather than a nicety — without it a scan would
|
||||||
|
//! happily adopt a copy of the game the player started an hour ago, and a session ending could kill
|
||||||
|
//! it.
|
||||||
|
//! * **There is no launch reaper and no readable environment.** Steam's `SteamLaunch AppId=` argv
|
||||||
|
//! trick is Linux-only, and reading another process's environment block on Windows needs
|
||||||
|
//! `NtQueryInformationProcess` against an undocumented layout — not something to build a
|
||||||
|
//! game-killing decision on. So the recipe is the image path: exactly the game's executable, or any
|
||||||
|
//! executable under its install directory. Every Windows store the library scans reports one or
|
||||||
|
//! both ([`crate::library::DetectSpec`]).
|
||||||
|
|
||||||
|
use super::{ProcRef, START_SLACK_SECS};
|
||||||
|
use crate::library::DetectSpec;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||||
|
use windows::Win32::System::Diagnostics::ToolHelp::{
|
||||||
|
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
|
||||||
|
};
|
||||||
|
use windows::Win32::System::Threading::{
|
||||||
|
GetProcessTimes, OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_FORMAT,
|
||||||
|
PROCESS_QUERY_LIMITED_INFORMATION,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 100-nanosecond `FILETIME` ticks per second — the unit every Win32 time API here reports in.
|
||||||
|
const FILETIME_TICKS_PER_SEC: f64 = 10_000_000.0;
|
||||||
|
|
||||||
|
/// Image-path buffer, in UTF-16 units. Deliberately well past `MAX_PATH` (260): a game installed under
|
||||||
|
/// a deep library folder can exceed it, and a truncated path would silently fail to match. A path
|
||||||
|
/// longer than this makes `QueryFullProcessImageNameW` fail, which skips that process — the same
|
||||||
|
/// outcome as any other unreadable one.
|
||||||
|
const IMAGE_PATH_MAX: usize = 4096;
|
||||||
|
|
||||||
|
/// A process scanner over the live system. Unit (unlike the Linux one, which is parameterized for its
|
||||||
|
/// fixture tests): there is no way to hand Win32 a fake process table, so the Windows matching logic
|
||||||
|
/// is tested through its pure helpers ([`under_dir`], [`same_path`]) plus a live-process test.
|
||||||
|
pub struct Scanner;
|
||||||
|
|
||||||
|
impl Scanner {
|
||||||
|
pub fn system() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seconds on the Windows process-start timeline — the `FILETIME` epoch, which is what
|
||||||
|
/// `GetProcessTimes` reports a creation time in. `None` if the clock could not be read.
|
||||||
|
///
|
||||||
|
/// Derived from `SystemTime` rather than a Win32 call because both are the same UTC epoch offset
|
||||||
|
/// by a constant, and the only use is comparing against a creation time read moments later; a
|
||||||
|
/// clock step between the two would need to exceed [`START_SLACK_SECS`] to matter.
|
||||||
|
pub fn now_stamp(&self) -> Option<f64> {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.ok()?;
|
||||||
|
/// Seconds between the `FILETIME` epoch (1601-01-01) and the Unix epoch.
|
||||||
|
const FILETIME_EPOCH_OFFSET_SECS: f64 = 11_644_473_600.0;
|
||||||
|
Some(now.as_secs_f64() + FILETIME_EPOCH_OFFSET_SECS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every process matching any of `spec`'s signals, restricted to those that started at or after
|
||||||
|
/// `min_start` (seconds on the [`Self::now_stamp`] timeline; `None` disables the filter).
|
||||||
|
pub fn find(&self, spec: &DetectSpec, min_start: Option<f64>) -> Vec<ProcRef> {
|
||||||
|
// Only the image-based signals exist on Windows: no reaper argv, no readable environment. A
|
||||||
|
// spec carrying none must match *nothing* — falling through would scan on an empty predicate.
|
||||||
|
if spec.exe.is_none() && spec.install_dir.is_none() && spec.process_name.is_none() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let exe = spec
|
||||||
|
.exe
|
||||||
|
.as_deref()
|
||||||
|
.map(|e| e.canonicalize().unwrap_or_else(|_| e.to_path_buf()));
|
||||||
|
let dir = spec
|
||||||
|
.install_dir
|
||||||
|
.as_deref()
|
||||||
|
.map(|d| d.canonicalize().unwrap_or_else(|_| d.to_path_buf()));
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for pid in snapshot_pids() {
|
||||||
|
// pid 0 (System Idle) and 4 (System) are neither openable nor ever a game.
|
||||||
|
if pid <= 4 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((start, image)) = process_start_and_image(pid) else {
|
||||||
|
continue; // exited mid-scan, or a protected process we can't query — never a game
|
||||||
|
};
|
||||||
|
if let Some(min) = min_start {
|
||||||
|
if start as f64 / FILETIME_TICKS_PER_SEC + START_SLACK_SECS < min {
|
||||||
|
continue; // predates this launch — never ours (rule 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let hit = exe.as_deref().is_some_and(|w| same_path(&image, w))
|
||||||
|
|| dir.as_deref().is_some_and(|d| under_dir(&image, d))
|
||||||
|
// The operator-supplied fallback: the image's file name alone. Windows paths are
|
||||||
|
// case-insensitive anyway, so this matches the platform rather than relaxing anything.
|
||||||
|
|| spec
|
||||||
|
.process_name
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|w| same_name(&image, w));
|
||||||
|
if hit {
|
||||||
|
out.push(ProcRef { pid, start });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of `procs` are still the same live processes — pid present **and** creation time
|
||||||
|
/// unchanged, so a recycled pid is never reported alive (rule 2). Windows reuses pids briskly, so
|
||||||
|
/// this check is what makes signalling a remembered pid safe at all.
|
||||||
|
pub fn alive(&self, procs: &[ProcRef]) -> Vec<ProcRef> {
|
||||||
|
procs
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|p| process_start_and_image(p.pid).is_some_and(|(start, _)| start == p.start))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An out-of-band opinion on whether the game is still running, used only to **veto** declaring it
|
||||||
|
/// gone (see [`super::running_hint`]).
|
||||||
|
///
|
||||||
|
/// For a Steam title: Steam records a per-app `Running` flag in the logged-in user's hive. That flag is
|
||||||
|
/// not trustworthy on its own — Steam sets it around updates and DLC installs too, and leaves it stale
|
||||||
|
/// if it crashes — but as a veto it is exactly right. If the matcher can't see the game (its executable
|
||||||
|
/// lives outside the install dir the manifest named, or a nested launcher owns it) while Steam still
|
||||||
|
/// says the app is running, ending the session would be a false positive the player feels immediately.
|
||||||
|
/// Erring towards "still running" only ever leaves a stream up.
|
||||||
|
///
|
||||||
|
/// Reads every **loaded** user hive under `HKEY_USERS` rather than resolving the interactive user's SID
|
||||||
|
/// through `WTSQueryUserToken`: only logged-in users' hives are loaded, which is the same set, and it
|
||||||
|
/// avoids a token dance for a best-effort hint.
|
||||||
|
pub fn steam_running_hint(appid: u32) -> Option<bool> {
|
||||||
|
use winreg::enums::{HKEY_USERS, KEY_READ};
|
||||||
|
use winreg::RegKey;
|
||||||
|
|
||||||
|
let users = RegKey::predef(HKEY_USERS);
|
||||||
|
let mut saw_key = false;
|
||||||
|
for sid in users.enum_keys().flatten() {
|
||||||
|
// The `…_Classes` companion hives hold no Steam state.
|
||||||
|
if sid.ends_with("_Classes") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = format!("{sid}\\Software\\Valve\\Steam\\Apps\\{appid}");
|
||||||
|
let Ok(app) = users.open_subkey_with_flags(&path, KEY_READ) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
saw_key = true;
|
||||||
|
if app.get_value::<u32, _>("Running").unwrap_or(0) != 0 {
|
||||||
|
return Some(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A key that exists and says 0 is a real "not running"; no key at all means Steam has never run
|
||||||
|
// this app on this box, which is no opinion rather than a negative one.
|
||||||
|
saw_key.then_some(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every pid in a Toolhelp snapshot. Mirrors the walk in [`crate::detect`]'s Windows facts, which
|
||||||
|
/// wants basenames rather than pids.
|
||||||
|
fn snapshot_pids() -> Vec<u32> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
// SAFETY: the canonical Toolhelp walk. `entry` is zeroed with `dwSize` set before the first read
|
||||||
|
// (the 260-wide `szExeFile` array has no usable `Default`), and the snapshot handle is closed on
|
||||||
|
// every exit path.
|
||||||
|
unsafe {
|
||||||
|
let Ok(snap) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
let mut entry = PROCESSENTRY32W {
|
||||||
|
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
|
||||||
|
..std::mem::zeroed()
|
||||||
|
};
|
||||||
|
if Process32FirstW(snap, &mut entry).is_ok() {
|
||||||
|
loop {
|
||||||
|
out.push(entry.th32ProcessID);
|
||||||
|
if Process32NextW(snap, &mut entry).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = CloseHandle(snap);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A process's creation time (`FILETIME` ticks) and full image path.
|
||||||
|
///
|
||||||
|
/// Opened with `PROCESS_QUERY_LIMITED_INFORMATION` — the least privilege that answers both questions,
|
||||||
|
/// and the one that works against elevated and protected-ish processes without asking for the right to
|
||||||
|
/// read their memory. `None` for anything that can't be opened or has already exited, which is the
|
||||||
|
/// routine case during a scan and never a reason to log.
|
||||||
|
fn process_start_and_image(pid: u32) -> Option<(u64, PathBuf)> {
|
||||||
|
// SAFETY: `OpenProcess` yields an owned handle only on `Ok`; it is closed exactly once below on
|
||||||
|
// every path. `GetProcessTimes` writes four `FILETIME`s we fully own; `QueryFullProcessImageNameW`
|
||||||
|
// writes into `buf` and updates `len` in place, bounded by the `len` we pass in (buf.len()).
|
||||||
|
unsafe {
|
||||||
|
let handle: HANDLE = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?;
|
||||||
|
let mut created = Default::default();
|
||||||
|
let (mut exit, mut kernel, mut user) =
|
||||||
|
(Default::default(), Default::default(), Default::default());
|
||||||
|
let times =
|
||||||
|
GetProcessTimes(handle, &mut created, &mut exit, &mut kernel, &mut user).is_ok();
|
||||||
|
|
||||||
|
let mut buf = [0u16; IMAGE_PATH_MAX];
|
||||||
|
let mut len = buf.len() as u32;
|
||||||
|
// `PROCESS_NAME_FORMAT(0)` is `PROCESS_NAME_WIN32` — a drive-letter path, which is what the
|
||||||
|
// library's store-derived paths look like (the alternative, `PROCESS_NAME_NATIVE`, yields
|
||||||
|
// `\Device\HarddiskVolume…` and would never compare equal to one).
|
||||||
|
let named = QueryFullProcessImageNameW(
|
||||||
|
handle,
|
||||||
|
PROCESS_NAME_FORMAT(0),
|
||||||
|
windows::core::PWSTR(buf.as_mut_ptr()),
|
||||||
|
&mut len,
|
||||||
|
)
|
||||||
|
.is_ok();
|
||||||
|
let _ = CloseHandle(handle);
|
||||||
|
|
||||||
|
if !times || !named {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let start = ((created.dwHighDateTime as u64) << 32) | created.dwLowDateTime as u64;
|
||||||
|
let image = PathBuf::from(String::from_utf16_lossy(&buf[..len as usize]));
|
||||||
|
Some((start, image))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `image` the same file as `want`? Windows paths are case-insensitive, and the scanner compares a
|
||||||
|
/// canonicalized store-derived path against a live image path, so the comparison must be too.
|
||||||
|
fn same_path(image: &Path, want: &Path) -> bool {
|
||||||
|
eq_ignore_case(image, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does `image` live under `dir`? Case-insensitive, and requires a separator after the directory so an
|
||||||
|
/// install dir of `…\Games\X` is not satisfied by `…\Games\XY\game.exe`.
|
||||||
|
fn under_dir(image: &Path, dir: &Path) -> bool {
|
||||||
|
let (i, d) = (wide_lower(image), wide_lower(dir));
|
||||||
|
let Some(rest) = i.strip_prefix(d.as_str()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
rest.starts_with('\\') || rest.starts_with('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `image`'s file name `want`? The operator-supplied [`DetectSpec::process_name`] fallback: a bare
|
||||||
|
/// name, compared against the image's last component only, so `Hades.exe` never matches a path that
|
||||||
|
/// merely contains it.
|
||||||
|
fn same_name(image: &Path, want: &str) -> bool {
|
||||||
|
image
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.is_some_and(|n| n.eq_ignore_ascii_case(want.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eq_ignore_case(a: &Path, b: &Path) -> bool {
|
||||||
|
wide_lower(a) == wide_lower(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercase a path for comparison, normalizing the `\\?\` prefix `canonicalize` prepends (a
|
||||||
|
/// store-derived path canonicalizes to the verbatim form while a live image path does not, and the two
|
||||||
|
/// must still compare equal).
|
||||||
|
fn wide_lower(p: &Path) -> String {
|
||||||
|
let s = p.to_string_lossy();
|
||||||
|
let s = s.strip_prefix(r"\\?\").unwrap_or(&s);
|
||||||
|
s.to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_dir_match_is_case_insensitive_and_separator_aware() {
|
||||||
|
let dir = Path::new(r"C:\Games\Hades");
|
||||||
|
assert!(under_dir(Path::new(r"c:\games\hades\Hades.exe"), dir));
|
||||||
|
assert!(under_dir(Path::new(r"C:\Games\Hades\bin\crash.exe"), dir));
|
||||||
|
// A sibling whose name merely starts with the same text is not under it.
|
||||||
|
assert!(!under_dir(Path::new(r"C:\Games\HadesII\game.exe"), dir));
|
||||||
|
// The directory itself is not "under" itself (no process image is a bare directory anyway).
|
||||||
|
assert!(!under_dir(dir, dir));
|
||||||
|
assert!(!under_dir(Path::new(r"D:\Games\Hades\Hades.exe"), dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exe_match_is_case_insensitive_and_ignores_the_verbatim_prefix() {
|
||||||
|
// `canonicalize` yields the `\\?\` verbatim form; a live image path never has it.
|
||||||
|
assert!(same_path(
|
||||||
|
Path::new(r"C:\Games\Hades\Hades.exe"),
|
||||||
|
Path::new(r"\\?\c:\games\hades\hades.exe")
|
||||||
|
));
|
||||||
|
assert!(!same_path(
|
||||||
|
Path::new(r"C:\Games\Hades\Hades.exe"),
|
||||||
|
Path::new(r"C:\Games\Hades\Other.exe")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The operator-supplied fallback ([`DetectSpec::process_name`]): the image's own file name and
|
||||||
|
/// nothing else — never a path that merely contains the name.
|
||||||
|
#[test]
|
||||||
|
fn process_name_matches_the_image_name_only() {
|
||||||
|
assert!(same_name(
|
||||||
|
Path::new(r"C:\Games\Hades\Hades.exe"),
|
||||||
|
"hades.exe"
|
||||||
|
));
|
||||||
|
assert!(same_name(
|
||||||
|
Path::new(r"C:\Games\Hades\Hades.exe"),
|
||||||
|
" Hades.exe "
|
||||||
|
));
|
||||||
|
// A longer name that starts the same way is a different program.
|
||||||
|
assert!(!same_name(
|
||||||
|
Path::new(r"C:\Games\Hades\HadesLauncher.exe"),
|
||||||
|
"Hades.exe"
|
||||||
|
));
|
||||||
|
// A directory called the same thing doesn't qualify its contents.
|
||||||
|
assert!(!same_name(
|
||||||
|
Path::new(r"C:\Games\Hades.exe\other.exe"),
|
||||||
|
"Hades.exe"
|
||||||
|
));
|
||||||
|
|
||||||
|
// …and it reaches the live scan, which the `find` early-return would otherwise skip.
|
||||||
|
let me = std::env::current_exe().expect("current exe");
|
||||||
|
let name = me.file_name().and_then(|n| n.to_str()).expect("exe name");
|
||||||
|
let spec = DetectSpec {
|
||||||
|
process_name: Some(name.to_ascii_uppercase()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(Scanner::system()
|
||||||
|
.find(&spec, None)
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.pid == std::process::id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_spec_with_no_path_signal_matches_nothing() {
|
||||||
|
// No reaper and no environment reading on Windows: an appid-only or env-only spec has nothing
|
||||||
|
// to match here, and must not fall through to matching everything.
|
||||||
|
let s = Scanner::system();
|
||||||
|
assert!(s.find(&DetectSpec::steam(570), None).is_empty());
|
||||||
|
assert!(s
|
||||||
|
.find(
|
||||||
|
&DetectSpec::default().with_env("HEROIC_APP_NAME", Some("Quail".into())),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
.is_empty());
|
||||||
|
assert!(s.find(&DetectSpec::default(), None).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live check: scan the real process table for this test process itself, which is the only way
|
||||||
|
/// to notice a wrong `PROCESSENTRY32W` size, a failing `OpenProcess` access mask, or creation
|
||||||
|
/// times read from the wrong `FILETIME` half.
|
||||||
|
#[test]
|
||||||
|
fn finds_this_process_by_its_own_image_path() {
|
||||||
|
let me = std::env::current_exe().expect("current exe");
|
||||||
|
let s = Scanner::system();
|
||||||
|
let pid = std::process::id();
|
||||||
|
let found = s.find(&DetectSpec::exe(&me), None);
|
||||||
|
assert!(
|
||||||
|
found.iter().any(|p| p.pid == pid),
|
||||||
|
"scanning the real process table did not find this test process ({pid}) as {}",
|
||||||
|
me.display()
|
||||||
|
);
|
||||||
|
// Its own directory finds it too.
|
||||||
|
let dir = me.parent().expect("exe has a parent");
|
||||||
|
assert!(s
|
||||||
|
.find(&DetectSpec::dir(dir), None)
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.pid == pid));
|
||||||
|
// The same process re-verifies as alive by (pid, creation time)…
|
||||||
|
let mine: Vec<ProcRef> = found.into_iter().filter(|p| p.pid == pid).collect();
|
||||||
|
assert_eq!(s.alive(&mine), mine);
|
||||||
|
// …and a wrong creation time for the same pid does not.
|
||||||
|
let recycled = vec![ProcRef {
|
||||||
|
pid,
|
||||||
|
start: mine[0].start ^ 0xFFFF,
|
||||||
|
}];
|
||||||
|
assert!(s.alive(&recycled).is_empty());
|
||||||
|
// A launch reference in the future excludes it — rule 1, against a real creation time.
|
||||||
|
let future = s.now_stamp().unwrap() + 3_600.0;
|
||||||
|
assert!(s.find(&DetectSpec::exe(&me), Some(future)).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,9 +104,13 @@ pub struct SessionPlan {
|
|||||||
/// AUs stay shard-aligned across mode/bitrate/stall rebuilds. `None` for the H.26x codecs.
|
/// AUs stay shard-aligned across mode/bitrate/stall rebuilds. `None` for the H.26x codecs.
|
||||||
pub wire_chunk: Option<usize>,
|
pub wire_chunk: Option<usize>,
|
||||||
/// The session may hand the encoder cursor bitmaps to composite (cursor-as-metadata
|
/// The session may hand the encoder cursor bitmaps to composite (cursor-as-metadata
|
||||||
/// captures — every non-gamescope compositor; gamescope embeds the pointer itself).
|
/// captures). Set via [`cursor_blend_for`] — the single platform rule — so it is `true` only
|
||||||
/// Encoders whose fast path cannot blend (the Vulkan EFC RGB-direct source) stay on their
|
/// where the ENCODER is the compositing stage (Linux cursor-forward and gamescope sessions);
|
||||||
/// blending path when this is set, so the pointer never silently vanishes from the stream.
|
/// Windows is always `false` (the IDD capturer composites the pointer itself). Encoders
|
||||||
|
/// whose fast path cannot blend (the Vulkan EFC RGB-direct source, native NV12) stay off
|
||||||
|
/// those shapes when this is set — see [`Self::output_format`] and
|
||||||
|
/// `encode::cursor_blend_capable`, the pre-open mirror that gates the cursor channel — so
|
||||||
|
/// the pointer never silently vanishes from the stream.
|
||||||
pub cursor_blend: bool,
|
pub cursor_blend: bool,
|
||||||
/// The session negotiated the cursor-forward channel (M2/M2c): the client draws the pointer
|
/// The session negotiated the cursor-forward channel (M2/M2c): the client draws the pointer
|
||||||
/// locally, so `cursor_blend` is off AND (on Windows) the capturer sets the driver's
|
/// locally, so `cursor_blend` is off AND (on Windows) the capturer sets the driver's
|
||||||
@@ -198,13 +202,15 @@ impl SessionPlan {
|
|||||||
// Producer-native NV12 (gamescope) is consumable only by the Linux Vulkan Video
|
// Producer-native NV12 (gamescope) is consumable only by the Linux Vulkan Video
|
||||||
// backend — resolved HERE from the plan's codec so the capturer never reaches back
|
// backend — resolved HERE from the plan's codec so the capturer never reaches back
|
||||||
// into encode (the same one-way edge as `gpu` above). BUT the native-NV12 encode path
|
// into encode (the same one-way edge as `gpu` above). BUT the native-NV12 encode path
|
||||||
// has no CSC stage to fold the cursor into (it assumes gamescope embeds its pointer,
|
// has no CSC stage to fold the cursor into — so ANY cursor-compositing session
|
||||||
// which it does NOT into the PipeWire node) — so a gamescope-cursor session (Phase C)
|
// (gamescope Phase C, whose XFixes pointer is absent from the PipeWire node, AND a
|
||||||
// must capture RGB instead, routing to the compute-CSC / VkSlotBlend blend that draws
|
// cursor-forward session, whose capture-mouse flip needs the host composite on
|
||||||
// `frame.cursor`. Costs the RGB→NV12 CSC we'd otherwise skip; the native-NV12 cursor
|
// demand) must capture RGB instead, routing to the compute-CSC / VkSlotBlend blend
|
||||||
// blend is the perf-preserving follow-up.
|
// that draws `frame.cursor`. Costs the RGB→NV12 CSC we'd otherwise skip; the
|
||||||
|
// native-NV12 cursor blend is the perf-preserving follow-up. (`cursor_blend`
|
||||||
|
// subsumes `gamescope_cursor` — see [`cursor_blend_for`].)
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
nv12_native: crate::encode::linux_native_nv12_ok(self.codec) && !self.gamescope_cursor,
|
nv12_native: crate::encode::linux_native_nv12_ok(self.codec) && !self.cursor_blend,
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
nv12_native: false,
|
nv12_native: false,
|
||||||
}
|
}
|
||||||
@@ -217,6 +223,26 @@ pub(crate) fn resolve_topology() -> SessionTopology {
|
|||||||
SessionTopology::SingleProcess
|
SessionTopology::SingleProcess
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// THE rule for [`SessionPlan::cursor_blend`], shared by every resolve caller (initial plan and
|
||||||
|
/// the mid-stream compositor re-gate) so they can't drift:
|
||||||
|
/// * **Linux**: the encoder is the compositing stage — blend for a cursor-forward session (the
|
||||||
|
/// capture-mouse flip needs the host composite on demand) and for gamescope (its capture
|
||||||
|
/// carries no pointer at all; the XFixes-sourced cursor must be drawn into the video).
|
||||||
|
/// * **Windows**: never — the IDD capturer composites the pointer itself (`cursor_blend.rs` /
|
||||||
|
/// DWM), and no Windows encode backend reads `frame.cursor`. Asking the encoder anyway made
|
||||||
|
/// `open_video`'s blends-cursor backstop fire spuriously on every cursor-channel session.
|
||||||
|
pub(crate) fn cursor_blend_for(cursor_forward: bool, gamescope: bool) -> bool {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let _ = (cursor_forward, gamescope);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
cursor_forward || gamescope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn resolve_encoder() -> EncoderBackend {
|
fn resolve_encoder() -> EncoderBackend {
|
||||||
match crate::encode::windows_resolved_backend() {
|
match crate::encode::windows_resolved_backend() {
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
//! Session⇄game lifetime settings (`<config>/session-settings.json`).
|
||||||
|
//!
|
||||||
|
//! Two operator choices, both about what a session and its game owe each other
|
||||||
|
//! (design/session-game-lifetime.md §8):
|
||||||
|
//!
|
||||||
|
//! * [`SessionSettings::session_on_game_exit`] — end the streaming session when the launched game
|
||||||
|
//! exits, so the client returns to its library instead of a bare desktop. **On by default**: it is
|
||||||
|
//! what a player expects, and it is already the shipped behavior for dedicated game sessions.
|
||||||
|
//! * [`SessionSettings::game_on_session_end`] — end the launched game when the session ends.
|
||||||
|
//! **Off by default** ([`GameOnSessionEnd::Keep`]), because ending a game can cost unsaved
|
||||||
|
//! progress. `Always` additionally waits out [`SessionSettings::disconnect_grace_seconds`] so a
|
||||||
|
//! network blip never costs a save.
|
||||||
|
//!
|
||||||
|
//! Deliberately its own small store rather than more axes on the display policy: keep-alive is about
|
||||||
|
//! how long a *display* survives a disconnect (default 10 s), while this is about a *game* and the
|
||||||
|
//! player's unsaved progress (default 5 minutes). Sharing one number would force one of them wrong.
|
||||||
|
//!
|
||||||
|
//! Storage follows the same shape as `DisplayPolicyStore` / `pf_gpu::GpuPrefStore`: a missing **or**
|
||||||
|
//! corrupt file means "unconfigured" with a warning (a settings file must never fail host startup),
|
||||||
|
//! writes are private-dir + temp + atomic rename, and the in-memory value changes only after the
|
||||||
|
//! write lands.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
/// What to do with the launched game when its session ends.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum GameOnSessionEnd {
|
||||||
|
/// Leave it running (the historical behavior, and the default — nothing is ever killed unless
|
||||||
|
/// the operator asks for it).
|
||||||
|
#[default]
|
||||||
|
Keep,
|
||||||
|
/// End it when the client stops the session deliberately, but leave it running if the client
|
||||||
|
/// merely vanished — so a dropped connection stays reconnectable.
|
||||||
|
OnQuit,
|
||||||
|
/// End it whenever the session ends. A deliberate stop ends it at once; a drop starts the
|
||||||
|
/// reconnect window and ends it only if nobody comes back.
|
||||||
|
Always,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameOnSessionEnd {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Keep => "keep",
|
||||||
|
Self::OnQuit => "on_quit",
|
||||||
|
Self::Always => "always",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default reconnect window before `Always` ends a game — long enough to cover a Wi-Fi
|
||||||
|
/// roam, a client crash-and-restart, or a walk to another room, because the cost of being wrong is
|
||||||
|
/// the player's unsaved progress.
|
||||||
|
const DEFAULT_GRACE_SECS: u32 = 300;
|
||||||
|
/// Clamp bounds for the window. The floor keeps a mis-typed `1` from making `Always` behave like an
|
||||||
|
/// instant kill; the ceiling (24 h) keeps a stray large value from pinning a lease forever.
|
||||||
|
const MIN_GRACE_SECS: u32 = 10;
|
||||||
|
const MAX_GRACE_SECS: u32 = 86_400;
|
||||||
|
|
||||||
|
fn default_grace() -> u32 {
|
||||||
|
DEFAULT_GRACE_SECS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn one() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persisted settings.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct SessionSettings {
|
||||||
|
#[serde(default = "one")]
|
||||||
|
pub version: u32,
|
||||||
|
/// End the launched game when the session ends. See [`GameOnSessionEnd`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_on_session_end: GameOnSessionEnd,
|
||||||
|
/// End the streaming session when the launched game exits.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub session_on_game_exit: bool,
|
||||||
|
/// How long a vanished client has to reconnect before `Always` ends its game. Ignored by the
|
||||||
|
/// other two policies.
|
||||||
|
#[serde(default = "default_grace")]
|
||||||
|
pub disconnect_grace_seconds: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SessionSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: 1,
|
||||||
|
game_on_session_end: GameOnSessionEnd::default(),
|
||||||
|
session_on_game_exit: true,
|
||||||
|
disconnect_grace_seconds: DEFAULT_GRACE_SECS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionSettings {
|
||||||
|
/// Clamp anything a hand-edited file (or a plugin) could get wrong.
|
||||||
|
pub fn sanitized(mut self) -> Self {
|
||||||
|
self.version = 1;
|
||||||
|
self.disconnect_grace_seconds = self
|
||||||
|
.disconnect_grace_seconds
|
||||||
|
.clamp(MIN_GRACE_SECS, MAX_GRACE_SECS);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The store: the loaded file value (`None` when no file exists) behind its path.
|
||||||
|
pub struct SessionSettingsStore {
|
||||||
|
path: PathBuf,
|
||||||
|
cur: Mutex<Option<SessionSettings>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionSettingsStore {
|
||||||
|
/// Load from `path`. Missing ⇒ unconfigured; corrupt ⇒ unconfigured **with a warning**, never a
|
||||||
|
/// startup failure.
|
||||||
|
pub fn load_from(path: PathBuf) -> Self {
|
||||||
|
let cur = match std::fs::read(&path) {
|
||||||
|
Ok(bytes) => match serde_json::from_slice::<SessionSettings>(&bytes) {
|
||||||
|
Ok(s) => Some(s.sanitized()),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %path.display(),
|
||||||
|
"session-settings.json unreadable — using built-in defaults: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(_) => None,
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
cur: Mutex::new(cur),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored settings, or the defaults when unconfigured.
|
||||||
|
pub fn get(&self) -> SessionSettings {
|
||||||
|
self.cur
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an operator has ever configured this host (drives the console's "using defaults" hint).
|
||||||
|
pub fn configured(&self) -> bool {
|
||||||
|
self.cur.lock().unwrap_or_else(|e| e.into_inner()).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist + adopt (sanitized first). Memory changes only if the write lands, so a full disk
|
||||||
|
/// can't leave the running host disagreeing with its own file.
|
||||||
|
pub fn set(&self, settings: SessionSettings) -> Result<()> {
|
||||||
|
let settings = settings.sanitized();
|
||||||
|
if let Some(dir) = self.path.parent() {
|
||||||
|
pf_paths::create_private_dir(dir)?;
|
||||||
|
}
|
||||||
|
let tmp = self.path.with_extension("json.tmp");
|
||||||
|
pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&settings)?)?;
|
||||||
|
std::fs::rename(&tmp, &self.path)?;
|
||||||
|
*self.cur.lock().unwrap_or_else(|e| e.into_inner()) = Some(settings);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process-wide store, loaded once on first access — the same global-accessor shape as
|
||||||
|
/// `vdisplay::prefs()` / `pf_gpu::prefs()`, because the lifetime decisions happen deep in the session
|
||||||
|
/// teardown path where no app state is threaded.
|
||||||
|
pub fn store() -> &'static SessionSettingsStore {
|
||||||
|
static STORE: OnceLock<SessionSettingsStore> = OnceLock::new();
|
||||||
|
STORE.get_or_init(|| {
|
||||||
|
SessionSettingsStore::load_from(pf_paths::config_dir().join("session-settings.json"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effective settings (shorthand for `store().get()`).
|
||||||
|
pub fn get() -> SessionSettings {
|
||||||
|
store().get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which lifetime axes this build actually acts on, for the console to grey out the rest. Both
|
||||||
|
/// directions need a launch path and a way to see processes; macOS has neither today.
|
||||||
|
pub fn enforced() -> Vec<String> {
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
|
{
|
||||||
|
vec![
|
||||||
|
"session_on_game_exit".to_string(),
|
||||||
|
"game_on_session_end".to_string(),
|
||||||
|
"disconnect_grace_seconds".to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
|
{
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_are_the_documented_ones() {
|
||||||
|
let d = SessionSettings::default();
|
||||||
|
// End-session-on-game-exit ships ON; ending games ships OFF.
|
||||||
|
assert!(d.session_on_game_exit);
|
||||||
|
assert_eq!(d.game_on_session_end, GameOnSessionEnd::Keep);
|
||||||
|
assert_eq!(d.disconnect_grace_seconds, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_object_decodes_to_the_defaults() {
|
||||||
|
// Every field is `#[serde(default)]`, so a file written by an older/partial writer keeps
|
||||||
|
// working instead of failing the load.
|
||||||
|
let s: SessionSettings = serde_json::from_str("{}").expect("empty object");
|
||||||
|
assert!(s.session_on_game_exit);
|
||||||
|
assert_eq!(s.game_on_session_end, GameOnSessionEnd::Keep);
|
||||||
|
assert_eq!(s.disconnect_grace_seconds, 300);
|
||||||
|
assert_eq!(s.version, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn policy_names_are_snake_case_on_the_wire() {
|
||||||
|
// These strings are the API contract with the console.
|
||||||
|
let json = serde_json::to_string(&GameOnSessionEnd::OnQuit).unwrap();
|
||||||
|
assert_eq!(json, "\"on_quit\"");
|
||||||
|
let back: GameOnSessionEnd = serde_json::from_str("\"always\"").unwrap();
|
||||||
|
assert_eq!(back, GameOnSessionEnd::Always);
|
||||||
|
assert_eq!(GameOnSessionEnd::Keep.as_str(), "keep");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grace_is_clamped_both_ways() {
|
||||||
|
let low = SessionSettings {
|
||||||
|
disconnect_grace_seconds: 0,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(low.disconnect_grace_seconds, MIN_GRACE_SECS);
|
||||||
|
let high = SessionSettings {
|
||||||
|
disconnect_grace_seconds: u32::MAX,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(high.disconnect_grace_seconds, MAX_GRACE_SECS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_file_is_unconfigured_and_corrupt_file_does_not_fail() {
|
||||||
|
let td = tempfile::tempdir().unwrap();
|
||||||
|
let path = td.path().join("session-settings.json");
|
||||||
|
let store = SessionSettingsStore::load_from(path.clone());
|
||||||
|
assert!(!store.configured());
|
||||||
|
assert_eq!(store.get().game_on_session_end, GameOnSessionEnd::Keep);
|
||||||
|
|
||||||
|
std::fs::write(&path, b"{ not json").unwrap();
|
||||||
|
let store = SessionSettingsStore::load_from(path.clone());
|
||||||
|
assert!(
|
||||||
|
!store.configured(),
|
||||||
|
"corrupt file must read as unconfigured"
|
||||||
|
);
|
||||||
|
assert!(store.get().session_on_game_exit, "defaults still apply");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_persists_sanitized_and_round_trips() {
|
||||||
|
let td = tempfile::tempdir().unwrap();
|
||||||
|
let path = td.path().join("session-settings.json");
|
||||||
|
let store = SessionSettingsStore::load_from(path.clone());
|
||||||
|
store
|
||||||
|
.set(SessionSettings {
|
||||||
|
version: 99,
|
||||||
|
game_on_session_end: GameOnSessionEnd::Always,
|
||||||
|
session_on_game_exit: false,
|
||||||
|
disconnect_grace_seconds: 5, // below the floor
|
||||||
|
})
|
||||||
|
.expect("write");
|
||||||
|
assert!(store.configured());
|
||||||
|
let got = store.get();
|
||||||
|
assert_eq!(got.game_on_session_end, GameOnSessionEnd::Always);
|
||||||
|
assert!(!got.session_on_game_exit);
|
||||||
|
assert_eq!(got.disconnect_grace_seconds, MIN_GRACE_SECS);
|
||||||
|
assert_eq!(got.version, 1, "version is normalized, not echoed");
|
||||||
|
// A fresh load sees the same thing, and no temp file is left behind.
|
||||||
|
let reloaded = SessionSettingsStore::load_from(path.clone());
|
||||||
|
assert_eq!(reloaded.get().game_on_session_end, GameOnSessionEnd::Always);
|
||||||
|
assert!(!path.with_extension("json.tmp").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,10 @@ struct LiveSession {
|
|||||||
codec: Codec,
|
codec: Codec,
|
||||||
/// The session's teardown flag ([`stop_all`] → mgmt `DELETE /session`).
|
/// The session's teardown flag ([`stop_all`] → mgmt `DELETE /session`).
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
|
/// The session's *deliberate*-stop flag ([`stop_all_quit`]). Distinct from `stop`: it marks the
|
||||||
|
/// teardown as intended rather than a drop, which is what skips the display's keep-alive linger
|
||||||
|
/// and what the end-game-on-session-end policy keys off.
|
||||||
|
quit: Arc<AtomicBool>,
|
||||||
/// One-shot force-keyframe flag ([`force_idr_all`] → mgmt `POST /session/idr`); the encode loop
|
/// One-shot force-keyframe flag ([`force_idr_all`] → mgmt `POST /session/idr`); the encode loop
|
||||||
/// drains it alongside a client's decode-recovery keyframe request.
|
/// drains it alongside a client's decode-recovery keyframe request.
|
||||||
force_idr: Arc<AtomicBool>,
|
force_idr: Arc<AtomicBool>,
|
||||||
@@ -45,6 +49,9 @@ struct LiveSession {
|
|||||||
ttff_ms: Arc<AtomicU32>,
|
ttff_ms: Arc<AtomicU32>,
|
||||||
/// Most recent completed mid-stream resize (reconfigure → pipeline rebuilt), ms; 0 = none yet.
|
/// Most recent completed mid-stream resize (reconfigure → pipeline rebuilt), ms; 0 = none yet.
|
||||||
last_resize_ms: Arc<AtomicU32>,
|
last_resize_ms: Arc<AtomicU32>,
|
||||||
|
/// The launched game's lease, when this session launched a title — so `/status` can report what
|
||||||
|
/// is actually running and the console can show it (design/session-game-lifetime.md §8a).
|
||||||
|
game: Option<Arc<crate::gamelease::LeaseShared>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A resolved read of one live session, for the `/status` view.
|
/// A resolved read of one live session, for the `/status` view.
|
||||||
@@ -82,21 +89,49 @@ fn session_ref(s: &LiveSession) -> crate::events::SessionRef {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What [`register`] needs to publish a session. A named struct rather than a positional argument
|
||||||
|
/// list: half of these are same-typed `Arc<Atomic…>` handles, so a transposed pair would compile
|
||||||
|
/// cleanly and quietly report the wrong number on the Dashboard.
|
||||||
|
pub struct Registration {
|
||||||
|
/// Packed `w:16|h:16|hz:16` ([`crate::native::pack_mode`]); updated on a mode switch.
|
||||||
|
pub mode: Arc<AtomicU64>,
|
||||||
|
/// Live encoder target (kbps); updated on an adaptive-bitrate change.
|
||||||
|
pub bitrate_kbps: Arc<AtomicU32>,
|
||||||
|
pub codec: Codec,
|
||||||
|
/// Teardown flag ([`stop_all`]).
|
||||||
|
pub stop: Arc<AtomicBool>,
|
||||||
|
/// Deliberate-stop flag ([`stop_all_quit`]).
|
||||||
|
pub quit: Arc<AtomicBool>,
|
||||||
|
/// One-shot force-keyframe flag ([`force_idr_all`]).
|
||||||
|
pub force_idr: Arc<AtomicBool>,
|
||||||
|
/// Short client label (cert-fingerprint prefix / peer IP).
|
||||||
|
pub client: String,
|
||||||
|
pub hdr: bool,
|
||||||
|
/// Bring-up total slot (hello → first packet), ms.
|
||||||
|
pub ttff_ms: Arc<AtomicU32>,
|
||||||
|
/// Most recent mid-stream resize total, ms.
|
||||||
|
pub last_resize_ms: Arc<AtomicU32>,
|
||||||
|
/// The launched game's lease, when this session launched a title.
|
||||||
|
pub game: Option<Arc<crate::gamelease::LeaseShared>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Registers a live native session; the returned guard removes it on drop (session end).
|
/// Registers a live native session; the returned guard removes it on drop (session end).
|
||||||
/// Emits the `session.started` lifecycle event; the guard's drop emits `session.ended` — RAII,
|
/// Emits the `session.started` lifecycle event; the guard's drop emits `session.ended` — RAII,
|
||||||
/// so every exit path (return, `?`, panic-unwind) pairs them.
|
/// so every exit path (return, `?`, panic-unwind) pairs them.
|
||||||
#[allow(clippy::too_many_arguments)]
|
pub fn register(reg: Registration) -> LiveSessionGuard {
|
||||||
pub fn register(
|
let Registration {
|
||||||
mode: Arc<AtomicU64>,
|
mode,
|
||||||
bitrate_kbps: Arc<AtomicU32>,
|
bitrate_kbps,
|
||||||
codec: Codec,
|
codec,
|
||||||
stop: Arc<AtomicBool>,
|
stop,
|
||||||
force_idr: Arc<AtomicBool>,
|
quit,
|
||||||
client: String,
|
force_idr,
|
||||||
hdr: bool,
|
client,
|
||||||
ttff_ms: Arc<AtomicU32>,
|
hdr,
|
||||||
last_resize_ms: Arc<AtomicU32>,
|
ttff_ms,
|
||||||
) -> LiveSessionGuard {
|
last_resize_ms,
|
||||||
|
game,
|
||||||
|
} = reg;
|
||||||
let id = next_id();
|
let id = next_id();
|
||||||
let session = LiveSession {
|
let session = LiveSession {
|
||||||
id,
|
id,
|
||||||
@@ -104,11 +139,13 @@ pub fn register(
|
|||||||
bitrate_kbps,
|
bitrate_kbps,
|
||||||
codec,
|
codec,
|
||||||
stop,
|
stop,
|
||||||
|
quit,
|
||||||
force_idr,
|
force_idr,
|
||||||
client,
|
client,
|
||||||
hdr,
|
hdr,
|
||||||
ttff_ms,
|
ttff_ms,
|
||||||
last_resize_ms,
|
last_resize_ms,
|
||||||
|
game,
|
||||||
};
|
};
|
||||||
crate::events::emit(crate::events::EventKind::SessionStarted {
|
crate::events::emit(crate::events::EventKind::SessionStarted {
|
||||||
session: session_ref(&session),
|
session: session_ref(&session),
|
||||||
@@ -167,14 +204,135 @@ pub fn snapshot() -> Vec<SessionSnapshot> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signals every live native session to tear down (mgmt `DELETE /session`). Best-effort: the video
|
/// One launched game, as `/status` reports it.
|
||||||
/// + send loops observe the flag and exit, ending the stream; the guard then clears the entry.
|
pub struct GameSnapshot {
|
||||||
|
/// The session streaming it, or `None` for a game whose session is gone and which is waiting out
|
||||||
|
/// its reconnect window.
|
||||||
|
pub session_id: Option<u64>,
|
||||||
|
pub client: String,
|
||||||
|
pub app_id: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub store: Option<String>,
|
||||||
|
pub plane: crate::events::Plane,
|
||||||
|
/// `launching` / `running` / `exited`, or `grace` for a game on its reconnect window.
|
||||||
|
pub state: &'static str,
|
||||||
|
/// Seconds left before the game is ended, for a `grace` row.
|
||||||
|
pub grace_remaining_s: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The compat plane's launched game, while it has one.
|
||||||
|
///
|
||||||
|
/// GameStream sessions are not in the live-session registry above — that registry holds the native
|
||||||
|
/// loop's own `Arc` handles (mode, bitrate, the stop/quit flags), none of which the compat plane has.
|
||||||
|
/// It is single-session by construction (one `AppState.launch`), so one slot is the whole story, and
|
||||||
|
/// this is what keeps a Moonlight client's game visible on the Dashboard alongside a native one.
|
||||||
|
fn gs_game() -> &'static Mutex<Option<Arc<crate::gamelease::LeaseShared>>> {
|
||||||
|
static SLOT: OnceLock<Mutex<Option<Arc<crate::gamelease::LeaseShared>>>> = OnceLock::new();
|
||||||
|
SLOT.get_or_init(|| Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish the compat plane's game for the status surface; the returned guard retracts it when the
|
||||||
|
/// stream loop exits by any path (the plane's counterpart to [`LiveSessionGuard`]).
|
||||||
|
pub fn publish_gamestream_game(shared: Arc<crate::gamelease::LeaseShared>) -> GamestreamGameGuard {
|
||||||
|
*gs_game().lock().unwrap_or_else(|e| e.into_inner()) = Some(shared);
|
||||||
|
GamestreamGameGuard
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the compat plane's published game on drop.
|
||||||
|
pub struct GamestreamGameGuard;
|
||||||
|
|
||||||
|
impl Drop for GamestreamGameGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
*gs_game().lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every launched game the host currently knows about: the live sessions' games first, then the
|
||||||
|
/// compat plane's, then any game whose session has ended and which is waiting out its reconnect
|
||||||
|
/// window before being ended.
|
||||||
|
///
|
||||||
|
/// The sources are deliberately separate — a grace-pending game has no session to attribute it
|
||||||
|
/// to, and hiding it would make "the host is about to close my game" invisible in the UI.
|
||||||
|
pub fn games() -> Vec<GameSnapshot> {
|
||||||
|
let mut out: Vec<GameSnapshot> = registry()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| {
|
||||||
|
let g = s.game.as_ref()?;
|
||||||
|
Some(GameSnapshot {
|
||||||
|
session_id: Some(s.id),
|
||||||
|
client: g.client.clone(),
|
||||||
|
app_id: g.game.id.clone(),
|
||||||
|
title: g.game.title.clone(),
|
||||||
|
store: g.game.store.clone(),
|
||||||
|
plane: g.plane,
|
||||||
|
state: g.state().as_str(),
|
||||||
|
grace_remaining_s: None,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
out.extend(
|
||||||
|
gs_game()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.iter()
|
||||||
|
.map(|g| GameSnapshot {
|
||||||
|
// The compat plane has no session id to attribute it to; the console tells it from a
|
||||||
|
// grace row by the state, which is never `grace` while a session is streaming it.
|
||||||
|
session_id: None,
|
||||||
|
client: g.client.clone(),
|
||||||
|
app_id: g.game.id.clone(),
|
||||||
|
title: g.game.title.clone(),
|
||||||
|
store: g.game.store.clone(),
|
||||||
|
plane: g.plane,
|
||||||
|
state: g.state().as_str(),
|
||||||
|
grace_remaining_s: None,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
out.extend(
|
||||||
|
crate::gamelease::pending_snapshot()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(g, remaining)| GameSnapshot {
|
||||||
|
session_id: None,
|
||||||
|
client: g.client.clone(),
|
||||||
|
app_id: g.game.id.clone(),
|
||||||
|
title: g.game.title.clone(),
|
||||||
|
store: g.game.store.clone(),
|
||||||
|
plane: g.plane,
|
||||||
|
state: "grace",
|
||||||
|
grace_remaining_s: Some(remaining),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signals every live native session to tear down. Best-effort: the video + send loops observe the
|
||||||
|
/// flag and exit, ending the stream; the guard then clears the entry.
|
||||||
|
///
|
||||||
|
/// This is the *drop*-flavored stop — the session ends, but nothing treats it as intended. Prefer
|
||||||
|
/// [`stop_all_quit`] for an operator action.
|
||||||
pub fn stop_all() {
|
pub fn stop_all() {
|
||||||
for s in registry().lock().unwrap().iter() {
|
for s in registry().lock().unwrap().iter() {
|
||||||
s.stop.store(true, Ordering::SeqCst);
|
s.stop.store(true, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Signals every live native session to tear down **deliberately** (mgmt `DELETE /session`, the
|
||||||
|
/// console's and a plugin's Stop).
|
||||||
|
///
|
||||||
|
/// An operator pressing Stop is a decision, and the host now says so: `quit` before `stop` makes the
|
||||||
|
/// teardown look exactly like a client's own Stop, so the display skips its keep-alive linger and the
|
||||||
|
/// end-game-on-session-end policy sees an intent rather than a network drop. (Before this, a
|
||||||
|
/// management stop was indistinguishable from a client vanishing, which left the display lingering
|
||||||
|
/// for a session nobody was coming back to.)
|
||||||
|
pub fn stop_all_quit() {
|
||||||
|
for s in registry().lock().unwrap().iter() {
|
||||||
|
s.quit.store(true, Ordering::SeqCst);
|
||||||
|
s.stop.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Requests a forced keyframe on every live native session (mgmt `POST /session/idr`). The encode
|
/// Requests a forced keyframe on every live native session (mgmt `POST /session/idr`). The encode
|
||||||
/// loop drains the flag exactly like a client decode-recovery request.
|
/// loop drains the flag exactly like a client decode-recovery request.
|
||||||
pub fn force_idr_all() {
|
pub fn force_idr_all() {
|
||||||
@@ -182,3 +340,57 @@ pub fn force_idr_all() {
|
|||||||
s.force_idr.store(true, Ordering::Relaxed);
|
s.force_idr.store(true, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A Moonlight client's game has no live-session entry to hang off, so without the compat-plane
|
||||||
|
/// slot it would be missing from `/status` entirely — the Dashboard would show a stream with no
|
||||||
|
/// game while one was plainly running. Publishing must also be strictly scoped to the stream: the
|
||||||
|
/// row has to vanish with the guard, or a finished session leaves a ghost game on the console.
|
||||||
|
#[test]
|
||||||
|
fn a_gamestream_game_is_visible_only_while_its_stream_runs() {
|
||||||
|
let id = "steam:1701";
|
||||||
|
let mine = || {
|
||||||
|
games()
|
||||||
|
.into_iter()
|
||||||
|
.find(|g| g.app_id.as_deref() == Some(id))
|
||||||
|
};
|
||||||
|
|
||||||
|
let lease = crate::gamelease::open(
|
||||||
|
crate::gamelease::LeaseRequest {
|
||||||
|
game: crate::gamelease::GameRef {
|
||||||
|
id: Some(id.to_string()),
|
||||||
|
store: Some("steam".into()),
|
||||||
|
title: "Test Title".into(),
|
||||||
|
},
|
||||||
|
client: "192.0.2.7".into(),
|
||||||
|
plane: crate::events::Plane::Gamestream,
|
||||||
|
// No signals: an inert lease, so no watcher thread races this test's assertions.
|
||||||
|
spec: crate::library::DetectSpec::default(),
|
||||||
|
nested: false,
|
||||||
|
child: None,
|
||||||
|
launch_stamp: None,
|
||||||
|
},
|
||||||
|
Box::new(|| {}),
|
||||||
|
);
|
||||||
|
assert!(mine().is_none(), "not published yet");
|
||||||
|
|
||||||
|
{
|
||||||
|
let _pub = publish_gamestream_game(lease.shared());
|
||||||
|
let row = mine().expect("the compat plane's game is reported");
|
||||||
|
assert_eq!(
|
||||||
|
row.session_id, None,
|
||||||
|
"no live-session entry to attribute it to"
|
||||||
|
);
|
||||||
|
assert_eq!(row.plane, crate::events::Plane::Gamestream);
|
||||||
|
assert_eq!(row.client, "192.0.2.7");
|
||||||
|
assert_eq!(row.title, "Test Title");
|
||||||
|
// Never `grace` while the stream is up — that state is what the console keys its
|
||||||
|
// countdown and "End now" off.
|
||||||
|
assert_ne!(row.state, "grace");
|
||||||
|
}
|
||||||
|
assert!(mine().is_none(), "the row goes with the stream");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,7 +94,9 @@ pub fn run(opts: Options) -> Result<()> {
|
|||||||
want_hdr,
|
want_hdr,
|
||||||
"spike source: xdg ScreenCast portal (live monitor)"
|
"spike source: xdg ScreenCast portal (live monitor)"
|
||||||
);
|
);
|
||||||
capture::open_portal_monitor(want_hdr).context("open portal capturer")?
|
// Embedded cursor: the spike passes `cursor_blend = false` to its encoder open, so
|
||||||
|
// a metadata pointer would be composited by nothing.
|
||||||
|
capture::open_portal_monitor(want_hdr, false).context("open portal capturer")?
|
||||||
}
|
}
|
||||||
Source::KwinVirtual => {
|
Source::KwinVirtual => {
|
||||||
let compositor = crate::vdisplay::detect().unwrap_or(crate::vdisplay::Compositor::Kwin);
|
let compositor = crate::vdisplay::detect().unwrap_or(crate::vdisplay::Compositor::Kwin);
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
//! Asking a game to close on Windows, and killing it if it won't.
|
||||||
|
//!
|
||||||
|
//! The two halves of [`crate::gamelease`]'s termination ladder that need Win32: post `WM_CLOSE` to the
|
||||||
|
//! game's windows, then `TerminateProcess` whatever ignored it. Kept out of `procscan` (read-only by
|
||||||
|
//! construction) and out of `gamelease` (platform-neutral orchestration).
|
||||||
|
//!
|
||||||
|
//! ### Why the desktop dance
|
||||||
|
//!
|
||||||
|
//! The polite half is the reason this module is not three lines. `EnumWindows` enumerates the windows
|
||||||
|
//! of the **calling thread's desktop** — and the host runs as SYSTEM in session 0, whose desktop has
|
||||||
|
//! none of the interactive user's windows on it. Without first binding the thread to the input desktop
|
||||||
|
//! the enumeration comes back empty and the ladder silently degrades to killing every game outright,
|
||||||
|
//! which is exactly the unsaved-progress outcome the grace window exists to avoid.
|
||||||
|
//!
|
||||||
|
//! Same `OpenInputDesktop`/`SetThreadDesktop` pattern the input injector uses for `SendInput`
|
||||||
|
//! (`pf-inject`'s `sendinput.rs`), for the same reason: a UAC prompt, the lock screen or
|
||||||
|
//! Ctrl-Alt-Del swaps the input desktop out from under us.
|
||||||
|
|
||||||
|
use windows::Win32::Foundation::{CloseHandle, HWND, LPARAM, WPARAM};
|
||||||
|
use windows::Win32::System::StationsAndDesktops::{
|
||||||
|
CloseDesktop, OpenInputDesktop, SetThreadDesktop, DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS,
|
||||||
|
HDESK,
|
||||||
|
};
|
||||||
|
use windows::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
|
EnumWindows, GetWindowThreadProcessId, IsWindowVisible, PostMessageW, WM_CLOSE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Exit code reported for a game the host killed. Arbitrary but non-zero, so anything reading it can
|
||||||
|
/// tell it apart from a clean quit.
|
||||||
|
const KILLED_EXIT_CODE: u32 = 1;
|
||||||
|
|
||||||
|
/// `GENERIC_ALL` for a desktop open. The `windows` crate models desktop rights as their own type, so
|
||||||
|
/// the generic right isn't reachable through it — the same local const the input backends define
|
||||||
|
/// (`pf-inject`'s `sendinput.rs` / `pointer_windows.rs`).
|
||||||
|
const DESKTOP_GENERIC_ALL: u32 = 0x1000_0000;
|
||||||
|
|
||||||
|
/// Binds the calling thread to the desktop currently receiving input, for as long as it is held.
|
||||||
|
///
|
||||||
|
/// The thread this runs on is dedicated and short-lived (`pf1-gameterm`), so the previous desktop is
|
||||||
|
/// not restored — only the handle is released.
|
||||||
|
struct InputDesktop(HDESK);
|
||||||
|
|
||||||
|
impl InputDesktop {
|
||||||
|
/// `None` when the input desktop can't be opened or bound (an unprivileged host, or a secure
|
||||||
|
/// desktop we aren't allowed onto). Callers degrade rather than fail: without it the polite pass
|
||||||
|
/// finds no windows, and the kill pass still works.
|
||||||
|
fn attach() -> Option<Self> {
|
||||||
|
// SAFETY: FFI calls taking by-value args only (constant desktop flags, a bool, an access
|
||||||
|
// mask). `OpenInputDesktop` yields an owned `HDESK` only on `Ok`; it is then either installed
|
||||||
|
// on this thread and owned by the returned guard (closed exactly once in `Drop`), or closed
|
||||||
|
// here on the failure path. `SetThreadDesktop` rebinds only the calling thread — which owns
|
||||||
|
// this guard — and succeeds only when that thread has no windows or hooks, true of the fresh
|
||||||
|
// termination thread.
|
||||||
|
unsafe {
|
||||||
|
let h = OpenInputDesktop(
|
||||||
|
DESKTOP_CONTROL_FLAGS(0),
|
||||||
|
false,
|
||||||
|
DESKTOP_ACCESS_FLAGS(DESKTOP_GENERIC_ALL),
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
if SetThreadDesktop(h).is_ok() {
|
||||||
|
Some(Self(h))
|
||||||
|
} else {
|
||||||
|
let _ = CloseDesktop(h);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for InputDesktop {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `self.0` is the handle this guard owns and has not closed; `CloseDesktop` runs once
|
||||||
|
// here with no later use.
|
||||||
|
unsafe {
|
||||||
|
let _ = CloseDesktop(self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the enumeration callback needs, passed through `LPARAM`.
|
||||||
|
///
|
||||||
|
/// Owns its pid list rather than borrowing: the value round-trips through a raw pointer, and a
|
||||||
|
/// borrowed lifetime there is a subtlety with no upside for one small allocation per termination.
|
||||||
|
struct CloseCtx {
|
||||||
|
pids: Vec<u32>,
|
||||||
|
posted: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask every visible top-level window belonging to `pids` to close, the way clicking its X would —
|
||||||
|
/// so a game runs its own shutdown path and gets the chance to save. Returns how many windows were
|
||||||
|
/// asked.
|
||||||
|
///
|
||||||
|
/// Zero is a perfectly ordinary answer: a game may be mid-load with no window yet, or the host may not
|
||||||
|
/// have reached the input desktop. The caller's job is to wait and then insist, not to treat this as
|
||||||
|
/// success.
|
||||||
|
pub fn request_close(pids: &[u32]) -> usize {
|
||||||
|
if pids.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Without the input desktop this enumerates session 0 and finds nothing (see the module docs), so
|
||||||
|
// failing to attach means skipping the polite pass rather than doing it uselessly. Held for the
|
||||||
|
// whole enumeration.
|
||||||
|
let Some(_desktop) = InputDesktop::attach() else {
|
||||||
|
tracing::debug!(
|
||||||
|
"could not bind to the input desktop — skipping the polite close and going straight to \
|
||||||
|
the kill pass"
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let mut ctx = CloseCtx {
|
||||||
|
pids: pids.to_vec(),
|
||||||
|
posted: 0,
|
||||||
|
};
|
||||||
|
// SAFETY: `EnumWindows` invokes `enum_close` synchronously for each top-level window on this
|
||||||
|
// thread's desktop and returns before this frame exits, so the `&mut ctx` pointer it carries in
|
||||||
|
// the `LPARAM` stays valid for the whole call and is not aliased (nothing else touches `ctx`
|
||||||
|
// while the enumeration runs).
|
||||||
|
unsafe {
|
||||||
|
let _ = EnumWindows(Some(enum_close), LPARAM(&mut ctx as *mut CloseCtx as isize));
|
||||||
|
}
|
||||||
|
ctx.posted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `EnumWindows` callback: post `WM_CLOSE` to each visible window owned by one of the target pids.
|
||||||
|
///
|
||||||
|
/// Visible top-level windows only — a game's message-only and tool windows would swallow the close, and
|
||||||
|
/// posting to them achieves nothing while making the count meaningless.
|
||||||
|
unsafe extern "system" fn enum_close(hwnd: HWND, lparam: LPARAM) -> windows::core::BOOL {
|
||||||
|
// SAFETY: `lparam` is the `&mut CloseCtx` `request_close` passed in, valid for the whole
|
||||||
|
// enumeration; the callback is invoked synchronously on the same thread, so this is the only live
|
||||||
|
// reference to it.
|
||||||
|
let ctx = unsafe { &mut *(lparam.0 as *mut CloseCtx) };
|
||||||
|
let mut pid = 0u32;
|
||||||
|
// SAFETY: `hwnd` is the window the enumeration handed us; `pid` is a live local we own.
|
||||||
|
unsafe {
|
||||||
|
GetWindowThreadProcessId(hwnd, Some(&mut pid));
|
||||||
|
if ctx.pids.contains(&pid) && IsWindowVisible(hwnd).as_bool() {
|
||||||
|
// Posted, not sent: a `SendMessage` would block this thread on a hung game's message pump.
|
||||||
|
if PostMessageW(Some(hwnd), WM_CLOSE, WPARAM(0), LPARAM(0)).is_ok() {
|
||||||
|
ctx.posted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true.into() // keep enumerating — a game can own several windows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill `pids` outright. Returns how many were terminated.
|
||||||
|
///
|
||||||
|
/// The caller must have re-verified each pid against its recorded start time immediately before
|
||||||
|
/// calling this ([`crate::procscan::Scanner::alive`]) — Windows recycles pids briskly, and this call is
|
||||||
|
/// unforgiving about being pointed at the wrong one.
|
||||||
|
pub fn kill(pids: &[u32]) -> usize {
|
||||||
|
let mut killed = 0;
|
||||||
|
for &pid in pids {
|
||||||
|
// SAFETY: `OpenProcess` yields an owned handle only on `Ok`, which is closed exactly once
|
||||||
|
// below; `TerminateProcess` takes it by value plus a plain exit code.
|
||||||
|
unsafe {
|
||||||
|
if let Ok(h) = OpenProcess(PROCESS_TERMINATE, false, pid) {
|
||||||
|
if TerminateProcess(h, KILLED_EXIT_CODE).is_ok() {
|
||||||
|
killed += 1;
|
||||||
|
}
|
||||||
|
let _ = CloseHandle(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
killed
|
||||||
|
}
|
||||||
@@ -403,7 +403,7 @@ fn web_setup(args: &[String]) -> Result<()> {
|
|||||||
// only with `--allow-public-network`. Delete any prior rule first so an upgrade re-scopes it
|
// only with `--allow-public-network`. Delete any prior rule first so an upgrade re-scopes it
|
||||||
// instead of stacking a second (possibly all-profiles) rule behind the new one.
|
// instead of stacking a second (possibly all-profiles) rule behind the new one.
|
||||||
let fw_profile =
|
let fw_profile =
|
||||||
crate::service::firewall_profile_arg(crate::service::allow_public_network(args));
|
crate::service::firewall_profile_arg(crate::service::allow_public_network(args)?);
|
||||||
run_quiet(
|
run_quiet(
|
||||||
"netsh",
|
"netsh",
|
||||||
&[
|
&[
|
||||||
|
|||||||
@@ -718,8 +718,9 @@ fn install(args: &[String]) -> Result<()> {
|
|||||||
// Firewall scope: Domain + Private by default; `--allow-public-network` opts into Public too.
|
// Firewall scope: Domain + Private by default; `--allow-public-network` opts into Public too.
|
||||||
// Persist the choice (so the startup warning respects an opt-in) and re-scope idempotently —
|
// Persist the choice (so the startup warning respects an opt-in) and re-scope idempotently —
|
||||||
// remove any prior rules first so an upgrade tightens the scope instead of leaving a stale
|
// remove any prior rules first so an upgrade tightens the scope instead of leaving a stale
|
||||||
// all-profiles rule behind the new one.
|
// all-profiles rule behind the new one. With the flag absent (every upgrade) this resolves to
|
||||||
let allow_public = allow_public_network(args);
|
// the previously recorded choice, so the rewrite below is a no-op rather than a silent reset.
|
||||||
|
let allow_public = allow_public_network(args)?;
|
||||||
set_fw_public_marker(allow_public);
|
set_fw_public_marker(allow_public);
|
||||||
remove_firewall_rules();
|
remove_firewall_rules();
|
||||||
add_firewall_rules(allow_public);
|
add_firewall_rules(allow_public);
|
||||||
@@ -889,10 +890,37 @@ pub(crate) fn firewall_profile_arg(allow_public: bool) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `--allow-public-network` install opt-in (the installer's "Allow connections on Public
|
/// Resolve the Public-network firewall scope for this install/upgrade (the installer's "Allow
|
||||||
/// networks" task forwards it). Absent = the secure default (Domain + Private only).
|
/// connections on Public networks" task forwards the flag).
|
||||||
pub(crate) fn allow_public_network(args: &[String]) -> bool {
|
///
|
||||||
args.iter().any(|a| a == "--allow-public-network")
|
/// Tri-state, mirroring `--gamestream=on|off`:
|
||||||
|
/// * `--allow-public-network` or `=on` -> `true` — explicit opt-in. The bare form is kept for
|
||||||
|
/// back-compat with existing scripts and docs that pass it that way.
|
||||||
|
/// * `--allow-public-network=off` -> `false` — explicit opt-out.
|
||||||
|
/// * absent -> whatever the previous install recorded.
|
||||||
|
///
|
||||||
|
/// The absent case is what makes an UPGRADE non-destructive. A silent upgrade (`winget upgrade`)
|
||||||
|
/// has no wizard to carry the old checkbox forward, so the installer omits the flag entirely on an
|
||||||
|
/// upgrade and the recorded choice stands. Re-applying the task default instead would quietly
|
||||||
|
/// re-scope the firewall on every upgrade, undoing an opt-in the user made once. On a first-ever
|
||||||
|
/// install there is no marker, so absence resolves to the secure default (Domain + Private only).
|
||||||
|
///
|
||||||
|
/// Strict on the value: a typo'd `=of` must NOT fall through to the marker, because the marker may
|
||||||
|
/// say `true` — i.e. a mistyped opt-OUT would silently leave Public open.
|
||||||
|
pub(crate) fn allow_public_network(args: &[String]) -> Result<bool> {
|
||||||
|
for a in args {
|
||||||
|
if let Some(v) = a.strip_prefix("--allow-public-network") {
|
||||||
|
return match v {
|
||||||
|
"" | "=on" => Ok(true),
|
||||||
|
"=off" => Ok(false),
|
||||||
|
_ => bail!(
|
||||||
|
"--allow-public-network must be 'on' or 'off' (got '{}')",
|
||||||
|
v.trim_start_matches('=')
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(fw_public_marker().exists())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inbound firewall rules for the streaming + mgmt ports (best-effort; logs but never fails the
|
/// Inbound firewall rules for the streaming + mgmt ports (best-effort; logs but never fails the
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ and nothing you configure here runs anywhere near the streaming path.
|
|||||||
| `client.connected` / `client.disconnected` | a client session is admitted / goes away | device name, cert fingerprint, plane (`native`/`gamestream`); disconnect adds `reason`: `quit` (user stop), `timeout` (vanished), `error` |
|
| `client.connected` / `client.disconnected` | a client session is admitted / goes away | device name, cert fingerprint, plane (`native`/`gamestream`); disconnect adds `reason`: `quit` (user stop), `timeout` (vanished), `error` |
|
||||||
| `session.started` / `session.ended` | an A/V session registers / ends | session id, client label, mode (`3840x2160@120`), HDR |
|
| `session.started` / `session.ended` | an A/V session registers / ends | session id, client label, mode (`3840x2160@120`), HDR |
|
||||||
| `stream.started` / `stream.stopped` | video actually starts / stops | mode, HDR, client name, launched app id/title (when one was requested), plane |
|
| `stream.started` / `stream.stopped` | video actually starts / stops | mode, HDR, client name, launched app id/title (when one was requested), plane |
|
||||||
|
| `game.running` | a launched game's own process is seen running (not merely its launcher) | app id, title, store, client, plane |
|
||||||
|
| `game.exited` | a launched game is gone | the same, plus `reason`: `exited` (the player quit it) or `terminated` (the host closed it, per your [session⇄game settings](/docs/virtual-displays#when-a-game-ends-and-when-a-session-does)) |
|
||||||
| `pairing.pending` | an unpaired device knocks (once per device, not per retry) | device name, fingerprint, plane |
|
| `pairing.pending` | an unpaired device knocks (once per device, not per retry) | device name, fingerprint, plane |
|
||||||
| `pairing.completed` / `pairing.denied` | a pairing is approved+stored / denied | device name, fingerprint, plane |
|
| `pairing.completed` / `pairing.denied` | a pairing is approved+stored / denied | device name, fingerprint, plane |
|
||||||
| `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count |
|
| `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count |
|
||||||
@@ -124,6 +126,29 @@ best-effort, even if the session crashed:
|
|||||||
|
|
||||||
A `do` that fails logs, keeps going, and its own `undo` is skipped (it never took effect).
|
A `do` that fails logs, keeps going, and its own `undo` is skipped (it never took effect).
|
||||||
|
|
||||||
|
## Reacting to a game, not a stream
|
||||||
|
|
||||||
|
`stream.stopped` tells you the *stream* ended; `game.exited` tells you the *game* did. They are
|
||||||
|
often the same moment, but not always — a desktop stream has no game at all, and a stream can
|
||||||
|
outlive its game if you turned off "end the session when the game exits".
|
||||||
|
|
||||||
|
If you have been polling the host to work out when a game finished, you don't need to any more:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "hooks": [
|
||||||
|
{ "on": "game.running", "run": "~/.config/punktfunk/scripts/game-up.sh" },
|
||||||
|
{ "on": "game.exited", "run": "~/.config/punktfunk/scripts/game-down.sh" }
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Both carry the title in `PF_EVENT_GAME_TITLE` / `PF_EVENT_GAME_APP`, and `game.exited` adds
|
||||||
|
`PF_EVENT_REASON` so a script can tell "the player quit" (`exited`) from "the host closed it"
|
||||||
|
(`terminated`) — worth checking before you, say, power the TV off.
|
||||||
|
|
||||||
|
Ending the session yourself when a game exits needs no script at all: it is the default behavior,
|
||||||
|
under Host → *Virtual displays* →
|
||||||
|
[When a game or a session ends](/docs/virtual-displays#when-a-game-ends-and-when-a-session-does).
|
||||||
|
|
||||||
## The event stream (`GET /api/v1/events`)
|
## The event stream (`GET /api/v1/events`)
|
||||||
|
|
||||||
For code, subscribe to the SSE stream on the management API (loopback + bearer token — the
|
For code, subscribe to the SSE stream on the management API (loopback + bearer token — the
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ redundant or stale.
|
|||||||
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. |
|
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. |
|
||||||
| `PUNKTFUNK_ZEROCOPY` | `1` · `0` *(default on)* | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). **On by default** — no need to set it; it falls back to a CPU path automatically. Set `0` to force the CPU path. One exception: Windows **Intel/QSV** keeps the CPU path by default until zero-copy is validated on Intel hardware — set `1` to try it there. |
|
| `PUNKTFUNK_ZEROCOPY` | `1` · `0` *(default on)* | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). **On by default** — no need to set it; it falls back to a CPU path automatically. Set `0` to force the CPU path. One exception: Windows **Intel/QSV** keeps the CPU path by default until zero-copy is validated on Intel hardware — set `1` to try it there. |
|
||||||
| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots **and Hyprland**. Auto-detected with the compositor. |
|
| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots **and Hyprland**. Auto-detected with the compositor. |
|
||||||
| `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). |
|
| `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). On a multi-GPU Windows box a forced hardware backend whose vendor contradicts the selected GPU (web-console preference) is **overridden** — the adapter wins and the host logs a warning; remove the stale pin. |
|
||||||
| `PUNKTFUNK_RENDER_NODE` | path | Linux DRM render node for zero-copy (default `/dev/dri/renderD128`). Set on multi-GPU boxes to pick the right GPU. |
|
| `PUNKTFUNK_RENDER_NODE` | path | Linux DRM render node for zero-copy (default `/dev/dri/renderD128`). Set on multi-GPU boxes to pick the right GPU. |
|
||||||
|
|
||||||
Resolution and refresh are **not** set here — **the client chooses them.** When a device connects,
|
Resolution and refresh are **not** set here — **the client chooses them.** When a device connects,
|
||||||
@@ -117,6 +117,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
|||||||
| `PUNKTFUNK_VDISPLAY` | `pf` | Virtual-display backend. The bundled pf-vdisplay IddCx driver is the only backend now — informational; leave as `pf`. |
|
| `PUNKTFUNK_VDISPLAY` | `pf` | Virtual-display backend. The bundled pf-vdisplay IddCx driver is the only backend now — informational; leave as `pf`. |
|
||||||
| `PUNKTFUNK_SECURE_DDA` | `1` | Capture the secure desktop (UAC / lock / login) so the stream survives those transitions. |
|
| `PUNKTFUNK_SECURE_DDA` | `1` | Capture the secure desktop (UAC / lock / login) so the stream survives those transitions. |
|
||||||
| `PUNKTFUNK_MONITOR_LINGER_MS` | ms (default `10000`) | Defer tearing a per-client virtual display down after disconnect. A reconnect inside the window preempts it and creates a fresh one (a reused IddCx swap-chain is dead); the stable per-client monitor id keeps Windows' saved display config applying either way. Superseded by the console's **Keep alive** setting — see [Virtual displays](/docs/virtual-displays). |
|
| `PUNKTFUNK_MONITOR_LINGER_MS` | ms (default `10000`) | Defer tearing a per-client virtual display down after disconnect. A reconnect inside the window preempts it and creates a fresh one (a reused IddCx swap-chain is dead); the stable per-client monitor id keeps Windows' saved display config applying either way. Superseded by the console's **Keep alive** setting — see [Virtual displays](/docs/virtual-displays). |
|
||||||
|
| `PUNKTFUNK_EXCLUSIVE_REASSERT_MS` | ms (default `2000`), `0` = off | How often the host re-checks that **exclusive** display topology actually held. Windows (or a GPU driver / display-poller tool) can quietly re-activate a physical panel moments after the host disabled it — seen on hybrid Intel+NVIDIA laptops — putting windows, the cursor, and the lock screen on a screen that isn't streamed. The host re-asserts and logs when that happens; `0` restores the old fire-and-forget behavior. |
|
||||||
| `PUNKTFUNK_RENDER_ADAPTER` | description substring | Multi-GPU boxes only: force the NVENC/capture GPU by adapter Description substring (e.g. `4090`). Leave unset on single-GPU machines. |
|
| `PUNKTFUNK_RENDER_ADAPTER` | description substring | Multi-GPU boxes only: force the NVENC/capture GPU by adapter Description substring (e.g. `4090`). Leave unset on single-GPU machines. |
|
||||||
| `PUNKTFUNK_HOST_CMD` | e.g. `serve --gamestream` | The host subcommand the service launches. Default `serve --gamestream`; use `serve` for a secure native-only host. |
|
| `PUNKTFUNK_HOST_CMD` | e.g. `serve --gamestream` | The host subcommand the service launches. Default `serve --gamestream`; use `serve` for a secure native-only host. |
|
||||||
|
|
||||||
|
|||||||
@@ -136,14 +136,55 @@ Per-backend support:
|
|||||||
refresh**, with just the game inside. The game boots straight in — no Steam Big Picture to navigate,
|
refresh**, with just the game inside. The game boots straight in — no Steam Big Picture to navigate,
|
||||||
no game-mode desktop. Steam titles launch with the client hidden (`steam -silent`); non-Steam titles
|
no game-mode desktop. Steam titles launch with the client hidden (`steam -silent`); non-Steam titles
|
||||||
start almost instantly (gamescope up in ~1 s, then the game's own boot). Combined with **keep alive**,
|
start almost instantly (gamescope up in ~1 s, then the game's own boot). Combined with **keep alive**,
|
||||||
the game keeps running when you disconnect and you re-attach straight back into it; when you quit the
|
the game keeps running when you disconnect and you re-attach straight back into it.
|
||||||
game, the session ends cleanly and your client returns to its library.
|
|
||||||
|
|
||||||
Dedicated needs `gamescope` installed on the host; if it isn't, a launch falls back to **Auto**
|
Dedicated needs `gamescope` installed on the host; if it isn't, a launch falls back to **Auto**
|
||||||
routing. This axis is independent of the preset — pick it under Host → *Virtual displays*. On a box
|
routing. This axis is independent of the preset — pick it under Host → *Virtual displays*. On a box
|
||||||
that's already in Steam game mode, a dedicated Steam launch frees game mode's Steam first and restores
|
that's already in Steam game mode, a dedicated Steam launch frees game mode's Steam first and restores
|
||||||
it when the session ends. (GameStream / Moonlight launches follow the same routing.)
|
it when the session ends. (GameStream / Moonlight launches follow the same routing.)
|
||||||
|
|
||||||
|
## When a game ends, and when a session does
|
||||||
|
|
||||||
|
A streaming session and the game the host launched for it can share a fate. Two switches, under
|
||||||
|
Host → *Virtual displays* → **When a game or a session ends**. They apply to every store and both
|
||||||
|
protocols — and only ever to a game **this host launched for the session**: a game you started
|
||||||
|
yourself is never touched.
|
||||||
|
|
||||||
|
### When the game exits
|
||||||
|
|
||||||
|
**End the session** (default). Quit the game and your client goes back to its own library instead of
|
||||||
|
staring at your desktop. This is what a dedicated game session has always done; it now works on
|
||||||
|
every path — your live KDE/GNOME/Sway desktop, an attached gamescope, and Moonlight.
|
||||||
|
|
||||||
|
**Keep streaming** if you stream the desktop and treat the game as incidental.
|
||||||
|
|
||||||
|
### When the session ends
|
||||||
|
|
||||||
|
Whether stopping — or losing — a session also closes the game.
|
||||||
|
|
||||||
|
- **Leave it running** (default). Nothing is ever closed. Disconnect, and the game plays on for when
|
||||||
|
you come back.
|
||||||
|
- **Close it on Stop** — closing the client, or pressing *Stop* in the console, closes the game.
|
||||||
|
A network drop does not: you get your game back when you reconnect.
|
||||||
|
- **Always close it** — a drop closes it too, but only after a **reconnect window** (5 minutes by
|
||||||
|
default). Reconnect inside the window and nothing happens; the console shows the countdown while
|
||||||
|
it runs, with an **End now** button if you'd rather not wait.
|
||||||
|
|
||||||
|
Closing a game costs whatever it hadn't saved, which is why nothing closes by default. The host asks
|
||||||
|
first — a polite close, the same thing clicking the window's X does, so the game runs its own
|
||||||
|
shutdown — and only forces the issue after ten seconds of being ignored.
|
||||||
|
|
||||||
|
> **Keep alive and this setting are different clocks.** Keep-alive decides how long the *display*
|
||||||
|
> outlives a disconnect (10 s by default); the reconnect window decides how long the *game* does
|
||||||
|
> (5 minutes). A display set to **Forever** stays up regardless of what happens to the game — a
|
||||||
|
> pinned display is a deliberate "this box is a game host" choice, and closing a game doesn't undo it.
|
||||||
|
|
||||||
|
### Automation
|
||||||
|
|
||||||
|
The host publishes `game.running` and `game.exited` events (the latter says whether the player quit
|
||||||
|
it or the host closed it), so a hook or plugin can react without polling. See
|
||||||
|
[Automation](/docs/automation).
|
||||||
|
|
||||||
## Persistent scaling
|
## Persistent scaling
|
||||||
|
|
||||||
Set your display **scaling** once and have it stick across reconnects. This works by giving each
|
Set your display **scaling** once and have it stick across reconnects. This works by giving each
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ and enter the PIN on your [client](/docs/clients). The host's own management API
|
|||||||
The service reads `%ProgramData%\punktfunk\host.env`. The defaults work out of the box; common knobs:
|
The service reads `%ProgramData%\punktfunk\host.env`. The defaults work out of the box; common knobs:
|
||||||
|
|
||||||
- `PUNKTFUNK_ENCODER=auto` — `auto` picks NVENC/AMF/QSV by GPU vendor. Force one with `nvenc`, `amf`,
|
- `PUNKTFUNK_ENCODER=auto` — `auto` picks NVENC/AMF/QSV by GPU vendor. Force one with `nvenc`, `amf`,
|
||||||
`qsv`, or `sw` (software).
|
`qsv`, or `sw` (software). On a multi-GPU box the [web console's GPU preference](/docs/web-console)
|
||||||
|
wins: a forced backend whose vendor doesn't match the selected GPU is ignored (the host logs a
|
||||||
|
warning) — remove the stale line rather than fighting it.
|
||||||
- `PUNKTFUNK_HOST_CMD` — the service runs `serve --gamestream` by default (native punktfunk/1 **plus**
|
- `PUNKTFUNK_HOST_CMD` — the service runs `serve --gamestream` by default (native punktfunk/1 **plus**
|
||||||
the GameStream/Moonlight-compat planes). Set it to `serve` for a **secure native-only** host with no
|
the GameStream/Moonlight-compat planes). Set it to `serve` for a **secure native-only** host with no
|
||||||
GameStream surface (GameStream pairs over plain HTTP and uses weaker legacy encryption — trusted LAN
|
GameStream surface (GameStream pairs over plain HTTP and uses weaker legacy encryption — trusted LAN
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
;/*++
|
;/*++
|
||||||
; punktfunk virtual DualSense — UMDF2 HID minidriver INF (M0 spike).
|
; punktfunk virtual PlayStation/Valve pads — UMDF2 HID minidriver INF (M0 spike).
|
||||||
|
; One package, four hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck.
|
||||||
; Adapted from the WDK vhidmini2 UMDF2 sample (VhidminiUm.inx).
|
; Adapted from the WDK vhidmini2 UMDF2 sample (VhidminiUm.inx).
|
||||||
; Depends on MsHidUmdf.inf (build >= 22000).
|
; Depends on MsHidUmdf.inf (build >= 22000).
|
||||||
; Install: devgen /add /hardwareid "root\pf_dualsense" (after pnputil /add-driver /install)
|
; Install: devgen /add /hardwareid "root\pf_dualsense" (after pnputil /add-driver /install)
|
||||||
@@ -27,10 +28,19 @@ pf_dualsense.dll=1
|
|||||||
[pf.NT$ARCH$.10.0...22000]
|
[pf.NT$ARCH$.10.0...22000]
|
||||||
; Hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests); `pf_dualsense`
|
; Hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests); `pf_dualsense`
|
||||||
; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so
|
; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so
|
||||||
; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` for the host's
|
; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` / `pf_steamdeck`
|
||||||
; virtual DualShock 4 / DualSense Edge — the same driver binds all of them and serves the matching
|
; for the host's other virtual pads — ONE driver binds all of them (every model line below installs
|
||||||
; identity per the device_type byte the host stamps into shared memory.
|
; the same `pfDualSense` section) and serves the matching HID identity per the device_type byte the
|
||||||
%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense, pf_dualshock4, pf_dualsenseedge, pf_steamdeck
|
; host stamps into shared memory.
|
||||||
|
;
|
||||||
|
; Each id carries its OWN description: Device Manager reads this string, and a single shared
|
||||||
|
; "Virtual DualSense" made an emulated DualShock 4 look like the controller-type setting had been
|
||||||
|
; ignored. The HID layer (VID/PID, report descriptor, product string) was always per-type; this
|
||||||
|
; makes the human-readable name agree with it.
|
||||||
|
%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense
|
||||||
|
%DeviceDescDS4%=pfDualSense, pf_dualshock4
|
||||||
|
%DeviceDescEdge%=pfDualSense, pf_dualsenseedge
|
||||||
|
%DeviceDescDeck%=pfDualSense, pf_steamdeck
|
||||||
|
|
||||||
[pfDualSense.NT]
|
[pfDualSense.NT]
|
||||||
CopyFiles=UMDriverCopy
|
CopyFiles=UMDriverCopy
|
||||||
@@ -78,4 +88,9 @@ ProviderString ="punktfunk"
|
|||||||
ManufacturerString ="punktfunk"
|
ManufacturerString ="punktfunk"
|
||||||
ClassName ="HID device"
|
ClassName ="HID device"
|
||||||
Disk_Description ="punktfunk DualSense Installation Disk"
|
Disk_Description ="punktfunk DualSense Installation Disk"
|
||||||
|
; One per hardware id — these are what Device Manager shows. Keep them aligned with the product
|
||||||
|
; strings the driver serves per device_type (src/lib.rs `on_get_string`).
|
||||||
DeviceDesc ="punktfunk Virtual DualSense"
|
DeviceDesc ="punktfunk Virtual DualSense"
|
||||||
|
DeviceDescDS4 ="punktfunk Virtual DualShock 4"
|
||||||
|
DeviceDescEdge ="punktfunk Virtual DualSense Edge"
|
||||||
|
DeviceDescDeck ="punktfunk Virtual Steam Deck Controller"
|
||||||
|
|||||||
@@ -88,12 +88,12 @@
|
|||||||
|
|
||||||
[Setup]
|
[Setup]
|
||||||
AppId={{7C9E6A52-1F4B-4E8D-A3C7-2B5D8F1E0A93}
|
AppId={{7C9E6A52-1F4B-4E8D-A3C7-2B5D8F1E0A93}
|
||||||
AppName=punktfunk host
|
AppName=Punktfunk Host
|
||||||
AppVersion={#MyAppVersion}
|
AppVersion={#MyAppVersion}
|
||||||
AppPublisher=unom
|
AppPublisher=unom
|
||||||
AppPublisherURL=https://git.unom.io/unom/punktfunk
|
AppPublisherURL=https://git.unom.io/unom/punktfunk
|
||||||
DefaultDirName={autopf}\punktfunk
|
DefaultDirName={autopf}\punktfunk
|
||||||
DefaultGroupName=punktfunk
|
DefaultGroupName=Punktfunk
|
||||||
DisableProgramGroupPage=yes
|
DisableProgramGroupPage=yes
|
||||||
UsePreviousAppDir=yes
|
UsePreviousAppDir=yes
|
||||||
PrivilegesRequired=admin
|
PrivilegesRequired=admin
|
||||||
@@ -123,7 +123,7 @@ WizardStyle=modern
|
|||||||
SetupIconFile={#BrandingDir}\punktfunk.ico
|
SetupIconFile={#BrandingDir}\punktfunk.ico
|
||||||
WizardImageFile={#BrandingDir}\wizard-image-*.bmp
|
WizardImageFile={#BrandingDir}\wizard-image-*.bmp
|
||||||
WizardSmallImageFile={#BrandingDir}\wizard-small-*.bmp
|
WizardSmallImageFile={#BrandingDir}\wizard-small-*.bmp
|
||||||
UninstallDisplayName=punktfunk host {#MyAppVersion}
|
UninstallDisplayName=Punktfunk Host {#MyAppVersion}
|
||||||
; The branded multi-size .ico (installed below). The host exe now embeds the same icon + a
|
; The branded multi-size .ico (installed below). The host exe now embeds the same icon + a
|
||||||
; "Punktfunk Host" FileDescription (build.rs winresource) for Task Manager/Explorer; the file
|
; "Punktfunk Host" FileDescription (build.rs winresource) for Task Manager/Explorer; the file
|
||||||
; copy stays as the uninstall-entry icon.
|
; copy stays as the uninstall-entry icon.
|
||||||
@@ -140,7 +140,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
|
|||||||
; Shown when MinVersion rejects the OS — name the actual requirement instead of Inno's generic
|
; Shown when MinVersion rejects the OS — name the actual requirement instead of Inno's generic
|
||||||
; "requires Windows version 10.0.22621" (users on Windows 10 LTSC hit this; see the pf-vdisplay
|
; "requires Windows version 10.0.22621" (users on Windows 10 LTSC hit this; see the pf-vdisplay
|
||||||
; IddCx 1.10 note at MinVersion above).
|
; IddCx 1.10 note at MinVersion above).
|
||||||
WinVersionTooLowError=punktfunk host requires Windows 11 22H2 (build 22621) or newer.%n%nIts virtual display driver needs the IddCx 1.10 framework, which is not available on older Windows — including all editions of Windows 10 (LTSC too) and Windows 11 21H2.
|
WinVersionTooLowError=Punktfunk Host requires Windows 11 22H2 (build 22621) or newer.%n%nIts virtual display driver needs the IddCx 1.10 framework, which is not available on older Windows — including all editions of Windows 10 (LTSC too) and Windows 11 21H2.
|
||||||
|
|
||||||
[Tasks]
|
[Tasks]
|
||||||
#ifdef WithDriver
|
#ifdef WithDriver
|
||||||
@@ -160,18 +160,18 @@ Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan
|
|||||||
#endif
|
#endif
|
||||||
; Host-config choice, applied via `service install --gamestream=on|off` (writes PUNKTFUNK_HOST_CMD
|
; Host-config choice, applied via `service install --gamestream=on|off` (writes PUNKTFUNK_HOST_CMD
|
||||||
; in host.env; a hand-customized value is left alone). Checked = the Moonlight-compatible unified
|
; in host.env; a hand-customized value is left alone). Checked = the Moonlight-compatible unified
|
||||||
; host (the common Windows setup); unchecked = the secure native-only host (punktfunk clients only).
|
; host (the common Windows setup); unchecked = the secure native-only host (Punktfunk clients only).
|
||||||
Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)"
|
Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)"
|
||||||
; Firewall scope, forwarded as `--allow-public-network` to `service install` / `web setup`. Unchecked
|
; Firewall scope, forwarded as `--allow-public-network` to `service install` / `web setup`. Unchecked
|
||||||
; (default) = accept connections on Private + Domain networks only (the trusted-network profiles
|
; (default) = accept connections on Private + Domain networks only (the trusted-network profiles
|
||||||
; punktfunk is meant for). Check ONLY for a network you trust that Windows classifies as Public (e.g.
|
; punktfunk is meant for). Check ONLY for a network you trust that Windows classifies as Public (e.g.
|
||||||
; some headless / no-gateway LAN setups) - it opens the streaming + console ports on Public too.
|
; some headless / no-gateway LAN setups) - it opens the streaming + console ports on Public too.
|
||||||
Name: "allowpublicfw"; Description: "Allow connections on Public networks (only for a trusted network Windows marks as Public)"; Flags: unchecked
|
Name: "allowpublicfw"; Description: "Allow connections on Public networks (only for a trusted network Windows marks as Public)"; Flags: unchecked
|
||||||
Name: "startservice"; Description: "Start the punktfunk host service now (also starts on every boot)"
|
Name: "startservice"; Description: "Start the Punktfunk Host service now (also starts on every boot)"
|
||||||
; The per-user status tray (punktfunk-tray.exe): shows running/stopped/failed at a glance and
|
; The per-user status tray (punktfunk-tray.exe): shows running/stopped/failed at a glance and
|
||||||
; offers open-console / start / stop / restart without a terminal. HKLM Run = every user who signs
|
; offers open-console / start / stop / restart without a terminal. HKLM Run = every user who signs
|
||||||
; in to this host box gets one (each session keeps exactly one via a Local\ mutex).
|
; in to this host box gets one (each session keeps exactly one via a Local\ mutex).
|
||||||
Name: "trayicon"; Description: "Show the punktfunk status icon in the notification area at sign-in"
|
Name: "trayicon"; Description: "Show the Punktfunk status icon in the notification area at sign-in"
|
||||||
|
|
||||||
[Files]
|
[Files]
|
||||||
Source: "{#BinDir}\punktfunk-host.exe"; DestDir: "{app}"; Flags: ignoreversion
|
Source: "{#BinDir}\punktfunk-host.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
@@ -280,15 +280,15 @@ Filename: "powershell.exe"; \
|
|||||||
; service install records current_exe() as the SCM binPath, so it must run from {app}, not {tmp}.
|
; service install records current_exe() as the SCM binPath, so it must run from {app}, not {tmp}.
|
||||||
; --gamestream=on|off carries the wizard's GameStream task choice into host.env's PUNKTFUNK_HOST_CMD.
|
; --gamestream=on|off carries the wizard's GameStream task choice into host.env's PUNKTFUNK_HOST_CMD.
|
||||||
Filename: "{app}\punktfunk-host.exe"; Parameters: "service install {code:GamestreamParam}{code:PublicFwParam}"; WorkingDir: "{app}"; \
|
Filename: "{app}\punktfunk-host.exe"; Parameters: "service install {code:GamestreamParam}{code:PublicFwParam}"; WorkingDir: "{app}"; \
|
||||||
StatusMsg: "Registering the punktfunk host service..."; Flags: runhidden waituntilterminated
|
StatusMsg: "Registering the Punktfunk Host service..."; Flags: runhidden waituntilterminated
|
||||||
Filename: "{app}\punktfunk-host.exe"; Parameters: "service start"; WorkingDir: "{app}"; \
|
Filename: "{app}\punktfunk-host.exe"; Parameters: "service start"; WorkingDir: "{app}"; \
|
||||||
StatusMsg: "Starting the punktfunk host service..."; Flags: runhidden waituntilterminated; Tasks: startservice
|
StatusMsg: "Starting the Punktfunk Host service..."; Flags: runhidden waituntilterminated; Tasks: startservice
|
||||||
#ifdef WithWeb
|
#ifdef WithWeb
|
||||||
; Provision the console AFTER the host service is up (so the mgmt token exists): write the ACL'd
|
; Provision the console AFTER the host service is up (so the mgmt token exists): write the ACL'd
|
||||||
; login password, register the PunktfunkWeb scheduled task (boot, SYSTEM, restart-on-failure),
|
; login password, register the PunktfunkWeb scheduled task (boot, SYSTEM, restart-on-failure),
|
||||||
; open TCP 47992, and start it. {code:WebSetupParams} appends -PasswordFile only on a fresh install.
|
; open TCP 47992, and start it. {code:WebSetupParams} appends -PasswordFile only on a fresh install.
|
||||||
Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}{code:PublicFwParam}"; WorkingDir: "{app}"; \
|
Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}{code:PublicFwParam}"; WorkingDir: "{app}"; \
|
||||||
StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated
|
StatusMsg: "Setting up the Punktfunk web console..."; Flags: runhidden waituntilterminated
|
||||||
#endif
|
#endif
|
||||||
#ifdef WithScripting
|
#ifdef WithScripting
|
||||||
; Register the plugin/script runner's scheduled task (boot, restart-on-failure) but leave it
|
; Register the plugin/script runner's scheduled task (boot, restart-on-failure) but leave it
|
||||||
@@ -302,7 +302,7 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParam
|
|||||||
; in the command, so no Inno {{ }} escaping needed.
|
; in the command, so no Inno {{ }} escaping needed.
|
||||||
Filename: "powershell.exe"; \
|
Filename: "powershell.exe"; \
|
||||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \
|
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \
|
||||||
StatusMsg: "Registering the punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated
|
StatusMsg: "Registering the Punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated
|
||||||
#endif
|
#endif
|
||||||
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the
|
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the
|
||||||
; icon appears without waiting for the next sign-in.
|
; icon appears without waiting for the next sign-in.
|
||||||
@@ -341,6 +341,19 @@ Filename: "powershell.exe"; \
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
[Code]
|
[Code]
|
||||||
|
{ Captured in InitializeSetup - BEFORE [Run] calls `service install`, which creates host.env. True on
|
||||||
|
a first-ever install, False on an upgrade. Gates the install-time-only CONFIG choices (see
|
||||||
|
GamestreamParam): a silent run has no wizard on screen, so every task checkbox falls back to its
|
||||||
|
script default. Re-applying those defaults on an upgrade would overwrite a setting the user chose
|
||||||
|
on an earlier run, with nothing shown. `winget upgrade` runs us exactly that way. }
|
||||||
|
var
|
||||||
|
FreshHostInstall: Boolean;
|
||||||
|
|
||||||
|
function HostEnvPath: String;
|
||||||
|
begin
|
||||||
|
Result := ExpandConstant('{commonappdata}\punktfunk\host.env');
|
||||||
|
end;
|
||||||
|
|
||||||
{ True if another Moonlight-compatible streaming host is installed - by its SCM service key or its
|
{ True if another Moonlight-compatible streaming host is installed - by its SCM service key or its
|
||||||
Program Files directory. Sunshine and its forks all register a "<Name>Service" and install under
|
Program Files directory. Sunshine and its forks all register a "<Name>Service" and install under
|
||||||
Program Files, so a registry + directory probe catches them without enumerating processes (which
|
Program Files, so a registry + directory probe catches them without enumerating processes (which
|
||||||
@@ -359,6 +372,8 @@ var
|
|||||||
Found: String;
|
Found: String;
|
||||||
begin
|
begin
|
||||||
Result := True;
|
Result := True;
|
||||||
|
{ Record the fresh-vs-upgrade verdict while host.env still reflects the PREVIOUS run. }
|
||||||
|
FreshHostInstall := not FileExists(HostEnvPath);
|
||||||
Found := '';
|
Found := '';
|
||||||
if StreamHostPresent('SunshineService', 'Sunshine') then Found := Found + ' - Sunshine' + #13#10;
|
if StreamHostPresent('SunshineService', 'Sunshine') then Found := Found + ' - Sunshine' + #13#10;
|
||||||
if StreamHostPresent('ApolloService', 'Apollo') then Found := Found + ' - Apollo' + #13#10;
|
if StreamHostPresent('ApolloService', 'Apollo') then Found := Found + ' - Apollo' + #13#10;
|
||||||
@@ -366,7 +381,12 @@ begin
|
|||||||
if StreamHostPresent('VibepolloService', 'Vibepollo') then Found := Found + ' - Vibepollo' + #13#10;
|
if StreamHostPresent('VibepolloService', 'Vibepollo') then Found := Found + ' - Vibepollo' + #13#10;
|
||||||
if StreamHostPresent('LuminalShineService', 'LuminalShine') then Found := Found + ' - LuminalShine' + #13#10;
|
if StreamHostPresent('LuminalShineService', 'LuminalShine') then Found := Found + ' - LuminalShine' + #13#10;
|
||||||
if Found <> '' then
|
if Found <> '' then
|
||||||
Result := MsgBox(
|
{ SuppressibleMsgBox, NOT MsgBox: a plain MsgBox ignores /SUPPRESSMSGBOXES and displays even
|
||||||
|
under /VERYSILENT - i.e. an unattended install (winget) would block on a modal dialog with no
|
||||||
|
wizard on screen and nobody to click it. Suppressed, this returns Default = IDNO, so a silent
|
||||||
|
install onto a box that already runs Sunshine/Apollo ABORTS (Setup exits non-zero) instead of
|
||||||
|
proceeding into the unsupported dual-host state the message describes. }
|
||||||
|
Result := SuppressibleMsgBox(
|
||||||
'Another game-streaming host is already installed on this PC:' + #13#10#13#10 + Found + #13#10 +
|
'Another game-streaming host is already installed on this PC:' + #13#10#13#10 + Found + #13#10 +
|
||||||
'Running Punktfunk alongside Sunshine / Apollo / other Moonlight-compatible hosts is NOT ' +
|
'Running Punktfunk alongside Sunshine / Apollo / other Moonlight-compatible hosts is NOT ' +
|
||||||
'supported. They bind the same GameStream network ports (47984, 47989, 47998-48010) and ' +
|
'supported. They bind the same GameStream network ports (47984, 47989, 47998-48010) and ' +
|
||||||
@@ -374,15 +394,24 @@ begin
|
|||||||
'already in use" errors and capture glitches.' + #13#10#13#10 +
|
'already in use" errors and capture glitches.' + #13#10#13#10 +
|
||||||
'Stop and uninstall the other host before using Punktfunk.' + #13#10#13#10 +
|
'Stop and uninstall the other host before using Punktfunk.' + #13#10#13#10 +
|
||||||
'Continue with the installation anyway?',
|
'Continue with the installation anyway?',
|
||||||
mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES;
|
mbConfirmation, MB_YESNO or MB_DEFBUTTON2, IDNO) = IDYES;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
{ The GameStream task choice, forwarded to `service install` (which writes host.env's
|
{ The GameStream task choice, forwarded to `service install` (which writes host.env's
|
||||||
PUNKTFUNK_HOST_CMD - only if it is unset or still one of the two canonical values, so a
|
PUNKTFUNK_HOST_CMD - only if it is unset or still one of the two canonical values, so a
|
||||||
hand-customized command line survives upgrades). }
|
hand-customized command line survives upgrades).
|
||||||
|
|
||||||
|
FRESH INSTALL ONLY. On an upgrade the flag is omitted entirely, which `service install` reads as
|
||||||
|
"keep host.env as-is" (windows/service.rs: "None = flag absent, keep host.env as-is"). Passing an
|
||||||
|
explicit on/off would rewrite PUNKTFUNK_HOST_CMD whenever it still holds either canonical value -
|
||||||
|
so a user who chose GameStream ON, then upgraded with the task unchecked (which is what EVERY
|
||||||
|
silent run does, since there is no wizard to carry the old choice forward), would have it turned
|
||||||
|
OFF with nothing on screen. Only a hand-edited command line survived that. }
|
||||||
function GamestreamParam(Param: String): String;
|
function GamestreamParam(Param: String): String;
|
||||||
begin
|
begin
|
||||||
if WizardIsTaskSelected('gamestream') then
|
if not FreshHostInstall then
|
||||||
|
Result := ''
|
||||||
|
else if WizardIsTaskSelected('gamestream') then
|
||||||
Result := '--gamestream=on'
|
Result := '--gamestream=on'
|
||||||
else
|
else
|
||||||
Result := '--gamestream=off';
|
Result := '--gamestream=off';
|
||||||
@@ -392,13 +421,21 @@ end;
|
|||||||
(default = Private/Domain only). Forwarded to both `service install` and `web setup`. Returns a
|
(default = Private/Domain only). Forwarded to both `service install` and `web setup`. Returns a
|
||||||
LEADING SPACE so it concatenates after the preceding code-substitution param without a gap.
|
LEADING SPACE so it concatenates after the preceding code-substitution param without a gap.
|
||||||
(Do NOT write a literal code-constant token in this comment: Inno's brace comments do not nest,
|
(Do NOT write a literal code-constant token in this comment: Inno's brace comments do not nest,
|
||||||
so its closing brace would end the comment early and break the [Code] parse.) }
|
so its closing brace would end the comment early and break the [Code] parse.)
|
||||||
|
|
||||||
|
FRESH INSTALL ONLY, for the same reason as GamestreamParam: on an upgrade the flag is omitted and
|
||||||
|
both `service install` and `web setup` resolve the scope from the marker the previous install
|
||||||
|
recorded. Re-applying the task default would re-scope the firewall on every upgrade - and since
|
||||||
|
this task is default-UNCHECKED, a silent upgrade would silently REVOKE a Public opt-in the user
|
||||||
|
made once. The explicit =on/=off form is used so a fresh install still states its choice. }
|
||||||
function PublicFwParam(Param: String): String;
|
function PublicFwParam(Param: String): String;
|
||||||
begin
|
begin
|
||||||
if WizardIsTaskSelected('allowpublicfw') then
|
if not FreshHostInstall then
|
||||||
Result := ' --allow-public-network'
|
Result := ''
|
||||||
|
else if WizardIsTaskSelected('allowpublicfw') then
|
||||||
|
Result := ' --allow-public-network=on'
|
||||||
else
|
else
|
||||||
Result := '';
|
Result := ' --allow-public-network=off';
|
||||||
end;
|
end;
|
||||||
|
|
||||||
#ifdef WithWeb
|
#ifdef WithWeb
|
||||||
@@ -443,7 +480,7 @@ var
|
|||||||
begin
|
begin
|
||||||
FreshWebInstall := not FileExists(WebPasswordPath);
|
FreshWebInstall := not FileExists(WebPasswordPath);
|
||||||
WebPwPage := CreateInputQueryPage(wpSelectTasks,
|
WebPwPage := CreateInputQueryPage(wpSelectTasks,
|
||||||
'Web console', 'Set the punktfunk web console login password',
|
'Web console', 'Set the Punktfunk web console login password',
|
||||||
'The management console is served on https://this-computer:47992 and is login-gated. Keep the ' +
|
'The management console is served on https://this-computer:47992 and is login-gated. Keep the ' +
|
||||||
'secure password generated below (it is shown again on the final page) or enter your own - you ' +
|
'secure password generated below (it is shown again on the final page) or enter your own - you ' +
|
||||||
'can change it later in %ProgramData%\punktfunk\web-password.');
|
'can change it later in %ProgramData%\punktfunk\web-password.');
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# winget manifests — Windows host
|
||||||
|
|
||||||
|
The reviewed source of truth for the `unom.PunktfunkHost` winget package. Everything except
|
||||||
|
`PackageVersion` / `InstallerUrl` / `InstallerSha256` / `ReleaseNotesUrl` is edited **here**;
|
||||||
|
`scripts/ci/winget-manifest.ps1` only substitutes those four per release, so the switches,
|
||||||
|
agreements and installation notes stay under normal code review.
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `unom.PunktfunkHost.yaml` | Version manifest — ties the other two together. |
|
||||||
|
| `unom.PunktfunkHost.installer.yaml` | Installer type, scope, silent switches, `ProductCode`, URL + hash. |
|
||||||
|
| `unom.PunktfunkHost.locale.en-US.yaml` | User-facing metadata, `Agreements`, `InstallationNotes`. |
|
||||||
|
|
||||||
|
## Why these choices
|
||||||
|
|
||||||
|
- **`InstallerType: inno`, `Scope: machine`, `ElevationRequirement: elevatesSelf`.** The host
|
||||||
|
registers a SYSTEM service, installs drivers and opens firewall ports; `PrivilegesRequired=admin`
|
||||||
|
in the `.iss` means Setup raises its own UAC prompt. There is no per-user scope.
|
||||||
|
- **`ProductCode: {7C9E6A52-…}_is1`** — Inno's ARP key is `<AppId>_is1`. This is what correlates an
|
||||||
|
installed host with the package for `winget list` / `winget upgrade`. **It must track `AppId` in
|
||||||
|
`packaging/windows/punktfunk-host.iss`** — if that GUID ever changes, change it here too or
|
||||||
|
upgrades silently stop being detected.
|
||||||
|
- **`interactive` is in `InstallModes`.** `winget install unom.PunktfunkHost --interactive` runs the
|
||||||
|
full existing wizard: every task checkbox, the web-console password page, the VB-CABLE notice.
|
||||||
|
Nothing about the installer changes to support it.
|
||||||
|
- **No `/MERGETASKS` in the silent switches.** A silent install deliberately takes the *same* task
|
||||||
|
defaults the wizard shows, so the product does not differ by install channel — a per-channel
|
||||||
|
default is a support trap ("it works when I install it by hand"). The disclosures the wizard puts
|
||||||
|
on screen are carried by `Agreements` instead, which winget shows *before* install and requires
|
||||||
|
the user to accept.
|
||||||
|
- **`UpgradeBehavior: install`** — Inno upgrades in place (`UsePreviousAppDir=yes`). Uninstalling
|
||||||
|
first would run the `[UninstallRun]` service + driver teardown between versions.
|
||||||
|
|
||||||
|
## Opting out of individual tasks
|
||||||
|
|
||||||
|
Inno's `/MERGETASKS` takes `!` prefixes to deselect a default-checked task. Use `--override`
|
||||||
|
(replaces winget's switches) rather than `--custom` (appends — you would end up with two
|
||||||
|
`/MERGETASKS` on one command line):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
winget install unom.PunktfunkHost --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!gamestream"
|
||||||
|
```
|
||||||
|
|
||||||
|
Task names: `installdriver`, `installgamepad`, `installaudiocable`, `installhdrlayer`,
|
||||||
|
`gamestream`, `allowpublicfw`, `startservice`, `trayicon`.
|
||||||
|
|
||||||
|
## Two installer behaviours that exist for this path
|
||||||
|
|
||||||
|
Both are in `packaging/windows/punktfunk-host.iss` and both also fix pre-existing bugs on the
|
||||||
|
plain double-click upgrade path:
|
||||||
|
|
||||||
|
- **`InitializeSetup` uses `SuppressibleMsgBox`, not `MsgBox`.** A plain `MsgBox` ignores
|
||||||
|
`/SUPPRESSMSGBOXES` and displays even under `/VERYSILENT` — an unattended install on a box that
|
||||||
|
already runs Sunshine/Apollo would block on an invisible modal dialog. Suppressed it returns
|
||||||
|
`IDNO`, so that install aborts (Setup exits non-zero) rather than proceeding into the unsupported
|
||||||
|
dual-host state.
|
||||||
|
- **`GamestreamParam` is fresh-install-only.** On an upgrade the flag is omitted entirely, which
|
||||||
|
`service install` reads as "keep host.env as-is". Passing an explicit on/off would rewrite
|
||||||
|
`PUNKTFUNK_HOST_CMD` whenever it still holds either canonical value — so a silent upgrade, where
|
||||||
|
no wizard carries the old choice forward, would flip a user's GameStream setting with nothing on
|
||||||
|
screen.
|
||||||
|
- **`PublicFwParam` is fresh-install-only too**, and `--allow-public-network` is now tri-state
|
||||||
|
(`=on` / `=off` / absent → keep the recorded choice, resolved from the `fw-allow-public` marker in
|
||||||
|
`windows/service.rs`). This task is default-*unchecked*, so without the change a silent upgrade
|
||||||
|
would have silently **revoked** a Public-network opt-in the user made once. The bare
|
||||||
|
`--allow-public-network` form still means `on` for existing scripts; a malformed value is a hard
|
||||||
|
error rather than a fall-through, since a typo'd opt-*out* must never resolve to "keep Public
|
||||||
|
open".
|
||||||
|
|
||||||
|
## Release flow
|
||||||
|
|
||||||
|
`.gitea/workflows/windows-host.yml` runs on stable `v*` tags only, **after** the installer is
|
||||||
|
attached to the Gitea release — winget validates the URL and hash, so a manifest must never be
|
||||||
|
published ahead of its artifact:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scripts/ci/winget-manifest.ps1 -Version 0.19.2 `
|
||||||
|
-InstallerPath C:\t\out\punktfunk-host-setup-0.19.2.exe -OutDir C:\t\out\winget
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated trio is attached to the same release. Canary builds are excluded: winget pins one
|
||||||
|
immutable artifact per version, so the rolling `canary/` alias has nothing it could point at.
|
||||||
|
|
||||||
|
## Validating a change
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
winget validate --manifest packaging\winget
|
||||||
|
winget install --manifest packaging\winget # local install from the manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
For a throwaway check, `winget-pkgs`' `Tools\SandboxTest.ps1` runs a manifest in Windows Sandbox.
|
||||||
|
Note the host needs a real GPU and installs drivers, so a Sandbox run exercises the *manifest*
|
||||||
|
(download, hash, switches, ARP correlation) rather than a working stream.
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
Through **our own REST source** on unom-1 — see [`server/`](server/README.md). It sits alongside the
|
||||||
|
docs (3220) and flatpak (3230) services, behind the same edge Caddy; `windows-host.yml` rebuilds and
|
||||||
|
ships its catalogue on every stable tag, so releasing is one pipeline with no manual step.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest # elevated, once
|
||||||
|
winget install unom.PunktfunkHost
|
||||||
|
```
|
||||||
|
|
||||||
|
These manifests stay in winget-pkgs' own format rather than a bespoke one, so submitting upstream
|
||||||
|
later is a copy, not a rewrite. Two things would need attention on that path: the signing note
|
||||||
|
below, and `Agreements` being verified-developers-only in the community repo.
|
||||||
|
|
||||||
|
> **Signing.** The installer is currently signed with a self-signed cert (`CN=unom`, subject ==
|
||||||
|
> issuer) and ships a `.cer` users import manually. winget does not sign anything; it downloads and
|
||||||
|
> runs the same binary, so SmartScreen behaves exactly as it does for a browser download. That is a
|
||||||
|
> pre-existing condition rather than something winget introduces — but the community repo
|
||||||
|
> (`microsoft/winget-pkgs`) gates on it via its `Binary-Validation-Error` /
|
||||||
|
> `Validation-Defender-Error` checks, so a submission there needs a publicly-trusted cert (Azure
|
||||||
|
> Trusted Signing is the cheap path). A self-hosted source has no such gate.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
# Generated by build-data.mjs from the published release manifests, then rsynced to the box by
|
||||||
|
# windows-host.yml on each stable tag. Never hand-edited, and regenerating it is one command, so
|
||||||
|
# committing it would only produce a merge conflict on every release.
|
||||||
|
data/
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Punktfunk winget REST source
|
||||||
|
|
||||||
|
A self-hosted [winget REST source](https://github.com/microsoft/winget-cli-restsource) serving the
|
||||||
|
Punktfunk Windows host, so users can install and upgrade with `winget` instead of downloading a
|
||||||
|
`setup.exe` by hand.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# one-time, from an ELEVATED prompt (winget requires admin to add a source)
|
||||||
|
winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest
|
||||||
|
|
||||||
|
winget install unom.PunktfunkHost # silent, wizard defaults
|
||||||
|
winget install unom.PunktfunkHost --interactive # the full wizard
|
||||||
|
winget upgrade unom.PunktfunkHost
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why self-hosted rather than the community repo
|
||||||
|
|
||||||
|
`microsoft/winget-pkgs` gates submissions on its `Binary-Validation-Error` /
|
||||||
|
`Validation-Defender-Error` checks, and the host installer is currently signed with a self-signed
|
||||||
|
cert (`CN=unom`). That is a pre-existing condition — winget does not sign anything, so SmartScreen
|
||||||
|
behaves identically whether the installer arrives by browser or by `winget` — but it does block that
|
||||||
|
route until a publicly trusted cert is in place. A self-hosted source has no such gate, can carry
|
||||||
|
`Agreements` (verified-developers-only upstream), and can serve channels the community repo would
|
||||||
|
never accept.
|
||||||
|
|
||||||
|
## What it implements
|
||||||
|
|
||||||
|
The winget client consumes exactly three endpoints. The reference implementation's other twenty
|
||||||
|
(`/packages/**`) are its **admin** API for mutating a CosmosDB; a catalogue generated at release
|
||||||
|
time has nothing to mutate, so they are not implemented.
|
||||||
|
|
||||||
|
| Endpoint | Notes |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /information` | Source identity, `ServerSupportedVersions`, declared capabilities. |
|
||||||
|
| `POST /manifestSearch` | `Query` / `Inclusions` (OR) / `Filters` (AND) / `FetchAllManifests`. **204** on no match — not an empty 200. |
|
||||||
|
| `GET /packageManifests/{id}` | Full manifest; honours `?Version=`. Identifier match is case-insensitive. |
|
||||||
|
| `GET /healthz` | Not part of the spec — liveness for compose. |
|
||||||
|
|
||||||
|
`NormalizedPackageNameAndPublisher` is deliberately declared **unsupported**. winget derives it
|
||||||
|
client-side from ARP entries with its own normalization (case folding plus stripping legal suffixes,
|
||||||
|
punctuation and version-like tails); claiming support would mean reimplementing that exactly, and a
|
||||||
|
near-miss silently mis-correlates an installed host. Correlation rides on `ProductCode` instead,
|
||||||
|
which Inno gives us exactly (`<AppId>_is1`).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
| --- | --- |
|
||||||
|
| `handler.mjs` | The three endpoints. Pure `(Request, catalogue) -> Response`, no I/O, no deps. |
|
||||||
|
| `server.mjs` | node:http front. Loads the catalogue, reloads on mtime change. |
|
||||||
|
| `build-data.mjs` | Generates `data/data.json` from the published release manifests. Build-time only. |
|
||||||
|
| `test.mjs` | 28 checks driving `handler.mjs` directly. |
|
||||||
|
| `compose.production.yml` | The unom-1 service. |
|
||||||
|
|
||||||
|
## How it runs
|
||||||
|
|
||||||
|
```
|
||||||
|
Gitea releases (manifest trio per stable tag)
|
||||||
|
│ build-data.mjs ← walks ALL releases, no local state [CI, Linux job]
|
||||||
|
▼
|
||||||
|
data/data.json (~8 KB) ← rsynced to unom-1 on each stable tag
|
||||||
|
▼
|
||||||
|
bun + server.mjs on unom-1:3240 ← edge Caddy TLS ← winget.punktfunk.unom.io
|
||||||
|
```
|
||||||
|
|
||||||
|
Stock `oven/bun` image with the two `.mjs` files bind-mounted — the same shape as the flatpak server
|
||||||
|
(stock `caddy:2-alpine` + a bind-mounted Caddyfile). There is no image to build, publish or pull, so
|
||||||
|
a code change deploys by scp plus `docker compose up -d`.
|
||||||
|
|
||||||
|
The server has **zero dependencies**: it reads a generated JSON file. Parsing YAML and talking to
|
||||||
|
Gitea is `build-data.mjs`'s job, and that runs in CI. Only that build step needs `node_modules`.
|
||||||
|
|
||||||
|
**Content and config deploy separately**, matching the flatpak repo:
|
||||||
|
|
||||||
|
- `deploy-services.yml` (`winget` job) ships `compose.production.yml` + the two `.mjs` files.
|
||||||
|
- `windows-host.yml` (`winget-source` job) builds and ships `data/data.json` on each stable tag.
|
||||||
|
|
||||||
|
`server.mjs` reloads the catalogue when its mtime changes, so publishing a release needs no restart.
|
||||||
|
A half-written file mid-rsync keeps the previous catalogue rather than taking the source down.
|
||||||
|
|
||||||
|
### Why the catalogue is derived from releases
|
||||||
|
|
||||||
|
`build-data.mjs` walks the Gitea releases rather than reading local files. That is what makes
|
||||||
|
`winget install --version` and `winget upgrade` work: winget resolves both against the version
|
||||||
|
*list*, so a source that only knew the newest release could neither pin an older version nor show an
|
||||||
|
upgrade path from one.
|
||||||
|
|
||||||
|
It also means the source holds no state — re-running the build reproduces the same `data.json`, so a
|
||||||
|
lost deployment is one command away and nothing can drift.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build:local # data/data.json from ../*.yaml (single version — fine for dev)
|
||||||
|
npm test # 28 checks, no network, no server
|
||||||
|
npm start # http://localhost:3240
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm test` is the gate that matters. A malformed response does not fail loudly — winget reports "no
|
||||||
|
package found", so a shape regression looks like a missing package rather than a broken source. CI
|
||||||
|
runs it before anything reaches the box.
|
||||||
|
|
||||||
|
Note a real `winget source add` will not accept a local instance: it requires HTTPS with a publicly
|
||||||
|
trusted certificate, which is what the edge vhost provides in production.
|
||||||
|
|
||||||
|
## First-time setup
|
||||||
|
|
||||||
|
1. **DNS** — `winget.punktfunk.unom.io` → the unom-1 hcloud box, in the `unom.io` Cloudflare zone
|
||||||
|
(DNS-only, same as `docs`). ✅ *done*
|
||||||
|
2. **Caddy vhost** on unom-1, next to the existing `docs.punktfunk.unom.io` block:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
winget.punktfunk.unom.io {
|
||||||
|
reverse_proxy 127.0.0.1:3240
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Until this exists the hostname resolves but the TLS handshake fails — Caddy has no certificate
|
||||||
|
for a name it does not serve. That is the expected state, not a broken deploy.
|
||||||
|
3. Run `deploy-services.yml` to place the compose file and start the container. It serves 503 until
|
||||||
|
a catalogue exists — expected, and visible on `/healthz`.
|
||||||
|
4. Publish a stable tag, or build and ship the catalogue by hand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packaging/winget/server && npm install && npm run build && npm test
|
||||||
|
scp data/data.json <deploy-user>@<unom-1>:~/unom-winget/data/data.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Then verify from anywhere:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://winget.punktfunk.unom.io/information | jq .
|
||||||
|
curl -s -X POST https://winget.punktfunk.unom.io/manifestSearch \
|
||||||
|
-H 'content-type: application/json' -d '{"FetchAllManifests":true}' | jq '.Data[].PackageIdentifier'
|
||||||
|
```
|
||||||
|
|
||||||
|
> Releases published before winget support shipped have no manifests attached, so `npm run build`
|
||||||
|
> skips them. Until the first stable tag lands with them, build with `--local ..` to serve the
|
||||||
|
> checked-in manifests, or backfill the manifest trio onto an existing release.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// Build src/data.json — the payload the Worker serves — from published winget manifests.
|
||||||
|
//
|
||||||
|
// Source of truth is the Gitea RELEASES, not local files: windows-host.yml attaches the manifest
|
||||||
|
// trio to each stable tag, so walking releases yields every version the source should offer. That
|
||||||
|
// matters because winget resolves `--version` and `upgrade` against the version LIST; a source that
|
||||||
|
// only knows the newest release can neither pin an older one nor show an upgrade path from it.
|
||||||
|
//
|
||||||
|
// Deriving from releases also means this script holds no state. Re-running it reproduces the same
|
||||||
|
// data.json, so a lost deployment is one command away and there is no accumulated file to drift.
|
||||||
|
//
|
||||||
|
// node build-data.mjs # from Gitea releases (what CI runs)
|
||||||
|
// node build-data.mjs --local ../ # from a local manifest dir (dev, single version)
|
||||||
|
// node build-data.mjs --out src/data.json
|
||||||
|
//
|
||||||
|
// The `yaml` dep is a BUILD-time dependency only — the Worker ships data.json, never a parser.
|
||||||
|
import { readFileSync, writeFileSync, readdirSync, mkdirSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { parse as parseYaml } from "yaml";
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const API = process.env.PF_GITEA_API ?? "https://git.unom.io/api/v1";
|
||||||
|
const REPO = process.env.PF_GITEA_REPO ?? "unom/punktfunk";
|
||||||
|
const PACKAGE_ID = "unom.PunktfunkHost";
|
||||||
|
const SOURCE_IDENTIFIER = process.env.PF_SOURCE_ID ?? "unom.punktfunk";
|
||||||
|
// The API contract this source implements. Advertised via /information so a future client can
|
||||||
|
// negotiate; bump only alongside a real change to the response shapes.
|
||||||
|
const SERVER_SUPPORTED_VERSIONS = ["1.1.0"];
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const argVal = (flag) => {
|
||||||
|
const i = args.indexOf(flag);
|
||||||
|
return i >= 0 ? args[i + 1] : undefined;
|
||||||
|
};
|
||||||
|
const outPath = resolve(HERE, argVal("--out") ?? "src/data.json");
|
||||||
|
const localDir = argVal("--local");
|
||||||
|
|
||||||
|
/** One version's three manifests -> the shape the Worker serves. */
|
||||||
|
function toVersionEntry(installer, locale, productCodeFallback) {
|
||||||
|
// The REST manifest nests DefaultLocale + Installers under each version, whereas the YAML splits
|
||||||
|
// them across files keyed by PackageVersion. Strip the per-file bookkeeping that does not belong
|
||||||
|
// in the merged object.
|
||||||
|
const { PackageIdentifier: _i, PackageVersion, ManifestType: _t, ManifestVersion: _v, Installers, ...installerRest } = installer;
|
||||||
|
const { PackageIdentifier: _i2, PackageVersion: _pv, ManifestType: _t2, ManifestVersion: _v2, PackageLocale, ...localeRest } = locale;
|
||||||
|
|
||||||
|
// Installer-level fields are inherited by each entry in Installers unless overridden there. The
|
||||||
|
// REST shape has no inheritance, so fold the parent down into every installer.
|
||||||
|
const installers = (Installers ?? []).map((inst) => ({ ...installerRest, ...inst }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
PackageVersion,
|
||||||
|
DefaultLocale: { PackageLocale, ...localeRest },
|
||||||
|
Installers: installers,
|
||||||
|
ProductCodes: [installer.ProductCode ?? productCodeFallback].filter(Boolean),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url) {
|
||||||
|
const res = await fetch(url, { headers: { accept: "application/json" } });
|
||||||
|
if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchText(url) {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function versionsFromReleases() {
|
||||||
|
const releases = await fetchJson(`${API}/repos/${REPO}/releases?limit=100`);
|
||||||
|
const out = [];
|
||||||
|
for (const rel of releases) {
|
||||||
|
if (rel.draft) continue;
|
||||||
|
const assets = rel.assets ?? [];
|
||||||
|
const find = (suffix) =>
|
||||||
|
assets.find((a) => a.name === `${PACKAGE_ID}${suffix}`)?.browser_download_url;
|
||||||
|
const installerUrl = find(".installer.yaml");
|
||||||
|
const localeUrl = find(".locale.en-US.yaml");
|
||||||
|
// Releases from before winget support shipped have no manifests — skip, don't fail.
|
||||||
|
if (!installerUrl || !localeUrl) continue;
|
||||||
|
const [installer, locale] = await Promise.all([
|
||||||
|
fetchText(installerUrl).then(parseYaml),
|
||||||
|
fetchText(localeUrl).then(parseYaml),
|
||||||
|
]);
|
||||||
|
out.push({ entry: toVersionEntry(installer, locale), locale, prerelease: rel.prerelease });
|
||||||
|
console.log(` + ${rel.tag_name} (${installer.PackageVersion})`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionsFromLocal(dir) {
|
||||||
|
const d = resolve(HERE, dir);
|
||||||
|
const names = readdirSync(d);
|
||||||
|
const read = (suffix) => {
|
||||||
|
const n = names.find((x) => x === `${PACKAGE_ID}${suffix}`);
|
||||||
|
if (!n) throw new Error(`missing ${PACKAGE_ID}${suffix} in ${d}`);
|
||||||
|
return parseYaml(readFileSync(join(d, n), "utf8"));
|
||||||
|
};
|
||||||
|
const installer = read(".installer.yaml");
|
||||||
|
const locale = read(".locale.en-US.yaml");
|
||||||
|
console.log(` + local (${installer.PackageVersion})`);
|
||||||
|
return [{ entry: toVersionEntry(installer, locale), locale, prerelease: false }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest first, so winget's "latest" is the head of the list. */
|
||||||
|
function compareVersionsDesc(a, b) {
|
||||||
|
const parts = (v) => v.PackageVersion.split(/[.\-+]/).map((p) => (/^\d+$/.test(p) ? Number(p) : p));
|
||||||
|
const [pa, pb] = [parts(a), parts(b)];
|
||||||
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||||
|
const x = pa[i] ?? 0;
|
||||||
|
const y = pb[i] ?? 0;
|
||||||
|
if (x === y) continue;
|
||||||
|
// A numeric segment outranks a string one (0.20.0 > 0.20.0-rc1).
|
||||||
|
if (typeof x === typeof y) return x > y ? -1 : 1;
|
||||||
|
return typeof x === "number" ? -1 : 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(localDir ? `building from local dir ${localDir}` : `building from ${API}/repos/${REPO} releases`);
|
||||||
|
const collected = localDir ? versionsFromLocal(localDir) : await versionsFromReleases();
|
||||||
|
if (collected.length === 0) {
|
||||||
|
throw new Error("no winget manifests found - refusing to write an empty source");
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = collected.map((c) => c.entry).sort(compareVersionsDesc);
|
||||||
|
// Package-level search metadata comes from the NEWEST release's locale manifest, so a rename or a
|
||||||
|
// retagged description takes effect without rewriting history.
|
||||||
|
const newestLocale = collected.sort((a, b) => compareVersionsDesc(a.entry, b.entry))[0].locale;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
sourceIdentifier: SOURCE_IDENTIFIER,
|
||||||
|
serverSupportedVersions: SERVER_SUPPORTED_VERSIONS,
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
PackageIdentifier: PACKAGE_ID,
|
||||||
|
PackageName: newestLocale.PackageName,
|
||||||
|
Publisher: newestLocale.Publisher,
|
||||||
|
Moniker: newestLocale.Moniker ?? null,
|
||||||
|
Tags: newestLocale.Tags ?? [],
|
||||||
|
// Union across versions: an installed 0.19.2 must still correlate after 0.20.0 ships.
|
||||||
|
ProductCodes: [...new Set(versions.flatMap((v) => v.ProductCodes))],
|
||||||
|
Versions: versions.map((v) => ({
|
||||||
|
PackageVersion: v.PackageVersion,
|
||||||
|
ProductCodes: v.ProductCodes,
|
||||||
|
})),
|
||||||
|
Manifest: { PackageIdentifier: PACKAGE_ID, Versions: versions },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
mkdirSync(dirname(outPath), { recursive: true });
|
||||||
|
writeFileSync(outPath, `${JSON.stringify(data, null, 2)}\n`);
|
||||||
|
console.log(`wrote ${outPath} (${versions.length} version(s): ${versions.map((v) => v.PackageVersion).join(", ")})`);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# winget REST source for the punktfunk Windows host, on unom-1 (Hetzner Cloud).
|
||||||
|
#
|
||||||
|
# Caddy on that same box terminates TLS for winget.punktfunk.unom.io and reverse_proxies to :3240,
|
||||||
|
# exactly as it already does for docs.punktfunk.unom.io -> :3220. This inner service speaks plain
|
||||||
|
# HTTP and is not published beyond the box. `winget source add` refuses anything but HTTPS with a
|
||||||
|
# publicly trusted certificate, so that edge vhost is a hard requirement, not decoration.
|
||||||
|
#
|
||||||
|
# (Note: the sibling docs/flatpak compose files still carry stale comments about a
|
||||||
|
# `home-reverse-proxy-1` and 192.168.50.50 from an earlier home-lab topology. The public hostnames
|
||||||
|
# resolve straight to the hcloud box and are served by Caddy there — no local proxy is involved.)
|
||||||
|
#
|
||||||
|
# Stock bun image + bind-mounted sources, mirroring the flatpak server's stock-caddy approach: no
|
||||||
|
# image to build, publish or pull, so a code change deploys by scp + `docker compose up -d`.
|
||||||
|
#
|
||||||
|
# ./data/data.json is NOT shipped by deploy-services.yml — windows-host.yml rsyncs it on each stable
|
||||||
|
# tag (the same split the flatpak repo uses: config deploys separately from content). server.mjs
|
||||||
|
# reloads it on mtime change, so publishing a release needs no restart here.
|
||||||
|
name: punktfunk-winget-prod
|
||||||
|
services:
|
||||||
|
winget:
|
||||||
|
image: oven/bun:1-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["bun", "run", "/app/server.mjs"]
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
PORT: "3240"
|
||||||
|
PF_WINGET_DATA: /app/data/data.json
|
||||||
|
ports:
|
||||||
|
- "3240:3240"
|
||||||
|
volumes:
|
||||||
|
- ./server.mjs:/app/server.mjs:ro
|
||||||
|
- ./handler.mjs:/app/handler.mjs:ro
|
||||||
|
- ./data:/app/data:ro
|
||||||
|
healthcheck:
|
||||||
|
# Fails while the catalogue is missing or unparseable, which is otherwise invisible — a broken
|
||||||
|
# source answers requests fine and just reports "no package found" to the client.
|
||||||
|
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3240/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// Punktfunk's winget REST source — the read path only.
|
||||||
|
//
|
||||||
|
// The winget client consumes exactly three endpoints. `microsoft/winget-cli-restsource`'s other 20
|
||||||
|
// (`/packages/**` CRUD) are that reference implementation's ADMIN API for mutating a CosmosDB —
|
||||||
|
// they are not part of what a client calls, and a source whose data is generated at release time
|
||||||
|
// has nothing to mutate. So this serves:
|
||||||
|
//
|
||||||
|
// GET /information server identity + declared capabilities
|
||||||
|
// POST /manifestSearch search; 204 when nothing matches
|
||||||
|
// GET /packageManifests/{PackageIdentifier} the full manifest (optional ?Version=)
|
||||||
|
//
|
||||||
|
// Contract: documentation/WinGet-1.1.0.yaml in that repo. Every response is wrapped in `Data`
|
||||||
|
// (ResponseObjectSchema). Note the spec's content-type key is literally `application/Json`; the
|
||||||
|
// client is not picky about the response's own header, so we send standard `application/json`.
|
||||||
|
//
|
||||||
|
// Pure request->response logic with NO I/O and no dependencies: the catalogue is passed in, so
|
||||||
|
// server.mjs owns reloading it and test.mjs can drive this directly without a server or a network.
|
||||||
|
// `data` is produced by build-data.mjs from the published release manifests — see the README.
|
||||||
|
|
||||||
|
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
||||||
|
|
||||||
|
const json = (body, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
|
||||||
|
|
||||||
|
// A bare 204 carries no body — this is how the spec says "no results", distinct from an empty array.
|
||||||
|
const noContent = () => new Response(null, { status: 204 });
|
||||||
|
|
||||||
|
const error = (status, message) =>
|
||||||
|
json({ ErrorCode: status, ErrorMessage: message }, status);
|
||||||
|
|
||||||
|
// Match fields we deliberately do NOT claim to serve.
|
||||||
|
//
|
||||||
|
// `NormalizedPackageNameAndPublisher` is the notable one: winget derives it client-side from ARP
|
||||||
|
// entries using its own normalization (case folding plus stripping legal suffixes, punctuation and
|
||||||
|
// version-like tails). Claiming support would mean reimplementing that exactly, and a near-miss
|
||||||
|
// produces silently wrong correlation between an installed host and this package. ProductCode is
|
||||||
|
// exact and Inno already gives us one, so correlation rides on that instead.
|
||||||
|
//
|
||||||
|
// Command / PackageFamilyName are genuinely absent (no declared commands; not an MSIX). Market is
|
||||||
|
// not modelled.
|
||||||
|
const UNSUPPORTED_MATCH_FIELDS = [
|
||||||
|
"Command",
|
||||||
|
"PackageFamilyName",
|
||||||
|
"NormalizedPackageNameAndPublisher",
|
||||||
|
"Market",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Compare one candidate string against a SearchRequestMatch. */
|
||||||
|
function matches(value, request) {
|
||||||
|
if (value == null || request == null) return false;
|
||||||
|
const keyword = String(request.KeyWord ?? "");
|
||||||
|
const hay = String(value).toLowerCase();
|
||||||
|
const needle = keyword.toLowerCase();
|
||||||
|
switch (request.MatchType) {
|
||||||
|
case "Exact":
|
||||||
|
return String(value) === keyword;
|
||||||
|
case "StartsWith":
|
||||||
|
return hay.startsWith(needle);
|
||||||
|
// Fuzzy variants are served as substring rather than pretending to a real fuzzy ranker. That
|
||||||
|
// over-matches slightly, which is the safe direction: winget re-ranks and filters what we return.
|
||||||
|
case "Substring":
|
||||||
|
case "Fuzzy":
|
||||||
|
case "FuzzySubstring":
|
||||||
|
return hay.includes(needle);
|
||||||
|
case "Wildcard":
|
||||||
|
return true;
|
||||||
|
case "CaseInsensitive":
|
||||||
|
default:
|
||||||
|
return hay === needle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The candidate strings a package offers for a given PackageMatchField. */
|
||||||
|
function fieldValues(pkg, field) {
|
||||||
|
switch (field) {
|
||||||
|
case "PackageIdentifier":
|
||||||
|
return [pkg.PackageIdentifier];
|
||||||
|
case "PackageName":
|
||||||
|
return [pkg.PackageName];
|
||||||
|
case "Moniker":
|
||||||
|
return pkg.Moniker ? [pkg.Moniker] : [];
|
||||||
|
case "Tag":
|
||||||
|
return pkg.Tags ?? [];
|
||||||
|
case "ProductCode":
|
||||||
|
return pkg.ProductCodes ?? [];
|
||||||
|
default:
|
||||||
|
return []; // including everything in UNSUPPORTED_MATCH_FIELDS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A free-text `Query` sweeps the fields a human would expect to type. */
|
||||||
|
function matchesQuery(pkg, query) {
|
||||||
|
if (!query) return true; // no Query block = match everything, narrowed by Inclusions/Filters
|
||||||
|
return ["PackageIdentifier", "PackageName", "Moniker", "Tag"].some((f) =>
|
||||||
|
fieldValues(pkg, f).some((v) => matches(v, query)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesOne(pkg, entry) {
|
||||||
|
// Both Inclusions and Filters use SearchRequestInclusionAndFilterSchema.
|
||||||
|
const req = entry?.RequestMatch;
|
||||||
|
return fieldValues(pkg, entry?.PackageMatchField).some((v) => matches(v, req));
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(data, body) {
|
||||||
|
const { Query, Inclusions, Filters, FetchAllManifests, MaximumResults } = body ?? {};
|
||||||
|
|
||||||
|
let hits = data.packages.filter((pkg) => {
|
||||||
|
if (FetchAllManifests) return true;
|
||||||
|
if (!matchesQuery(pkg, Query)) return false;
|
||||||
|
// Inclusions widen (OR); an empty/absent list imposes nothing.
|
||||||
|
if (Array.isArray(Inclusions) && Inclusions.length > 0) {
|
||||||
|
if (!Inclusions.some((i) => matchesOne(pkg, i))) return false;
|
||||||
|
}
|
||||||
|
// Filters narrow (AND) — every filter must hold.
|
||||||
|
if (Array.isArray(Filters) && Filters.length > 0) {
|
||||||
|
if (!Filters.every((f) => matchesOne(pkg, f))) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Number.isInteger(MaximumResults) && MaximumResults > 0) {
|
||||||
|
hits = hits.slice(0, MaximumResults);
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(request, data) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
// Tolerate a mount under a sub-path (e.g. /winget/information) so the same Worker can sit behind
|
||||||
|
// a reverse proxy that does not strip a prefix.
|
||||||
|
const path = url.pathname.replace(/\/+$/, "");
|
||||||
|
const tail = (name) => path === `/${name}` || path.endsWith(`/${name}`);
|
||||||
|
|
||||||
|
if (tail("information") && request.method === "GET") {
|
||||||
|
return json({
|
||||||
|
Data: {
|
||||||
|
SourceIdentifier: data.sourceIdentifier,
|
||||||
|
ServerSupportedVersions: data.serverSupportedVersions,
|
||||||
|
UnsupportedPackageMatchFields: UNSUPPORTED_MATCH_FIELDS,
|
||||||
|
RequiredPackageMatchFields: [],
|
||||||
|
UnsupportedQueryParameters: [],
|
||||||
|
RequiredQueryParameters: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tail("manifestSearch")) {
|
||||||
|
if (request.method !== "POST") return error(405, "manifestSearch requires POST");
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return error(400, "malformed JSON body");
|
||||||
|
}
|
||||||
|
const hits = search(data, body);
|
||||||
|
if (hits.length === 0) return noContent();
|
||||||
|
return json({
|
||||||
|
Data: hits.map((pkg) => ({
|
||||||
|
PackageIdentifier: pkg.PackageIdentifier,
|
||||||
|
PackageName: pkg.PackageName,
|
||||||
|
Publisher: pkg.Publisher,
|
||||||
|
Versions: pkg.Versions.map((v) => ({
|
||||||
|
PackageVersion: v.PackageVersion,
|
||||||
|
ProductCodes: v.ProductCodes ?? [],
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
RequiredPackageMatchFields: [],
|
||||||
|
UnsupportedPackageMatchFields: UNSUPPORTED_MATCH_FIELDS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestMatch = path.match(/\/packageManifests\/([^/]+)$/);
|
||||||
|
if (manifestMatch) {
|
||||||
|
if (request.method !== "GET") return error(405, "packageManifests requires GET");
|
||||||
|
const id = decodeURIComponent(manifestMatch[1]);
|
||||||
|
// PackageIdentifier is case-insensitive per the winget client's own handling.
|
||||||
|
const pkg = data.packages.find(
|
||||||
|
(p) => p.PackageIdentifier.toLowerCase() === id.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (!pkg) return error(404, `unknown PackageIdentifier '${id}'`);
|
||||||
|
|
||||||
|
const wanted = url.searchParams.get("Version");
|
||||||
|
const versions = wanted
|
||||||
|
? pkg.Manifest.Versions.filter((v) => v.PackageVersion === wanted)
|
||||||
|
: pkg.Manifest.Versions;
|
||||||
|
if (versions.length === 0) return error(404, `version '${wanted}' not found`);
|
||||||
|
|
||||||
|
return json({ Data: { PackageIdentifier: pkg.Manifest.PackageIdentifier, Versions: versions } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return error(404, `no route for ${request.method} ${url.pathname}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps handle() so no request can escape as an unhandled rejection. */
|
||||||
|
export async function respond(request, data) {
|
||||||
|
try {
|
||||||
|
return await handle(request, data);
|
||||||
|
} catch (e) {
|
||||||
|
return error(500, `unhandled: ${e?.message ?? e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { handle, search, matches };
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "punktfunk-winget-source",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "punktfunk-winget-source",
|
||||||
|
"devDependencies": {
|
||||||
|
"yaml": "^2.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "punktfunk-winget-source",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "winget REST source for the Punktfunk Windows host (read path only).",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build-data.mjs --out data/data.json",
|
||||||
|
"build:local": "node build-data.mjs --local .. --out data/data.json",
|
||||||
|
"test": "node test.mjs",
|
||||||
|
"start": "node server.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"yaml": "^2.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user