Compare commits

...
Author SHA1 Message Date
enricobuehler 72189b29ec Merge branch 'main' into worktree-decky-shortcut-dup
ci / rust (pull_request) Successful in 6m55s
ci / web (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m21s
ci / bun-nix (pull_request) Successful in 34s
ci / rust-arm64 (pull_request) Successful in 1m30s
2026-08-12 22:24:34 +02:00
enricobuehler 339a1d70f9 fix(decky): stop toasting on every launch and every failed panel refresh
Field complaint: the plugin toasts too much. Inventory of all 14 toast
sites says almost all are rare, explicit-tap feedback (pairing, update
buttons, recovery actions) — but two were routine-volume offenders:

  * startStream toasted "Starting stream — <host>" on EVERY successful
    launch, i.e. the overwhelming majority of all toasts the plugin ever
    shows. It repeats the button the user just pressed, and lands ON TOP
    of the starting stream after the QAM closes. Gone; launch FAILURES
    still toast (the QAM may already be closed, so inline state would go
    unseen).
  * useHosts.refresh() toasted "Couldn't list hosts" from its catch —
    and the panel remounts (and refreshes) on every QAM open, so a broken
    backend nagged on each open. It's now a third inline `problem` row
    ("Couldn't scan for hosts"), sitting next to the Refresh button that
    retries it, like the client-unavailable/client-outdated states
    already did.

The update-flow, pairing, trust and recovery toasts stay: each is a rare,
single, information-carrying response to an explicit tap (or, for the
request-access hint, the only warning that the connect is about to park).

Verified: tsc --noEmit and the rollup bundle pass.
2026-08-12 22:05:16 +02:00
enricobuehler 79dba7f95a fix(decky): a boot race minted a new library shortcut on every plugin load
Field report: each Steam start added another visible "Punktfunk" entry
(spotted in the desktop client, where the pile is plain to see).

Mechanism: db063792 made shortcutStillExists() actually answer for the
first time — and its callers treat a null overview as "the user deleted
the shortcut" and AddShortcut a replacement. But the plugin mounts while
Steam is still starting up, BEFORE appStore has registered its overviews,
so the remembered (perfectly live) appId looks up as null on every boot:
mint a duplicate, remember the new id, orphan yesterday's. One new entry
per load, forever.

The deleted verdict now has to be earned, and creation is a last resort:

  * shortcutStillExists() only believes "absent" once the store is
    demonstrably hydrated: wait out App.WaitForServicesInitialized (raced
    against the poll budget so a wedged signal can't hang the guard),
    poll until allApps is non-empty, then one grace recheck — overview
    registration can trail the bulk hydration. Unverifiable within budget
    answers true: a false "alive" merely no-ops until the next ask, a
    false "dead" duplicates forever.
  * On a genuinely lost id, both ensure paths first ADOPT an existing
    same-named shortcut (excluding the other role's) instead of minting
    an N+1th — which also heals installs the old builds already littered.
  * Both ensures are single-flight: mount's fire-and-forget can now be
    mid-wait when a QAM press arrives, and two ensures racing past the
    liveness check would each AddShortcut.
  * "Recreate library shortcut" additionally sweeps surplus "Punktfunk"
    shortcuts (RemoveShortcut) and toasts the count — cleanup for piles
    already minted. Deliberately button-only, never mount: automatic
    library deletion at boot is a bigger hazard than the mess.

Verified: tsc --noEmit and the rollup bundle both pass; the launch paths
(launchStream / launchGamepadUi) hit the fast path unchanged — a live
overview answers the first query and nothing waits.
2026-08-12 21:54:06 +02:00
enricobuehler d7430fe2bd Merge pull request 'fix(ci): gate C counts comments, and a comment named the env mutators verbatim' (#183) from fix/gate-c-comment-token into main
ci / rust-arm64 (push) Successful in 2m24s
ci / web (push) Successful in 1m46s
ci / bun-nix (push) Successful in 46s
ci / docs-site (push) Successful in 1m50s
deb / build-publish (push) Successful in 4m25s
deb / build-publish-client-arm64 (push) Successful in 6m32s
flatpak / build-publish (push) Successful in 6m59s
deb / build-publish-host (push) Successful in 12m2s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m42s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m23s
arch / build-publish (push) Successful in 13m38s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m34s
ci / rust (push) Successful in 13m31s
2026-08-12 16:57:38 +00:00
enricobuehler 6f81ec24ba fix(ci): gate C counts comments, and a comment named the env mutators verbatim
ci / rust-arm64 (pull_request) Successful in 1m50s
ci / docs-site (pull_request) Successful in 1m33s
ci / web (pull_request) Successful in 2m30s
ci / rust (pull_request) Successful in 14m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m21s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m32s
ci / bun-nix (pull_request) Failing after 13m41s
55a3d8b9 (#181) added the edition-2024 lint-level rationale to the session
bin's header naming std::env::set_var/remove_var — gate C's grep counts
comments by contract, so main went red at 5 mentions against the 4-call-site
baseline. Reword the comment instead of raising the baseline: a baseline of 5
with one comment inside would hide the next real call site.

Verified: scripts/ci/check-unsafe-hygiene.sh clean, cargo fmt clean.
2026-08-12 18:57:07 +02:00
enricobuehler 539236de91 Merge pull request 'feat(pad-audio): Linux hosts stream pad audio — the per-pad PipeWire sink (WP3)' (#182) from worktree-linux-pad-audio into main
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 41s
ci / rust-arm64 (push) Canceled after 0s
ci / docs-site (push) Canceled after 14s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 17s
deb / build-publish-host (push) Canceled after 16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 20s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
apple / swift (push) Successful in 1m44s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m41s
android / android (push) Successful in 6m37s
apple / screenshots (push) Successful in 5m49s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 3m48s
docker / builders-arm64cross (push) Successful in 26s
windows-host / package (push) Successful in 13m15s
windows-host / winget-source (push) Skipped
docker / deploy-docs (push) Successful in 46s
windows-host / canary-manifest (push) Successful in 32s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 28m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 29m10s
2026-08-12 16:55:06 +00:00
enricobuehler 118758ff0b fix(pad-audio): the Linux pad sink speaks GE-Proton's AUX0-3 channel shape
ci / bun-nix (pull_request) Successful in 1m31s
ci / rust-arm64 (pull_request) Successful in 1m47s
ci / docs-site (pull_request) Successful in 1m54s
apple / swift (pull_request) Successful in 1m59s
ci / web (pull_request) Successful in 2m3s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 3m18s
android / android (pull_request) Successful in 6m59s
A field report (GE-Proton 11-5, real DualSense on-host) surfaced the missing
constraint: haptics only work when the pad's card runs the Pro Audio profile —
because GE's route opens the node through its bundled pipewire-alsa plugin
with aux_channels=1, and its pulse fallback forces a PA AUX0..3 map with
stream.dont-remix (proton-ds5-haptic patches 0013/0115/0116: "the hidden
PipeWire parent for a DualSense output exposes AUX0 through AUX3"). A
positioned FL FR RL RR sink puts those writers through position channelmix
instead of index passthrough.

The sink now advertises AUX0..AUX3. Proven on the box: an AUX-mapped
rear-pair-only tone captures index-exact (speaker pair 0.0000, coil pair
0.3662); a positioned stray stream folds into the speaker pair and never
excites the coils. The devtest reports per-pair peaks so exactly this class
of remix bug is visible.

Also confirmed from the GE patch set while here: device matching is
device.bus/vendor.id/product.id + the Sony/Wireless_Controller name
substrings (both of which the sink carries), and the MMDevice container is
now synthesized from the wine-side HID USB parent (patch 0112) — the old
pure-PW-node GUID_NULL concern no longer applies on GE >= 11-4.
2026-08-12 18:53:35 +02:00
enricobuehler dcde856178 feat(pad-audio): Linux hosts stream pad audio — the per-pad PipeWire sink (WP3)
The 0xD1 plane was Windows-host-only: host_cap() answered false and spawn()
was a stub everywhere else, so an Android tier-A client against a Linux host
negotiated the cap off and stayed on wire rumble. The whole downstream
machinery (framer, silence gate, lanes, 0xD1 send) was already capture-
agnostic — only the capturer was WASAPI.

- audio/linux/pad_sink.rs: one Audio/Sink stream node per DualSense-family
  pad, minted with the identity the matchers read (ALSA-style node.name with
  the pad's pairing MAC, description "Wireless Controller", bus/vendor/
  product/form-factor proplist, per-pad serial), 4-ch F32 48 kHz FL FR RL RR,
  no default-sink claim, priority.session 50. The process() callback IS the
  capture. PUNKTFUNK_PAD_SINK_NAME/_DESC override the strings for field
  debugging ({pad}/{mac} expand).
- native/pad_audio.rs: the shared logic and lanes compile on Linux;
  pad_audio_thread is generic over the capturer (open-with-backoff kept);
  host_cap() Linux arm = client asked + PUNKTFUNK_PAD_AUDIO + a reachable
  PipeWire socket; spawn() Linux arm mints the sink lazily in the streamer
  thread. spawn() gains an edge flag (Edge identity; ignored on Windows).
- devtest pad-sink-test: mint one sink and capture from it, no client — the
  WP3 on-glass gate. Verified on a Bazzite 44 host: identity served through
  pipewire-pulse, rear-pair (voice-coil) tone captured bit-exact over both
  the native and pulse legs.
- docs: PUNKTFUNK_PAD_AUDIO{,_SLOTS} are no longer (Windows); the roadmap
  non-goal narrows to Bluetooth client pads.

Gates (fedora:44 container, natively on the .41 box): cargo build --release
--locked (nvenc+vulkan-encode), clippy --all-targets -D warnings, cargo test
pad_audio+pad_sink 11/11, cargo fmt.
2026-08-12 18:53:35 +02:00
enricobuehler 77918674c3 Merge pull request 'An over-declared HEVC level no longer demotes native Vulkan decode, and the Windows client legs build again' (#181) from worktree-vk-level-gate-clamp into main
android / android (push) Failing after 1m20s
apple / swift (push) Successful in 1m37s
ci / rust-arm64 (push) Successful in 1m45s
ci / docs-site (push) Successful in 1m29s
ci / rust (push) Failing after 3m2s
ci / bun-nix (push) Successful in 2m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m4s
ci / web (push) Successful in 3m46s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
apple / screenshots (push) Canceled after 3m29s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m53s
arch / build-publish (push) Canceled after 5m25s
deb / build-publish (push) Canceled after 3m2s
deb / build-publish-host (push) Canceled after 2m45s
deb / build-publish-client-arm64 (push) Canceled after 2m39s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 33s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 30s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 28s
flatpak / build-publish (push) Canceled after 2m37s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 3m11s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 2s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-12 16:48:56 +00:00
enricobuehler faefbae830 fix(pf-presenter): spell MAKEINTRESOURCE(1) as ptr::without_provenance — clippy 1.96's manual_dangling_ptr reads the integer-ordinal cast as a dangling pointer and fails the Windows -D warnings gate (masked on main by the client bins failing to build first)
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m16s
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Failing after 1m35s
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m18s
ci / rust (pull_request) Failing after 2m21s
ci / rust-arm64 (pull_request) Successful in 3m45s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m42s
android / android (pull_request) Successful in 4m29s
2026-08-12 18:47:45 +02:00
enricobuehler 44fa12a298 test(pf-vkdecode): bind the PTL level reads so the SAFETY comments precede their unsafe blocks (clippy::undocumented_unsafe_blocks counts nothing inside macro arguments) 2026-08-12 18:47:45 +02:00
enricobuehler 55a3d8b919 fix(clients/session): the edition-2024 session bin cannot build on Windows
The half of the #177 fallout #180's follow-up could not reach: WP20 wrapped
the session bin's single-threaded-startup env writes in the `unsafe {}`
blocks edition 2024 requires — under `#![forbid(unsafe_code)]`, which no
inner attribute can override, so `punktfunk-client-session` fails with two
hard errors on every Windows leg (main push runs 17615/17616 red at Build;
verified on .173). Same resolution as #180 gave the GTK shell: `forbid`
becomes `deny`, and the three documented SAFETY sites carry the localized
`#[allow(unsafe_code)]` pf-update models.
2026-08-12 18:47:45 +02:00
enricobuehler a02014ec19 fix(pf-vkdecode): treat an over-declared stream level as a clamp, not a refusal
A 2026-08-12 field report (RTX 5060 client): every HEVC session demoted to
D3D11VA with 81 "outside device caps: stream level (Std code point 12) above
the device's maxLevelIdc (H.265 Std level 11)" refusals — the host's AMF
encoder stamps general_level_idc 6.2 (the codec maximum) on a 4K120 stream
that needs 5.2, and NVIDIA's driver caps H.265 decode at 6.1. The hardware
decodes the actual stream trivially; only the declaration was oversized.
AV1 passed the same gate, which is why "native-vulkan runs only with AV1".

The declared level is a claim, and the stream's real demands are enforced
where they are physical facts — coded extent and DPB depth, both checked at
session build. So the up-front level gate (H.264 + H.265) now warns once and
proceeds, and every SPS/VPS handed to the Vulkan parameters object has its
level clamped to the device ceiling (a set above maxLevelIdc is invalid
usage). AV1's gate is untouched: its code space is the bitstream's own and
no over-declaration has been seen in the field.

Verified on .173 (RTX 4090, driver 610.88): HEVC and AV1 both decode on the
native Vulkan rung at 60 fps against an NVENC host; unit tests pin the clamp
(lowers, only lowers, mutates the driver-visible block in place).
2026-08-12 18:46:57 +02:00
enricobuehler 5f55b820bc Merge pull request 'Edition-2024 follow-up: the three gates only the PR's own CI could reach' (#180) from worktree-edition-2024 into main
ci / rust (push) Failing after 3m38s
apple / swift (push) Successful in 1m49s
android / android (push) Failing after 5m32s
ci / docs-site (push) Successful in 1m12s
ci / rust-arm64 (push) Failing after 3m36s
ci / bun-nix (push) Successful in 1m24s
ci / web (push) Successful in 2m24s
arch / build-publish (push) Failing after 5m50s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
deb / build-publish (push) Failing after 1m29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m14s
docker / builders-arm64cross (push) Successful in 10s
deb / build-publish-client-arm64 (push) Failing after 3m43s
apple / screenshots (push) Successful in 5m38s
deb / build-publish-host (push) Successful in 6m17s
docker / deploy-docs (push) Failing after 4m38s
windows-host / package (push) Successful in 12m27s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
flatpak / build-publish (push) Canceled after 6m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 6m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m50s
2026-08-12 16:34:13 +00:00
20 changed files with 1087 additions and 131 deletions
+12 -4
View File
@@ -215,9 +215,10 @@ export function useHosts() {
const [views, setViews] = useState<HostView[]>([]);
const [scanning, setScanning] = useState(false);
// Why the list is empty, when it is empty for a reason other than an empty LAN. Rendering
// either of these as "No hosts yet" would blame the user's network for the plugin's problem:
// any of these as "No hosts yet" would blame the user's network for the plugin's problem:
// "client-outdated" — the installed client predates `punktfunk discover`
// "client-unavailable" — there is no client installed at all
// "list-failed" — the refresh itself blew up (backend down, call threw)
const [problem, setProblem] = useState<string | null>(null);
const refresh = useCallback(async () => {
@@ -236,7 +237,11 @@ export function useHosts() {
);
setViews(mergeHosts(s.hosts ?? [], d.hosts ?? []));
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` });
// Inline, not a toast: the panel remounts (and refreshes) on every QAM open, so while
// the backend is unhappy a toast here nagged on each open. The panel row also sits next
// to the Refresh button that retries it, which is where the eyes already are.
console.warn("punktfunk: host list refresh failed", e);
setProblem("list-failed");
} finally {
setScanning(false);
}
@@ -454,9 +459,12 @@ export async function startStream(
): Promise<void> {
try {
await launchStream(v.ref, opts);
// No success toast: the user just pressed the button that names this host/card, the QAM
// closes, and Steam's own launch UI takes over — a toast here fired on EVERY launch and
// then sat on top of the starting stream. Failure still toasts (the QAM may already be
// closed, so inline error state would go unseen).
Navigation.CloseSideMenus();
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"}${v.name}` });
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
toaster.toast({ title: "Punktfunk", body: `Launch failed${label ? ` (${label})` : ""}: ${e}` });
}
}
+18 -6
View File
@@ -46,15 +46,23 @@ import { OsMark } from "./os-icon";
import { ensureGamepadUiShortcut, launchGamepadUi, recreateShortcuts, stopStream } from "./steam";
import { TrustSheet } from "./trust";
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut
// and sweeps duplicate entries (the piles a boot race used to mint, one per Steam start).
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
// CEF localStorage that self-heal fixes on the next mount, but this gives an in-session button
// that works even without a reload. Always ends in a toast so the tap has feedback.
async function recreatePunktfunkShortcut(): Promise<void> {
const appId = await recreateShortcuts();
const { appId, removedDuplicates } = await recreateShortcuts();
toaster.toast({
title: "Punktfunk",
body: appId != null ? "Shortcut restored to your library" : "Couldn't create the shortcut",
body:
appId == null
? "Couldn't create the shortcut"
: removedDuplicates > 0
? `Shortcut restored — removed ${removedDuplicates} duplicate ${
removedDuplicates === 1 ? "entry" : "entries"
}`
: "Shortcut restored to your library",
});
}
@@ -222,12 +230,16 @@ const QamPanel: FC = () => {
label={
problem === "client-unavailable"
? "Punktfunk isnt installed"
: "Update the Punktfunk client"
: problem === "list-failed"
? "Couldnt scan for hosts"
: "Update the Punktfunk client"
}
description={
problem === "client-unavailable"
? "This panel launches the Punktfunk app, which isnt on this Deck yet. Install it in Desktop Mode."
: "This client is too old to find hosts on your network. Saved hosts still work."
: problem === "list-failed"
? "Something went wrong while scanning — Refresh tries again."
: "This client is too old to find hosts on your network. Saved hosts still work."
}
/>
</PanelSectionRow>
@@ -313,7 +325,7 @@ const QamPanel: FC = () => {
<PanelSectionRow>
<ButtonItem
layout="below"
description="Missing the Punktfunk entry in your library? This puts it back."
description="Missing the Punktfunk entry in your library, or seeing several? This puts one back and removes the rest."
onClick={() => void recreatePunktfunkShortcut()}
>
<FaPlus style={{ marginRight: "0.5em" }} />
+220 -39
View File
@@ -44,6 +44,7 @@ declare const SteamClient: {
): Promise<unknown>;
RunGame(gameId: string, _unused: string, _i: number, _j: number): void;
TerminateApp(gameId: string, _b: boolean): void;
RemoveShortcut(appId: number): void;
};
};
@@ -62,29 +63,114 @@ declare const collectionStore:
// that the reuse path below silently repoints (SetShortcut* on a dead id is a no-op), and the
// entry never comes back.
declare const appStore:
| { GetAppOverviewByAppID?: (appId: number) => unknown | null }
| {
GetAppOverviewByAppID?: (appId: number) => unknown | null;
allApps?: SteamAppOverviewLike[];
}
| undefined;
/** True if a remembered appId still maps to a live Steam shortcut. When appStore is unavailable
* we can't tell, so assume it exists — better to keep reusing than risk a duplicate library
* entry from a false "missing". A confident null means the shortcut was deleted → recreate. */
function shortcutStillExists(appId: number): boolean {
// The overview surface we read when scanning the library — Steam internals, so everything is
// optional and accessed defensively.
interface SteamAppOverviewLike {
appid?: number;
display_name?: string;
BIsShortcut?: () => boolean;
}
// Steam-injected global whose WaitForServicesInitialized resolves once the client's app
// services are up (the MoonDeck-verified readiness signal). Services-init alone doesn't
// guarantee the overview map is populated, so it's paired with the hydration witness below.
declare const App:
| { WaitForServicesInitialized?: () => Promise<boolean> }
| undefined;
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
let servicesInitialized: Promise<void> | undefined;
function waitForServicesInitialized(): Promise<void> {
servicesInitialized ??= (async () => {
try {
if (typeof App !== "undefined" && App?.WaitForServicesInitialized) {
await App.WaitForServicesInitialized();
}
} catch {
/* no signal — the hydration witness still gates the verdict */
}
})();
return servicesInitialized;
}
/** Has appStore demonstrably finished its initial load? An empty `allApps` means "not yet":
* any account that ever had our shortcut has at least one app, so a populated map is the
* witness that a null overview lookup is an ANSWER rather than a not-loaded-yet. null =
* can't tell (missing global, API drift). */
function appStoreHydrated(): boolean | null {
try {
if (typeof appStore === "undefined" || !appStore) {
return null;
}
const apps = appStore.allApps;
return Array.isArray(apps) ? apps.length > 0 : null;
} catch {
return null;
}
}
/** One overview lookup: true = live, false = absent, null = can't tell. */
function queryShortcutAlive(appId: number): boolean | null {
try {
// Call it as a METHOD on appStore — NEVER as an extracted function. Its implementation
// reads the store's own state (`this.m_mapApps`), so `const get = appStore.GetAppOverview…;
// get(id)` throws on the lost `this`, and the catch below turns that into a permanent
// "true". That is not a stale-data bug but a total one: the guard then answers "still
// exists" for EVERY appId, so a dangling id is never dropped, the reuse path repoints a
// dead shortcut (silent no-ops), and "recreate" reports success having done nothing.
// `typeof` first: `appStore` is a Steam-injected global, and a bare reference to a missing
// one is a ReferenceError that optional chaining does NOT prevent.
// "can't tell". `typeof` first: `appStore` is a Steam-injected global, and a bare
// reference to a missing one is a ReferenceError that optional chaining does NOT prevent.
if (typeof appStore === "undefined" || !appStore?.GetAppOverviewByAppID) {
return true; // no way to verify — preserve the reuse path
return null;
}
return appStore.GetAppOverviewByAppID(appId) != null;
} catch {
return null;
}
}
// How long to wait for the app store before conceding liveness can't be verified. A Deck boot
// hydrates the store within a few seconds of plugin mount; 30 s is comfortably past any real
// boot, and the wait only burns on the absent/unverifiable paths — a live overview answers on
// the first query. Overview registration can trail the bulk hydration by a beat, so a
// "hydrated but absent" verdict gets one grace recheck before it counts as deleted.
const STORE_WAIT_MS = 30_000;
const STORE_POLL_MS = 1_000;
const STORE_GRACE_MS = 2_000;
/** True if a remembered appId still maps to a live Steam shortcut.
*
* The dangerous verdict is FALSE — it sends the caller to AddShortcut, so a wrong "deleted"
* mints a duplicate library entry. And a bare null-overview check gets it wrong on EVERY
* boot: the plugin mounts while Steam is still starting up, before appStore has registered
* its overviews, so the remembered (perfectly live) appId looks up as null and each boot
* added another visible "Punktfunk" — the field-reported duplicate pile. Absent is therefore
* only believed once the store is demonstrably hydrated; if that can't be established within
* budget the answer is true, because a false "alive" merely no-ops Set-calls until the next
* ask (and the recreate button re-asks when the store IS ready) while a false "dead"
* duplicates forever. */
async function shortcutStillExists(appId: number): Promise<boolean> {
if (queryShortcutAlive(appId) === true) {
return true;
}
// Race the init signal against the same budget the poll loop gets: a signal that never
// resolves must not wedge the guard (the single-flight ensure would stay occupied forever).
await Promise.race([waitForServicesInitialized(), sleep(STORE_WAIT_MS)]);
for (let waited = 0; waited < STORE_WAIT_MS; waited += STORE_POLL_MS) {
if (queryShortcutAlive(appId) === true) {
return true;
}
if (appStoreHydrated() === true) {
await sleep(STORE_GRACE_MS);
return queryShortcutAlive(appId) !== false; // null = unverifiable → reuse
}
await sleep(STORE_POLL_MS);
}
return true; // store never became inspectable — reusing beats duplicating
}
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
@@ -156,6 +242,67 @@ async function applyArtwork(appId: number, isRetry = false): Promise<void> {
// share it so Steam keys them to the SAME controller config (configset key = lowercase name).
const SHORTCUT_NAME = "Punktfunk";
/** Find an existing "Punktfunk" shortcut to ADOPT instead of minting a new library entry — the
* healing path for a lost/wiped appId, and for the duplicate piles the boot race left behind
* in the field: rebind one of the existing entries to the role rather than adding an N+1th.
* (The caller rewrites exe/dir/opts/visibility anyway, so any of them serves.) Only overviews
* Steam itself says are shortcuts qualify, and the other role's remembered id is excluded so
* the two roles never collapse onto one shortcut. */
function findAdoptableShortcut(excludeAppId: number | null): number | null {
try {
if (typeof appStore === "undefined" || !Array.isArray(appStore?.allApps)) {
return null;
}
for (const app of appStore.allApps) {
if (
app?.display_name === SHORTCUT_NAME &&
typeof app.appid === "number" &&
app.appid !== excludeAppId &&
app.BIsShortcut?.() === true
) {
return app.appid;
}
}
} catch {
/* Steam internals drifted — AddShortcut is the fallback */
}
return null;
}
/** Remove every "Punktfunk" shortcut beyond the two remembered role ids — the cleanup for
* piles already minted by the boot race. Deliberately reachable ONLY from the user-pressed
* recreate button, never from mount: automatic library deletion at boot is a bigger hazard
* than the mess it would tidy. Returns how many entries were removed. */
function removeDuplicateShortcuts(): number {
let removed = 0;
try {
if (typeof appStore === "undefined" || !Array.isArray(appStore?.allApps)) {
return 0;
}
const keep = [recall(STORAGE_KEY_STREAM), recall(STORAGE_KEY_UI)];
// Snapshot before removing — RemoveShortcut mutates the store's list under the iteration.
const surplus = appStore.allApps.filter(
(app) =>
app?.display_name === SHORTCUT_NAME &&
typeof app.appid === "number" &&
!keep.includes(app.appid) &&
app.BIsShortcut?.() === true,
);
for (const app of surplus) {
SteamClient.Apps.RemoveShortcut(app.appid as number);
try {
localStorage.removeItem(artKey(app.appid as number));
} catch {
/* ignore */
}
removed++;
}
} catch (e) {
console.warn("punktfunk: duplicate-shortcut sweep incomplete", e);
}
return removed;
}
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
// preserving the exec bit, and ~/homebrew/plugins is root-owned so the unprivileged plugin
// backend can't chmod it back on. Passing the script as an argument to the always-executable
@@ -223,7 +370,7 @@ async function ensureControllerConfig(): Promise<void> {
* the current runner path. Reuses/repoints the remembered shortcut (the plugin dir can change
* across reinstalls, and pre-two-shortcut installs had this one visible).
*/
async function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
async function doEnsureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
const info = await runnerInfo();
if (!info.exists) {
throw new Error(`launch wrapper missing at ${info.runner}`);
@@ -232,25 +379,38 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string;
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
// Reuse the remembered shortcut only if it still exists — a stale appId (shortcut deleted, key
// outlived it across a reinstall) must fall through to AddShortcut, not be silently repointed.
// outlived it across a reinstall) must fall through, not be silently repointed. On a lost id,
// ADOPT an existing same-named shortcut before AddShortcut so a wiped key never duplicates.
const remembered = recall(STORAGE_KEY_STREAM);
if (remembered != null && shortcutStillExists(remembered)) {
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
setShortcutHidden(remembered, true); // migrate pre-two-shortcut installs (were visible)
void applyArtwork(remembered);
return { appId: remembered, runner: info.runner, clientBin: info.client_bin ?? "" };
let appId =
remembered != null && (await shortcutStillExists(remembered)) ? remembered : null;
if (appId == null) {
appId =
findAdoptableShortcut(recall(STORAGE_KEY_UI)) ??
(await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""));
remember(STORAGE_KEY_STREAM, appId);
}
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
SteamClient.Apps.SetShortcutExe(appId, SHELL);
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
setShortcutHidden(appId, true);
setShortcutHidden(appId, true); // also migrates pre-two-shortcut installs (were visible)
void applyArtwork(appId);
remember(STORAGE_KEY_STREAM, appId);
return { appId, runner: info.runner, clientBin: info.client_bin ?? "" };
}
// Concurrent ensure calls share one run per role — two ensures racing past the liveness check
// would each AddShortcut, which is exactly the duplicate class this file exists to prevent (and
// the store-readiness wait makes the window real: mount's fire-and-forget ensure can be mid-wait
// when a QAM press arrives). Sequential calls still re-run, so per-launch repointing is kept.
let streamEnsureInFlight: Promise<{ appId: number; runner: string; clientBin: string }> | null =
null;
function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
streamEnsureInFlight ??= doEnsureStreamShortcut().finally(() => {
streamEnsureInFlight = null;
});
return streamEnsureInFlight;
}
/**
* Ensure the GAMEPAD-UI shortcut (visible, stateless) — the library-facing "Punktfunk" entry
* that opens the client's console home (bare `--browse`: host picker + pairing + settings).
@@ -258,7 +418,7 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string;
* kept VISIBLE. Idempotent — call on plugin mount so the library entry always exists and stays
* repointed to the current plugin dir. Best-effort: returns null on any failure.
*/
export async function ensureGamepadUiShortcut(): Promise<number | null> {
async function doEnsureGamepadUiShortcut(): Promise<number | null> {
try {
const info = await runnerInfo();
if (!info.exists) {
@@ -275,18 +435,20 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`;
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
// localStorage key survived a plugin reinstall) falls through to AddShortcut so the visible
// library entry actually comes back instead of repointing a dead id.
// localStorage key survived a plugin reinstall) falls through so the visible library entry
// actually comes back instead of repointing a dead id. On a lost id, ADOPT an existing
// same-named shortcut (a boot-race duplicate, or the entry whose key was wiped) before
// AddShortcut — creation is the last resort, never the response to a mere lookup miss.
let appId = recall(STORAGE_KEY_UI);
if (appId != null && shortcutStillExists(appId)) {
SteamClient.Apps.SetShortcutExe(appId, SHELL);
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
} else {
appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
if (appId == null || !(await shortcutStillExists(appId))) {
appId =
findAdoptableShortcut(recall(STORAGE_KEY_STREAM)) ??
(await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""));
remember(STORAGE_KEY_UI, appId);
}
SteamClient.Apps.SetShortcutExe(appId, SHELL);
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
SteamClient.Apps.SetAppLaunchOptions(appId, launchOpts);
setShortcutHidden(appId, false); // the visible library entry
void applyArtwork(appId);
@@ -297,18 +459,32 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
}
}
// Same single-flight rule as the stream role (see ensureStreamShortcut).
let uiEnsureInFlight: Promise<number | null> | null = null;
export function ensureGamepadUiShortcut(): Promise<number | null> {
uiEnsureInFlight ??= doEnsureGamepadUiShortcut().finally(() => {
uiEnsureInFlight = null;
});
return uiEnsureInFlight;
}
/**
* Force the visible "Punktfunk" library entry back into existence — the recovery button for
* "my shortcut disappeared". Drops any remembered appId that no longer maps to a live shortcut
* (so it can't shadow a fresh AddShortcut), then re-ensures. Safe to press anytime: a shortcut
* that still exists is left in place (no duplicate); a missing one is recreated. Covers the case
* self-heal-on-mount can't — deleting the shortcut WITHOUT reinstalling (no mount → no ensure).
* Returns the (new or existing) visible appId, or null on failure.
* Also sweeps surplus "Punktfunk" entries (the piles the boot race minted before the store-
* readiness gate existed) — the button is where that cleanup lives, never mount. Returns the
* (new or existing) visible appId (null on failure) plus how many duplicates were removed.
*/
export async function recreateShortcuts(): Promise<number | null> {
export async function recreateShortcuts(): Promise<{
appId: number | null;
removedDuplicates: number;
}> {
for (const key of [STORAGE_KEY_STREAM, STORAGE_KEY_UI]) {
const id = recall(key);
if (id != null && !shortcutStillExists(id)) {
if (id != null && !(await shortcutStillExists(id))) {
try {
localStorage.removeItem(artKey(id)); // stale art marker for the dead appId
localStorage.removeItem(key);
@@ -317,8 +493,13 @@ export async function recreateShortcuts(): Promise<number | null> {
}
}
}
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next launch.
return ensureGamepadUiShortcut();
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next
// launch. Sweep AFTER the ensure so the remembered ids are fresh — and only when the ensure
// succeeded: on a failed ensure the "keep" list can't be trusted, and deleting candidates a
// later ensure would adopt could leave the library with no entry at all.
const appId = await ensureGamepadUiShortcut();
const removedDuplicates = appId != null ? removeDuplicateShortcuts() : 0;
return { appId, removedDuplicates };
}
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
+16 -3
View File
@@ -13,7 +13,13 @@
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end,
//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed.
#![forbid(unsafe_code)]
// `deny`, not `forbid`: edition 2024 makes the std process-environment mutators unsafe
// (WP20 — the env-mutation class made visible; named-API mentions here would count against
// the unsafe-hygiene gate C baseline, which tracks this file's real call sites), and this
// bin's three single-threaded-startup env writes carry documented SAFETY comments under
// localized `#[allow(unsafe_code)]` (the pf-update idiom). A `forbid` cannot be overridden
// at those sites and refuses the file.
#![deny(unsafe_code)]
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
mod console;
@@ -533,6 +539,7 @@ mod session_main {
/// initialises, so a call placed after them leaves the triage tool describing a device
/// that cannot decode while the streaming path decodes on it.
#[cfg(target_os = "linux")]
#[allow(unsafe_code)] // the two SAFETY-commented single-threaded-startup env writes below
fn enable_radv_video_decode() {
const TOKEN: &str = "video_decode";
match std::env::var("RADV_PERFTEST") {
@@ -840,7 +847,10 @@ mod session_main {
// SAFETY: still the single-threaded startup stretch of `run()` — the
// early-exit probes above return out of the process, and everything that
// spawns threads (the session, the console, SDL) only starts below.
unsafe { std::env::set_var(var, value) };
#[allow(unsafe_code)]
unsafe {
std::env::set_var(var, value)
};
}
}
}
@@ -856,7 +866,10 @@ mod session_main {
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
// SAFETY: as the settings block above — single-threaded startup, before SDL
// (the reader of these variables) or any other thread exists.
unsafe { std::env::remove_var(var) };
#[allow(unsafe_code)]
unsafe {
std::env::remove_var(var)
};
}
}
+11 -1
View File
@@ -63,7 +63,17 @@ pub(crate) fn stamp_window_icon(window: &sdl3::video::Window) {
let module = GetModuleHandleW(std::ptr::null());
for (which, metric) in [(ICON_SMALL, SM_CXSMICON), (ICON_BIG, SM_CXICON)] {
let px = GetSystemMetrics(metric);
let icon = LoadImageW(module, 1 as *const u16, IMAGE_ICON, px, px, LR_DEFAULTCOLOR);
// MAKEINTRESOURCE(1): an integer resource ordinal smuggled through the name
// pointer, never dereferenced — `without_provenance` says exactly that (and
// `1 as *const u16` reads as a dangling pointer to clippy 1.96).
let icon = LoadImageW(
module,
std::ptr::without_provenance(1),
IMAGE_ICON,
px,
px,
LR_DEFAULTCOLOR,
);
if !icon.is_null() {
SendMessageW(hwnd, WM_SETICON, which as WPARAM, icon as LPARAM);
}
+24 -10
View File
@@ -46,6 +46,7 @@ use pf_bitstream::h264::PlanError;
use pf_bitstream::h264::PlanWarning;
use tracing::debug;
use tracing::trace;
use tracing::warn;
use crate::caps::derive_caps;
use crate::caps::query_h264_caps;
@@ -685,6 +686,9 @@ pub struct VkH264Decoder {
/// Session generation: bumped on every rebuild, stamped into frames.
generation: u64,
device_lost: bool,
/// The over-declared-level warning has fired (once per decoder — the condition
/// is a property of the stream's SPS, so repeating it per AU is noise).
level_clamp_warned: bool,
}
impl VkH264Decoder {
@@ -723,6 +727,7 @@ impl VkH264Decoder {
decoded: 0,
generation: 0,
device_lost: false,
level_clamp_warned: false,
})
}
@@ -1365,18 +1370,26 @@ impl VkH264Decoder {
unsafe { query_h264_caps(&self.dev, std_profile) }.map_err(VkDecodeError::from)?;
self.caps = Some((std_profile, derive_caps(&raw)?));
}
// The level gate: a stream above the device's maxLevelIdc is refused up
// front (within one codec the Std code points ascend with the level, so
// the comparison is numeric), never submitted on a hope. The ceiling came
// from an H.264 caps query, so it is compared against an H.264 code point
// — the pairing MaxLevelIdc's tag exists to keep honest.
// The declared level vs the device ceiling: a DECLARED level above
// `maxLevelIdc` is NOT a refusal — encoders over-claim levels in the wild
// (the H.265 twin carries the field evidence: AMF stamps the codec
// maximum). The stream's REAL demands are enforced where they are
// physical facts — coded extent and DPB depth, checked in
// `rebuild_state` — and the session's parameter sets are clamped to the
// ceiling (`SessionConfig::max_level_idc`) so the driver is never handed
// a level above its caps. The comparison stays within one codec's Std
// code space (`MaxLevelIdc`'s tag carries that argument).
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
let stream_level = level_to_std(plan.picture.level_idc);
if stream_level > caps_max_level.code_point() {
return Err(VkDecodeError::Unsupported(format!(
"stream level (Std code point {stream_level}) above the device's \
maxLevelIdc ({caps_max_level})"
)));
if stream_level > caps_max_level.code_point() && !self.level_clamp_warned {
self.level_clamp_warned = true;
warn!(
stream_level,
ceiling = %caps_max_level,
"stream declares an H.264 level above the device ceiling — the \
declared level is advisory (over-declared by some encoders); \
proceeding with the parameter sets clamped to the ceiling"
);
}
let coded = vk::Extent2D {
width: plan.picture.coded_width,
@@ -1488,6 +1501,7 @@ impl VkH264Decoder {
max_dpb_slots: required_slots,
max_active_references: (required_slots - 1).min(caps.max_active_references),
std_profile_idc: std_profile,
max_level_idc: caps.max_level_idc.code_point(),
};
let mut pool_plan = plan_pools(caps, required_slots);
// TEST-ONLY readback hook: the GPU parity test (tests/gpu_parity.rs)
+26 -10
View File
@@ -57,6 +57,7 @@ use pf_bitstream::h265::PlanError;
use pf_bitstream::h265::PlanWarning;
use tracing::debug;
use tracing::trace;
use tracing::warn;
use crate::caps::DecodeCaps;
use crate::caps::DecodeProfile;
@@ -219,6 +220,9 @@ pub struct VkH265Decoder {
/// Recovery owed after a failed AU whose planning had already advanced
/// ([`RecoveryLatch`] docs for the whole argument).
recovery: RecoveryLatch,
/// The over-declared-level warning has fired (once per decoder — the condition
/// is a property of the stream's SPS, so repeating it per AU is noise).
level_clamp_warned: bool,
}
impl VkH265Decoder {
@@ -266,6 +270,7 @@ impl VkH265Decoder {
generation: 0,
device_lost: false,
recovery: RecoveryLatch::default(),
level_clamp_warned: false,
})
}
@@ -1004,18 +1009,28 @@ impl VkH265Decoder {
let raw = unsafe { query_h265_caps(&self.dev, key) }.map_err(VkDecodeError::from)?;
self.caps = Some((key, derive_caps_h265(&raw, wanted)?));
}
// The level gate: a stream above the device's maxLevelIdc is refused up
// front (within one codec the Std code points ascend with the level, so
// the comparison is numeric), never submitted on a hope. The ceiling came
// from an H.265 caps query, so it is compared against an H.265 code point
// — the pairing MaxLevelIdc's tag exists to keep honest.
// The declared level vs the device ceiling: a DECLARED level above
// `maxLevelIdc` is NOT a refusal. The level in an SPS is a claim, and
// encoders over-claim in the wild — AMF stamps 6.2 (the codec maximum)
// on 4K120 streams that need 5.2, which on an RTX 5060 (ceiling 6.1)
// demoted every HEVC session to D3D11VA (2026-08-12 field report). The
// stream's REAL demands are enforced where they are physical facts:
// coded extent and DPB depth, checked in `rebuild_state`. The session's
// parameter sets are clamped to the ceiling (`SessionConfigH265::
// max_level_idc`) so the driver is never handed a level above its caps,
// and the comparison stays within one codec's Std code space
// (`MaxLevelIdc`'s tag carries that argument).
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
let stream_level = level_to_std_h265(plan.picture.level_idc);
if stream_level > caps_max_level.code_point() {
return Err(VkDecodeError::Unsupported(format!(
"stream level (Std code point {stream_level}) above the device's \
maxLevelIdc ({caps_max_level})"
)));
if stream_level > caps_max_level.code_point() && !self.level_clamp_warned {
self.level_clamp_warned = true;
warn!(
stream_level,
ceiling = %caps_max_level,
"stream declares an H.265 level above the device ceiling — the \
declared level is advisory (over-declared by some encoders); \
proceeding with the parameter sets clamped to the ceiling"
);
}
let coded = vk::Extent2D {
width: plan.picture.coded_width,
@@ -1108,6 +1123,7 @@ impl VkH265Decoder {
max_dpb_slots: required_slots,
max_active_references: (required_slots - 1).min(caps.max_active_references),
profile: key,
max_level_idc: caps.max_level_idc.code_point(),
};
let mut pool_plan = plan_pools(caps, required_slots);
// TEST-ONLY readback hook, exactly as the H.264 decoder's: the parity
+31
View File
@@ -115,6 +115,17 @@ impl OwnedStdSps {
pub fn std(&self) -> &hh::StdVideoH264SequenceParameterSet {
&self.std
}
/// Lower `level_idc` to `max` when the stream declares a higher one. The
/// declared level is a claim encoders over-state in the wild, and a set above
/// the device's `maxLevelIdc` is invalid usage; the stream's real demands are
/// enforced by the session's coded extent and DPB depth. The "no mutation"
/// contract above is about a LIVE object's blocks — this runs before handover.
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH264LevelIdc) {
if self.std.level_idc > max {
self.std.level_idc = max;
}
}
}
/// The converted PPS plus the scaling-list allocation its `pScalingLists` targets.
@@ -831,4 +842,24 @@ mod tests {
ParamsError::InvalidWeightedBipredIdc(3)
);
}
/// The over-declared-level clamp ([`OwnedStdSps::clamp_level`]): lowering
/// writes the ceiling into the Std SPS; a ceiling at or above the declared
/// level changes nothing.
#[test]
fn clamp_level_lowers_and_only_lowers() {
let sps = full_sps();
let declared = level_to_std(sps.level_idc);
let mut owned = sps_to_std(&sps).unwrap();
assert_eq!(owned.std().level_idc, declared);
// A ceiling above the declared level is a no-op.
owned.clamp_level(hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_6_2);
assert_eq!(owned.std().level_idc, declared);
// A ceiling below it is written through.
let ceiling = hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_3_1;
assert!(ceiling < declared, "fixture declares above 3.1");
owned.clamp_level(ceiling);
assert_eq!(owned.std().level_idc, ceiling);
}
}
+54
View File
@@ -202,6 +202,19 @@ impl OwnedStdH265Vps {
pub fn std(&self) -> &hh::StdVideoH265VideoParameterSet {
&self.std
}
/// Lower the profile/tier/level block's `general_level_idc` to `max` when the
/// stream declares a higher one. The declared level is a CLAIM, and encoders
/// over-claim in the wild (AMF stamps 6.2 — the codec maximum — on streams that
/// need 5.2); handing the driver a level above its `maxLevelIdc` is invalid
/// usage, while the stream's real demands are enforced by the session's coded
/// extent and DPB depth. The "no mutation" ownership contract is about blocks a
/// LIVE parameters object points at; this runs before the set is handed over.
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH265LevelIdc) {
if self._ptl_backing.general_level_idc > max {
self._ptl_backing.general_level_idc = max;
}
}
}
/// The converted SPS plus the heap allocations its embedded pointers target.
@@ -229,6 +242,14 @@ impl OwnedStdH265Sps {
pub fn std(&self) -> &hh::StdVideoH265SequenceParameterSet {
&self.std
}
/// Lower `general_level_idc` to the device ceiling — [`OwnedStdH265Vps::clamp_level`]
/// carries the argument.
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH265LevelIdc) {
if self._ptl_backing.general_level_idc > max {
self._ptl_backing.general_level_idc = max;
}
}
}
/// The converted PPS plus the scaling-list allocation its `pScalingLists`
@@ -2000,4 +2021,37 @@ mod tests {
"the vector opens with VPS + SPS + PPS"
);
}
/// The over-declared-level clamp (the AMF 6.2-on-everything field case):
/// lowering writes the ceiling into the PTL backing the driver will read;
/// a ceiling at or above the declared level changes nothing.
#[test]
fn clamp_level_lowers_the_ptl_and_only_lowers() {
let sps = full_sps();
let declared = level_to_std(sps.profile_tier_level.general_level_idc);
let mut owned = sps_to_std_h265(&sps).unwrap();
// SAFETY: pProfileTierLevel targets `owned`'s boxed backing.
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
assert_eq!(level, declared);
// A ceiling above the declared level is a no-op.
owned.clamp_level(hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_2);
// SAFETY: as above.
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
assert_eq!(level, declared);
// A ceiling below it is written through — and the pointer still targets
// the wrapper's own backing (the clamp mutates in place, never re-points).
let ceiling = hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_3_1;
assert!(ceiling < declared, "fixture declares above 3.1");
owned.clamp_level(ceiling);
// SAFETY: as above.
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
assert_eq!(level, ceiling);
let mut owned_vps = fallback_vps_from_sps(&sps).unwrap();
owned_vps.clamp_level(ceiling);
// SAFETY: as above, the VPS wrapper's own backing.
let vps_level = unsafe { (*owned_vps.std().pProfileTierLevel).general_level_idc };
assert!(vps_level <= ceiling);
}
}
+10 -2
View File
@@ -168,6 +168,10 @@ pub struct SessionConfig {
/// The Std profile the session was created against (a profile change is a
/// renegotiation too).
pub std_profile_idc: hh::StdVideoH264ProfileIdc,
/// The device's `maxLevelIdc` for this profile (Std code point). Every SPS
/// handed to the parameters object has its declared level clamped to this —
/// see `SessionConfigH265::max_level_idc` for the whole argument.
pub max_level_idc: hh::StdVideoH264LevelIdc,
}
/// Session creation/parameter failures the decoder maps into its error type.
@@ -593,11 +597,14 @@ impl VideoSession {
match action {
ParamsAction::Current => Ok(()),
ParamsAction::Add { add_sps, add_pps } => {
let owned_sps = if add_sps {
let mut owned_sps = if add_sps {
Some(sps_to_std(sps)?)
} else {
None
};
if let Some(s) = owned_sps.as_mut() {
s.clamp_level(self.config.max_level_idc);
}
let owned_pps = if add_pps {
Some(pps_to_std(pps)?)
} else {
@@ -643,7 +650,8 @@ impl VideoSession {
pps_id = pps.pic_parameter_set_id,
"recreating session parameters (content change or capacity)"
);
let owned_sps = sps_to_std(sps)?;
let mut owned_sps = sps_to_std(sps)?;
owned_sps.clamp_level(self.config.max_level_idc);
let owned_pps = pps_to_std(pps)?;
// SAFETY: fn contract — live device + live session. The wrappers
// are MOVED IN and come back owned by the fresh object, so they
+18 -4
View File
@@ -260,6 +260,12 @@ pub struct SessionConfigH265 {
/// format / bit depths, all four of which a stream can renegotiate (an SPS
/// switching Main→Main 10 mid-stream is a session rebuild, not an update).
pub profile: H265ProfileKey,
/// The device's `maxLevelIdc` for this profile (Std code point). Every VPS/SPS
/// handed to the parameters object has its declared level clamped to this —
/// over-declared levels are common (AMF stamps 6.2 on 4K streams) and a set
/// above the ceiling is invalid usage, while the stream's real demands are
/// already enforced by `max_coded_extent` / `max_dpb_slots`.
pub max_level_idc: hh::StdVideoH265LevelIdc,
}
/// A live parameters object **and every Std parameter set it was given**, in one
@@ -525,12 +531,18 @@ impl VideoSessionH265 {
} => {
// Every owned wrapper below stays alive until after the update
// call: the Std structs embed pointers into their heap blocks.
let owned_vps = if add_vps { Some(vps.to_std()?) } else { None };
let owned_sps = if add_sps {
let mut owned_vps = if add_vps { Some(vps.to_std()?) } else { None };
let mut owned_sps = if add_sps {
Some(sps_to_std_h265(sps)?)
} else {
None
};
if let Some(v) = owned_vps.as_mut() {
v.clamp_level(self.config.max_level_idc);
}
if let Some(s) = owned_sps.as_mut() {
s.clamp_level(self.config.max_level_idc);
}
let owned_pps = if add_pps {
Some(pps_to_std_h265(pps)?)
} else {
@@ -582,8 +594,10 @@ impl VideoSessionH265 {
pps_id = pps.pic_parameter_set_id,
"recreating H.265 session parameters (content change or capacity)"
);
let owned_vps = vps.to_std()?;
let owned_sps = sps_to_std_h265(sps)?;
let mut owned_vps = vps.to_std()?;
let mut owned_sps = sps_to_std_h265(sps)?;
owned_vps.clamp_level(self.config.max_level_idc);
owned_sps.clamp_level(self.config.max_level_idc);
let owned_pps = pps_to_std_h265(pps)?;
// SAFETY: fn contract — live device + live session. The wrappers
// are MOVED IN and come back owned by the fresh object, so they
+4
View File
@@ -183,6 +183,10 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
mod audio_control;
#[cfg(target_os = "linux")]
mod linux;
// DualSense pad-audio sink + capture, the Linux analogue of `pad_endpoint` below: the session
// layer mints per-pad sinks and the CLI exposes the `pad-sink-test` devtest.
#[cfg(target_os = "linux")]
pub(crate) use linux::pad_sink;
// DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio).
// pub(crate): the session layer queries endpoints by pad index and the CLI exposes the
// `pad-endpoint` devtest.
@@ -27,6 +27,7 @@
//! surround session can replace a stereo capturer without leaking a PipeWire consumer (see
//! CLAUDE.md: a wedged link head-blocks the daemon).
pub(crate) mod pad_sink;
mod stream_sink;
use super::{AudioCapturer, MicBackendStats, VirtualMic, SAMPLE_RATE};
@@ -0,0 +1,452 @@
//! Per-pad DualSense audio sink (Linux): one PipeWire `Audio/Sink` stream node per
//! DualSense-family pad, wearing the identity DS5-native titles and GE-Proton's
//! controller-audio routing match on — so a game that renders voice-coil haptics or pad-speaker
//! audio finds "the controller's audio device" and plays into us. We own the sink, so the
//! `process()` callback IS the capture: 4-ch F32 48 kHz (FL FR RL RR — front pair = speaker,
//! back pair = voice coils, the same quad layout the Windows endpoint is stamped with) lands
//! directly in the chunk channel that feeds the 0xD1 lanes (`native/pad_audio.rs`).
//!
//! Modeled on the stream-sink mode of [`super::PwAudioCapturer`] (same MainLoop-on-a-thread,
//! Terminate channel, ready handshake, bounded lossy chunk hand-off) with two deliberate
//! differences: **no default-sink claim** (nothing may auto-route here — games target it BY
//! IDENTITY) and a low `priority.session` so WirePlumber never elects it against real hardware.
//!
//! **Identity** (design `dualsense-audio-haptics-and-speaker.md` §3/§5): GE-Proton 11-2+
//! matches layered — pulse proplist (`device.bus == "usb"`, `device.vendor.id == 0x054c`,
//! `device.product.id ∈ {0x0ce6, 0x0df2}`), then name substrings
//! (`Sony_Interactive_Entertainment…Wireless_Controller`, `DualSense`); the community
//! WirePlumber rule keys on the node-name substring and sets `node.description =
//! "Wireless Controller"` (we mint it that way from the start). A pure PipeWire node cannot
//! satisfy wine's ContainerId derivation (udev walk to a `usb_device` parent → `GUID_NULL`)
//! nor GE's raw-ALSA fast path — both fall back to the Pulse-routed leg, which winepulse
//! serves from exactly this node (it enumerates sinks). Every identity string has an env
//! override for field debugging (`PUNKTFUNK_PAD_SINK_NAME` / `PUNKTFUNK_PAD_SINK_DESC`, with
//! `{pad}` / `{mac}` placeholders).
use anyhow::{anyhow, Context, Result};
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError};
use std::thread;
use std::time::Duration;
/// Message asking the PipeWire loop thread to quit (sent from `Drop`).
struct Terminate;
/// The pad sink's fixed channel count — quad, mirroring the Windows endpoint stamp
/// (`native/pad_audio.rs::CAP_CHANNELS` splits on the same layout).
const PAD_CHANNELS: u32 = 4;
/// How many pad slots may carry a sink (`PUNKTFUNK_PAD_AUDIO_SLOTS`, default all 4 — a PipeWire
/// stream node is cheap, unlike the Windows devnode mint whose default is 1).
pub(crate) fn pad_audio_slots() -> u8 {
std::env::var("PUNKTFUNK_PAD_AUDIO_SLOTS")
.ok()
.and_then(|s| s.parse::<u8>().ok())
.unwrap_or(4)
.clamp(1, 4)
}
/// Whether a PipeWire daemon is plausibly reachable from this process — the Linux analogue of
/// "startup provisioning published at least one endpoint" for [`host_cap`]'s existence leg
/// (`native/pad_audio.rs`). A stat, not a connect: the handshake path runs per-Hello and must
/// not block. `PIPEWIRE_REMOTE` names a non-default socket — trust it (the session capturer
/// honors it via libpipewire, and a wrong value degrades to spawn-time failure, pad kept).
pub(crate) fn pipewire_reachable() -> bool {
if std::env::var_os("PIPEWIRE_REMOTE").is_some() {
return true;
}
std::env::var_os("XDG_RUNTIME_DIR")
.map(|dir| std::path::Path::new(&dir).join("pipewire-0").exists())
.unwrap_or(false)
}
/// The pad's virtual MAC as colon-separated display hex — [`ds_pairing_reply`]'s bytes 1..7
/// are LSB-first (the report layout `hid-playstation` adopts as the HID `uniq` via `%pMR`,
/// i.e. printed reversed), so the display form reverses them. Unique per pad (the low octet
/// carries the pad index), which keeps multi-pad sinks distinct for the same reason the MAC
/// itself must be: SDL/Steam and the matchers dedup by serial.
///
/// [`ds_pairing_reply`]: pf_inject::dualsense_proto::ds_pairing_reply
fn pad_mac(pad: u8) -> String {
let reply = crate::inject::dualsense_proto::ds_pairing_reply(pad);
let m = &reply[1..7];
format!(
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
m[5], m[4], m[3], m[2], m[1], m[0]
)
}
/// Expand the `{pad}` / `{mac}` placeholders of an identity template. Callers pass the MAC in
/// the form the surrounding string wants: colon display form for proplist values, bare hex for
/// the ALSA-style node name (udev serials carry no colons).
fn expand(template: &str, pad: u8, mac: &str) -> String {
template
.replace("{pad}", &pad.to_string())
.replace("{mac}", mac)
}
/// The full identity a pad sink wears, resolved once at open.
struct PadSinkIdentity {
node_name: String,
description: String,
serial: String,
product_id: &'static str,
product_name: &'static str,
}
impl PadSinkIdentity {
fn new(pad: u8, edge: bool) -> PadSinkIdentity {
let mac = pad_mac(pad);
let mac_bare: String = mac.chars().filter(|c| *c != ':').collect();
let (model, product_id, product_name) = if edge {
(
"DualSense_Edge",
"0df2",
"DualSense Edge Wireless Controller",
)
} else {
("DualSense", "0ce6", "DualSense Wireless Controller")
};
// The ALSA-style name a REAL pad's card gets from udev (vendor_product_serial), which
// is what every known name-substring matcher was written against. `-00.analog-surround-40`
// = card profile suffix for the quad layout.
let node_name = match std::env::var("PUNKTFUNK_PAD_SINK_NAME") {
Ok(t) if !t.trim().is_empty() => expand(&t, pad, &mac_bare),
_ => format!(
"alsa_output.usb-Sony_Interactive_Entertainment_{model}_Wireless_Controller_{mac_bare}-00.analog-surround-40"
),
};
// What the community WirePlumber rule renames real pads TO — minted that way directly.
let description = match std::env::var("PUNKTFUNK_PAD_SINK_DESC") {
Ok(t) if !t.trim().is_empty() => expand(&t, pad, &mac),
_ => "Wireless Controller".to_string(),
};
PadSinkIdentity {
node_name,
description,
serial: format!(
"Sony_Interactive_Entertainment_{model}_Wireless_Controller_{mac_bare}"
),
product_id,
product_name,
}
}
}
/// A live per-pad sink + its capture. Same next-chunk contract as every
/// [`AudioCapturer`](crate::audio::AudioCapturer): empty chunk = quiet sink (keep me), `Err` =
/// dead loop thread (reopen me). Dropping tears the sink node down promptly via the Terminate
/// channel (a wedged PipeWire link head-blocks the daemon — see the session capturer's docs).
pub struct PadSinkCapturer {
chunks: Receiver<Vec<f32>>,
quit: pipewire::channel::Sender<Terminate>,
/// The minted node name, for logs and the devtest.
pub node_name: String,
}
impl PadSinkCapturer {
/// Mint the sink for wire pad `pad` (`edge` = DualSense Edge identity) and start capturing.
/// Fails if PipeWire is unreachable — the caller's reopen-with-backoff owns the retry.
pub fn open(pad: u8, edge: bool) -> Result<PadSinkCapturer> {
let identity = PadSinkIdentity::new(pad, edge);
let node_name = identity.node_name.clone();
let (tx, rx) = sync_channel::<Vec<f32>>(64);
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
// Bring-up handshake (the session capturer's discipline): a PipeWire that isn't running
// must surface as an open ERROR, engaging the caller's backoff — not a zombie thread.
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
thread::Builder::new()
.name(format!("punktfunk-pw-pad{pad}"))
.spawn(move || {
if let Err(e) = pad_sink_thread(tx, quit_rx, identity, ready_tx) {
tracing::warn!(pad, error = %format!("{e:#}"), "pipewire pad-sink thread failed");
}
})
.context("spawn pipewire pad-sink thread")?;
match ready_rx.recv_timeout(Duration::from_secs(5)) {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(e),
Err(_) => return Err(anyhow!("pipewire pad-sink init timed out")),
}
Ok(PadSinkCapturer {
chunks: rx,
quit: quit_tx,
node_name,
})
}
}
impl Drop for PadSinkCapturer {
fn drop(&mut self) {
// A failed send means the loop thread already exited — nothing to tear down.
let _ = self.quit.send(Terminate);
}
}
impl crate::audio::AudioCapturer for PadSinkCapturer {
fn next_chunk(&mut self) -> Result<Vec<f32>> {
match self.chunks.recv_timeout(Duration::from_secs(5)) {
Ok(c) => Ok(c),
// A quiet pad sink (no game rendering pad audio — the common case) is NOT a
// failure; the per-pad streamer keeps us and its silence gate stays closed.
Err(RecvTimeoutError::Timeout) => Ok(Vec::new()),
Err(RecvTimeoutError::Disconnected) => Err(anyhow!("pipewire pad-sink thread ended")),
}
}
fn channels(&self) -> u32 {
PAD_CHANNELS
}
}
/// SPA channel positions for the pad quad: AUX0..AUX3 (`enum spa_audio_channel`:
/// `SPA_AUDIO_CHANNEL_START_Aux` = 0x1000), NOT a positioned FL FR RL RR layout. This is the
/// shape a REAL DualSense exposes on the PipeWire path GE-Proton's haptics were built and
/// field-validated against: its `open_dualsense_haptic_pcm` targets the node through the
/// bundled pipewire-alsa plugin with `aux_channels=1` — "the hidden PipeWire parent for a
/// DualSense output exposes AUX0 through AUX3" (proton-ds5-haptic patch 0115) — and its pulse
/// fallback forces a `PA_CHANNEL_POSITION_AUX0..3` map. On a real pad that shape is the card's
/// Pro Audio profile (the community-reported requirement for GE ≥11-4). Aux positions carry no
/// spatial meaning, so nothing in the graph position-remixes into (or out of) the sink —
/// writers land by INDEX, exactly the raw quad the pad speaks: ch0/1 = speaker, ch2/3 = voice
/// coils (the same order the Windows endpoint is stamped with and `split_quad` assumes).
fn pad_positions() -> [u32; 64] {
const AUX0: u32 = 0x1000;
let mut pos = [0u32; 64];
pos[..4].copy_from_slice(&[AUX0, AUX0 + 1, AUX0 + 2, AUX0 + 3]);
pos
}
/// The `!Send` MainLoop/Stream thread: mint the sink, hand capture chunks over, run until
/// Terminate / daemon death. Mirrors the session capturer's `pw_thread` stream-sink arm minus
/// the default-sink claim and the desktop-plane stats (the pad plane's observability lives in
/// the streamer's gate/encode logs).
fn pad_sink_thread(
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
quit_rx: pipewire::channel::Receiver<Terminate>,
identity: PadSinkIdentity,
ready: std::sync::mpsc::SyncSender<Result<()>>,
) -> Result<()> {
use pipewire as pw;
use pw::{properties::properties, spa};
use spa::param::audio::{AudioFormat, AudioInfoRaw};
use spa::pod::Pod;
let result = (|| -> Result<()> {
pf_capture::pwinit::ensure_init();
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw pad-sink MainLoop")?;
let context =
pw::context::ContextRc::new(&mainloop, None).context("pw pad-sink Context")?;
let core = context
.connect_rc(None)
.context("pw pad-sink connect (is PipeWire running in this session?)")?;
let _quit_guard = quit_rx.attach(mainloop.loop_(), {
let mainloop = mainloop.clone();
move |_| mainloop.quit()
});
// Daemon death ends this thread → the chunk channel disconnects → `next_chunk` errors →
// the per-pad streamer reopens with backoff (the session capturer's zombie-thread fix).
let _core_listener = core
.add_listener_local()
.error({
let mainloop = mainloop.clone();
move |id, _seq, res, message| {
tracing::warn!(id, res, message, "pipewire core error — pad sink ends");
mainloop.quit();
}
})
.register();
let mut props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CLASS => "Audio/Sink",
// One Opus-haptics frame (~5 ms) per quantum, like the session sink — haptics are
// felt latency; bursty delivery would ride through to the client's jitter buffer.
*pw::keys::NODE_LATENCY => "240/48000",
// Must NEVER win WirePlumber's default election against real hardware — games reach
// this sink BY IDENTITY, nothing auto-routes here (no stream_sink claim either).
"priority.session" => "50",
// The pulse-proplist leg of GE-Proton's match (§3): bus + vendor/product ids, plus
// the human-readable pair pavucontrol and the game view show.
"device.bus" => "usb",
"device.vendor.id" => "054c",
"device.vendor.name" => "Sony Interactive Entertainment",
"device.form_factor" => "gamepad",
};
props.insert(*pw::keys::NODE_NAME, identity.node_name.as_str());
props.insert(*pw::keys::NODE_DESCRIPTION, identity.description.as_str());
props.insert(*pw::keys::NODE_NICK, identity.description.as_str());
props.insert("device.serial", identity.serial.as_str());
props.insert("device.product.id", identity.product_id);
props.insert("device.product.name", identity.product_name);
let stream = pw::stream::StreamBox::new(&core, "punktfunk-pad-audio", props)
.context("pw pad-sink Stream")?;
// Lossy-drop counter: a full channel means the 0xD1 encode thread stalled. Invisible
// drops cost a field investigation on the desktop plane once — count and warn here too,
// power-of-two throttled (this callback runs at the graph quantum).
struct PadUd {
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
dropped: u64,
}
let ud = PadUd { tx, dropped: 0 };
let _listener = stream
.add_local_listener_with_user_data(ud)
.state_changed({
let mainloop = mainloop.clone();
move |_s, _ud, old, new| {
tracing::debug!(?old, ?new, "pipewire pad-sink stream state");
if matches!(new, pw::stream::StreamState::Error(_)) {
mainloop.quit();
}
}
})
.param_changed(move |_stream, _ud, id, param| {
let Some(param) = param else { return };
if id != pw::spa::param::ParamType::Format.as_raw() {
return;
}
let mut info = AudioInfoRaw::default();
if info.parse(param).is_ok() {
// We own the sink, so this IS the format games render into (nothing can
// have narrowed it upstream — the same guarantee as stream-sink mode).
tracing::info!(
format = ?info.format(),
rate = info.rate(),
channels = info.channels(),
"pad-sink format negotiated"
);
}
})
.process(|stream, ud| {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let d = &mut datas[0];
let (offset, size) = {
let c = d.chunk();
(c.offset() as usize, c.size() as usize)
};
let Some(buf) = d.data() else { return };
if offset > buf.len() {
return;
}
let region = &buf[offset..(offset + size).min(buf.len())];
// Negotiated as F32LE; reinterpret the byte region as interleaved f32.
let n = region.len() / 4;
let mut samples = Vec::with_capacity(n);
for i in 0..n {
let b = [
region[i * 4],
region[i * 4 + 1],
region[i * 4 + 2],
region[i * 4 + 3],
];
samples.push(f32::from_le_bytes(b));
}
if ud.tx.try_send(samples).is_err() {
ud.dropped += 1;
if ud.dropped.is_power_of_two() {
tracing::warn!(
dropped = ud.dropped,
"pad-audio encode thread not keeping up — captured pad audio \
dropped (haptics will click)"
);
}
}
}));
if outcome.is_err() {
tracing::error!("panic in pipewire pad-sink callback — chunk dropped");
}
})
.register()
.context("register pad-sink stream listener")?;
let mut info = AudioInfoRaw::new();
info.set_format(AudioFormat::F32LE);
info.set_rate(crate::audio::SAMPLE_RATE);
info.set_channels(PAD_CHANNELS);
info.set_position(pad_positions());
let obj = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
properties: info.into(),
};
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(obj),
)
.context("serialize pad-sink format pod")?
.0
.into_inner();
let mut params = [Pod::from_bytes(&values).context("pad-sink pod from bytes")?];
// RT_PROCESS for the same reason as every host-owned stream node here: the sink must be
// a synchronous graph member that joins its producers' driver group, or `process()`
// never fires on a busy graph (see the mic's connect comment in mod.rs).
stream
.connect(
spa::utils::Direction::Input, // we CONSUME what games render into the sink
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.context("pw pad-sink stream connect")?;
let _ = ready.send(Ok(()));
mainloop.run();
tracing::debug!("pipewire pad-sink loop exited (capturer dropped)");
Ok(())
})();
if let Err(e) = &result {
let _ = ready.send(Err(anyhow!("{e:#}")));
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pad_mac_is_reversed_display_form_and_per_pad_unique() {
// DS_FEATURE_PAIRING bytes 1..7 are 74 E7 D6 3A 53 35 LSB-first → display reverses.
assert_eq!(pad_mac(0), "35:53:3A:D6:E7:74");
// The pad index offsets the LOW octet — the LAST display octet.
assert_eq!(pad_mac(1), "35:53:3A:D6:E7:75");
assert_ne!(pad_mac(2), pad_mac(3));
}
#[test]
fn identity_carries_every_match_surface() {
let id = PadSinkIdentity::new(0, false);
// The name-substring matchers (GE-Proton + the community WirePlumber rule).
assert!(id.node_name.contains("Sony_Interactive_Entertainment"));
assert!(id.node_name.contains("Wireless_Controller"));
assert!(id.node_name.contains("DualSense"));
assert!(id.node_name.ends_with("-00.analog-surround-40"));
// No colons in a udev-style serial/name.
assert!(!id.node_name.contains(':'));
assert_eq!(id.description, "Wireless Controller");
assert_eq!(id.product_id, "0ce6");
let edge = PadSinkIdentity::new(1, true);
assert!(edge.node_name.contains("DualSense_Edge"));
assert_eq!(edge.product_id, "0df2");
// Distinct pads mint distinct names (the serial octet).
assert_ne!(id.node_name, PadSinkIdentity::new(1, false).node_name);
}
#[test]
fn template_expansion() {
assert_eq!(expand("pad{pad}-{mac}", 2, "AABB"), "pad2-AABB");
assert_eq!(expand("static", 0, "x"), "static");
}
}
+60
View File
@@ -231,6 +231,66 @@ pub fn dualsense_test(args: &[String]) -> Result<()> {
Ok(())
}
/// Mint one pad-audio PipeWire sink (the Linux 0xD1 source, `audio::pad_sink`) and capture
/// from it — the WP3 on-glass gate with no client involved. Verify the identity with
/// `pactl list sinks` (name/description/proplist) and drive it with
/// `pw-play --target <node.name> <file>` (or `paplay -d <node.name>`); captured chunks print
/// a per-second summary here. `--pad N` (default 0), `--edge`, `--seconds N` (default 30).
#[cfg(target_os = "linux")]
pub fn pad_sink_test(args: &[String]) -> Result<()> {
use crate::audio::AudioCapturer as _;
use std::time::{Duration, Instant};
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(30);
let pad: u8 = args
.iter()
.skip_while(|a| *a != "--pad")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let edge = args.iter().any(|a| a == "--edge");
let mut cap = crate::audio::pad_sink::PadSinkCapturer::open(pad, edge)
.context("mint pad-audio sink (is PipeWire running in this session?)")?;
println!(
"pad sink minted: node.name = {}\n inspect: pactl list sinks | grep -A20 punktfunk-pad\n \
drive it: pw-play --target '{}' <48k-file>\nCapturing for {secs}s",
cap.node_name, cap.node_name
);
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut chunks, mut samples) = (0u64, 0u64);
// Per-pair peaks: ch0/1 = speaker, ch2/3 = voice coils — the split_quad contract. Proving
// the pairs separately is the point of this devtest: a positional remix upstream would
// smear or zero one pair while a global peak still looks healthy.
let (mut peak_spk, mut peak_coil) = (0f32, 0f32);
let mut last_report = Instant::now();
while Instant::now() < deadline {
let c = cap.next_chunk().context("pad sink capture")?;
if !c.is_empty() {
chunks += 1;
samples += c.len() as u64;
for f in c.chunks_exact(4) {
peak_spk = peak_spk.max(f[0].abs()).max(f[1].abs());
peak_coil = peak_coil.max(f[2].abs()).max(f[3].abs());
}
}
if last_report.elapsed() >= Duration::from_secs(1) {
last_report = Instant::now();
println!(
" chunks={chunks} samples={samples} (~{:.1}ms of 4ch audio) \
peak_speaker={peak_spk:.4} peak_coils={peak_coil:.4}",
samples as f64 / (4.0 * 48.0)
);
(chunks, samples, peak_spk, peak_coil) = (0, 0, 0.0, 0.0);
}
}
println!("pad-sink-test: done");
Ok(())
}
/// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no
/// streaming session): answers the full hid-nintendo probe conversation, then cycles the
/// A/B buttons (positionally swapped) + sweeps the left stick, printing rumble / player-
+3
View File
@@ -623,6 +623,9 @@ fn real_main() -> Result<()> {
// Create a virtual DualSense via UHID and exercise it (validation, no streaming session).
#[cfg(target_os = "linux")]
Some("dualsense-test") => devtest::dualsense_test(&args),
// Mint one pad-audio PipeWire sink and capture from it — the Linux 0xD1 source gate.
#[cfg(target_os = "linux")]
Some("pad-sink-test") => devtest::pad_sink_test(&args),
// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no session).
#[cfg(target_os = "linux")]
Some("switchpro-test") => devtest::switchpro_test(&args),
+11 -4
View File
@@ -616,8 +616,10 @@ impl PadAudioSlots {
/// Idempotent spawn: same kinds → keep the running streamer; changed kinds → restart with
/// the new mask; not running → spawn (a slot without an endpoint stays empty — bounded
/// retries, since arrivals are only re-sent a few times per slot open).
fn ensure(&mut self, conn: &quinn::Connection, pad: u8, kinds: u8) {
/// retries, since arrivals are only re-sent a few times per slot open). `edge` picks the
/// DualSense Edge identity for the Linux sink (ignored on Windows — endpoints are
/// pre-stamped).
fn ensure(&mut self, conn: &quinn::Connection, pad: u8, kinds: u8, edge: bool) {
let idx = pad as usize;
if idx >= MAX_WIRE_PADS {
return;
@@ -648,7 +650,7 @@ impl PadAudioSlots {
self.stop(idx);
}
let stop = Arc::new(AtomicBool::new(false));
if let Some(h) = pad_audio::spawn(conn.clone(), pad, kinds, stop) {
if let Some(h) = pad_audio::spawn(conn.clone(), pad, kinds, edge, stop) {
self.slots[idx] = Some((kinds, h));
}
}
@@ -1087,7 +1089,12 @@ pub(super) fn input_thread(
0
};
if want != 0 {
pad_streams.ensure(&conn, pad, want);
pad_streams.ensure(
&conn,
pad,
want,
matches!(kind, GamepadPref::DualSenseEdge),
);
} else {
pad_streams.stop(idx);
}
+109 -43
View File
@@ -1,6 +1,8 @@
//! Per-pad DualSense audio (the 0xD1 pad-audio plane): WASAPI loopback of a pre-provisioned pad
//! endpoint ([`crate::audio::pad_endpoint`]) → 4-ch de-interleave into the speaker (front) and
//! voice-coil haptics (back) pairs → per-kind silence gate → stereo Opus (48 kHz, CBR, LowDelay)
//! Per-pad DualSense audio (the 0xD1 pad-audio plane): capture of the pad's own audio device —
//! Windows: WASAPI loopback of a pre-provisioned endpoint ([`crate::audio::pad_endpoint`]);
//! Linux: the per-pad PipeWire sink we mint (`crate::audio::pad_sink`) — → 4-ch de-interleave
//! into the speaker (front) and voice-coil haptics (back) pairs → per-kind silence gate →
//! stereo Opus (48 kHz, CBR, LowDelay)
//! → [`PAD_AUDIO_MAGIC`](punktfunk_core::quic::PAD_AUDIO_MAGIC) datagrams. One thread per
//! arriving pad, spawned/reaped by the input thread ([`super::input`]) as arrivals declare
//! renderers and pads leave. Modeled on the session audio thread ([`super::audio`]): the same
@@ -11,45 +13,45 @@ use super::*;
/// `kinds` bit for the haptics stream (bit N = wire kind N — the same packing the arrival's
/// audio-caps bits use, see [`punktfunk_core::input::decode_gamepad_arrival`]).
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
pub(super) const KIND_BIT_HAPTICS: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS;
/// `kinds` bit for the speaker stream.
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
pub(super) const KIND_BIT_SPEAKER: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER;
/// Haptics frames are 5 ms (the session-audio cadence — haptics are felt latency); speaker
/// frames are 10 ms (speaker content tolerates the buffering for the coding efficiency). Both
/// are the wire contract's cadences (`punktfunk_core::quic::PAD_AUDIO_KIND_*`).
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
const HAPTICS_FRAME_MS: u32 = 5;
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
const SPEAKER_FRAME_MS: u32 = 10;
/// Samples per frame (per channel) at 48 kHz: 240 / 480.
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
const HAPTICS_FRAME_SAMPLES: usize =
crate::audio::SAMPLE_RATE as usize * HAPTICS_FRAME_MS as usize / 1000;
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
const SPEAKER_FRAME_SAMPLES: usize =
crate::audio::SAMPLE_RATE as usize * SPEAKER_FRAME_MS as usize / 1000;
/// The capture's channel count — the pad endpoint is stamped quad (FL FR BL BR: front pair =
/// speaker, back pair = voice coils). Mirrors `pad_endpoint::PAD_CHANNELS` (Windows-gated, so
/// the pure splitter logic keeps its own copy).
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
const CAP_CHANNELS: usize = 4;
/// Peak (absolute sample) at or above which a frame counts as signal — the gate OPENS on that
/// very frame (haptics are felt latency; the first active frame must ship). ≈ 60 dBFS.
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
const GATE_OPEN_PEAK: f32 = 1e-3;
/// How long the gate keeps sending after the last signal frame before it CLOSES (hangover):
/// long enough that a decaying haptic tail (and the client decoder's own tail) is never
/// clipped, short enough that an idle pad costs nothing in steady state.
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
const GATE_HANGOVER_MS: u32 = 250;
/// Per-kind Opus bitrate — a stereo voice-coil / pad-speaker pair needs far less than the
/// session plane's 128 kbps; 64 kbps CBR keeps every frame comfortably under one MTU.
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "linux"))]
const PAD_AUDIO_BITRATE: i32 = 64_000;
/// The per-kind silence gate — the steady-state-cost feature: an idle pad endpoint (games
@@ -57,7 +59,7 @@ const PAD_AUDIO_BITRATE: i32 = 64_000;
/// stream of coded silence. Opens the instant a frame carries signal ([`GATE_OPEN_PEAK`]);
/// closes only after [`GATE_HANGOVER_MS`] of continuous sub-threshold frames. Pure logic,
/// unit-tested below.
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
struct SilenceGate {
/// Consecutive sub-threshold frames that close the gate ([`GATE_HANGOVER_MS`] ÷ frame ms).
hangover_frames: u32,
@@ -67,7 +69,7 @@ struct SilenceGate {
open: bool,
}
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
impl SilenceGate {
fn new(frame_ms: u32) -> SilenceGate {
SilenceGate {
@@ -101,13 +103,13 @@ impl SilenceGate {
/// loss by seq continuity (the mic-mute discipline, pf-client-core/src/audio.rs). It is also
/// kept across capture reopens (the session audio thread's discipline, audio.rs): the client
/// sees a gap, not a restart.
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
struct LaneCtl {
gate: SilenceGate,
seq: u32,
}
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
impl LaneCtl {
fn new(frame_ms: u32) -> LaneCtl {
LaneCtl {
@@ -133,7 +135,7 @@ impl LaneCtl {
/// speaker (channels 0/1), back = voice-coil haptics (channels 2/3). A ragged tail (not a
/// multiple of 4 — the capturer only ever delivers whole frames) is dropped, never smeared
/// across channels.
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
fn split_quad(block: &[f32]) -> (Vec<f32>, Vec<f32>) {
let mut front = Vec::with_capacity(block.len() / 2);
let mut back = Vec::with_capacity(block.len() / 2);
@@ -148,7 +150,7 @@ fn split_quad(block: &[f32]) -> (Vec<f32>, Vec<f32>) {
/// frames — haptics every 5 ms from the back pair, speaker every 10 ms from the front pair —
/// emitting ONLY the kinds enabled in `kinds` (a disabled kind is never even split out, so it
/// can never reach an encoder). Pure logic, unit-tested; the capture thread wraps it.
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
struct PadFramer {
kinds: u8,
/// Raw interleaved 4-ch accumulation, drained in 5 ms blocks.
@@ -157,7 +159,7 @@ struct PadFramer {
front: Vec<f32>,
}
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "linux", test))]
impl PadFramer {
fn new(kinds: u8) -> PadFramer {
PadFramer {
@@ -238,11 +240,12 @@ impl Drop for PadAudioHandle {
/// Whether this session's Welcome should advertise
/// [`HOST_CAP_PAD_AUDIO`](punktfunk_core::quic::HOST_CAP_PAD_AUDIO): the client asked
/// ([`CLIENT_CAP_PAD_AUDIO`](punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO)), this is a Windows
/// host with the feature on (`PUNKTFUNK_PAD_AUDIO` != "0"), and startup provisioning published
/// at least one endpoint (`pad_endpoint::provision_at_startup`). Still-running provisioning
/// reads as "none yet": a session racing host startup simply negotiates without pad audio and
/// picks it up on its next connect.
/// ([`CLIENT_CAP_PAD_AUDIO`](punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO)), the feature is on
/// (`PUNKTFUNK_PAD_AUDIO` != "0"), and the pad audio source exists — Windows: startup
/// provisioning published at least one endpoint (`pad_endpoint::provision_at_startup`; a
/// still-running provisioning reads as "none yet" and the next connect picks it up); Linux: a
/// PipeWire daemon is reachable (the per-pad sinks are minted lazily at spawn, so reachability
/// IS the existence question).
pub(super) fn host_cap(client_caps: u8) -> bool {
let asked = client_caps & punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO != 0;
#[cfg(target_os = "windows")]
@@ -257,9 +260,15 @@ pub(super) fn host_cap(client_caps: u8) -> bool {
&& crate::audio::pad_endpoint::provisioned_endpoints()
.is_some_and(|eps| !eps.is_empty())
}
#[cfg(not(target_os = "windows"))]
#[cfg(target_os = "linux")]
{
// Only the Windows virtual DualSense exposes pad audio endpoints today.
asked
&& std::env::var_os("PUNKTFUNK_PAD_AUDIO").is_none_or(|v| v != "0")
&& crate::audio::pad_sink::pipewire_reachable()
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
// No pad audio source on this host OS.
let _ = asked;
false
}
@@ -276,6 +285,7 @@ pub(super) fn spawn(
conn: quinn::Connection,
pad: u8,
kinds: u8,
_edge: bool,
stop: Arc<AtomicBool>,
) -> Option<PadAudioHandle> {
if kinds & (KIND_BIT_HAPTICS | KIND_BIT_SPEAKER) == 0 {
@@ -310,10 +320,18 @@ pub(super) fn spawn(
return None;
}
let stop_t = stop.clone();
let endpoint_id = ep.endpoint_id;
match std::thread::Builder::new()
.name(format!("punktfunk1-pad{pad}"))
.spawn(move || pad_audio_thread(conn, pad, kinds, ep.endpoint_id, stop_t))
{
.spawn(move || {
pad_audio_thread(
conn,
pad,
kinds,
move || crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id),
stop_t,
)
}) {
Ok(join) => Some(PadAudioHandle {
stop,
join: Some(join),
@@ -325,13 +343,60 @@ pub(super) fn spawn(
}
}
/// Stub — pad endpoints exist only behind the Windows virtual DualSense; other hosts run pads
/// without the audio side (and never advertise the cap, see [`host_cap`]).
#[cfg(not(target_os = "windows"))]
/// Linux: mint the pad's PipeWire sink lazily inside the streamer thread (the same
/// open-with-backoff loop the Windows capture rides — a PipeWire hiccup at arrival time starts
/// pad audio late, not never). `edge` picks the DualSense Edge identity for the sink. `None`
/// only for empty kinds, a slot past `PUNKTFUNK_PAD_AUDIO_SLOTS`, or a failed thread spawn;
/// the pad itself keeps working either way, just without audio.
#[cfg(target_os = "linux")]
pub(super) fn spawn(
conn: quinn::Connection,
pad: u8,
kinds: u8,
edge: bool,
stop: Arc<AtomicBool>,
) -> Option<PadAudioHandle> {
if kinds & (KIND_BIT_HAPTICS | KIND_BIT_SPEAKER) == 0 {
return None;
}
if pad >= crate::audio::pad_sink::pad_audio_slots() {
tracing::debug!(
pad,
"pad-audio arrival past PUNKTFUNK_PAD_AUDIO_SLOTS — not streaming"
);
return None;
}
let stop_t = stop.clone();
match std::thread::Builder::new()
.name(format!("punktfunk1-pad{pad}"))
.spawn(move || {
pad_audio_thread(
conn,
pad,
kinds,
move || crate::audio::pad_sink::PadSinkCapturer::open(pad, edge),
stop_t,
)
}) {
Ok(join) => Some(PadAudioHandle {
stop,
join: Some(join),
}),
Err(e) => {
tracing::warn!(pad, error = %e, "pad-audio thread spawn failed — pad streams without audio");
None
}
}
}
/// Stub — pad audio sources exist only behind the Windows and Linux virtual DualSense; other
/// hosts run pads without the audio side (and never advertise the cap, see [`host_cap`]).
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
pub(super) fn spawn(
_conn: quinn::Connection,
_pad: u8,
_kinds: u8,
_edge: bool,
_stop: Arc<AtomicBool>,
) -> Option<PadAudioHandle> {
None
@@ -339,7 +404,7 @@ pub(super) fn spawn(
/// One enabled kind's encoder lane: admission/seq control + its stereo Opus encoder + the
/// power-of-two warn throttle (a stuck encoder would otherwise fail ~200 times a second).
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "linux"))]
struct Lane {
kind: u8,
ctl: LaneCtl,
@@ -349,7 +414,7 @@ struct Lane {
/// Build one stereo encoder per enabled kind: 48 kHz LowDelay hard-CBR like the session audio
/// plane ([`super::audio`]), at the pad plane's 64 kbps.
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn build_lanes(kinds: u8) -> Result<Vec<Lane>, opus::Error> {
let mut lanes = Vec::new();
for (bit, kind, frame_ms) in [
@@ -384,18 +449,19 @@ fn build_lanes(kinds: u8) -> Result<Vec<Lane>, opus::Error> {
Ok(lanes)
}
/// The per-pad streaming thread: loopback capture → framer → per-kind gate/encode → 0xD1
/// datagrams. Capture death reopens with the session-audio backoff ([`INJECTOR_REOPEN_BACKOFF`],
/// encoders + seq kept); a send error ends the thread (the connection — the session — is gone).
#[cfg(target_os = "windows")]
fn pad_audio_thread(
/// The per-pad streaming thread: capture of the pad's audio device (`open` builds the
/// platform's capturer — Windows loopback / Linux minted sink) → framer → per-kind gate/encode
/// → 0xD1 datagrams. Capture death reopens with the session-audio backoff
/// ([`INJECTOR_REOPEN_BACKOFF`], encoders + seq kept); a send error ends the thread (the
/// connection — the session — is gone).
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn pad_audio_thread<C: crate::audio::AudioCapturer>(
conn: quinn::Connection,
pad: u8,
kinds: u8,
endpoint_id: String,
open: impl Fn() -> anyhow::Result<C>,
stop: Arc<AtomicBool>,
) {
use crate::audio::AudioCapturer as _;
let mut lanes = match build_lanes(kinds) {
Ok(l) => l,
Err(e) => {
@@ -413,7 +479,7 @@ fn pad_audio_thread(
// Reopen-with-backoff (the audio.rs discipline): a capture death (endpoint invalidated,
// audio-engine restart) reopens instead of muting the pad for the rest of the session. The
// first open ALSO rides this loop, so an open lost to endpoint churn starts late, not never.
let mut capturer: Option<crate::audio::pad_endpoint::PadLoopbackCapturer> = None;
let mut capturer: Option<C> = None;
let mut last_failed: Option<std::time::Instant> = None;
tracing::info!(
pad,
@@ -427,7 +493,7 @@ fn pad_audio_thread(
std::thread::sleep(std::time::Duration::from_millis(200));
continue;
}
match crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id) {
match open() {
Ok(c) => {
if last_failed.take().is_some() {
tracing::info!(pad, "pad-audio capture reopened");
+3 -2
View File
@@ -144,8 +144,9 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|---|---|---|
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` · `steamcontroller2` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, `sc2`, `ibex`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. `steamcontroller2` (the 2026 Steam Controller) is passed through **as-is** — the host presents a real SC2 (`28DE:1302`) that Steam Input drives directly, mirroring the physical pad's raw reports (Linux only). DualSense (Edge)/DualShock 4 work on Linux (UHID) and Windows (UMDF); the Steam Deck pad too (Windows via the promoted UMDF identity); Switch Pro and the classic Steam Controller need Linux UHID. Unsupported choices fold to Xbox 360. |
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
| `PUNKTFUNK_PAD_AUDIO` | `1` · `0` *(default on)* | **(Windows)** Controller audio: what a game plays through the DualSense's built-in speaker and voice-coil haptics is streamed to the client's physical pad as its own low-latency plane. On by default and free while idle — silence is never encoded or sent; `0` turns it off host-wide. |
| `PUNKTFUNK_PAD_AUDIO_SLOTS` | `1``4` *(default `1`)* | **(Windows)** How many controllers can have their own audio at once. Each slot is a pre-provisioned virtual endpoint, so the default stays at one; raise it for multi-pad sessions. |
| `PUNKTFUNK_PAD_AUDIO` | `1` · `0` *(default on)* | Controller audio: what a game plays through the DualSense's built-in speaker and voice-coil haptics is streamed to the client's physical pad as its own low-latency plane. On by default and free while idle — silence is never encoded or sent; `0` turns it off host-wide. On Windows the pad's audio device is a pre-provisioned virtual endpoint; on Linux it is a per-pad PipeWire sink minted with the DualSense identity games match on. |
| `PUNKTFUNK_PAD_AUDIO_SLOTS` | `1``4` *(default: Windows `1`, Linux `4`)* | How many controllers can have their own audio at once. On Windows each slot is a pre-provisioned virtual endpoint, so the default stays at one; a Linux sink is minted lazily and costs nothing idle, so every slot is on. |
| `PUNKTFUNK_PAD_SINK_NAME` / `PUNKTFUNK_PAD_SINK_DESC` | templates | **(Linux, field debugging)** Override the minted pad sink's `node.name` / `node.description`. `{pad}` and `{mac}` expand per pad. Only for chasing a title whose device matcher wants different strings — the defaults carry every known match surface. |
## Audio / microphone
+4 -3
View File
@@ -97,6 +97,7 @@ head-tracked remote spatial audio that no streaming stack does today.
simply has no 4:4:4 path yet, and it waits on hardware that advertises a HEVC 4:4:4 encode
entrypoint to build and validate against. On either vendor, [PyroWave](/docs/pyrowave) already
carries full chroma today.
- **DualSense voice-coil haptics.** Scoped and shelved — it rides the controller's USB audio
interface and has near-zero game support on Linux. Rumble, adaptive triggers and the lightbar
already work.
- **DualSense voice-coil haptics over Bluetooth client pads.** The controller exposes no audio
interface over Bluetooth, so the audio-haptics plane is USB-only on the client side — a BT
DualSense keeps classic rumble. (Hosts stream pad audio on both Windows and Linux; rumble,
adaptive triggers and the lightbar work everywhere regardless.)