Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
124cb66324 | ||
|
|
6774c4e7a2 | ||
|
|
f06b84be63 | ||
|
|
d6dbb391d6 | ||
|
|
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
|
||||
|
||||
@@ -7,14 +7,22 @@
|
||||
//!
|
||||
//! Reliability (this is the whole point — a sleeping host has no ARP entry, so a plain unicast
|
||||
//! can't wake it, and `255.255.255.255` alone leaves only via the default route). For each
|
||||
//! known host MAC we send the 102-byte packet to:
|
||||
//! * every non-loopback IPv4 interface's **subnet-directed broadcast** (routes to that NIC's
|
||||
//! segment — this is what covers multi-homed clients on VPN/docker/multiple LANs), and
|
||||
//! * the **limited broadcast** `255.255.255.255`, and
|
||||
//! * optionally a **unicast** to the host's last-known IP (covers the brief window where the
|
||||
//! host is reachable but hasn't re-advertised, and NICs that wake on a directed unicast),
|
||||
//! known host MAC we send the 102-byte packet:
|
||||
//! * **out of every non-loopback IPv4 interface**, from a socket bound to that interface's own
|
||||
//! address, to both that NIC's **subnet-directed broadcast** and the **limited broadcast**
|
||||
//! `255.255.255.255` — binding the source is what forces the datagram onto that segment
|
||||
//! instead of whatever the default route happens to be (a VPN/mesh interface, typically), and
|
||||
//! * from an unbound socket to `255.255.255.255` and, when known, a **unicast** to the host's
|
||||
//! last-known IP (covers the brief window where the host is reachable but hasn't
|
||||
//! re-advertised, and NICs that wake on a directed unicast),
|
||||
//!
|
||||
//! on the two conventional WoL ports (9 and 7), repeated a few times to survive UDP loss.
|
||||
//!
|
||||
//! **Wi-Fi hosts (WoWLAN) ride the same path**, and the per-interface egress above is what makes
|
||||
//! them work: a station in WoWLAN sleep stays associated, and the AP buffers broadcast frames for
|
||||
//! its sleeping stations and flushes them on the next DTIM beacon — so the broadcast does reach
|
||||
//! the sleeping NIC, but only if the datagram actually leaves via the wireless interface. The
|
||||
//! host end of it (arming the NIC's magic-packet trigger) is `punktfunk-host`'s `wol` module.
|
||||
|
||||
use std::io;
|
||||
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
|
||||
@@ -64,41 +72,63 @@ pub fn build_magic_packet(mac: Mac) -> [u8; 102] {
|
||||
/// directed broadcast with no route) doesn't fail the whole wake. Errors only if no socket
|
||||
/// could be opened or nothing could be sent at all.
|
||||
pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option<Ipv4Addr>) -> io::Result<()> {
|
||||
send_magic_packet_on(macs, last_known_ip, &WOL_PORTS)
|
||||
}
|
||||
|
||||
/// [`send_magic_packet`] with the destination ports spelled out. Private because the ports are
|
||||
/// not a caller's business — it exists so the tests can aim a real send at a port they're allowed
|
||||
/// to bind (9 and 7 are privileged) and assert the bytes that come off the wire.
|
||||
fn send_magic_packet_on(
|
||||
macs: &[Mac],
|
||||
last_known_ip: Option<Ipv4Addr>,
|
||||
ports: &[u16],
|
||||
) -> io::Result<()> {
|
||||
if macs.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"no MAC addresses",
|
||||
));
|
||||
}
|
||||
let packets: Vec<[u8; 102]> = macs.iter().map(|m| build_magic_packet(*m)).collect();
|
||||
|
||||
// Build the target IP set: each interface's directed broadcast, the limited broadcast, and
|
||||
// the optional last-known unicast. Dedup so a single-NIC client doesn't send twice.
|
||||
let mut targets = broadcast_addrs();
|
||||
targets.push(Ipv4Addr::BROADCAST); // 255.255.255.255
|
||||
// Targets that go out the default route (or wherever the routing table sends them): the
|
||||
// limited broadcast as a baseline, plus the optional unicast — destination routing picks the
|
||||
// right NIC for a unicast, so it doesn't need per-interface treatment.
|
||||
let mut routed: Vec<Ipv4Addr> = vec![Ipv4Addr::BROADCAST];
|
||||
if let Some(ip) = last_known_ip {
|
||||
targets.push(ip);
|
||||
routed.push(ip);
|
||||
}
|
||||
targets.sort_unstable();
|
||||
targets.dedup();
|
||||
|
||||
// One broadcast-enabled socket bound to all interfaces. Directed broadcasts route to the
|
||||
// matching NIC via the routing table; the limited broadcast leaves via the default route.
|
||||
let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
|
||||
sock.set_broadcast(true)?;
|
||||
|
||||
let mut sent_any = false;
|
||||
for _ in 0..BURST {
|
||||
for mac in macs {
|
||||
let pkt = build_magic_packet(*mac);
|
||||
for ip in &targets {
|
||||
for port in WOL_PORTS {
|
||||
let dst = SocketAddr::V4(SocketAddrV4::new(*ip, port));
|
||||
if sock.send_to(&pkt, dst).is_ok() {
|
||||
sent_any = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-interface pass. One socket per non-loopback IPv4 address, bound to that address so the
|
||||
// datagram leaves on THAT segment: without this, `255.255.255.255` follows the default route
|
||||
// only (a VPN/mesh NIC on most of these machines) and never touches the LAN — or the Wi-Fi
|
||||
// segment the sleeping WoWLAN station is associated to.
|
||||
for (local, bcast) in local_v4_segments() {
|
||||
let Ok(sock) = UdpSocket::bind(SocketAddrV4::new(local, 0)) else {
|
||||
// Bind failed (address just went away, or the OS refuses it) — fall back to the
|
||||
// routed socket below, which still reaches this segment's directed broadcast.
|
||||
routed.push(bcast);
|
||||
continue;
|
||||
};
|
||||
if sock.set_broadcast(true).is_err() {
|
||||
routed.push(bcast);
|
||||
continue;
|
||||
}
|
||||
sent_any |= blast(&sock, &packets, &[bcast, Ipv4Addr::BROADCAST], ports);
|
||||
}
|
||||
|
||||
// Routed pass, and the only pass on a machine whose interfaces can't be enumerated.
|
||||
if let Ok(sock) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) {
|
||||
// A refused SO_BROADCAST doesn't abort the pass: the unicast target still goes out, and
|
||||
// the per-interface sockets above may already have carried the broadcast.
|
||||
let _ = sock.set_broadcast(true);
|
||||
routed.sort_unstable();
|
||||
routed.dedup();
|
||||
sent_any |= blast(&sock, &packets, &routed, ports);
|
||||
} else if !sent_any {
|
||||
return Err(io::Error::other("no socket could be opened for the wake"));
|
||||
}
|
||||
|
||||
if sent_any {
|
||||
@@ -108,10 +138,33 @@ pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option<Ipv4Addr>) -> io::R
|
||||
}
|
||||
}
|
||||
|
||||
/// Subnet-directed broadcast address of every non-loopback IPv4 interface (`ip | !netmask`,
|
||||
/// or the OS-provided broadcast when present). Best-effort: interface enumeration failing
|
||||
/// (permissions, exotic platform) yields an empty list, and the limited broadcast still fires.
|
||||
fn broadcast_addrs() -> Vec<Ipv4Addr> {
|
||||
/// Send every packet to every target, on every port, [`BURST`] times. Returns whether any
|
||||
/// single datagram made it out — an unroutable target is expected and never fails the wake.
|
||||
fn blast(sock: &UdpSocket, packets: &[[u8; 102]], targets: &[Ipv4Addr], ports: &[u16]) -> bool {
|
||||
let mut sent_any = false;
|
||||
for _ in 0..BURST {
|
||||
for pkt in packets {
|
||||
for ip in targets {
|
||||
// A degenerate 0.0.0.0 (unconfigured NIC) is not a destination.
|
||||
if ip.is_unspecified() {
|
||||
continue;
|
||||
}
|
||||
for port in ports {
|
||||
let dst = SocketAddr::V4(SocketAddrV4::new(*ip, *port));
|
||||
if sock.send_to(pkt, dst).is_ok() {
|
||||
sent_any = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sent_any
|
||||
}
|
||||
|
||||
/// Every non-loopback IPv4 interface as `(its own address, its subnet-directed broadcast)`. The
|
||||
/// broadcast is the OS-provided one where present, else `ip | !netmask`. Best-effort: enumeration
|
||||
/// failing (permissions, exotic platform) yields an empty list and the routed pass still fires.
|
||||
fn local_v4_segments() -> Vec<(Ipv4Addr, Ipv4Addr)> {
|
||||
let mut out = Vec::new();
|
||||
let ifaces = match if_addrs::get_if_addrs() {
|
||||
Ok(i) => i,
|
||||
@@ -122,14 +175,13 @@ fn broadcast_addrs() -> Vec<Ipv4Addr> {
|
||||
continue;
|
||||
}
|
||||
if let if_addrs::IfAddr::V4(v4) = iface.addr {
|
||||
if v4.ip.is_unspecified() {
|
||||
continue; // nothing to bind to
|
||||
}
|
||||
let bcast = v4
|
||||
.broadcast
|
||||
.unwrap_or_else(|| Ipv4Addr::from(u32::from(v4.ip) | !u32::from(v4.netmask)));
|
||||
// Skip a degenerate 0.0.0.0 (unconfigured) and the all-ones limited broadcast we
|
||||
// already add unconditionally.
|
||||
if !bcast.is_unspecified() && bcast != Ipv4Addr::BROADCAST {
|
||||
out.push(bcast);
|
||||
}
|
||||
out.push((v4.ip, bcast));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -183,10 +235,47 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_addrs_never_contains_limited_or_unspecified() {
|
||||
for b in broadcast_addrs() {
|
||||
assert_ne!(b, Ipv4Addr::BROADCAST);
|
||||
assert!(!b.is_unspecified());
|
||||
fn local_segments_are_bindable_and_have_a_broadcast() {
|
||||
for (local, bcast) in local_v4_segments() {
|
||||
// The local address is what we bind the per-interface socket to, so it must be a
|
||||
// real address — and it must never be the loopback (filtered) or unspecified.
|
||||
assert!(!local.is_unspecified());
|
||||
assert!(!local.is_loopback());
|
||||
assert!(!bcast.is_unspecified());
|
||||
// Binding to an address the OS just reported must work; a failure here would mean
|
||||
// the per-interface pass silently degrades to the routed one.
|
||||
assert!(UdpSocket::bind(SocketAddrV4::new(local, 0)).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blast_reports_nothing_sent_for_an_empty_target_list() {
|
||||
let sock = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind loopback");
|
||||
let pkt = [build_magic_packet([1, 2, 3, 4, 5, 6])];
|
||||
assert!(!blast(&sock, &pkt, &[], &WOL_PORTS));
|
||||
// An unconfigured 0.0.0.0 target is skipped rather than sent to.
|
||||
assert!(!blast(&sock, &pkt, &[Ipv4Addr::UNSPECIFIED], &WOL_PORTS));
|
||||
// Loopback is a real destination — this one must go out.
|
||||
assert!(blast(&sock, &pkt, &[Ipv4Addr::LOCALHOST], &[9999]));
|
||||
}
|
||||
|
||||
/// The whole send path, end to end: a real receiver gets a real magic packet with the right
|
||||
/// bytes. Aimed at loopback on an unprivileged port (WoL's own 9 and 7 need root to bind),
|
||||
/// which exercises the routed pass's unicast leg — the one a WoWLAN host is woken by when
|
||||
/// the AP filters broadcast to sleeping stations.
|
||||
#[test]
|
||||
fn send_delivers_the_magic_packet_to_a_listener() {
|
||||
let rx = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind receiver");
|
||||
let port = rx.local_addr().expect("local addr").port();
|
||||
rx.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.expect("read timeout");
|
||||
|
||||
let mac: Mac = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02];
|
||||
send_magic_packet_on(&[mac], Some(Ipv4Addr::LOCALHOST), &[port]).expect("send");
|
||||
|
||||
let mut buf = [0u8; 256];
|
||||
let (n, _from) = rx.recv_from(&mut buf).expect("a magic packet must arrive");
|
||||
assert_eq!(n, 102);
|
||||
assert_eq!(buf[..102], build_magic_packet(mac));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
//! Host-side Wake-on-LAN support.
|
||||
//! Host-side Wake-on-LAN / Wake-on-Wireless-LAN support.
|
||||
//!
|
||||
//! Two jobs, both best-effort (a failure here never affects streaming):
|
||||
//! 1. [`wake_macs`] — report the host's wake-capable NIC MAC(s) so a client can persist them
|
||||
//! (from the mDNS `mac` TXT record, [`crate::discovery`]) and wake this host later, once it's
|
||||
//! asleep and no longer advertising.
|
||||
//! asleep and no longer advertising. Wired and Wi-Fi NICs alike: a magic packet is the same
|
||||
//! packet either way, and an associated station in WoWLAN sleep receives the broadcast the
|
||||
//! AP buffers for it.
|
||||
//! 2. [`warn_if_not_armed`] — *detect & warn only* whether the NIC is actually armed to wake on a
|
||||
//! magic packet. We never change NIC settings (that's the user's call); we just surface the
|
||||
//! single most common reason WoL silently fails.
|
||||
//!
|
||||
//! Wired and wireless are armed through completely different interfaces, so the check follows the
|
||||
//! NIC: `ethtool <iface>` reports the wired `Wake-on: g` bit, while a Wi-Fi NIC's magic-packet
|
||||
//! trigger lives in nl80211's WoWLAN state and is read with `iw phy <phy> wowlan show`. Asking
|
||||
//! ethtool about a Wi-Fi NIC is what the previous version did, and it is actively misleading:
|
||||
//! most wireless drivers print `Wake-on: d` whether or not WoWLAN is armed, so an armed host got
|
||||
//! warned that it wasn't — with a fix command (`ethtool -s wlan0 wol g`) that its driver rejects.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
@@ -61,8 +70,8 @@ pub fn wake_macs(primary_ip: IpAddr) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// Log whether the host NIC bearing `primary_ip` is armed to wake on a magic packet. Detect &
|
||||
/// warn only — never modifies settings. Linux-only (reads `ethtool <iface>`); a no-op elsewhere
|
||||
/// and silent when it can't tell (no `ethtool`, insufficient privilege).
|
||||
/// warn only — never modifies settings. Linux-only (shells out to `iw`/`ethtool`); a no-op
|
||||
/// elsewhere and silent when it can't tell (tool missing, insufficient privilege).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn warn_if_not_armed(primary_ip: IpAddr) {
|
||||
let ifaces = if_addrs::get_if_addrs().unwrap_or_default();
|
||||
@@ -73,6 +82,41 @@ pub fn warn_if_not_armed(primary_ip: IpAddr) {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// A NIC with an nl80211 phy is wireless: ask nl80211 about WoWLAN, not ethtool about WoL.
|
||||
if let Some(phy) = wireless_phy(&iface) {
|
||||
match wowlan_has_magic(phy.as_deref(), &iface) {
|
||||
Some(true) => tracing::info!(
|
||||
iface = %iface,
|
||||
phy = phy.as_deref().unwrap_or("?"),
|
||||
"Wake-on-WLAN armed (magic packet) on host Wi-Fi NIC"
|
||||
),
|
||||
Some(false) => {
|
||||
let phy = phy.as_deref().unwrap_or("phy0");
|
||||
// A device the kernel won't arm can't wake on anything, so name that separately
|
||||
// — enabling a WoWLAN trigger alone would not fix it.
|
||||
let extra = if device_wakeup_enabled(&iface) == Some(false) {
|
||||
" The kernel also has wake-up switched off for this device \
|
||||
(/sys/class/net/<iface>/device/power/wakeup reads `disabled`), which blocks \
|
||||
a network wake by itself."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
tracing::warn!(
|
||||
iface = %iface,
|
||||
"Wake-on-WLAN is NOT armed on this host's Wi-Fi NIC — clients cannot wake it \
|
||||
from sleep. Enable it with: sudo iw phy {phy} wowlan enable magic-packet \
|
||||
(NetworkManager resets that on every re-connect; make it stick with: sudo \
|
||||
nmcli connection modify <connection> 802-11-wireless.wake-on-wlan magic). \
|
||||
The adapter must also stay powered and associated while the host sleeps, and \
|
||||
be allowed to wake the machine in BIOS/UEFI.{extra}",
|
||||
)
|
||||
}
|
||||
None => {} // couldn't determine — stay quiet rather than cry wolf
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match ethtool_wol_has_magic(&iface) {
|
||||
Some(true) => {
|
||||
tracing::info!(iface = %iface, "Wake-on-LAN armed (magic packet) on host NIC")
|
||||
@@ -81,7 +125,7 @@ pub fn warn_if_not_armed(primary_ip: IpAddr) {
|
||||
iface = %iface,
|
||||
"Wake-on-LAN is NOT armed on this host's NIC — clients cannot wake it from sleep. \
|
||||
Enable it with: sudo ethtool -s {iface} wol g (and turn on 'Wake on LAN'/'Wake on \
|
||||
PCIe' in BIOS). Wired Ethernet is required; Wi-Fi wake is unreliable.",
|
||||
PCIe' in BIOS).",
|
||||
),
|
||||
None => {} // couldn't determine — stay quiet rather than cry wolf
|
||||
}
|
||||
@@ -90,6 +134,80 @@ pub fn warn_if_not_armed(primary_ip: IpAddr) {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn warn_if_not_armed(_primary_ip: IpAddr) {}
|
||||
|
||||
/// Is `iface` a Wi-Fi NIC, and if so which nl80211 phy backs it? `Some(Some("phy0"))` = wireless
|
||||
/// and we know the phy (so we can query and name it); `Some(None)` = wireless but the phy name
|
||||
/// couldn't be read; `None` = wired (or sysfs is unavailable, which reads the same way — the
|
||||
/// ethtool path then applies, exactly as before).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn wireless_phy(iface: &str) -> Option<Option<String>> {
|
||||
let dir = format!("/sys/class/net/{iface}/phy80211");
|
||||
if !std::path::Path::new(&dir).exists() {
|
||||
return None;
|
||||
}
|
||||
let name = std::fs::read_to_string(format!("{dir}/name"))
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
Some(name)
|
||||
}
|
||||
|
||||
/// Whether a Wi-Fi NIC is armed for a magic-packet wake. `iw` is authoritative — it reads the
|
||||
/// live nl80211 WoWLAN state, which is where the trigger actually lives.
|
||||
///
|
||||
/// Two fallbacks for when `iw` can't answer (binary missing, driver without the WoWLAN command,
|
||||
/// no phy name, or a kernel that wants privilege we don't have — the host runs as a plain user
|
||||
/// service, so that last one is not hypothetical):
|
||||
/// * a *positive* ethtool reading counts, a negative one never does — a handful of drivers
|
||||
/// (brcmfmac and friends, i.e. most Raspberry Pi / SoC Wi-Fi) really do expose the
|
||||
/// magic-packet bit through ethtool, while the far more common `Wake-on: d` from a wireless
|
||||
/// driver means nothing at all;
|
||||
/// * failing that, sysfs `device/power/wakeup` — world-readable, and a `disabled` there is
|
||||
/// conclusive in the negative direction: the kernel will not arm this device to wake the
|
||||
/// machine, so whatever WoWLAN triggers the firmware holds can never fire.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn wowlan_has_magic(phy: Option<&str>, iface: &str) -> Option<bool> {
|
||||
if let Some(v) = phy.and_then(iw_wowlan_has_magic) {
|
||||
return Some(v);
|
||||
}
|
||||
if let Some(true) = ethtool_wol_has_magic(iface) {
|
||||
return Some(true);
|
||||
}
|
||||
// Only the negative is meaningful: `enabled` says the device may wake the machine, not that a
|
||||
// magic packet is one of the things that will do it.
|
||||
match device_wakeup_enabled(iface) {
|
||||
Some(false) => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// sysfs `/sys/class/net/<iface>/device/power/wakeup` — `enabled`/`disabled`, i.e. whether the
|
||||
/// kernel will arm this device to wake the system at all. `None` when the attribute isn't there
|
||||
/// (platform/SDIO devices often have none) or can't be read.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn device_wakeup_enabled(iface: &str) -> Option<bool> {
|
||||
let text =
|
||||
std::fs::read_to_string(format!("/sys/class/net/{iface}/device/power/wakeup")).ok()?;
|
||||
match text.trim() {
|
||||
"enabled" => Some(true),
|
||||
"disabled" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask nl80211 (via `iw phy <phy> wowlan show`) whether the magic-packet trigger is enabled.
|
||||
/// `None` if `iw` is missing or the driver doesn't implement WoWLAN.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn iw_wowlan_has_magic(phy: &str) -> Option<bool> {
|
||||
let out = std::process::Command::new("iw")
|
||||
.args(["phy", phy, "wowlan", "show"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_iw_wowlan(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// Parse `ethtool <iface>` for the *current* Wake-on setting and report whether it includes `g`
|
||||
/// (wake on MagicPacket). Returns `None` if ethtool is missing/failed or the field is absent.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -101,7 +219,13 @@ fn ethtool_wol_has_magic(iface: &str) -> Option<bool> {
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
parse_ethtool_wol(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// `ethtool <iface>` output → does the *current* Wake-on setting include `g` (MagicPacket)?
|
||||
/// `None` when the field is absent. Split out from the command so it can be unit-tested on any
|
||||
/// platform.
|
||||
fn parse_ethtool_wol(text: &str) -> Option<bool> {
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
// The current setting is "Wake-on: <flags>"; skip the "Supports Wake-on: ..." capability
|
||||
@@ -112,3 +236,88 @@ fn ethtool_wol_has_magic(iface: &str) -> Option<bool> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `iw phy <phy> wowlan show` output → is the magic-packet trigger enabled? The two shapes are
|
||||
///
|
||||
/// ```text
|
||||
/// WoWLAN is disabled
|
||||
/// ```
|
||||
/// ```text
|
||||
/// WoWLAN is enabled:
|
||||
/// * wake up on magic packet
|
||||
/// * wake up on pattern match, up to 20 patterns of 16 - 128 bytes
|
||||
/// ```
|
||||
///
|
||||
/// `* wake up on anything` (the nl80211 `any` trigger) counts too — that NIC wakes on every frame
|
||||
/// it receives, magic packets included. Enabled with only other triggers reads as NOT armed,
|
||||
/// which is the honest answer: a magic packet won't wake it. `None` when the output says nothing
|
||||
/// about WoWLAN at all. Split out from the command so it can be unit-tested on any platform.
|
||||
fn parse_iw_wowlan(text: &str) -> Option<bool> {
|
||||
let mut seen = false;
|
||||
let mut magic = false;
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if let Some(state) = t.strip_prefix("WoWLAN is ") {
|
||||
seen = true;
|
||||
if state
|
||||
.trim()
|
||||
.trim_end_matches(':')
|
||||
.eq_ignore_ascii_case("disabled")
|
||||
{
|
||||
return Some(false);
|
||||
}
|
||||
} else if seen && t.starts_with('*') {
|
||||
let l = t.to_ascii_lowercase();
|
||||
if l.contains("magic packet") || l.contains("anything") {
|
||||
magic = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
seen.then_some(magic)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_ethtool_wol, parse_iw_wowlan};
|
||||
|
||||
#[test]
|
||||
fn ethtool_current_setting_not_capability_line() {
|
||||
let armed =
|
||||
"Settings for enp5s0:\n\tSupports Wake-on: pumbg\n\tWake-on: g\n\tLink detected: yes\n";
|
||||
assert_eq!(parse_ethtool_wol(armed), Some(true));
|
||||
// "Supports Wake-on: ...g..." must NOT be read as the current setting.
|
||||
let off = "Settings for enp5s0:\n\tSupports Wake-on: pumbg\n\tWake-on: d\n";
|
||||
assert_eq!(parse_ethtool_wol(off), Some(false));
|
||||
assert_eq!(
|
||||
parse_ethtool_wol("Settings for lo:\n\tLink detected: yes\n"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iw_wowlan_states() {
|
||||
assert_eq!(parse_iw_wowlan("WoWLAN is disabled\n"), Some(false));
|
||||
assert_eq!(
|
||||
parse_iw_wowlan("WoWLAN is enabled:\n * wake up on magic packet\n"),
|
||||
Some(true)
|
||||
);
|
||||
// Enabled, but not for magic packets — a magic packet will not wake this NIC.
|
||||
assert_eq!(
|
||||
parse_iw_wowlan(
|
||||
"WoWLAN is enabled:\n * wake up on pattern match, up to 20 patterns of 16 - 128 bytes\n"
|
||||
),
|
||||
Some(false)
|
||||
);
|
||||
// The `any` trigger wakes on every received frame, magic packets included.
|
||||
assert_eq!(
|
||||
parse_iw_wowlan("WoWLAN is enabled:\n * wake up on anything (device continues operating normally)\n"),
|
||||
Some(true)
|
||||
);
|
||||
// Nothing to go on — the driver has no WoWLAN command.
|
||||
assert_eq!(parse_iw_wowlan(""), None);
|
||||
assert_eq!(
|
||||
parse_iw_wowlan("Wiphy phy0\n\tmax # scan SSIDs: 20\n"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -104,10 +104,11 @@ and capture/display glitches.
|
||||
Clients wake a saved host by themselves — auto-wake is on by default — but only once they have seen
|
||||
it awake, which is how they learn its MAC address, and only if the machine is armed to answer a magic
|
||||
packet. The arming is what's usually missing, and a **Linux** host tells you outright: search the web
|
||||
console's **Logs** page for `Wake-on-LAN`, and the line either confirms the card is armed or names
|
||||
the interface and the exact command to arm it. Windows and macOS hosts don't run that check, so go
|
||||
straight to the BIOS/UEFI and network-card steps in
|
||||
[Arming the machine](/docs/wake-on-lan#arming-the-machine).
|
||||
console's **Logs** page for `Wake-on-` — `Wake-on-LAN` for a wired card, `Wake-on-WLAN` for a Wi-Fi
|
||||
one — and the line either confirms the card is armed or names the interface and the exact command to
|
||||
arm it. A Wi-Fi card is armed by a different command than a wired one, and the log line gives the
|
||||
right one. Windows and macOS hosts don't run that check, so go straight to the BIOS/UEFI and
|
||||
network-card steps in [Arming the machine](/docs/wake-on-lan#arming-the-machine).
|
||||
|
||||
## Video is slow to start, or fails across subnets
|
||||
|
||||
|
||||
@@ -30,14 +30,35 @@ That ordering is the whole prerequisite:
|
||||
> says so rather than pretending. On every client but the Linux one you can also type the MAC in by
|
||||
> hand; see the table below.
|
||||
|
||||
The packet goes to every local interface's subnet broadcast address *and* to `255.255.255.255`, on
|
||||
The packet goes **out of every one of the client's network interfaces** — from a socket bound to
|
||||
that interface's own address, aimed at both its subnet broadcast address and `255.255.255.255` — on
|
||||
UDP ports 9 and 7, repeated three times, plus a unicast to the host's last known address. That
|
||||
spread is deliberate: a sleeping machine has no ARP entry, so a plain unicast cannot find it.
|
||||
spread is deliberate: a sleeping machine has no ARP entry, so a plain unicast cannot find it, and a
|
||||
broadcast sent without binding an interface leaves by the default route only, which on a machine
|
||||
running a VPN or a mesh network is not the LAN the host sleeps on.
|
||||
|
||||
Neither the advert nor a magic packet is authenticated. That is fine here — a wrong address only
|
||||
makes the wake fail, and the host's certificate fingerprint still gates the actual connection. See
|
||||
[Security](/docs/security).
|
||||
|
||||
### Over Wi-Fi
|
||||
|
||||
A host on Wi-Fi wakes from the same packet. The mechanism is **WoWLAN** (Wake on Wireless LAN):
|
||||
the adapter stays associated to your access point while the machine sleeps, the access point holds
|
||||
broadcast frames for its sleeping stations and releases them on the next beacon, and the adapter
|
||||
wakes the machine when one of them is a magic packet. Punktfunk publishes a Wi-Fi card's address
|
||||
exactly like a wired one, so there is nothing different to do on the client — but the card has to be
|
||||
armed for it, which is a different switch from the wired one. See
|
||||
[Linux (Wi-Fi)](#linux-wi-fi) and [Windows](#windows) below.
|
||||
|
||||
Two things can still stop it, and neither is visible from Punktfunk:
|
||||
|
||||
- Some access points and mesh systems drop or rate-limit broadcast traffic to sleeping stations
|
||||
(often as "multicast enhancement", "broadcast filtering" or IGMP snooping). If wired hosts wake
|
||||
and a Wi-Fi one never does, that is the first thing to turn off.
|
||||
- Some laptops and adapters cut power to the Wi-Fi card in deeper sleep states, which drops the
|
||||
association and with it any chance of a wake.
|
||||
|
||||
## Waking from a client
|
||||
|
||||
**Auto-wake on connect** is a client setting, and it is **on by default**. You find it in Settings,
|
||||
@@ -135,7 +156,7 @@ whether a machine may be woken off the network is yours to make.
|
||||
### Check the host log first
|
||||
|
||||
This is the fastest diagnosis. On **Linux**, the host inspects the card carrying the address it
|
||||
advertises, each time it starts advertising, and writes one of two lines:
|
||||
advertises, each time it starts advertising, and writes one line about it. A wired card:
|
||||
|
||||
```text
|
||||
Wake-on-LAN armed (magic packet) on host NIC
|
||||
@@ -145,18 +166,29 @@ Wake-on-LAN armed (magic packet) on host NIC
|
||||
Wake-on-LAN is NOT armed on this host's NIC — clients cannot wake it from sleep.
|
||||
```
|
||||
|
||||
A Wi-Fi card, which is armed through an entirely different mechanism and is asked about separately
|
||||
(`iw phy … wowlan show`, not `ethtool`):
|
||||
|
||||
```text
|
||||
Wake-on-WLAN armed (magic packet) on host Wi-Fi NIC
|
||||
```
|
||||
|
||||
```text
|
||||
Wake-on-WLAN is NOT armed on this host's Wi-Fi NIC — clients cannot wake it from sleep.
|
||||
```
|
||||
|
||||
The warning line goes on to name the interface and the exact command to fix it. The host only
|
||||
reports; it never changes the card's settings. It stays silent when it cannot tell — `ethtool`
|
||||
missing, or not enough privilege — rather than guessing, and it says nothing at all when mDNS
|
||||
adverts are switched off (`PUNKTFUNK_MDNS=0` or `--no-mdns`), because then no address is published
|
||||
either.
|
||||
reports; it never changes the card's settings. It stays silent when it cannot tell — `iw` or
|
||||
`ethtool` missing, a driver that doesn't answer, or not enough privilege — rather than guessing, and
|
||||
it says nothing at all when mDNS adverts are switched off (`PUNKTFUNK_MDNS=0` or `--no-mdns`),
|
||||
because then no address is published either.
|
||||
|
||||
Read the line on the web console's **Logs** page, or in the journal with
|
||||
`journalctl --user -u punktfunk-host`. See [Troubleshooting](/docs/troubleshooting#still-stuck).
|
||||
|
||||
**Windows and macOS hosts do not run this check**, so there is no log line to look for there.
|
||||
|
||||
### Linux
|
||||
### Linux (wired)
|
||||
|
||||
Ask the card what it is doing. `Supports Wake-on:` is the capability; `Wake-on:` is the current
|
||||
setting. `g` means magic packet, `d` means disabled.
|
||||
@@ -174,6 +206,42 @@ sudo ethtool -s enp5s0 wol g
|
||||
On many systems that does not survive a reboot. Re-run `ethtool enp5s0` after the next boot to check,
|
||||
and make it permanent through your distribution's network configuration if it reset.
|
||||
|
||||
### Linux (Wi-Fi)
|
||||
|
||||
`ethtool` is the wrong tool here — most wireless drivers report `Wake-on: d` whether or not they are
|
||||
armed, because the trigger lives in the wireless stack instead. Ask `iw`, using the *phy* behind the
|
||||
interface (`/sys/class/net/wlan0/phy80211/name`, usually `phy0`):
|
||||
|
||||
```bash
|
||||
iw phy phy0 wowlan show
|
||||
```
|
||||
|
||||
`WoWLAN is disabled` means no wake. Armed looks like this, and the `* wake up on magic packet` line
|
||||
is the one that matters:
|
||||
|
||||
```text
|
||||
WoWLAN is enabled:
|
||||
* wake up on magic packet
|
||||
```
|
||||
|
||||
Arm it:
|
||||
|
||||
```bash
|
||||
sudo iw phy phy0 wowlan enable magic-packet
|
||||
```
|
||||
|
||||
That setting is per-phy and NetworkManager re-applies its own on every connection, so on a
|
||||
NetworkManager system make it stick on the connection instead — this survives reboots and
|
||||
reconnects:
|
||||
|
||||
```bash
|
||||
sudo nmcli connection modify <connection> 802-11-wireless.wake-on-wlan magic
|
||||
```
|
||||
|
||||
`iw phy phy0 wowlan show` reporting `command failed: Operation not supported` means the driver has no
|
||||
WoWLAN support at all; that adapter cannot be woken over Wi-Fi. Check `iw list | grep -A5 "WoWLAN"`
|
||||
for what the hardware claims to support.
|
||||
|
||||
### Windows
|
||||
|
||||
Open **Device Manager**, find the network adapter under **Network adapters**, and open its
|
||||
@@ -181,10 +249,17 @@ properties. On the **Power Management** tab, allow the device to wake the comput
|
||||
**Advanced** tab, enable the adapter's magic-packet wake property if it has one. Exact wording
|
||||
depends on the driver.
|
||||
|
||||
Wi-Fi adapters use the same two tabs. The **Advanced** property is often called **Wake on Magic
|
||||
Packet** there too, sometimes **Wake on Wireless LAN**; many Wi-Fi drivers expose neither, and those
|
||||
cannot be woken over Wi-Fi. `powercfg /devicequery wake_armed` lists every device currently allowed
|
||||
to wake the machine — if the adapter is not in it, nothing on the network can wake this host.
|
||||
|
||||
## Limits
|
||||
|
||||
- **Wired Ethernet is what works.** Waking over Wi-Fi is unreliable and depends entirely on the
|
||||
adapter and the platform.
|
||||
- **Wired Ethernet is the sure thing; Wi-Fi works when the adapter supports WoWLAN.** Punktfunk
|
||||
sends the same packet either way and publishes a Wi-Fi card's address like any other, but whether
|
||||
a sleeping adapter is still listening is the adapter's and the access point's decision —
|
||||
see [Over Wi-Fi](#over-wi-fi).
|
||||
- **Connect once while the host is awake**, on the same local network, before you rely on waking it.
|
||||
A host you only ever added by address, on a network where mDNS never reached it, has no learned
|
||||
address — the CLI will tell you so, and the apps will not offer the wake action. Typing the MAC in
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/plugin-kit",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
|
||||
Reference in New Issue
Block a user