Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
907080f92b | ||
|
|
9425c6d40a | ||
|
|
ab8c7ec37c | ||
|
|
64e2af17c5 | ||
|
|
72189b29ec | ||
|
|
339a1d70f9 | ||
|
|
79dba7f95a | ||
|
|
d7430fe2bd | ||
|
|
6f81ec24ba | ||
|
|
539236de91 | ||
|
|
118758ff0b | ||
|
|
dcde856178 | ||
|
|
77918674c3 |
@@ -198,6 +198,28 @@ Streaming sessions still hold the box awake through their own `PowerRequest` ass
|
||||
before. New knob: `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a
|
||||
third-party virtual audio driver misbehaves while its render side is paused.
|
||||
|
||||
### Windows host — audio no longer costs local-game frame time
|
||||
|
||||
🛑 **The host could tank a locally-played game's frame lows** (field-reported 2026-08-12:
|
||||
Helldivers 2 at 1% lows of 2–5 FPS, cured by uninstalling). Two mechanisms, both fixed:
|
||||
|
||||
- **The minted-endpoint retry storm.** The virtual-mic resolve ran a FULL provisioning pass on
|
||||
every reopen with no cooldown, no in-flight guard, and no give-up — and the pass reached
|
||||
`UpdateDriverForPlugAndPlayDevicesW` even over an already-existing devnode. On a box where
|
||||
minting cannot converge, the pump's reopen backoff (capped 60 s) turned that into a SetupAPI
|
||||
sweep + PnP driver re-bind + default-device writes roughly once a minute, forever — each
|
||||
raising the system-wide device-change broadcast games service by rebuilding their audio
|
||||
graphs. Provisioning now short-circuits to a no-PnP fast path while the minted devices are
|
||||
healthy, waits on an in-flight pass instead of racing a second one, honours the 60 s retry
|
||||
cooldown from the blocking path too, and stops for the host lifetime after five unlatched
|
||||
passes (a service restart re-arms minting).
|
||||
- **Session tuning never reverted.** The first streaming session put the whole host process at
|
||||
HIGH priority class with a 1 ms global timer (`timeBeginPeriod`) and DWM MMCSS, documented as
|
||||
"reverts at process exit" — but the host is a 24/7 service, so after one stream it competed
|
||||
at HIGH priority against whatever the user played locally, forever. The process-wide tuning
|
||||
is now refcounted across the hot stream threads and reverts when the last one exits
|
||||
(= session teardown), the same lifetime the per-thread MMCSS effects already ride.
|
||||
|
||||
## v0.27.0
|
||||
|
||||
87 commits since v0.26.0.
|
||||
|
||||
@@ -215,9 +215,10 @@ export function useHosts() {
|
||||
const [views, setViews] = useState<HostView[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
// Why the list is empty, when it is empty for a reason other than an empty LAN. Rendering
|
||||
// either of these as "No hosts yet" would blame the user's network for the plugin's problem:
|
||||
// any of these as "No hosts yet" would blame the user's network for the plugin's problem:
|
||||
// "client-outdated" — the installed client predates `punktfunk discover`
|
||||
// "client-unavailable" — there is no client installed at all
|
||||
// "list-failed" — the refresh itself blew up (backend down, call threw)
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -236,7 +237,11 @@ export function useHosts() {
|
||||
);
|
||||
setViews(mergeHosts(s.hosts ?? [], d.hosts ?? []));
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` });
|
||||
// Inline, not a toast: the panel remounts (and refreshes) on every QAM open, so while
|
||||
// the backend is unhappy a toast here nagged on each open. The panel row also sits next
|
||||
// to the Refresh button that retries it, which is where the eyes already are.
|
||||
console.warn("punktfunk: host list refresh failed", e);
|
||||
setProblem("list-failed");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
@@ -454,9 +459,12 @@ export async function startStream(
|
||||
): Promise<void> {
|
||||
try {
|
||||
await launchStream(v.ref, opts);
|
||||
// No success toast: the user just pressed the button that names this host/card, the QAM
|
||||
// closes, and Steam's own launch UI takes over — a toast here fired on EVERY launch and
|
||||
// then sat on top of the starting stream. Failure still toasts (the QAM may already be
|
||||
// closed, so inline error state would go unseen).
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${v.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed${label ? ` (${label})` : ""}: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,15 +46,23 @@ import { OsMark } from "./os-icon";
|
||||
import { ensureGamepadUiShortcut, launchGamepadUi, recreateShortcuts, stopStream } from "./steam";
|
||||
import { TrustSheet } from "./trust";
|
||||
|
||||
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
|
||||
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut
|
||||
// and sweeps duplicate entries (the piles a boot race used to mint, one per Steam start).
|
||||
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
|
||||
// CEF localStorage that self-heal fixes on the next mount, but this gives an in-session button
|
||||
// that works even without a reload. Always ends in a toast so the tap has feedback.
|
||||
async function recreatePunktfunkShortcut(): Promise<void> {
|
||||
const appId = await recreateShortcuts();
|
||||
const { appId, removedDuplicates } = await recreateShortcuts();
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: appId != null ? "Shortcut restored to your library" : "Couldn't create the shortcut",
|
||||
body:
|
||||
appId == null
|
||||
? "Couldn't create the shortcut"
|
||||
: removedDuplicates > 0
|
||||
? `Shortcut restored — removed ${removedDuplicates} duplicate ${
|
||||
removedDuplicates === 1 ? "entry" : "entries"
|
||||
}`
|
||||
: "Shortcut restored to your library",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -222,12 +230,16 @@ const QamPanel: FC = () => {
|
||||
label={
|
||||
problem === "client-unavailable"
|
||||
? "Punktfunk isn’t installed"
|
||||
: "Update the Punktfunk client"
|
||||
: problem === "list-failed"
|
||||
? "Couldn’t scan for hosts"
|
||||
: "Update the Punktfunk client"
|
||||
}
|
||||
description={
|
||||
problem === "client-unavailable"
|
||||
? "This panel launches the Punktfunk app, which isn’t on this Deck yet. Install it in Desktop Mode."
|
||||
: "This client is too old to find hosts on your network. Saved hosts still work."
|
||||
: problem === "list-failed"
|
||||
? "Something went wrong while scanning — Refresh tries again."
|
||||
: "This client is too old to find hosts on your network. Saved hosts still work."
|
||||
}
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
@@ -313,7 +325,7 @@ const QamPanel: FC = () => {
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Missing the Punktfunk entry in your library? This puts it back."
|
||||
description="Missing the Punktfunk entry in your library, or seeing several? This puts one back and removes the rest."
|
||||
onClick={() => void recreatePunktfunkShortcut()}
|
||||
>
|
||||
<FaPlus style={{ marginRight: "0.5em" }} />
|
||||
|
||||
+220
-39
@@ -44,6 +44,7 @@ declare const SteamClient: {
|
||||
): Promise<unknown>;
|
||||
RunGame(gameId: string, _unused: string, _i: number, _j: number): void;
|
||||
TerminateApp(gameId: string, _b: boolean): void;
|
||||
RemoveShortcut(appId: number): void;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,29 +63,114 @@ declare const collectionStore:
|
||||
// that the reuse path below silently repoints (SetShortcut* on a dead id is a no-op), and the
|
||||
// entry never comes back.
|
||||
declare const appStore:
|
||||
| { GetAppOverviewByAppID?: (appId: number) => unknown | null }
|
||||
| {
|
||||
GetAppOverviewByAppID?: (appId: number) => unknown | null;
|
||||
allApps?: SteamAppOverviewLike[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
/** True if a remembered appId still maps to a live Steam shortcut. When appStore is unavailable
|
||||
* we can't tell, so assume it exists — better to keep reusing than risk a duplicate library
|
||||
* entry from a false "missing". A confident null means the shortcut was deleted → recreate. */
|
||||
function shortcutStillExists(appId: number): boolean {
|
||||
// The overview surface we read when scanning the library — Steam internals, so everything is
|
||||
// optional and accessed defensively.
|
||||
interface SteamAppOverviewLike {
|
||||
appid?: number;
|
||||
display_name?: string;
|
||||
BIsShortcut?: () => boolean;
|
||||
}
|
||||
|
||||
// Steam-injected global whose WaitForServicesInitialized resolves once the client's app
|
||||
// services are up (the MoonDeck-verified readiness signal). Services-init alone doesn't
|
||||
// guarantee the overview map is populated, so it's paired with the hydration witness below.
|
||||
declare const App:
|
||||
| { WaitForServicesInitialized?: () => Promise<boolean> }
|
||||
| undefined;
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
let servicesInitialized: Promise<void> | undefined;
|
||||
function waitForServicesInitialized(): Promise<void> {
|
||||
servicesInitialized ??= (async () => {
|
||||
try {
|
||||
if (typeof App !== "undefined" && App?.WaitForServicesInitialized) {
|
||||
await App.WaitForServicesInitialized();
|
||||
}
|
||||
} catch {
|
||||
/* no signal — the hydration witness still gates the verdict */
|
||||
}
|
||||
})();
|
||||
return servicesInitialized;
|
||||
}
|
||||
|
||||
/** Has appStore demonstrably finished its initial load? An empty `allApps` means "not yet":
|
||||
* any account that ever had our shortcut has at least one app, so a populated map is the
|
||||
* witness that a null overview lookup is an ANSWER rather than a not-loaded-yet. null =
|
||||
* can't tell (missing global, API drift). */
|
||||
function appStoreHydrated(): boolean | null {
|
||||
try {
|
||||
if (typeof appStore === "undefined" || !appStore) {
|
||||
return null;
|
||||
}
|
||||
const apps = appStore.allApps;
|
||||
return Array.isArray(apps) ? apps.length > 0 : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One overview lookup: true = live, false = absent, null = can't tell. */
|
||||
function queryShortcutAlive(appId: number): boolean | null {
|
||||
try {
|
||||
// Call it as a METHOD on appStore — NEVER as an extracted function. Its implementation
|
||||
// reads the store's own state (`this.m_mapApps`), so `const get = appStore.GetAppOverview…;
|
||||
// get(id)` throws on the lost `this`, and the catch below turns that into a permanent
|
||||
// "true". That is not a stale-data bug but a total one: the guard then answers "still
|
||||
// exists" for EVERY appId, so a dangling id is never dropped, the reuse path repoints a
|
||||
// dead shortcut (silent no-ops), and "recreate" reports success having done nothing.
|
||||
// `typeof` first: `appStore` is a Steam-injected global, and a bare reference to a missing
|
||||
// one is a ReferenceError that optional chaining does NOT prevent.
|
||||
// "can't tell". `typeof` first: `appStore` is a Steam-injected global, and a bare
|
||||
// reference to a missing one is a ReferenceError that optional chaining does NOT prevent.
|
||||
if (typeof appStore === "undefined" || !appStore?.GetAppOverviewByAppID) {
|
||||
return true; // no way to verify — preserve the reuse path
|
||||
return null;
|
||||
}
|
||||
return appStore.GetAppOverviewByAppID(appId) != null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// How long to wait for the app store before conceding liveness can't be verified. A Deck boot
|
||||
// hydrates the store within a few seconds of plugin mount; 30 s is comfortably past any real
|
||||
// boot, and the wait only burns on the absent/unverifiable paths — a live overview answers on
|
||||
// the first query. Overview registration can trail the bulk hydration by a beat, so a
|
||||
// "hydrated but absent" verdict gets one grace recheck before it counts as deleted.
|
||||
const STORE_WAIT_MS = 30_000;
|
||||
const STORE_POLL_MS = 1_000;
|
||||
const STORE_GRACE_MS = 2_000;
|
||||
|
||||
/** True if a remembered appId still maps to a live Steam shortcut.
|
||||
*
|
||||
* The dangerous verdict is FALSE — it sends the caller to AddShortcut, so a wrong "deleted"
|
||||
* mints a duplicate library entry. And a bare null-overview check gets it wrong on EVERY
|
||||
* boot: the plugin mounts while Steam is still starting up, before appStore has registered
|
||||
* its overviews, so the remembered (perfectly live) appId looks up as null and each boot
|
||||
* added another visible "Punktfunk" — the field-reported duplicate pile. Absent is therefore
|
||||
* only believed once the store is demonstrably hydrated; if that can't be established within
|
||||
* budget the answer is true, because a false "alive" merely no-ops Set-calls until the next
|
||||
* ask (and the recreate button re-asks when the store IS ready) while a false "dead"
|
||||
* duplicates forever. */
|
||||
async function shortcutStillExists(appId: number): Promise<boolean> {
|
||||
if (queryShortcutAlive(appId) === true) {
|
||||
return true;
|
||||
}
|
||||
// Race the init signal against the same budget the poll loop gets: a signal that never
|
||||
// resolves must not wedge the guard (the single-flight ensure would stay occupied forever).
|
||||
await Promise.race([waitForServicesInitialized(), sleep(STORE_WAIT_MS)]);
|
||||
for (let waited = 0; waited < STORE_WAIT_MS; waited += STORE_POLL_MS) {
|
||||
if (queryShortcutAlive(appId) === true) {
|
||||
return true;
|
||||
}
|
||||
if (appStoreHydrated() === true) {
|
||||
await sleep(STORE_GRACE_MS);
|
||||
return queryShortcutAlive(appId) !== false; // null = unverifiable → reuse
|
||||
}
|
||||
await sleep(STORE_POLL_MS);
|
||||
}
|
||||
return true; // store never became inspectable — reusing beats duplicating
|
||||
}
|
||||
|
||||
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
|
||||
@@ -156,6 +242,67 @@ async function applyArtwork(appId: number, isRetry = false): Promise<void> {
|
||||
// share it so Steam keys them to the SAME controller config (configset key = lowercase name).
|
||||
const SHORTCUT_NAME = "Punktfunk";
|
||||
|
||||
/** Find an existing "Punktfunk" shortcut to ADOPT instead of minting a new library entry — the
|
||||
* healing path for a lost/wiped appId, and for the duplicate piles the boot race left behind
|
||||
* in the field: rebind one of the existing entries to the role rather than adding an N+1th.
|
||||
* (The caller rewrites exe/dir/opts/visibility anyway, so any of them serves.) Only overviews
|
||||
* Steam itself says are shortcuts qualify, and the other role's remembered id is excluded so
|
||||
* the two roles never collapse onto one shortcut. */
|
||||
function findAdoptableShortcut(excludeAppId: number | null): number | null {
|
||||
try {
|
||||
if (typeof appStore === "undefined" || !Array.isArray(appStore?.allApps)) {
|
||||
return null;
|
||||
}
|
||||
for (const app of appStore.allApps) {
|
||||
if (
|
||||
app?.display_name === SHORTCUT_NAME &&
|
||||
typeof app.appid === "number" &&
|
||||
app.appid !== excludeAppId &&
|
||||
app.BIsShortcut?.() === true
|
||||
) {
|
||||
return app.appid;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* Steam internals drifted — AddShortcut is the fallback */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Remove every "Punktfunk" shortcut beyond the two remembered role ids — the cleanup for
|
||||
* piles already minted by the boot race. Deliberately reachable ONLY from the user-pressed
|
||||
* recreate button, never from mount: automatic library deletion at boot is a bigger hazard
|
||||
* than the mess it would tidy. Returns how many entries were removed. */
|
||||
function removeDuplicateShortcuts(): number {
|
||||
let removed = 0;
|
||||
try {
|
||||
if (typeof appStore === "undefined" || !Array.isArray(appStore?.allApps)) {
|
||||
return 0;
|
||||
}
|
||||
const keep = [recall(STORAGE_KEY_STREAM), recall(STORAGE_KEY_UI)];
|
||||
// Snapshot before removing — RemoveShortcut mutates the store's list under the iteration.
|
||||
const surplus = appStore.allApps.filter(
|
||||
(app) =>
|
||||
app?.display_name === SHORTCUT_NAME &&
|
||||
typeof app.appid === "number" &&
|
||||
!keep.includes(app.appid) &&
|
||||
app.BIsShortcut?.() === true,
|
||||
);
|
||||
for (const app of surplus) {
|
||||
SteamClient.Apps.RemoveShortcut(app.appid as number);
|
||||
try {
|
||||
localStorage.removeItem(artKey(app.appid as number));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
removed++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("punktfunk: duplicate-shortcut sweep incomplete", e);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
|
||||
// preserving the exec bit, and ~/homebrew/plugins is root-owned so the unprivileged plugin
|
||||
// backend can't chmod it back on. Passing the script as an argument to the always-executable
|
||||
@@ -223,7 +370,7 @@ async function ensureControllerConfig(): Promise<void> {
|
||||
* the current runner path. Reuses/repoints the remembered shortcut (the plugin dir can change
|
||||
* across reinstalls, and pre-two-shortcut installs had this one visible).
|
||||
*/
|
||||
async function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
|
||||
async function doEnsureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
throw new Error(`launch wrapper missing at ${info.runner}`);
|
||||
@@ -232,25 +379,38 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string;
|
||||
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
|
||||
|
||||
// Reuse the remembered shortcut only if it still exists — a stale appId (shortcut deleted, key
|
||||
// outlived it across a reinstall) must fall through to AddShortcut, not be silently repointed.
|
||||
// outlived it across a reinstall) must fall through, not be silently repointed. On a lost id,
|
||||
// ADOPT an existing same-named shortcut before AddShortcut so a wiped key never duplicates.
|
||||
const remembered = recall(STORAGE_KEY_STREAM);
|
||||
if (remembered != null && shortcutStillExists(remembered)) {
|
||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||
setShortcutHidden(remembered, true); // migrate pre-two-shortcut installs (were visible)
|
||||
void applyArtwork(remembered);
|
||||
return { appId: remembered, runner: info.runner, clientBin: info.client_bin ?? "" };
|
||||
let appId =
|
||||
remembered != null && (await shortcutStillExists(remembered)) ? remembered : null;
|
||||
if (appId == null) {
|
||||
appId =
|
||||
findAdoptableShortcut(recall(STORAGE_KEY_UI)) ??
|
||||
(await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""));
|
||||
remember(STORAGE_KEY_STREAM, appId);
|
||||
}
|
||||
|
||||
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
setShortcutHidden(appId, true);
|
||||
setShortcutHidden(appId, true); // also migrates pre-two-shortcut installs (were visible)
|
||||
void applyArtwork(appId);
|
||||
remember(STORAGE_KEY_STREAM, appId);
|
||||
return { appId, runner: info.runner, clientBin: info.client_bin ?? "" };
|
||||
}
|
||||
|
||||
// Concurrent ensure calls share one run per role — two ensures racing past the liveness check
|
||||
// would each AddShortcut, which is exactly the duplicate class this file exists to prevent (and
|
||||
// the store-readiness wait makes the window real: mount's fire-and-forget ensure can be mid-wait
|
||||
// when a QAM press arrives). Sequential calls still re-run, so per-launch repointing is kept.
|
||||
let streamEnsureInFlight: Promise<{ appId: number; runner: string; clientBin: string }> | null =
|
||||
null;
|
||||
function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
|
||||
streamEnsureInFlight ??= doEnsureStreamShortcut().finally(() => {
|
||||
streamEnsureInFlight = null;
|
||||
});
|
||||
return streamEnsureInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the GAMEPAD-UI shortcut (visible, stateless) — the library-facing "Punktfunk" entry
|
||||
* that opens the client's console home (bare `--browse`: host picker + pairing + settings).
|
||||
@@ -258,7 +418,7 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string;
|
||||
* kept VISIBLE. Idempotent — call on plugin mount so the library entry always exists and stays
|
||||
* repointed to the current plugin dir. Best-effort: returns null on any failure.
|
||||
*/
|
||||
export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
async function doEnsureGamepadUiShortcut(): Promise<number | null> {
|
||||
try {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
@@ -275,18 +435,20 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`;
|
||||
|
||||
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
|
||||
// localStorage key survived a plugin reinstall) falls through to AddShortcut so the visible
|
||||
// library entry actually comes back instead of repointing a dead id.
|
||||
// localStorage key survived a plugin reinstall) falls through so the visible library entry
|
||||
// actually comes back instead of repointing a dead id. On a lost id, ADOPT an existing
|
||||
// same-named shortcut (a boot-race duplicate, or the entry whose key was wiped) before
|
||||
// AddShortcut — creation is the last resort, never the response to a mere lookup miss.
|
||||
let appId = recall(STORAGE_KEY_UI);
|
||||
if (appId != null && shortcutStillExists(appId)) {
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
} else {
|
||||
appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
if (appId == null || !(await shortcutStillExists(appId))) {
|
||||
appId =
|
||||
findAdoptableShortcut(recall(STORAGE_KEY_STREAM)) ??
|
||||
(await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""));
|
||||
remember(STORAGE_KEY_UI, appId);
|
||||
}
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, launchOpts);
|
||||
setShortcutHidden(appId, false); // the visible library entry
|
||||
void applyArtwork(appId);
|
||||
@@ -297,18 +459,32 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// Same single-flight rule as the stream role (see ensureStreamShortcut).
|
||||
let uiEnsureInFlight: Promise<number | null> | null = null;
|
||||
export function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
uiEnsureInFlight ??= doEnsureGamepadUiShortcut().finally(() => {
|
||||
uiEnsureInFlight = null;
|
||||
});
|
||||
return uiEnsureInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the visible "Punktfunk" library entry back into existence — the recovery button for
|
||||
* "my shortcut disappeared". Drops any remembered appId that no longer maps to a live shortcut
|
||||
* (so it can't shadow a fresh AddShortcut), then re-ensures. Safe to press anytime: a shortcut
|
||||
* that still exists is left in place (no duplicate); a missing one is recreated. Covers the case
|
||||
* self-heal-on-mount can't — deleting the shortcut WITHOUT reinstalling (no mount → no ensure).
|
||||
* Returns the (new or existing) visible appId, or null on failure.
|
||||
* Also sweeps surplus "Punktfunk" entries (the piles the boot race minted before the store-
|
||||
* readiness gate existed) — the button is where that cleanup lives, never mount. Returns the
|
||||
* (new or existing) visible appId (null on failure) plus how many duplicates were removed.
|
||||
*/
|
||||
export async function recreateShortcuts(): Promise<number | null> {
|
||||
export async function recreateShortcuts(): Promise<{
|
||||
appId: number | null;
|
||||
removedDuplicates: number;
|
||||
}> {
|
||||
for (const key of [STORAGE_KEY_STREAM, STORAGE_KEY_UI]) {
|
||||
const id = recall(key);
|
||||
if (id != null && !shortcutStillExists(id)) {
|
||||
if (id != null && !(await shortcutStillExists(id))) {
|
||||
try {
|
||||
localStorage.removeItem(artKey(id)); // stale art marker for the dead appId
|
||||
localStorage.removeItem(key);
|
||||
@@ -317,8 +493,13 @@ export async function recreateShortcuts(): Promise<number | null> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next launch.
|
||||
return ensureGamepadUiShortcut();
|
||||
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next
|
||||
// launch. Sweep AFTER the ensure so the remembered ids are fresh — and only when the ensure
|
||||
// succeeded: on a failed ensure the "keep" list can't be trusted, and deleting candidates a
|
||||
// later ensure would adopt could leave the library with no entry at all.
|
||||
const appId = await ensureGamepadUiShortcut();
|
||||
const removedDuplicates = appId != null ? removeDuplicateShortcuts() : 0;
|
||||
return { appId, removedDuplicates };
|
||||
}
|
||||
|
||||
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
//! 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.
|
||||
// `deny`, not `forbid`: edition 2024 makes `std::env::set_var`/`remove_var` unsafe (WP20 —
|
||||
// the env-mutation class made visible), 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`, 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"))]
|
||||
|
||||
@@ -8,14 +8,16 @@
|
||||
//!
|
||||
//! Raw C-ABI FFI (winmm/kernel32/dwmapi/avrt) rather than the `windows` crate so it builds without
|
||||
//! pulling new windows-rs features. No-op on non-Windows. Per-thread effects (MMCSS, execution
|
||||
//! state) auto-revert at thread exit (= session end); the process-wide bits revert at process exit.
|
||||
//! state) auto-revert at thread exit (= session end); the process-wide bits are refcounted over
|
||||
//! the hot threads and revert when the LAST one exits — the host must not keep HIGH priority and
|
||||
//! a 1 ms global timer while a local game runs and nobody streams (2026-08-12 field report).
|
||||
//! See `design/host-latency-plan.md` Tier 3A.
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod imp {
|
||||
#![allow(non_snake_case)]
|
||||
use std::ffi::c_void;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::Mutex;
|
||||
|
||||
type Handle = *mut c_void;
|
||||
type Bool = i32;
|
||||
@@ -23,6 +25,7 @@ mod imp {
|
||||
#[link(name = "winmm")]
|
||||
unsafe extern "system" {
|
||||
fn timeBeginPeriod(uPeriod: u32) -> u32;
|
||||
fn timeEndPeriod(uPeriod: u32) -> u32;
|
||||
}
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
@@ -55,6 +58,7 @@ mod imp {
|
||||
}
|
||||
|
||||
const HIGH_PRIORITY_CLASS: u32 = 0x0000_0080;
|
||||
const NORMAL_PRIORITY_CLASS: u32 = 0x0000_0020;
|
||||
const ES_CONTINUOUS: u32 = 0x8000_0000;
|
||||
const ES_SYSTEM_REQUIRED: u32 = 0x0000_0001;
|
||||
const ES_DISPLAY_REQUIRED: u32 = 0x0000_0002;
|
||||
@@ -114,16 +118,19 @@ mod imp {
|
||||
}
|
||||
}
|
||||
|
||||
static PROCESS_TUNED: OnceLock<()> = OnceLock::new();
|
||||
/// Live hot (session) threads. A Mutex, not an atomic: the 0↔1 transitions carry the
|
||||
/// apply/revert side effects, and an interleaved fetch_add/fetch_sub pair could otherwise
|
||||
/// finish with a running session untuned (transitions are rare — thread start/exit only).
|
||||
static HOT_THREADS: Mutex<usize> = Mutex::new(0);
|
||||
|
||||
/// Process-wide tuning, applied exactly once. Reverts at process exit. Best-effort: each call is
|
||||
/// independent and a failure is ignored (e.g. a non-elevated host may not get HIGH class).
|
||||
fn tune_process_once() {
|
||||
/// Process-wide tuning, applied when the FIRST hot thread registers. Best-effort: each call
|
||||
/// is independent and a failure is ignored (e.g. a non-elevated host may not get HIGH class).
|
||||
fn tune_process() {
|
||||
// SAFETY: each call is a C-ABI FFI into winmm/kernel32/dwmapi declared with a matching
|
||||
// `extern "system"` signature; every argument is a plain integer (no pointers/buffers escape),
|
||||
// and `GetCurrentProcess()` returns the current-process pseudo-handle (a constant, always valid,
|
||||
// never closed). The body runs inside `get_or_init`, so it executes exactly once per process.
|
||||
PROCESS_TUNED.get_or_init(|| unsafe {
|
||||
// never closed).
|
||||
unsafe {
|
||||
// 1 ms timer granularity (default ~15.6 ms) — the floor for precise frame pacing and the
|
||||
// encode|send split's sub-ms sleeps.
|
||||
timeBeginPeriod(1);
|
||||
@@ -134,16 +141,66 @@ mod imp {
|
||||
// control/capture/encode/send threads on the CPU (Apollo does the same).
|
||||
SetPriorityClass(GetCurrentProcess(), HIGH_PRIORITY_CLASS);
|
||||
tracing::info!("windows session tuning applied (timer 1ms, DWM MMCSS, HIGH priority)");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Call at the start of each capture/encode/send (hot stream) thread. Applies the process-wide
|
||||
/// tuning once, registers the calling thread with MMCSS ("Games"), and asserts the display/system
|
||||
/// must stay awake for as long as this thread lives. The MMCSS handle is intentionally leaked and
|
||||
/// the execution-state assertion is bound to this thread — both are reverted by the OS when the
|
||||
/// thread exits, so a session that ends tears them down without explicit bookkeeping.
|
||||
/// The mirror of [`tune_process`], run when the LAST hot thread exits. Leaving the tuning in
|
||||
/// place used to be the design ("reverts at process exit") — but the host is a 24/7 service,
|
||||
/// so after one stream it competed at HIGH class with a 1 ms global timer against whatever
|
||||
/// the user played locally, forever.
|
||||
fn untune_process() {
|
||||
// SAFETY: same FFI surface as `tune_process` — plain-integer arguments, constant
|
||||
// pseudo-handle, no pointers or buffers.
|
||||
unsafe {
|
||||
timeEndPeriod(1); // pairs the timeBeginPeriod(1)
|
||||
DwmEnableMMCSS(0);
|
||||
SetPriorityClass(GetCurrentProcess(), NORMAL_PRIORITY_CLASS);
|
||||
tracing::info!("windows session tuning reverted (timer, DWM MMCSS, NORMAL priority)");
|
||||
}
|
||||
}
|
||||
|
||||
/// One per hot thread, parked in TLS by [`on_hot_thread`]; its Drop runs at thread exit
|
||||
/// (= session teardown), the same lifetime the MMCSS/execution-state effects already ride.
|
||||
struct HotThreadGuard;
|
||||
|
||||
impl Drop for HotThreadGuard {
|
||||
fn drop(&mut self) {
|
||||
// A poisoned lock skips the revert (best-effort, like every call here) instead of
|
||||
// panicking inside a TLS destructor.
|
||||
if let Ok(mut n) = HOT_THREADS.lock() {
|
||||
*n -= 1;
|
||||
if *n == 0 {
|
||||
untune_process();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static HOT_THREAD: std::cell::OnceCell<HotThreadGuard> =
|
||||
const { std::cell::OnceCell::new() };
|
||||
}
|
||||
|
||||
/// Call at the start of each capture/encode/send (hot stream) thread. Registers the thread in
|
||||
/// the process-tuning refcount (first in applies, last out reverts), registers it with MMCSS
|
||||
/// ("Games"), and asserts the display/system must stay awake for as long as this thread lives.
|
||||
/// The MMCSS handle is intentionally leaked and the execution-state assertion is bound to this
|
||||
/// thread — both are reverted by the OS when the thread exits, and the refcount guard's TLS
|
||||
/// Drop runs there too, so a session that ends tears everything down without explicit
|
||||
/// bookkeeping.
|
||||
pub fn on_hot_thread() {
|
||||
tune_process_once();
|
||||
HOT_THREAD.with(|slot| {
|
||||
if slot.get().is_none() {
|
||||
{
|
||||
let mut n = HOT_THREADS.lock().unwrap();
|
||||
*n += 1;
|
||||
if *n == 1 {
|
||||
tune_process();
|
||||
}
|
||||
}
|
||||
let _ = slot.set(HotThreadGuard);
|
||||
}
|
||||
});
|
||||
// SAFETY: C-ABI FFI declared with matching `extern "system"` signatures. SetThreadExecutionState
|
||||
// takes only flag bits. `task` is a local NUL-terminated UTF-16 buffer ("Games\0") alive for the
|
||||
// whole block, so `task.as_ptr()` is a valid LPCWSTR for the call, and `&mut idx` is a live local
|
||||
|
||||
@@ -183,6 +183,10 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||
mod audio_control;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
// DualSense pad-audio sink + capture, the Linux analogue of `pad_endpoint` below: the session
|
||||
// layer mints per-pad sinks and the CLI exposes the `pad-sink-test` devtest.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) use linux::pad_sink;
|
||||
// DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio).
|
||||
// pub(crate): the session layer queries endpoints by pad index and the CLI exposes the
|
||||
// `pad-endpoint` devtest.
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
//! surround session can replace a stereo capturer without leaking a PipeWire consumer (see
|
||||
//! CLAUDE.md: a wedged link head-blocks the daemon).
|
||||
|
||||
pub(crate) mod pad_sink;
|
||||
mod stream_sink;
|
||||
|
||||
use super::{AudioCapturer, MicBackendStats, VirtualMic, SAMPLE_RATE};
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
//! Per-pad DualSense audio sink (Linux): one PipeWire `Audio/Sink` stream node per
|
||||
//! DualSense-family pad, wearing the identity DS5-native titles and GE-Proton's
|
||||
//! controller-audio routing match on — so a game that renders voice-coil haptics or pad-speaker
|
||||
//! audio finds "the controller's audio device" and plays into us. We own the sink, so the
|
||||
//! `process()` callback IS the capture: 4-ch F32 48 kHz (FL FR RL RR — front pair = speaker,
|
||||
//! back pair = voice coils, the same quad layout the Windows endpoint is stamped with) lands
|
||||
//! directly in the chunk channel that feeds the 0xD1 lanes (`native/pad_audio.rs`).
|
||||
//!
|
||||
//! Modeled on the stream-sink mode of [`super::PwAudioCapturer`] (same MainLoop-on-a-thread,
|
||||
//! Terminate channel, ready handshake, bounded lossy chunk hand-off) with two deliberate
|
||||
//! differences: **no default-sink claim** (nothing may auto-route here — games target it BY
|
||||
//! IDENTITY) and a low `priority.session` so WirePlumber never elects it against real hardware.
|
||||
//!
|
||||
//! **Identity** (design `dualsense-audio-haptics-and-speaker.md` §3/§5): GE-Proton 11-2+
|
||||
//! matches layered — pulse proplist (`device.bus == "usb"`, `device.vendor.id == 0x054c`,
|
||||
//! `device.product.id ∈ {0x0ce6, 0x0df2}`), then name substrings
|
||||
//! (`Sony_Interactive_Entertainment…Wireless_Controller`, `DualSense`); the community
|
||||
//! WirePlumber rule keys on the node-name substring and sets `node.description =
|
||||
//! "Wireless Controller"` (we mint it that way from the start). A pure PipeWire node cannot
|
||||
//! satisfy wine's ContainerId derivation (udev walk to a `usb_device` parent → `GUID_NULL`)
|
||||
//! nor GE's raw-ALSA fast path — both fall back to the Pulse-routed leg, which winepulse
|
||||
//! serves from exactly this node (it enumerates sinks). Every identity string has an env
|
||||
//! override for field debugging (`PUNKTFUNK_PAD_SINK_NAME` / `PUNKTFUNK_PAD_SINK_DESC`, with
|
||||
//! `{pad}` / `{mac}` placeholders).
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Message asking the PipeWire loop thread to quit (sent from `Drop`).
|
||||
struct Terminate;
|
||||
|
||||
/// The pad sink's fixed channel count — quad, mirroring the Windows endpoint stamp
|
||||
/// (`native/pad_audio.rs::CAP_CHANNELS` splits on the same layout).
|
||||
const PAD_CHANNELS: u32 = 4;
|
||||
|
||||
/// How many pad slots may carry a sink (`PUNKTFUNK_PAD_AUDIO_SLOTS`, default all 4 — a PipeWire
|
||||
/// stream node is cheap, unlike the Windows devnode mint whose default is 1).
|
||||
pub(crate) fn pad_audio_slots() -> u8 {
|
||||
std::env::var("PUNKTFUNK_PAD_AUDIO_SLOTS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u8>().ok())
|
||||
.unwrap_or(4)
|
||||
.clamp(1, 4)
|
||||
}
|
||||
|
||||
/// Whether a PipeWire daemon is plausibly reachable from this process — the Linux analogue of
|
||||
/// "startup provisioning published at least one endpoint" for [`host_cap`]'s existence leg
|
||||
/// (`native/pad_audio.rs`). A stat, not a connect: the handshake path runs per-Hello and must
|
||||
/// not block. `PIPEWIRE_REMOTE` names a non-default socket — trust it (the session capturer
|
||||
/// honors it via libpipewire, and a wrong value degrades to spawn-time failure, pad kept).
|
||||
pub(crate) fn pipewire_reachable() -> bool {
|
||||
if std::env::var_os("PIPEWIRE_REMOTE").is_some() {
|
||||
return true;
|
||||
}
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(|dir| std::path::Path::new(&dir).join("pipewire-0").exists())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The pad's virtual MAC as colon-separated display hex — [`ds_pairing_reply`]'s bytes 1..7
|
||||
/// are LSB-first (the report layout `hid-playstation` adopts as the HID `uniq` via `%pMR`,
|
||||
/// i.e. printed reversed), so the display form reverses them. Unique per pad (the low octet
|
||||
/// carries the pad index), which keeps multi-pad sinks distinct for the same reason the MAC
|
||||
/// itself must be: SDL/Steam and the matchers dedup by serial.
|
||||
///
|
||||
/// [`ds_pairing_reply`]: pf_inject::dualsense_proto::ds_pairing_reply
|
||||
fn pad_mac(pad: u8) -> String {
|
||||
let reply = crate::inject::dualsense_proto::ds_pairing_reply(pad);
|
||||
let m = &reply[1..7];
|
||||
format!(
|
||||
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
|
||||
m[5], m[4], m[3], m[2], m[1], m[0]
|
||||
)
|
||||
}
|
||||
|
||||
/// Expand the `{pad}` / `{mac}` placeholders of an identity template. Callers pass the MAC in
|
||||
/// the form the surrounding string wants: colon display form for proplist values, bare hex for
|
||||
/// the ALSA-style node name (udev serials carry no colons).
|
||||
fn expand(template: &str, pad: u8, mac: &str) -> String {
|
||||
template
|
||||
.replace("{pad}", &pad.to_string())
|
||||
.replace("{mac}", mac)
|
||||
}
|
||||
|
||||
/// The full identity a pad sink wears, resolved once at open.
|
||||
struct PadSinkIdentity {
|
||||
node_name: String,
|
||||
description: String,
|
||||
serial: String,
|
||||
product_id: &'static str,
|
||||
product_name: &'static str,
|
||||
}
|
||||
|
||||
impl PadSinkIdentity {
|
||||
fn new(pad: u8, edge: bool) -> PadSinkIdentity {
|
||||
let mac = pad_mac(pad);
|
||||
let mac_bare: String = mac.chars().filter(|c| *c != ':').collect();
|
||||
let (model, product_id, product_name) = if edge {
|
||||
(
|
||||
"DualSense_Edge",
|
||||
"0df2",
|
||||
"DualSense Edge Wireless Controller",
|
||||
)
|
||||
} else {
|
||||
("DualSense", "0ce6", "DualSense Wireless Controller")
|
||||
};
|
||||
// The ALSA-style name a REAL pad's card gets from udev (vendor_product_serial), which
|
||||
// is what every known name-substring matcher was written against. `-00.analog-surround-40`
|
||||
// = card profile suffix for the quad layout.
|
||||
let node_name = match std::env::var("PUNKTFUNK_PAD_SINK_NAME") {
|
||||
Ok(t) if !t.trim().is_empty() => expand(&t, pad, &mac_bare),
|
||||
_ => format!(
|
||||
"alsa_output.usb-Sony_Interactive_Entertainment_{model}_Wireless_Controller_{mac_bare}-00.analog-surround-40"
|
||||
),
|
||||
};
|
||||
// What the community WirePlumber rule renames real pads TO — minted that way directly.
|
||||
let description = match std::env::var("PUNKTFUNK_PAD_SINK_DESC") {
|
||||
Ok(t) if !t.trim().is_empty() => expand(&t, pad, &mac),
|
||||
_ => "Wireless Controller".to_string(),
|
||||
};
|
||||
PadSinkIdentity {
|
||||
node_name,
|
||||
description,
|
||||
serial: format!(
|
||||
"Sony_Interactive_Entertainment_{model}_Wireless_Controller_{mac_bare}"
|
||||
),
|
||||
product_id,
|
||||
product_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A live per-pad sink + its capture. Same next-chunk contract as every
|
||||
/// [`AudioCapturer`](crate::audio::AudioCapturer): empty chunk = quiet sink (keep me), `Err` =
|
||||
/// dead loop thread (reopen me). Dropping tears the sink node down promptly via the Terminate
|
||||
/// channel (a wedged PipeWire link head-blocks the daemon — see the session capturer's docs).
|
||||
pub struct PadSinkCapturer {
|
||||
chunks: Receiver<Vec<f32>>,
|
||||
quit: pipewire::channel::Sender<Terminate>,
|
||||
/// The minted node name, for logs and the devtest.
|
||||
pub node_name: String,
|
||||
}
|
||||
|
||||
impl PadSinkCapturer {
|
||||
/// Mint the sink for wire pad `pad` (`edge` = DualSense Edge identity) and start capturing.
|
||||
/// Fails if PipeWire is unreachable — the caller's reopen-with-backoff owns the retry.
|
||||
pub fn open(pad: u8, edge: bool) -> Result<PadSinkCapturer> {
|
||||
let identity = PadSinkIdentity::new(pad, edge);
|
||||
let node_name = identity.node_name.clone();
|
||||
let (tx, rx) = sync_channel::<Vec<f32>>(64);
|
||||
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
|
||||
// Bring-up handshake (the session capturer's discipline): a PipeWire that isn't running
|
||||
// must surface as an open ERROR, engaging the caller's backoff — not a zombie thread.
|
||||
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
|
||||
thread::Builder::new()
|
||||
.name(format!("punktfunk-pw-pad{pad}"))
|
||||
.spawn(move || {
|
||||
if let Err(e) = pad_sink_thread(tx, quit_rx, identity, ready_tx) {
|
||||
tracing::warn!(pad, error = %format!("{e:#}"), "pipewire pad-sink thread failed");
|
||||
}
|
||||
})
|
||||
.context("spawn pipewire pad-sink thread")?;
|
||||
match ready_rx.recv_timeout(Duration::from_secs(5)) {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => return Err(anyhow!("pipewire pad-sink init timed out")),
|
||||
}
|
||||
Ok(PadSinkCapturer {
|
||||
chunks: rx,
|
||||
quit: quit_tx,
|
||||
node_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PadSinkCapturer {
|
||||
fn drop(&mut self) {
|
||||
// A failed send means the loop thread already exited — nothing to tear down.
|
||||
let _ = self.quit.send(Terminate);
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::audio::AudioCapturer for PadSinkCapturer {
|
||||
fn next_chunk(&mut self) -> Result<Vec<f32>> {
|
||||
match self.chunks.recv_timeout(Duration::from_secs(5)) {
|
||||
Ok(c) => Ok(c),
|
||||
// A quiet pad sink (no game rendering pad audio — the common case) is NOT a
|
||||
// failure; the per-pad streamer keeps us and its silence gate stays closed.
|
||||
Err(RecvTimeoutError::Timeout) => Ok(Vec::new()),
|
||||
Err(RecvTimeoutError::Disconnected) => Err(anyhow!("pipewire pad-sink thread ended")),
|
||||
}
|
||||
}
|
||||
|
||||
fn channels(&self) -> u32 {
|
||||
PAD_CHANNELS
|
||||
}
|
||||
}
|
||||
|
||||
/// SPA channel positions for the pad quad: AUX0..AUX3 (`enum spa_audio_channel`:
|
||||
/// `SPA_AUDIO_CHANNEL_START_Aux` = 0x1000), NOT a positioned FL FR RL RR layout. This is the
|
||||
/// shape a REAL DualSense exposes on the PipeWire path GE-Proton's haptics were built and
|
||||
/// field-validated against: its `open_dualsense_haptic_pcm` targets the node through the
|
||||
/// bundled pipewire-alsa plugin with `aux_channels=1` — "the hidden PipeWire parent for a
|
||||
/// DualSense output exposes AUX0 through AUX3" (proton-ds5-haptic patch 0115) — and its pulse
|
||||
/// fallback forces a `PA_CHANNEL_POSITION_AUX0..3` map. On a real pad that shape is the card's
|
||||
/// Pro Audio profile (the community-reported requirement for GE ≥11-4). Aux positions carry no
|
||||
/// spatial meaning, so nothing in the graph position-remixes into (or out of) the sink —
|
||||
/// writers land by INDEX, exactly the raw quad the pad speaks: ch0/1 = speaker, ch2/3 = voice
|
||||
/// coils (the same order the Windows endpoint is stamped with and `split_quad` assumes).
|
||||
fn pad_positions() -> [u32; 64] {
|
||||
const AUX0: u32 = 0x1000;
|
||||
let mut pos = [0u32; 64];
|
||||
pos[..4].copy_from_slice(&[AUX0, AUX0 + 1, AUX0 + 2, AUX0 + 3]);
|
||||
pos
|
||||
}
|
||||
|
||||
/// The `!Send` MainLoop/Stream thread: mint the sink, hand capture chunks over, run until
|
||||
/// Terminate / daemon death. Mirrors the session capturer's `pw_thread` stream-sink arm minus
|
||||
/// the default-sink claim and the desktop-plane stats (the pad plane's observability lives in
|
||||
/// the streamer's gate/encode logs).
|
||||
fn pad_sink_thread(
|
||||
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
|
||||
quit_rx: pipewire::channel::Receiver<Terminate>,
|
||||
identity: PadSinkIdentity,
|
||||
ready: std::sync::mpsc::SyncSender<Result<()>>,
|
||||
) -> Result<()> {
|
||||
use pipewire as pw;
|
||||
use pw::{properties::properties, spa};
|
||||
use spa::param::audio::{AudioFormat, AudioInfoRaw};
|
||||
use spa::pod::Pod;
|
||||
|
||||
let result = (|| -> Result<()> {
|
||||
pf_capture::pwinit::ensure_init();
|
||||
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw pad-sink MainLoop")?;
|
||||
let context =
|
||||
pw::context::ContextRc::new(&mainloop, None).context("pw pad-sink Context")?;
|
||||
let core = context
|
||||
.connect_rc(None)
|
||||
.context("pw pad-sink connect (is PipeWire running in this session?)")?;
|
||||
|
||||
let _quit_guard = quit_rx.attach(mainloop.loop_(), {
|
||||
let mainloop = mainloop.clone();
|
||||
move |_| mainloop.quit()
|
||||
});
|
||||
|
||||
// Daemon death ends this thread → the chunk channel disconnects → `next_chunk` errors →
|
||||
// the per-pad streamer reopens with backoff (the session capturer's zombie-thread fix).
|
||||
let _core_listener = core
|
||||
.add_listener_local()
|
||||
.error({
|
||||
let mainloop = mainloop.clone();
|
||||
move |id, _seq, res, message| {
|
||||
tracing::warn!(id, res, message, "pipewire core error — pad sink ends");
|
||||
mainloop.quit();
|
||||
}
|
||||
})
|
||||
.register();
|
||||
|
||||
let mut props = properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CLASS => "Audio/Sink",
|
||||
// One Opus-haptics frame (~5 ms) per quantum, like the session sink — haptics are
|
||||
// felt latency; bursty delivery would ride through to the client's jitter buffer.
|
||||
*pw::keys::NODE_LATENCY => "240/48000",
|
||||
// Must NEVER win WirePlumber's default election against real hardware — games reach
|
||||
// this sink BY IDENTITY, nothing auto-routes here (no stream_sink claim either).
|
||||
"priority.session" => "50",
|
||||
// The pulse-proplist leg of GE-Proton's match (§3): bus + vendor/product ids, plus
|
||||
// the human-readable pair pavucontrol and the game view show.
|
||||
"device.bus" => "usb",
|
||||
"device.vendor.id" => "054c",
|
||||
"device.vendor.name" => "Sony Interactive Entertainment",
|
||||
"device.form_factor" => "gamepad",
|
||||
};
|
||||
props.insert(*pw::keys::NODE_NAME, identity.node_name.as_str());
|
||||
props.insert(*pw::keys::NODE_DESCRIPTION, identity.description.as_str());
|
||||
props.insert(*pw::keys::NODE_NICK, identity.description.as_str());
|
||||
props.insert("device.serial", identity.serial.as_str());
|
||||
props.insert("device.product.id", identity.product_id);
|
||||
props.insert("device.product.name", identity.product_name);
|
||||
let stream = pw::stream::StreamBox::new(&core, "punktfunk-pad-audio", props)
|
||||
.context("pw pad-sink Stream")?;
|
||||
|
||||
// Lossy-drop counter: a full channel means the 0xD1 encode thread stalled. Invisible
|
||||
// drops cost a field investigation on the desktop plane once — count and warn here too,
|
||||
// power-of-two throttled (this callback runs at the graph quantum).
|
||||
struct PadUd {
|
||||
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
|
||||
dropped: u64,
|
||||
}
|
||||
let ud = PadUd { tx, dropped: 0 };
|
||||
let _listener = stream
|
||||
.add_local_listener_with_user_data(ud)
|
||||
.state_changed({
|
||||
let mainloop = mainloop.clone();
|
||||
move |_s, _ud, old, new| {
|
||||
tracing::debug!(?old, ?new, "pipewire pad-sink stream state");
|
||||
if matches!(new, pw::stream::StreamState::Error(_)) {
|
||||
mainloop.quit();
|
||||
}
|
||||
}
|
||||
})
|
||||
.param_changed(move |_stream, _ud, id, param| {
|
||||
let Some(param) = param else { return };
|
||||
if id != pw::spa::param::ParamType::Format.as_raw() {
|
||||
return;
|
||||
}
|
||||
let mut info = AudioInfoRaw::default();
|
||||
if info.parse(param).is_ok() {
|
||||
// We own the sink, so this IS the format games render into (nothing can
|
||||
// have narrowed it upstream — the same guarantee as stream-sink mode).
|
||||
tracing::info!(
|
||||
format = ?info.format(),
|
||||
rate = info.rate(),
|
||||
channels = info.channels(),
|
||||
"pad-sink format negotiated"
|
||||
);
|
||||
}
|
||||
})
|
||||
.process(|stream, ud| {
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||
return;
|
||||
};
|
||||
let datas = buffer.datas_mut();
|
||||
if datas.is_empty() {
|
||||
return;
|
||||
}
|
||||
let d = &mut datas[0];
|
||||
let (offset, size) = {
|
||||
let c = d.chunk();
|
||||
(c.offset() as usize, c.size() as usize)
|
||||
};
|
||||
let Some(buf) = d.data() else { return };
|
||||
if offset > buf.len() {
|
||||
return;
|
||||
}
|
||||
let region = &buf[offset..(offset + size).min(buf.len())];
|
||||
// Negotiated as F32LE; reinterpret the byte region as interleaved f32.
|
||||
let n = region.len() / 4;
|
||||
let mut samples = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let b = [
|
||||
region[i * 4],
|
||||
region[i * 4 + 1],
|
||||
region[i * 4 + 2],
|
||||
region[i * 4 + 3],
|
||||
];
|
||||
samples.push(f32::from_le_bytes(b));
|
||||
}
|
||||
if ud.tx.try_send(samples).is_err() {
|
||||
ud.dropped += 1;
|
||||
if ud.dropped.is_power_of_two() {
|
||||
tracing::warn!(
|
||||
dropped = ud.dropped,
|
||||
"pad-audio encode thread not keeping up — captured pad audio \
|
||||
dropped (haptics will click)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}));
|
||||
if outcome.is_err() {
|
||||
tracing::error!("panic in pipewire pad-sink callback — chunk dropped");
|
||||
}
|
||||
})
|
||||
.register()
|
||||
.context("register pad-sink stream listener")?;
|
||||
|
||||
let mut info = AudioInfoRaw::new();
|
||||
info.set_format(AudioFormat::F32LE);
|
||||
info.set_rate(crate::audio::SAMPLE_RATE);
|
||||
info.set_channels(PAD_CHANNELS);
|
||||
info.set_position(pad_positions());
|
||||
let obj = pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
|
||||
properties: info.into(),
|
||||
};
|
||||
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
&pw::spa::pod::Value::Object(obj),
|
||||
)
|
||||
.context("serialize pad-sink format pod")?
|
||||
.0
|
||||
.into_inner();
|
||||
let mut params = [Pod::from_bytes(&values).context("pad-sink pod from bytes")?];
|
||||
|
||||
// RT_PROCESS for the same reason as every host-owned stream node here: the sink must be
|
||||
// a synchronous graph member that joins its producers' driver group, or `process()`
|
||||
// never fires on a busy graph (see the mic's connect comment in mod.rs).
|
||||
stream
|
||||
.connect(
|
||||
spa::utils::Direction::Input, // we CONSUME what games render into the sink
|
||||
None,
|
||||
pw::stream::StreamFlags::AUTOCONNECT
|
||||
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||
| pw::stream::StreamFlags::RT_PROCESS,
|
||||
&mut params,
|
||||
)
|
||||
.context("pw pad-sink stream connect")?;
|
||||
|
||||
let _ = ready.send(Ok(()));
|
||||
mainloop.run();
|
||||
tracing::debug!("pipewire pad-sink loop exited (capturer dropped)");
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = &result {
|
||||
let _ = ready.send(Err(anyhow!("{e:#}")));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pad_mac_is_reversed_display_form_and_per_pad_unique() {
|
||||
// DS_FEATURE_PAIRING bytes 1..7 are 74 E7 D6 3A 53 35 LSB-first → display reverses.
|
||||
assert_eq!(pad_mac(0), "35:53:3A:D6:E7:74");
|
||||
// The pad index offsets the LOW octet — the LAST display octet.
|
||||
assert_eq!(pad_mac(1), "35:53:3A:D6:E7:75");
|
||||
assert_ne!(pad_mac(2), pad_mac(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_carries_every_match_surface() {
|
||||
let id = PadSinkIdentity::new(0, false);
|
||||
// The name-substring matchers (GE-Proton + the community WirePlumber rule).
|
||||
assert!(id.node_name.contains("Sony_Interactive_Entertainment"));
|
||||
assert!(id.node_name.contains("Wireless_Controller"));
|
||||
assert!(id.node_name.contains("DualSense"));
|
||||
assert!(id.node_name.ends_with("-00.analog-surround-40"));
|
||||
// No colons in a udev-style serial/name.
|
||||
assert!(!id.node_name.contains(':'));
|
||||
assert_eq!(id.description, "Wireless Controller");
|
||||
assert_eq!(id.product_id, "0ce6");
|
||||
let edge = PadSinkIdentity::new(1, true);
|
||||
assert!(edge.node_name.contains("DualSense_Edge"));
|
||||
assert_eq!(edge.product_id, "0df2");
|
||||
// Distinct pads mint distinct names (the serial octet).
|
||||
assert_ne!(id.node_name, PadSinkIdentity::new(1, false).node_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_expansion() {
|
||||
assert_eq!(expand("pad{pad}-{mac}", 2, "AABB"), "pad2-AABB");
|
||||
assert_eq!(expand("static", 0, "x"), "static");
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
use super::pad_endpoint as pe;
|
||||
use super::{audio_control, wiring_plan};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -40,6 +40,17 @@ const ENDPOINT_WAIT: Duration = Duration::from_secs(15);
|
||||
/// Minimum spacing between provisioning retries once the startup attempt failed
|
||||
/// ([`ensure_provisioned`] is called from wiring passes, which recur freely).
|
||||
const RETRY_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
/// Full passes that ended unlatched before minting gives up for this host lifetime (a service
|
||||
/// restart re-arms). An unlatched pass that reaches the PnP surface costs the whole BOX, not
|
||||
/// just us: the driver (re)bind raises a device-change broadcast every running app services,
|
||||
/// and games rebuild their audio graph on it — a box that cannot mint must not pay that on
|
||||
/// every retry forever (field-measured 2026-08-12 as Helldivers 2 hitching to 2–5 FPS 1% lows,
|
||||
/// one hitch per mic-pump reopen).
|
||||
const MAX_UNLATCHED_ATTEMPTS: u32 = 5;
|
||||
/// How long [`ensure_blocking`] waits on a pass another thread already runs before giving the
|
||||
/// wiring plan the unlatched answer (a full cold-boot pass worst-cases around two
|
||||
/// [`ENDPOINT_WAIT`]s plus the stamp settles).
|
||||
const BLOCKING_WAIT: Duration = Duration::from_secs(90);
|
||||
|
||||
/// The two minted roles. `value` is the persisted marker; the needles drive
|
||||
/// [`discover_driver`].
|
||||
@@ -107,6 +118,26 @@ static PROVISIONED: OnceLock<Arc<MintedAudio>> = OnceLock::new();
|
||||
static PROVISIONING: AtomicBool = AtomicBool::new(false);
|
||||
/// When the last attempt STARTED — the [`RETRY_COOLDOWN`] anchor.
|
||||
static LAST_ATTEMPT: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
/// Completed passes that did not latch, across the worker and the blocking path — the
|
||||
/// [`MAX_UNLATCHED_ATTEMPTS`] give-up counter.
|
||||
static UNLATCHED_ATTEMPTS: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// Count one finished-but-unlatched pass; the crossing attempt logs the give-up exactly once.
|
||||
fn record_unlatched_attempt() {
|
||||
let n = UNLATCHED_ATTEMPTS.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if n == MAX_UNLATCHED_ATTEMPTS {
|
||||
tracing::warn!(
|
||||
attempts = n,
|
||||
"minted-audio provisioning keeps failing — giving up for this host lifetime so \
|
||||
retries stop broadcasting device changes at the whole box; the wiring plan keeps \
|
||||
the name-based ladder, a service restart re-arms minting"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn gave_up() -> bool {
|
||||
UNLATCHED_ATTEMPTS.load(Ordering::SeqCst) >= MAX_UNLATCHED_ATTEMPTS
|
||||
}
|
||||
|
||||
/// The wiring plan's tier-0 input: the minted ids, or all-empty while nothing is provisioned.
|
||||
///
|
||||
@@ -135,7 +166,7 @@ pub(crate) fn provisioned() -> Option<Arc<MintedAudio>> {
|
||||
/// Spawn the provisioning worker (idempotent; returns immediately). Called at host start next
|
||||
/// to the pad provider, and again from [`ensure_provisioned`] on the retry path.
|
||||
pub(crate) fn provision_at_startup() {
|
||||
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() {
|
||||
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() || gave_up() {
|
||||
return;
|
||||
}
|
||||
if PROVISIONED.get().is_some() || PROVISIONING.swap(true, Ordering::SeqCst) {
|
||||
@@ -155,13 +186,19 @@ pub(crate) fn provision_at_startup() {
|
||||
);
|
||||
let _ = PROVISIONED.set(Arc::new(m));
|
||||
}
|
||||
Ok(_) => tracing::info!(
|
||||
"no minted audio endpoints (Steam's streaming drivers absent?) — the \
|
||||
wiring plan keeps the name-based ladder"
|
||||
),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"),
|
||||
"minted-audio provisioning failed — the wiring plan keeps the name-based \
|
||||
ladder and a later wiring pass retries"),
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
"no minted audio endpoints (Steam's streaming drivers absent?) — the \
|
||||
wiring plan keeps the name-based ladder"
|
||||
);
|
||||
record_unlatched_attempt();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"minted-audio provisioning failed — the wiring plan keeps the name-based \
|
||||
ladder and a later wiring pass retries");
|
||||
record_unlatched_attempt();
|
||||
}
|
||||
}
|
||||
PROVISIONING.store(false, Ordering::SeqCst);
|
||||
});
|
||||
@@ -175,7 +212,7 @@ pub(crate) fn provision_at_startup() {
|
||||
/// [`RETRY_COOLDOWN`] — a box where Steam arrives later mints on a later pass instead of at
|
||||
/// the next reboot.
|
||||
pub(crate) fn ensure_provisioned() {
|
||||
if PROVISIONED.get().is_some() {
|
||||
if PROVISIONED.get().is_some() || gave_up() {
|
||||
return;
|
||||
}
|
||||
{
|
||||
@@ -219,6 +256,19 @@ fn ensure_all() -> Result<MintedAudio> {
|
||||
/// back any default device the fresh endpoint grabbed (measured on the pad program: a newly
|
||||
/// registered endpoint can take either default).
|
||||
fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
|
||||
// Steady state: a previous run's devnode with all endpoints live — resolve by marker and
|
||||
// return without touching PnP or the default-device policy. The full pass below (re)binds
|
||||
// the driver even over an existing devnode, and that bind raises a device-change broadcast
|
||||
// every running app services — right at first mint, ruinous from a retry path (each
|
||||
// broadcast makes games rebuild their audio graph; see [`MAX_UNLATCHED_ATTEMPTS`]).
|
||||
if let Some((devnode, render, capture)) = find_healthy_role(role)? {
|
||||
stamp_identity(&render, role, false);
|
||||
if let Some(cap) = capture.as_ref() {
|
||||
stamp_identity(cap, role, true);
|
||||
}
|
||||
return Ok((devnode, render, capture));
|
||||
}
|
||||
|
||||
let prev_render = audio_control::default_render_id();
|
||||
let prev_capture = audio_control::default_capture_id();
|
||||
|
||||
@@ -284,6 +334,27 @@ fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
|
||||
Ok((devnode, render, capture))
|
||||
}
|
||||
|
||||
/// The role's marker devnode with EVERY endpoint the role owes already registered, or `None`
|
||||
/// (missing devnode, missing endpoint, or an enumeration error → the caller runs the full
|
||||
/// pass). Same endpoint resolvers [`wait_for`] polls, so "healthy" here is exactly the state
|
||||
/// the full pass would declare ready.
|
||||
fn find_healthy_role(role: Role) -> Result<Option<(String, String, Option<String>)>> {
|
||||
let Some(devnode) = find_role_devnode(role)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(render) = pe::find_endpoint_for_devnode(&devnode)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let capture = match role {
|
||||
Role::Mic => match pe::find_capture_endpoint_for_devnode(&devnode)? {
|
||||
Some(cap) => Some(cap),
|
||||
None => return Ok(None),
|
||||
},
|
||||
Role::Speakers => None,
|
||||
};
|
||||
Ok(Some((devnode, render, capture)))
|
||||
}
|
||||
|
||||
/// How many stamp/settle passes a name gets before we accept "stored but not yet served"
|
||||
/// (a settled endpoint takes the stamp on the first pass; a freshly minted one may need the
|
||||
/// audio stack to notice — it serves after the next Audiosrv restart/reboot at the latest).
|
||||
@@ -510,7 +581,6 @@ pub(crate) fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, S
|
||||
)
|
||||
}
|
||||
|
||||
/// `audio-probe mint` devtest body: one synchronous provisioning pass, results printed.
|
||||
/// Synchronous provisioning — for the mic pump's resolve and the devtests.
|
||||
///
|
||||
/// The pump's FIRST open must not race the startup worker: measured on the target box, the
|
||||
@@ -520,15 +590,50 @@ pub(crate) fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, S
|
||||
/// (existing marker devnodes re-resolve in milliseconds; a cold boot pays the one-time mint)
|
||||
/// keeps the pump's target and the plan's verdict the same thing. Latched calls return
|
||||
/// immediately; the opt-out env is honoured like everywhere else.
|
||||
///
|
||||
/// While UNLATCHED this is where the pump's reopen backoff (capped at 60 s) used to meet an
|
||||
/// unguarded full pass: one PnP rebind + device-change broadcast roughly every minute, forever,
|
||||
/// on any box where minting cannot converge (the 2026-08-12 Helldivers 2 field report). Now a
|
||||
/// pass someone else already runs is WAITED for instead of raced, a failed pass repeats at most
|
||||
/// every [`RETRY_COOLDOWN`], and [`MAX_UNLATCHED_ATTEMPTS`] failures stop retrying for the
|
||||
/// host lifetime.
|
||||
pub(crate) fn ensure_blocking() {
|
||||
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() || PROVISIONED.get().is_some() {
|
||||
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some()
|
||||
|| PROVISIONED.get().is_some()
|
||||
|| gave_up()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok(m) = ensure_all() {
|
||||
if m.any() {
|
||||
let _ = PROVISIONED.set(Arc::new(m));
|
||||
// A pass is in flight (the startup worker, or a concurrent resolve): wait for its verdict
|
||||
// rather than racing a second SetupAPI/PnP sweep against it — that race is how the pump
|
||||
// once ended up wired to the cable while the worker minted (the dead-mic-air deploy race).
|
||||
if PROVISIONING.swap(true, Ordering::SeqCst) {
|
||||
let deadline = Instant::now() + BLOCKING_WAIT;
|
||||
while PROVISIONING.load(Ordering::SeqCst) && Instant::now() < deadline {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// We own the slot. First-ever resolve runs unconditionally (the cold-boot mint the doc
|
||||
// above insists on); after a failed pass the cooldown answers instead of a re-run.
|
||||
let run = {
|
||||
let mut last = LAST_ATTEMPT.lock().unwrap();
|
||||
if last.is_some_and(|t| t.elapsed() < RETRY_COOLDOWN) {
|
||||
false
|
||||
} else {
|
||||
*last = Some(Instant::now());
|
||||
true
|
||||
}
|
||||
};
|
||||
if run {
|
||||
match ensure_all() {
|
||||
Ok(m) if m.any() => {
|
||||
let _ = PROVISIONED.set(Arc::new(m));
|
||||
}
|
||||
_ => record_unlatched_attempt(),
|
||||
}
|
||||
}
|
||||
PROVISIONING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub(crate) fn devtest_mint() -> Result<()> {
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.)
|
||||
|
||||
Reference in New Issue
Block a user