Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72189b29ec | ||
|
|
339a1d70f9 | ||
|
|
79dba7f95a | ||
|
|
d7430fe2bd | ||
|
|
6f81ec24ba | ||
|
|
539236de91 | ||
|
|
118758ff0b | ||
|
|
dcde856178 | ||
|
|
77918674c3 | ||
|
|
faefbae830 | ||
|
|
44fa12a298 | ||
|
|
55a3d8b919 | ||
|
|
a02014ec19 | ||
|
|
5f55b820bc |
@@ -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,7 +13,13 @@
|
||||
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
|
||||
//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end,
|
||||
//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed.
|
||||
#![forbid(unsafe_code)]
|
||||
// `deny`, not `forbid`: edition 2024 makes the std process-environment mutators unsafe
|
||||
// (WP20 — the env-mutation class made visible; named-API mentions here would count against
|
||||
// the unsafe-hygiene gate C baseline, which tracks this file's real call sites), and this
|
||||
// bin's three single-threaded-startup env writes carry documented SAFETY comments under
|
||||
// localized `#[allow(unsafe_code)]` (the pf-update idiom). A `forbid` cannot be overridden
|
||||
// at those sites and refuses the file.
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||
mod console;
|
||||
@@ -533,6 +539,7 @@ mod session_main {
|
||||
/// initialises, so a call placed after them leaves the triage tool describing a device
|
||||
/// that cannot decode while the streaming path decodes on it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(unsafe_code)] // the two SAFETY-commented single-threaded-startup env writes below
|
||||
fn enable_radv_video_decode() {
|
||||
const TOKEN: &str = "video_decode";
|
||||
match std::env::var("RADV_PERFTEST") {
|
||||
@@ -840,7 +847,10 @@ mod session_main {
|
||||
// SAFETY: still the single-threaded startup stretch of `run()` — the
|
||||
// early-exit probes above return out of the process, and everything that
|
||||
// spawns threads (the session, the console, SDL) only starts below.
|
||||
unsafe { std::env::set_var(var, value) };
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
std::env::set_var(var, value)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -856,7 +866,10 @@ mod session_main {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
// SAFETY: as the settings block above — single-threaded startup, before SDL
|
||||
// (the reader of these variables) or any other thread exists.
|
||||
unsafe { std::env::remove_var(var) };
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
std::env::remove_var(var)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,17 @@ pub(crate) fn stamp_window_icon(window: &sdl3::video::Window) {
|
||||
let module = GetModuleHandleW(std::ptr::null());
|
||||
for (which, metric) in [(ICON_SMALL, SM_CXSMICON), (ICON_BIG, SM_CXICON)] {
|
||||
let px = GetSystemMetrics(metric);
|
||||
let icon = LoadImageW(module, 1 as *const u16, IMAGE_ICON, px, px, LR_DEFAULTCOLOR);
|
||||
// MAKEINTRESOURCE(1): an integer resource ordinal smuggled through the name
|
||||
// pointer, never dereferenced — `without_provenance` says exactly that (and
|
||||
// `1 as *const u16` reads as a dangling pointer to clippy 1.96).
|
||||
let icon = LoadImageW(
|
||||
module,
|
||||
std::ptr::without_provenance(1),
|
||||
IMAGE_ICON,
|
||||
px,
|
||||
px,
|
||||
LR_DEFAULTCOLOR,
|
||||
);
|
||||
if !icon.is_null() {
|
||||
SendMessageW(hwnd, WM_SETICON, which as WPARAM, icon as LPARAM);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ use pf_bitstream::h264::PlanError;
|
||||
use pf_bitstream::h264::PlanWarning;
|
||||
use tracing::debug;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::caps::derive_caps;
|
||||
use crate::caps::query_h264_caps;
|
||||
@@ -685,6 +686,9 @@ pub struct VkH264Decoder {
|
||||
/// Session generation: bumped on every rebuild, stamped into frames.
|
||||
generation: u64,
|
||||
device_lost: bool,
|
||||
/// The over-declared-level warning has fired (once per decoder — the condition
|
||||
/// is a property of the stream's SPS, so repeating it per AU is noise).
|
||||
level_clamp_warned: bool,
|
||||
}
|
||||
|
||||
impl VkH264Decoder {
|
||||
@@ -723,6 +727,7 @@ impl VkH264Decoder {
|
||||
decoded: 0,
|
||||
generation: 0,
|
||||
device_lost: false,
|
||||
level_clamp_warned: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1365,18 +1370,26 @@ impl VkH264Decoder {
|
||||
unsafe { query_h264_caps(&self.dev, std_profile) }.map_err(VkDecodeError::from)?;
|
||||
self.caps = Some((std_profile, derive_caps(&raw)?));
|
||||
}
|
||||
// The level gate: a stream above the device's maxLevelIdc is refused up
|
||||
// front (within one codec the Std code points ascend with the level, so
|
||||
// the comparison is numeric), never submitted on a hope. The ceiling came
|
||||
// from an H.264 caps query, so it is compared against an H.264 code point
|
||||
// — the pairing MaxLevelIdc's tag exists to keep honest.
|
||||
// The declared level vs the device ceiling: a DECLARED level above
|
||||
// `maxLevelIdc` is NOT a refusal — encoders over-claim levels in the wild
|
||||
// (the H.265 twin carries the field evidence: AMF stamps the codec
|
||||
// maximum). The stream's REAL demands are enforced where they are
|
||||
// physical facts — coded extent and DPB depth, checked in
|
||||
// `rebuild_state` — and the session's parameter sets are clamped to the
|
||||
// ceiling (`SessionConfig::max_level_idc`) so the driver is never handed
|
||||
// a level above its caps. The comparison stays within one codec's Std
|
||||
// code space (`MaxLevelIdc`'s tag carries that argument).
|
||||
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
|
||||
let stream_level = level_to_std(plan.picture.level_idc);
|
||||
if stream_level > caps_max_level.code_point() {
|
||||
return Err(VkDecodeError::Unsupported(format!(
|
||||
"stream level (Std code point {stream_level}) above the device's \
|
||||
maxLevelIdc ({caps_max_level})"
|
||||
)));
|
||||
if stream_level > caps_max_level.code_point() && !self.level_clamp_warned {
|
||||
self.level_clamp_warned = true;
|
||||
warn!(
|
||||
stream_level,
|
||||
ceiling = %caps_max_level,
|
||||
"stream declares an H.264 level above the device ceiling — the \
|
||||
declared level is advisory (over-declared by some encoders); \
|
||||
proceeding with the parameter sets clamped to the ceiling"
|
||||
);
|
||||
}
|
||||
let coded = vk::Extent2D {
|
||||
width: plan.picture.coded_width,
|
||||
@@ -1488,6 +1501,7 @@ impl VkH264Decoder {
|
||||
max_dpb_slots: required_slots,
|
||||
max_active_references: (required_slots - 1).min(caps.max_active_references),
|
||||
std_profile_idc: std_profile,
|
||||
max_level_idc: caps.max_level_idc.code_point(),
|
||||
};
|
||||
let mut pool_plan = plan_pools(caps, required_slots);
|
||||
// TEST-ONLY readback hook: the GPU parity test (tests/gpu_parity.rs)
|
||||
|
||||
@@ -57,6 +57,7 @@ use pf_bitstream::h265::PlanError;
|
||||
use pf_bitstream::h265::PlanWarning;
|
||||
use tracing::debug;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::caps::DecodeCaps;
|
||||
use crate::caps::DecodeProfile;
|
||||
@@ -219,6 +220,9 @@ pub struct VkH265Decoder {
|
||||
/// Recovery owed after a failed AU whose planning had already advanced
|
||||
/// ([`RecoveryLatch`] docs for the whole argument).
|
||||
recovery: RecoveryLatch,
|
||||
/// The over-declared-level warning has fired (once per decoder — the condition
|
||||
/// is a property of the stream's SPS, so repeating it per AU is noise).
|
||||
level_clamp_warned: bool,
|
||||
}
|
||||
|
||||
impl VkH265Decoder {
|
||||
@@ -266,6 +270,7 @@ impl VkH265Decoder {
|
||||
generation: 0,
|
||||
device_lost: false,
|
||||
recovery: RecoveryLatch::default(),
|
||||
level_clamp_warned: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1004,18 +1009,28 @@ impl VkH265Decoder {
|
||||
let raw = unsafe { query_h265_caps(&self.dev, key) }.map_err(VkDecodeError::from)?;
|
||||
self.caps = Some((key, derive_caps_h265(&raw, wanted)?));
|
||||
}
|
||||
// The level gate: a stream above the device's maxLevelIdc is refused up
|
||||
// front (within one codec the Std code points ascend with the level, so
|
||||
// the comparison is numeric), never submitted on a hope. The ceiling came
|
||||
// from an H.265 caps query, so it is compared against an H.265 code point
|
||||
// — the pairing MaxLevelIdc's tag exists to keep honest.
|
||||
// The declared level vs the device ceiling: a DECLARED level above
|
||||
// `maxLevelIdc` is NOT a refusal. The level in an SPS is a claim, and
|
||||
// encoders over-claim in the wild — AMF stamps 6.2 (the codec maximum)
|
||||
// on 4K120 streams that need 5.2, which on an RTX 5060 (ceiling 6.1)
|
||||
// demoted every HEVC session to D3D11VA (2026-08-12 field report). The
|
||||
// stream's REAL demands are enforced where they are physical facts:
|
||||
// coded extent and DPB depth, checked in `rebuild_state`. The session's
|
||||
// parameter sets are clamped to the ceiling (`SessionConfigH265::
|
||||
// max_level_idc`) so the driver is never handed a level above its caps,
|
||||
// and the comparison stays within one codec's Std code space
|
||||
// (`MaxLevelIdc`'s tag carries that argument).
|
||||
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
|
||||
let stream_level = level_to_std_h265(plan.picture.level_idc);
|
||||
if stream_level > caps_max_level.code_point() {
|
||||
return Err(VkDecodeError::Unsupported(format!(
|
||||
"stream level (Std code point {stream_level}) above the device's \
|
||||
maxLevelIdc ({caps_max_level})"
|
||||
)));
|
||||
if stream_level > caps_max_level.code_point() && !self.level_clamp_warned {
|
||||
self.level_clamp_warned = true;
|
||||
warn!(
|
||||
stream_level,
|
||||
ceiling = %caps_max_level,
|
||||
"stream declares an H.265 level above the device ceiling — the \
|
||||
declared level is advisory (over-declared by some encoders); \
|
||||
proceeding with the parameter sets clamped to the ceiling"
|
||||
);
|
||||
}
|
||||
let coded = vk::Extent2D {
|
||||
width: plan.picture.coded_width,
|
||||
@@ -1108,6 +1123,7 @@ impl VkH265Decoder {
|
||||
max_dpb_slots: required_slots,
|
||||
max_active_references: (required_slots - 1).min(caps.max_active_references),
|
||||
profile: key,
|
||||
max_level_idc: caps.max_level_idc.code_point(),
|
||||
};
|
||||
let mut pool_plan = plan_pools(caps, required_slots);
|
||||
// TEST-ONLY readback hook, exactly as the H.264 decoder's: the parity
|
||||
|
||||
@@ -115,6 +115,17 @@ impl OwnedStdSps {
|
||||
pub fn std(&self) -> &hh::StdVideoH264SequenceParameterSet {
|
||||
&self.std
|
||||
}
|
||||
|
||||
/// Lower `level_idc` to `max` when the stream declares a higher one. The
|
||||
/// declared level is a claim encoders over-state in the wild, and a set above
|
||||
/// the device's `maxLevelIdc` is invalid usage; the stream's real demands are
|
||||
/// enforced by the session's coded extent and DPB depth. The "no mutation"
|
||||
/// contract above is about a LIVE object's blocks — this runs before handover.
|
||||
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH264LevelIdc) {
|
||||
if self.std.level_idc > max {
|
||||
self.std.level_idc = max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The converted PPS plus the scaling-list allocation its `pScalingLists` targets.
|
||||
@@ -831,4 +842,24 @@ mod tests {
|
||||
ParamsError::InvalidWeightedBipredIdc(3)
|
||||
);
|
||||
}
|
||||
|
||||
/// The over-declared-level clamp ([`OwnedStdSps::clamp_level`]): lowering
|
||||
/// writes the ceiling into the Std SPS; a ceiling at or above the declared
|
||||
/// level changes nothing.
|
||||
#[test]
|
||||
fn clamp_level_lowers_and_only_lowers() {
|
||||
let sps = full_sps();
|
||||
let declared = level_to_std(sps.level_idc);
|
||||
|
||||
let mut owned = sps_to_std(&sps).unwrap();
|
||||
assert_eq!(owned.std().level_idc, declared);
|
||||
// A ceiling above the declared level is a no-op.
|
||||
owned.clamp_level(hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_6_2);
|
||||
assert_eq!(owned.std().level_idc, declared);
|
||||
// A ceiling below it is written through.
|
||||
let ceiling = hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_3_1;
|
||||
assert!(ceiling < declared, "fixture declares above 3.1");
|
||||
owned.clamp_level(ceiling);
|
||||
assert_eq!(owned.std().level_idc, ceiling);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +202,19 @@ impl OwnedStdH265Vps {
|
||||
pub fn std(&self) -> &hh::StdVideoH265VideoParameterSet {
|
||||
&self.std
|
||||
}
|
||||
|
||||
/// Lower the profile/tier/level block's `general_level_idc` to `max` when the
|
||||
/// stream declares a higher one. The declared level is a CLAIM, and encoders
|
||||
/// over-claim in the wild (AMF stamps 6.2 — the codec maximum — on streams that
|
||||
/// need 5.2); handing the driver a level above its `maxLevelIdc` is invalid
|
||||
/// usage, while the stream's real demands are enforced by the session's coded
|
||||
/// extent and DPB depth. The "no mutation" ownership contract is about blocks a
|
||||
/// LIVE parameters object points at; this runs before the set is handed over.
|
||||
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH265LevelIdc) {
|
||||
if self._ptl_backing.general_level_idc > max {
|
||||
self._ptl_backing.general_level_idc = max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The converted SPS plus the heap allocations its embedded pointers target.
|
||||
@@ -229,6 +242,14 @@ impl OwnedStdH265Sps {
|
||||
pub fn std(&self) -> &hh::StdVideoH265SequenceParameterSet {
|
||||
&self.std
|
||||
}
|
||||
|
||||
/// Lower `general_level_idc` to the device ceiling — [`OwnedStdH265Vps::clamp_level`]
|
||||
/// carries the argument.
|
||||
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH265LevelIdc) {
|
||||
if self._ptl_backing.general_level_idc > max {
|
||||
self._ptl_backing.general_level_idc = max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The converted PPS plus the scaling-list allocation its `pScalingLists`
|
||||
@@ -2000,4 +2021,37 @@ mod tests {
|
||||
"the vector opens with VPS + SPS + PPS"
|
||||
);
|
||||
}
|
||||
|
||||
/// The over-declared-level clamp (the AMF 6.2-on-everything field case):
|
||||
/// lowering writes the ceiling into the PTL backing the driver will read;
|
||||
/// a ceiling at or above the declared level changes nothing.
|
||||
#[test]
|
||||
fn clamp_level_lowers_the_ptl_and_only_lowers() {
|
||||
let sps = full_sps();
|
||||
let declared = level_to_std(sps.profile_tier_level.general_level_idc);
|
||||
|
||||
let mut owned = sps_to_std_h265(&sps).unwrap();
|
||||
// SAFETY: pProfileTierLevel targets `owned`'s boxed backing.
|
||||
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
|
||||
assert_eq!(level, declared);
|
||||
// A ceiling above the declared level is a no-op.
|
||||
owned.clamp_level(hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_2);
|
||||
// SAFETY: as above.
|
||||
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
|
||||
assert_eq!(level, declared);
|
||||
// A ceiling below it is written through — and the pointer still targets
|
||||
// the wrapper's own backing (the clamp mutates in place, never re-points).
|
||||
let ceiling = hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_3_1;
|
||||
assert!(ceiling < declared, "fixture declares above 3.1");
|
||||
owned.clamp_level(ceiling);
|
||||
// SAFETY: as above.
|
||||
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
|
||||
assert_eq!(level, ceiling);
|
||||
|
||||
let mut owned_vps = fallback_vps_from_sps(&sps).unwrap();
|
||||
owned_vps.clamp_level(ceiling);
|
||||
// SAFETY: as above, the VPS wrapper's own backing.
|
||||
let vps_level = unsafe { (*owned_vps.std().pProfileTierLevel).general_level_idc };
|
||||
assert!(vps_level <= ceiling);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,10 @@ pub struct SessionConfig {
|
||||
/// The Std profile the session was created against (a profile change is a
|
||||
/// renegotiation too).
|
||||
pub std_profile_idc: hh::StdVideoH264ProfileIdc,
|
||||
/// The device's `maxLevelIdc` for this profile (Std code point). Every SPS
|
||||
/// handed to the parameters object has its declared level clamped to this —
|
||||
/// see `SessionConfigH265::max_level_idc` for the whole argument.
|
||||
pub max_level_idc: hh::StdVideoH264LevelIdc,
|
||||
}
|
||||
|
||||
/// Session creation/parameter failures the decoder maps into its error type.
|
||||
@@ -593,11 +597,14 @@ impl VideoSession {
|
||||
match action {
|
||||
ParamsAction::Current => Ok(()),
|
||||
ParamsAction::Add { add_sps, add_pps } => {
|
||||
let owned_sps = if add_sps {
|
||||
let mut owned_sps = if add_sps {
|
||||
Some(sps_to_std(sps)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(s) = owned_sps.as_mut() {
|
||||
s.clamp_level(self.config.max_level_idc);
|
||||
}
|
||||
let owned_pps = if add_pps {
|
||||
Some(pps_to_std(pps)?)
|
||||
} else {
|
||||
@@ -643,7 +650,8 @@ impl VideoSession {
|
||||
pps_id = pps.pic_parameter_set_id,
|
||||
"recreating session parameters (content change or capacity)"
|
||||
);
|
||||
let owned_sps = sps_to_std(sps)?;
|
||||
let mut owned_sps = sps_to_std(sps)?;
|
||||
owned_sps.clamp_level(self.config.max_level_idc);
|
||||
let owned_pps = pps_to_std(pps)?;
|
||||
// SAFETY: fn contract — live device + live session. The wrappers
|
||||
// are MOVED IN and come back owned by the fresh object, so they
|
||||
|
||||
@@ -260,6 +260,12 @@ pub struct SessionConfigH265 {
|
||||
/// format / bit depths, all four of which a stream can renegotiate (an SPS
|
||||
/// switching Main→Main 10 mid-stream is a session rebuild, not an update).
|
||||
pub profile: H265ProfileKey,
|
||||
/// The device's `maxLevelIdc` for this profile (Std code point). Every VPS/SPS
|
||||
/// handed to the parameters object has its declared level clamped to this —
|
||||
/// over-declared levels are common (AMF stamps 6.2 on 4K streams) and a set
|
||||
/// above the ceiling is invalid usage, while the stream's real demands are
|
||||
/// already enforced by `max_coded_extent` / `max_dpb_slots`.
|
||||
pub max_level_idc: hh::StdVideoH265LevelIdc,
|
||||
}
|
||||
|
||||
/// A live parameters object **and every Std parameter set it was given**, in one
|
||||
@@ -525,12 +531,18 @@ impl VideoSessionH265 {
|
||||
} => {
|
||||
// Every owned wrapper below stays alive until after the update
|
||||
// call: the Std structs embed pointers into their heap blocks.
|
||||
let owned_vps = if add_vps { Some(vps.to_std()?) } else { None };
|
||||
let owned_sps = if add_sps {
|
||||
let mut owned_vps = if add_vps { Some(vps.to_std()?) } else { None };
|
||||
let mut owned_sps = if add_sps {
|
||||
Some(sps_to_std_h265(sps)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(v) = owned_vps.as_mut() {
|
||||
v.clamp_level(self.config.max_level_idc);
|
||||
}
|
||||
if let Some(s) = owned_sps.as_mut() {
|
||||
s.clamp_level(self.config.max_level_idc);
|
||||
}
|
||||
let owned_pps = if add_pps {
|
||||
Some(pps_to_std_h265(pps)?)
|
||||
} else {
|
||||
@@ -582,8 +594,10 @@ impl VideoSessionH265 {
|
||||
pps_id = pps.pic_parameter_set_id,
|
||||
"recreating H.265 session parameters (content change or capacity)"
|
||||
);
|
||||
let owned_vps = vps.to_std()?;
|
||||
let owned_sps = sps_to_std_h265(sps)?;
|
||||
let mut owned_vps = vps.to_std()?;
|
||||
let mut owned_sps = sps_to_std_h265(sps)?;
|
||||
owned_vps.clamp_level(self.config.max_level_idc);
|
||||
owned_sps.clamp_level(self.config.max_level_idc);
|
||||
let owned_pps = pps_to_std_h265(pps)?;
|
||||
// SAFETY: fn contract — live device + live session. The wrappers
|
||||
// are MOVED IN and come back owned by the fresh object, so they
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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