Found while running the lutris plugin's own release gate against a live host on
.21: `parity --compare` reported `1 missing` — the plugin produced NO entry for
the one game Lutris had, while the host's built-in scanner produced it fine.
`scan` agreed: "present: 0 games". `detect` still said "present", because detect
only stats the file.
`openReadOnly` builds a `file:<path>?immutable=1` URI — the right idea, since
`immutable=1` is what makes this a pure read that cannot lock a running
launcher's database or spawn WAL sidecars next to it. But it opened that name
with `{ readonly: true }`, and the options object does NOT enable SQLite's URI
filename parsing. Without SQLITE_OPEN_URI the name is taken literally, no such
file exists, and the open throws:
SQLiteError: unable to open database file
Every path here then degrades that to silence by design: `openReadOnly` returns
`undefined` for "this launcher isn't installed", `withReadOnlyDb` passes the
`undefined` through, and callers write `withReadOnlyDb(...) ?? []`. So a total,
permanent failure to read ANY database was indistinguishable from an empty
library. The lutris plugin reported 0 games on every Linux box, always.
It is platform-split, which is why it survived review and local runs: macOS
links Apple's system SQLite, which is built with URI filenames enabled, so the
same call succeeds there. Linux uses Bun's bundled SQLite, which is not. MEASURED
both ways on bun 1.3.14.
Fixed by passing the flags explicitly — SQLITE_OPEN_READONLY | SQLITE_OPEN_URI.
(`{ readonly: true, uri: true }` is not a supported option shape; also measured.)
And the second half, which is why nothing caught it: `parity --compare` sets
`process.exitCode = 1` on a mismatch and then returns NORMALLY — a red parity is
a finished comparison, not a crashed command. `runPluginCli` then assigned
`process.exitCode = 0` unconditionally after the effect resolved, overwriting it.
So the one verb both plugin READMEs document as the release gate — "exits
non-zero on any difference", "do not publish a version whose parity run is red" —
always exited 0, and any scripted use of it passed. Now `??= 0`, so a code a
command set deliberately survives.
Tests: the sqlite helper had NO coverage at all, which is the whole reason a
total failure shipped looking like an empty library. Added five cases against a
REAL database file — reads rows back, handles a path needing URI escaping,
withReadOnlyDb round-trips, an absent file is `undefined` not a throw, and a bad
query still degrades to []. Verified they actually catch it: against the shipped
code on Linux, 4 of the 5 fail; with the fix, 21/21 in that file and 61/61 across
the suite, typecheck and build clean.
End to end on .21 with the fix: lutris `scan` goes 0 -> 1 games and
`parity --compare` reports "parity OK — 1 entries identical". A deliberately
doctored baseline now exits 1 instead of 0.
0.3.0 -> 0.3.1.
The nix gate landed in fb707b49 on `nixos/nix:latest`, and it has never run a
single step. That image carries nix and essentially nothing else — including no
`/bin/sleep` — and Gitea's act_runner starts every job container with
`entrypoint=["/bin/sleep", "10800"]`:
failed to create shim task: OCI runtime create failed: runc create failed:
unable to start container process: exec: "/bin/sleep":
stat /bin/sleep: no such file or directory
The expensive part is how it reports: with the container dead, every step is
marked `cancelled` rather than `failed`, which reads exactly like a run that was
superseded by a newer push. Run 15907 looked skipped, not broken.
Switch to `node:22-bookworm` and install Nix in a step:
* full Debian, so the entrypoint exists and coreutils are present;
* a real node, so `actions/checkout` works with no pre-checkout install dance
(the reason flatpak.yml's fedora job installs node before its checkout);
* audit.yml already pulls this image on this fleet, so it is known to resolve.
Nix comes from the Determinate installer with `--init none` — the container mode:
no systemd, no daemon. That distribution is also what the hand-verification Nix
box (.21) runs, so CI and it stay on the same Nix.
MEASURED in a real `node:22-bookworm` container rather than assumed, since the
last version of this file shipped on an untested assumption and cost a red run:
* `/bin/sleep` present — the entrypoint failure is gone;
* the installer completes and `nix --version` runs from the absolute path;
* `nix build` of a trivial derivation SUCCEEDS, `nix store ping` reports
`Store URL: local, Trusted: 1`, and flakes are enabled.
That last one is the trap this change also pins. `--init none` runs no daemon,
but the installer still writes a profile script exporting `NIX_REMOTE=daemon`;
anything sourcing it (any `-l` login shell) then dies on "cannot connect to
socket at '/nix/var/nix/daemon-socket/socket'". It is why the installer's own
self-test fails, harmlessly, in the middle of this step's log. The steps here
never source that profile — they invoke `$NIX` by absolute path — but `NIX_REMOTE`
is now pinned empty at job level so a later step cannot reintroduce it.
Also records `df -h` before the build: this fleet ran a runner out of disk today
(ci.yml's `web` job died with "no space left on device" mid-`bun install`), a Nix
build is the heaviest thing that would run here, and a future failure should be
attributable at a glance rather than guessed.
Still unproven, and only the first green run can settle it: whether `nix flake
check` evaluates this flake cleanly under CI, and whether the runner has the disk
to build punktfunk-web. Both now produce a real diagnostic instead of a container
that never started.
A launcher entry (design D4) has no "the game exited" moment to detect, but the lease was
still deciding its lifetime from whatever happened to be true at launch — and the two
outcomes disagree:
launcher NOT already running the spawned child stays alive -> `Child` lease
-> quitting the launcher ends the session
launcher ALREADY running the command forwards to the live instance and exits
inside SHIM_WINDOW, with no detect signals behind it
-> `Untracked` -> the session persists
Same tile, two lifetimes, chosen by something the user cannot see. Steam is the case that
settles which one is right: Big Picture is a *mode* of an already-running Steam client, not
a process — and on a Deck or SteamOS host Steam is always running — so no process signal can
express "the launcher's window closed". Heroic has the same shape for a different reason
(single-instance Electron: a second invocation forwards and exits).
So a launcher entry is now `LeaseKind::Untracked` unconditionally. The check sits AHEAD of
`nested`/`child`/`spec`, and that ordering is the fix rather than an implementation detail:
a launcher the host just started leaves a live child behind, and tracking that child is
precisely the inconsistency being removed.
`Untracked` already meant the right things downstream, so nothing else had to change: no
watcher thread, `terminate()` is a no-op ("nothing to end" — closing a session must not kill
the user's Steam), and no `GameExited` event, so a client does not bounce back to its library
when the launcher closes.
Threaded as `LaunchTarget::launcher` -> `LeaseRequest::launcher` from the entry's `role`.
Three call sites, one of which was the actual trap: the GameStream path does not build its
lease from a `LaunchTarget` at all, it goes through a `GsApp` intermediate that silently
dropped the field. An operator-typed `apps.json` command has no library entry behind it and
is never a launcher tile, so it passes `false` explicitly.
The test pins BOTH cases from the table above, plus the same request without the flag still
being `Matched` — so the assertions are the flag's doing and not an artifact of the fixture.
Gates: punktfunk-host 439 passed / 0 failed on .21 (+1).
Not covered here: nothing publishes launcher tiles until the plugins are released, so there
was no live exposure to fix — this is the semantics being made deliberate before the first
tile is ever clicked.
The first pass built each palette by rotating ONE colour field's hue, and it
showed: every option was a single tone at several brightnesses, which reads flat
next to any real gradient. A palette is now an ordered ramp of DISTINCT hues.
The 4×4 mesh samples that ramp along the diagonal with a fixed per-cell offset
table, so neighbouring cells land on different parts of it and the colours pool
and swirl instead of banding; the control points' existing drift then moves the
pools around. Violet keeps its explicit sixteen colours, so the default is
untouched.
Twelve of them now, dark first then pale: Violet, Nebula, Abyss, Ember, Moss,
Graphite, then Holo, Sunset, Bloom, Dawn, Mint, Opal. Holo and Sunset are
straight takes on the two reference gradients — foil and poster.
`every_palette_is_multi_tone` measures the hue spread across all sixteen cells
and fails under 45° (20° for Graphite and Opal, which are meant to be
restrained). It caught Ember at 35°, all reds and oranges — the very flatness
this rework exists to remove — and Graphite at 3° despite a comment claiming it
drifted cool to warm. Both were rebuilt until the numbers matched the prose.
The UI follows the palette now, rather than wearing brand violet over whatever
happens to be behind it. Each palette carries an accent and a light flag, and an
Ink derived from those (foreground, accent, on-accent, glass, scrim and its
strength) is published to the whole tree — a thread-local in the console, an
environment value on Apple, a CompositionLocal on Android. Pale palettes flip
the ink: dark text on white frost, with the materials, tray scrims and every
wash that sits under text following suit.
Three things only the renders could have told us:
- Additive blending blows out over a pale ground. Android's blobs and Apple's
legacy field composite with Plus/plusLighter, which over near-white
saturates every blob to white — Holo rendered as a grey wash. Pale palettes
blend normally.
- A white scrim at the dark field's strength BLEACHES the gradient. Mixing
toward black at 0.4 reads as depth; toward white at 0.4 destroys the chroma
it is drawn over. The scrim now carries a per-palette strength.
- White glass over a bright field has far less separating it from its backdrop
than dark glass over a dark one, and needed more body.
Verified: console build + clippy -D warnings + 173 tests, Apple build + 200
tests + an iOS-triple typecheck, Android compile + 62 tests, and eyeball passes
on real renders of both the vivid and the pale ends (console CPU rasters; a new
Roborazzi light-palette scene, which is what exposed the blend-mode bug).
The xbox scanner resolves each package's PackageFamilyName by enumerating
%ProgramData%\Microsoft\Windows\AppRepository\Packages. Probed on .173 (design doc S1),
that directory is:
NT AUTHORITY\LocalService UnauthorizedAccessException <- the plugin runner
LocalSystem count=348 <- the host service
So an extracted xbox plugin cannot build an AUMID, and `aumid` — which takes a complete
`<PFN>!<AppId>` — is unpublishable by one.
The design doc offered two fallbacks: entries without `aumid` (they would list but not
launch), or a one-shot elevated PFN resolve at install time. This is neither. The plugin
sends `<Identity>!<AppId>`, both read straight out of MicrosoftGame.config — which IS
readable de-privileged — and the host resolves the publisher hash at LAUNCH time, where it
already owns command construction.
That keeps D1 intact (the plugin supplies a validated value, the host builds the command),
it is the same shape as the `playnite` kind, and resolving late means a package update that
changes the publisher hash cannot leave a stale unlaunchable tile behind — which the
install-time cache would have.
`xbox_pfn` therefore moves from the scan path to the launch path rather than being deleted,
and its doc records the privilege asymmetry so the next reader doesn't re-derive it.
The charset guard is factored out (`aumid_part` / `valid_aumid`) and shared with the `aumid`
kind. On `aumid` it was belt-and-braces — every value was host-derived. On `xbox` it is
load-bearing: the Identity arrives over the wire from a plugin, and it is interpolated into
a `shell:AppsFolder\…` argument. Validated inbound at reconcile too, so a malformed value is
a 400 the plugin author can act on rather than a tile that fails on click.
Gates: punktfunk-host 438 passed / 0 failed on .21, and 55/0 for the library tests on .173
(where these arms actually compile). Both counts +1 for the new guard test.
Moving the bun packages to bun2nix (1db8f763) removed the aggregate deps hash
that used to go stale, but not the second, quieter way a generated file rots.
bun2nix is a pure function of bun.lock, and `bun.nix` is committed alongside it
— but it regenerates ONLY from a local `bun install` that runs lifecycle
scripts. It does not regenerate under `bun install --ignore-scripts`, which is
what every bun install in CI uses, and it cannot regenerate on a merge or
rebase, where git carries someone else's lockfile change past a `bun.nix`
generated before it and reports no conflict.
That is not theoretical. web/bun.nix went onto main holding brace-expansion@5.0.7
(plus two nested entries the `^5.0.8` override had already collapsed) while
web/bun.lock said 5.0.8: the override landed in ec9aa415, the bun2nix branch had
generated bun.nix off the pre-override lock, and the merge kept both. The Nix
build fetches node_modules strictly from bun.nix, so the derivation's offline
`bun install` was asked for a tarball its store cache did not contain and
`nix build .#punktfunk-web` failed. It stayed broken for 553 commits
(2026-07-27 -> 2026-08-05) and was closed by accident, when an unrelated
advisory bump in b79d90b4 happened to rerun a real `bun install`.
Nothing caught it because nothing could: the string "nix" appeared in exactly
one workflow file, and only in a comment about bun2nix breaking a Windows step.
* scripts/ci/check-bun-nix.sh regenerates each bun.nix from its committed
bun.lock and diffs, with `--fix` to rewrite them. It reads which packages to
check out of packages.nix's own `bunNix = src + ...` lines rather than a
hardcoded list, so a third bun package is covered the day it is added, and
an empty list is a hard error — a gate that checks nothing passes exactly
like a clean tree. It also enforces the bun2nix version pin that flake.nix
and the README only stated: bun.nix has no schema stability across releases,
so the flake input ref and both package.json devDependencies must agree, and
generation always uses the pinned version rather than a floating `bunx`.
* ci.yml gains a `bun-nix` job running it. Deliberately unfiltered and not in
nix.yml: it needs no Nix, takes under a minute, and the whole point is that
this drift arrives through commits that look unrelated to Nix.
* nix.yml is new and is the first CI that evaluates the flake at all —
`nix flake check --no-build` over every output, then a real build of
punktfunk-web + punktfunk-scripting, which are the two derivations whose
inputs churn and the only ones that do not compile Rust. That leg is the
end-to-end proof the drift gate cannot give: it catches a tarball the
registry no longer serves, or the codegen going quietly message-less. It is
path-filtered, has a workflow_dispatch opt-in for the hour-long Rust
packages, and keeps `pull_request` — flatpak.yml shipped push-only and let
manifest breakage reach main invisibly for weeks.
The script is POSIX sh (shellcheck-clean, exercised under dash, which is the
CI container's /bin/sh) and avoids process substitution, the parse-time failure
that silently disabled ci.yml's shader SPIR-V gate for 35 commits.
`windows/pyrowave.rs` raised the process's WDDM scheduling class to HIGH itself, once per
process, at every session open. `pf-frame::dxgi::auto_priority_gate` already owns that policy
for the whole process and runs from `create_device` — the call the Windows capture path always
makes before any PyroWave texture exists. Two owners of one process-wide setting.
The audit filed this as "downgrades REALTIME to HIGH", which undersells it. pf-frame's default
`auto` mode starts at HIGH and then UPGRADES to REALTIME once it has established that is safe —
HAGS off, or HAGS on with VRAM headroom — and leaves a monitor running that drops back when VRAM
tightens, because REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC hang. Opening
a PyroWave session after that upgrade stamped HIGH back over the class AND orphaned the
monitor's decision, losing the ceiling-raise on exactly the GPU-saturated workload PyroWave
exists to survive: it encodes on the shader cores a game saturates, where the measured spike is
~2 ms to 15-18 ms.
Removed rather than reconciled. `PyroWaveEncoder::open` takes no device, so there was nothing
session-specific to preserve, and the surviving owner is strictly better informed — it knows the
adapter, HAGS state and VRAM headroom, none of which this call site had.
The duplicated knob goes with it: `PUNKTFUNK_GPU_PRIORITY` is retired in favour of
`PUNKTFUNK_GPU_PRIORITY_CLASS` (`off|normal|high|realtime|auto`, default `auto`), which is a
superset — the removed knob could not express the auto gate at all. No other reference to it
exists in the tree.
Verified on .173: clippy -D warnings at nvenc,amf-qsv,qsv (host + pf-encode --all-targets),
amf-qsv without qsv, qsv alone, no-features, cargo test --features qsv (34 passed), rustfmt —
7 legs green. Windows-only file, so the Linux legs do not compile it.
Streaming a KDE desktop showed two cursors: the one the user was moving, and a
second one sitting underneath it that never moved. It was not KDE's — KWin 6.7.3
in cursor-as-metadata mode calls `setRenderCursor(false)` on every recorded buffer
and hands the cursor item to an exclusive `ItemTreeView`, so `shouldRenderItem()`
skips it and no pointer is ever painted into that stream. It was ours.
Both clients declared the render model as `captured && desktop`, so ANY released
pointer handed compositing back to the host. But releasing does not remove the
local cursor — it restores the ordinary window arrow over the video. The host then
blends its own pointer in underneath, and since a released client forwards no
motion, nothing drives it: it stays frozen wherever the host pointer was last left.
Caught live on the host with the render-model diag:
cursor diag: client_draws=false blended=true live=Some((-1, 622, true))
x = -1 — parked on the streamed output's left edge, unchanged sample after sample,
while the user moved their own cursor around freely. Engaging capture flipped it to
`client_draws=true blended=false` and the duplicate vanished, which is why it only
looked "stuck when not dragging": dragging means engaged, and engaged was the one
state that behaved.
The host may composite ONLY while the client holds a grabbed, hidden pointer — the
capture model, engaged — which is the single state with no local cursor on screen.
Released now counts as "the client draws it": the host stops compositing and keeps
forwarding shape/state over the channel (the forwarder ticks on this side of the
flip), so re-engaging is seamless and the client's cached shape stays warm.
`ffmpeg-next` is pulled with default features, so `ffmpeg-sys-next`'s build script
pkg-config-probes codec/device/filter/format/util/resampling/scaling and panics on
the first one missing. The spec named three.
RPM Fusion's `ffmpeg-devel` ships all seven in one package, which hid it. On a host
where those three instead resolve to Fedora's split `libav*-free-devel` packages,
`dnf builddep` installs exactly three and the build dies in a build script:
The system library `libavfilter` required by crate `ffmpeg-sys-next` was not found.
`cargo clippy -p punktfunk-client-windows -- -D warnings` fails on main with the
pinned 1.96.0 toolchain: `ticks % 5 == 0` trips `manual_is_multiple_of`. Pre-existing
and not from this branch — found while gating the launcher work on .173, because
neither macOS nor Linux ever compiles this crate.
Clippy's own suggestion, applied verbatim.
`local_art_bytes_is_confined_and_image_only` and `posix_local_art_is_classified_and_proxied`
built their `file://` values as `format!("file://{path}")`. On Windows that yields
`file://C:\covers\cover.png`, whose authority is `C:` — a UNC reference, not a local
file — so the read half failed on the box and the host suite was red there.
The parser is right and the tests were wrong: `@punktfunk/plugin-kit/library`'s `fileUrl`
emits `file:///C:/covers/cover.png` (three slashes, forward separators) and
`file_url_to_path` documents exactly that. A shared `file_url` helper now builds the value
the way the kit does, so both tests exercise the real plugin contract on both platforms
rather than a shape no plugin ever sends.
Found while gating the Playnite launch kinds on .173 — Linux CI never compiles these arms,
so the failure had gone unnoticed. Test-only: no product code changes.
The Playnite plugin emits `kind: "command"` for every game (a `start "" "playnite://…"`
shell line). The 2026-08-05 review made `command` operator-only, and `privileged_field`
refuses a PROVIDER reconcile carrying one — so on this branch the published
`@punktfunk/plugin-playnite@0.3.0` cannot publish anything at all. Not a launcher tile:
not one game. That is a regression against a shipped plugin, and it is the same hole
`launcher_ui` was created to close, one kind further along.
Two kinds, both host-owned so D1 holds — the plugin supplies a validated VALUE and
never a command line:
playnite valued by the game's GUID; resolves to
explorer.exe "playnite://playnite/start/<guid>", the same
protocol-via-a-concrete-EXE shape the `epic` kind uses. GUID-validated
on the way in (so a bad value is a 400 the plugin author can act on)
and again at launch.
launcher_ui now accepts "playnite" on Windows, resolving to
Playnite.FullscreenApp.exe with Playnite's own install dir as the
working directory.
Fullscreen, not Desktop, is the whole point of a couch tile — and it is also why this
one cannot ride the URI the games use: probed on .173, Playnite's registered
`playnite://` handler is bound to Playnite.DesktopApp.exe, so no URI opens fullscreen
mode. The exe is spawned directly, with the install dir read from Playnite's own
uninstall entry (HKCU, then HKLM for a machine-wide install), falling back to
%LOCALAPPDATA%\Playnite.
`valid_launcher_ui("playnite")` is answered by RESOLUTION rather than by a static list:
a host without Playnite installed refuses the entry instead of publishing a tile that
does nothing when a user clicks it. That is the same instinct that left Epic, GOG
Galaxy and the Xbox app off the list — each still needs its own verified activation,
and a guess would ship exactly that dead tile.
Gates: punktfunk-host 436 passed / 0 failed on .21 (the Linux arms), and the Windows
arms compiled and their library tests run on .173.
The host has been able to describe a launcher entry since M2 — `role: "launcher"`,
the `steam_ui` and `launcher_ui` kinds — and the web console has grouped them into
their own rail since M4. No other client ever looked. `pf-client-core` decoded
`role` into an `is_launcher()` helper with zero call sites, and the shared console
model dropped the field entirely on its way to the renderer.
So a launcher tile arrived everywhere else as an ordinary game with no cover art:
indistinguishable from a title whose poster failed to load, sorted into the middle
of the alphabet, and captioned "Play".
One contract, implemented in each client's own idiom:
* launchers never interleave with titles — they lead, and each group keeps the
host's title order
* grid surfaces get a labelled section; a coverflow keeps its single carousel and
names the group the cursor is in, changing as it crosses the boundary. A second
focus rail would mean a new up/down nav model in three renderers for two or
three tiles
* an art-less launcher gets an accent face naming its launcher, not a title
monogram on the neutral one — "opens Steam", not "a cover that didn't load"
* anything that is not `"launcher"` is a game, and a host that omits the field
renders exactly as before (design D4's intended degradation)
* launching is unchanged: the client sends an id, the host resolves the recipe
The grouping is enforced once per client stack rather than per screen. In the
console UI it is an invariant of `LibraryShared::set_games`, so the cursor
arithmetic, the art pump and every future consumer inherit it; on Apple and Android
it is applied where the library is fetched/parsed.
Fixed in passing: the Apple and Android store badges were hard-coded
`isCustom ? "Custom" : "Steam"`, so every Lutris, GOG, Heroic, Epic and Xbox title
was labelled "Steam". Both now carry the same store table the Rust clients use.
The CLI's `--library` gains a fourth column (`game`/`launcher`), appended rather
than folded into an existing one so anything reading the first three is untouched.
Gates: punktfunk-host 436 passed / 0 failed and pf-console-ui 49 passed / 0 failed
on .21 (three new tests), workspace clippy -D warnings and cargo fmt --check clean
there; `swift build` of the full PunktfunkClient and `:app:compileDebugKotlin` clean
on macOS; `cargo check` + `clippy -D warnings` for the Windows client on .173.
Still unproven on hardware: no launcher tile has been clicked on a real host — that
needs the plugins published, which needs this branch's base merged first.
The stream watchdog polled a bare "has the session ended" boolean, so it
had exactly one thing it could say and said it every time: "Connection
lost — the host may be asleep. Wake it to reconnect." That ran when the
player quit their game, when an operator ended the session from the
console, and when they pressed Back themselves — telling them to go wake
a host that was never asleep.
It now reads the end reason. Only a connection that actually died gets
that line, a host-side failure gets its own, and the three deliberate
endings say nothing at all: leaving the stream is already the feedback,
and a toast on top of it is just noise.
A game launched from a library also returns to that library instead of
host selection, which needs the intent hoisted out of the console shell:
the stream replaces that shell in the composition, discarding the
`remember`s holding its screen and host, so by the time the session ends
there is nothing left to navigate back with. The parent holds it across
the gap and the shell consumes it on the way in. The touch UI has no
library — only the console shell does — so there it is the toast fix
alone.
The desktop clients turned every host-side close into "Host ended the
session", and a reason string means "abnormal" to everything downstream:
the GTK and Windows shells raised a banner, the console overlay drew a
status strip. Quitting a game you launched yourself produced all of
that. Now only a host error or a lost connection carries a message; the
deliberate endings return the silence those shells already give a clean
exit, which is also what puts the console back in its library with
nothing in the way.
The Apple client gains the same distinction. It had one line for every
ending — "Session ended by <host>." — which is fine for an operator
stopping the session and wrong for a link that died, so each now says
what happened. A game exiting stays silent and returns to the library it
was launched from.
Both read the reason while the connection is still up, because tearing
it down is what makes it unreadable, and both fall back to their previous
wording when there is no verdict — an older core, or a close that raced
the read — rather than inventing a new one for a case they cannot see.
A session ending was a single bit. A player quitting their game, an
operator ending the session from the console, a stop the client itself
asked for, a host crashing and a Wi-Fi drop all arrived as the same
"closed" — so every client had to write one message covering all of
them, and every client picked an error. That is how quitting your own
game came to be reported as trouble on all three.
The information was already there and thrown away: the host closes with
APP_EXITED when a launched game exits, with 0 when it ends the session
cleanly and 1 when it fails, and a link that simply dies never closes at
all. The connection watcher now classifies that into a
PunktfunkEndReason — local, game exited, host ended, host error, lost —
and latches it before the shutdown flag, since the two are read by
different threads and the reason must never arrive second.
Exposed as punktfunk_connection_end_reason. This replaces the
game-exited flag added a moment ago rather than joining it: that
question is one row of this table, and it was never released. Still
additive to any embedder that ignores it, and the host sends the same
bytes either way, so the wire is untouched.
`is_normal()` is the question nearly every caller actually has, so both
the Rust and C surfaces answer it directly rather than making each
client re-derive which of five values are worth alarming a user about.
Quit a game you launched from a host's library and the stream ended with
"Session ended by <host>." on the host-selection screen — an error
report for something you had just done on purpose, and several taps away
from starting the next title.
The host has always said what happened: it closes the connection with
APP_EXITED when the game it launched for a session exits, and that
code's own documentation describes this feature. Nothing ever read it —
a search across every client found zero consumers. (It also could not
reach anyone until the previous commit, since the close only happens
once the lease declares the game gone.)
The core now records the reason as it observes the close, latched before
the shutdown flag because different threads watch the two, and exposes
it as punktfunk_connection_game_exited. Purely additive: a client that
never asks behaves exactly as before, the host sends identical bytes,
and the wire version is untouched — ABI 17.
The Apple client asks while the connection is still up, then treats a
game exit as the normal finish it is: no error banner, and if the
session began as a library launch it reopens that library so the next
title is one tap away. Any other ending — a stop, the host going away,
network loss — is unchanged. The other clients keep their existing
end-of-session behaviour; the call is there when they want it.
Pressing Escape mid-stream on an iPad handed the mouse back to iPadOS:
the captured cursor was swapped for the system one and the game stopped
receiving relative motion, so aiming died until you clicked back in.
Two previous attempts treated that release as unavoidable and built
recovery around it — a re-lock burst, then a click that re-asks. Both
came back from the field unchanged, because both fought the release
after it had already happened, inside the cooldown the platform applies
straight after its own "let me out" gesture.
The release was never unavoidable. This app had no UIKit key handling at
all: every key arrives on the GameController path, which is a parallel
HID feed that does not consume the UIKit event, and the only thing that
ever became first responder was the video view, and only to summon the
soft keyboard. So every hardware Escape reached UIKit unclaimed — and an
unclaimed key press is precisely what lets the system apply its own
default for that key. Apps that read a hardware keyboard the ordinary
way consume the event as a side effect and never see this.
So claim it. The stream controller becomes first responder while capture
is engaged and takes Escape in pressesBegan/pressesEnded, passing every
other press to super untouched. Escape still reaches the host on the
GameController path, so in-game menus open exactly as before; only the
system's own interpretation is suppressed. Scoped to captured input, so
Escape keeps dismissing sheets and leaving full screen whenever the
stream doesn't own the keyboard, and the deliberate ways out are
untouched — Cmd-Escape and Ctrl-Opt-Shift-Q are read off the same
GameController path and clear capture themselves.
The recovery path stays as a backstop and is retimed to match what was
measured: the old burst spent its entire budget within ~0.6 s of the
drop, i.e. wholly inside the cooldown, where the answer can only be no.
Retries now continue at 1.2 s and 2.4 s, and quietly — they don't hide
the cursor or mute pointer motion the way the burst does, so a longer
recovery costs nothing when it fails.
When a launched game's processes are all gone, the watcher asks one last
out-of-band question before ending the session: does the launcher still
think the game is up? On Windows that reads Steam's per-app `Running`
registry flag. It was only ever meant to be a tie-breaker for a scan that
momentarily can't see the game — a launcher re-execing, an engine
relaunching itself into a new pid.
It had no bound. Honouring the flag reset the confirm window every pass,
so a flag Steam left set — it does that whenever it doesn't cleanly
observe the exit: it crashed, it was closed first, the game re-parented —
pinned the lease in `running` for the life of the host. The console kept
showing the game, `session_on_game_exit` never fired, and the only way to
get the stream back was a manual "End". Reported from the field on
Windows 0.24.0. `steam_running_hint` also believes the FIRST hive that
says so, so a stale flag in any loaded profile was enough.
The absence timer now keeps running instead of being reset, and that is
what bounds it: past `VETO_LIMIT` (30 s) with nothing of the game on the
box, the launcher's opinion is stale rather than early and the session
ends anyway, logged at WARN so it is visible. Ending a moment early is
the cheaper failure — the stream drops while the game lives, the user
reconnects, and nothing is ever killed. Ending never was the bug.
The rule is now a pure `exit_confirmed(gone_for, hint_running)` with a
test. The watch loop polls a live process table and can't be unit-tested,
which is exactly how an unbounded veto shipped unnoticed.
Picking the device's native mode on a phone hands the host the panel's own
aspect ratio, so the aspect-fit presenter fills every pixel — including the ones
behind the sensor housing and under the four rounded corners. That is why the
corners look cut off at max resolution while 1080p has always been fine: a 16:9
mode on a 20:9 phone pillarboxes, and those black bars land exactly on the
unsafe regions.
So the fix is entirely a sizing one — no layout change, no input change. Ask the
host for a mode narrowed by the unsafe inset and the existing aspect-fit centres
it inside the safe region; pointer mapping follows for free, because both
clients derive the picture rect from the live host mode rather than assuming
full-bleed.
Apple: `SafeDisplay` (PunktfunkShared, pure + unit-tested) and a "This device
(safe area)" row beside the native one, using Moonlight's formula — full native
height, width less the left+right safe insets. The stream is always landscape
but the settings screen may be portrait, where the same housing is reported on
`top` and the horizontal insets read zero; the portrait top inset stands in,
gated so an iPad's status bar never fabricates an inset.
Android: the same shape via `SafeArea` + a `SAFE_AREA_MODE` sentinel resolved at
connect like the existing `0`=native one. The cutout insets get the same
portrait fallback, and the rounded corners are added on top — Android does not
count them as cutout, and a full-height picture needs exactly the corner radius
of horizontal clearance.
Both even-floor and clamp, since `validate_dimensions` rejects odd dimensions
and an inset subtraction lands odd about half the time. Where a display has
neither cutout nor rounded corners the safe mode equals the native one, which on
Apple lets the existing dedup drop the duplicate row.
A field report from an iPad: the host is not found on first run, and
restarting the client finds it. Pull-to-refresh appeared to do nothing.
Both were real. The Apple client's discovery had three ways to go
permanently deaf, each needing an app relaunch to clear:
- A failed resolve was never retried. `browseResultsChangedHandler`
only fires when the result SET changes, and a host whose resolve
failed is still in the set — so nothing ever re-offered it.
- A stuck resolve never ended. `NWConnection` has no timeout, so the
throwaway UDP flow used to resolve an address could sit in
`.preparing`/`.waiting` forever, and a service with a connection in
flight was skipped.
- `NWBrowser` parking in `.waiting` was ignored (only `.failed`
re-armed). On iOS that is where the local-network privacy prompt
lands on first launch after install: the browse starts, the system
asks, and the browser waits. Granting does not revive that browser —
only a new one sees the grant. That is the reported first-run bug.
HostDiscovery now runs a 1 Hz sweep that times out stuck resolves,
retries failed ones on a 1→30 s backoff, and re-arms a browser that
stopped working; the advert's TXT is re-read on every browse report, so
a host that re-keys or flips its pairing policy is followed. Returning
to the foreground re-arms the browse (iOS/tvOS: `onAppear` does not
fire across background/foreground, and a suspended browse stays dead).
Pull-to-refresh did nothing because there was no `.refreshable` in the
client at all. Added, plus the explicit control the report asked for:
a toolbar Refresh on iOS/macOS, an action-row button on tvOS, a Rescan
tile in the gamepad launcher, Scan Again on the empty state, a
header-bar button in the GTK client, a hosts-page button on Windows,
and Scan again on Android. Decky already had one.
The desktop/Android browses needed a rescan trigger to make those
buttons mean anything: mdns-sd re-queries on a doubling backoff capped
at ONE HOUR, so a long-lived browse is effectively passive and a host
that appears later can stay invisible. `discovery::Rescan` forces a
fresh query; the wake-and-wait loops use it too, so a host that just
booted is noticed in seconds rather than at the next backoff tick.
Also fixed, found on the way: clients/windows/src/discovery.rs is a
second copy of the browse that d0fa8bd3 ("pin mDNS discovery to IPv4 on
every client") missed. It took an arbitrary first address, so when a
host's OS responder answered AAAA the Windows GUI rendered a card that
failed on every click. It also never noticed a dropped receiver, leaking
a thread and a :5353 socket per wake-and-wait.
Gates: Apple macOS + iOS (arm64-apple-ios17.0, proven non-vacuous) build
clean, 195 tests pass incl. a new one asserting a rescan re-finds a
still-advertising host. On .21: fmt, clippy --all-targets -D warnings
and build clean for pf-client-core + client-linux + client-session,
117 tests pass. Android :kit: and :app: compileDebugKotlin clean.
The Windows client is UNGATED — its CI runner was unreachable.
The console settings were one 30-row scroll, which on a Deck meant thumbing past
Video and Audio to reach the pad settings. They are now split across sections —
Stream · Video · Audio · Controller · Interface · Profiles, plus Input on the
desktop console, which alone carries the touch/mouse rows. L1/R1 walks them,
each section remembers where its cursor was, and the names are the same word on
every client so a setting is where you looked for it last.
Shoulders are not the only route, because a D-pad remote hasn't got any: on
Android, Up from the first row moves onto the strip (left/right walks sections
there, A drops back in), and on tvOS the pills are focusable, so the focus
engine handles it — a Siri Remote has no extended gamepad profile and never
reaches the input poll at all. The desktop console needs neither; PageUp and
PageDown already map to the same events.
New "Background" row, six palettes: Violet (the brand default), Tide, Forest,
Ember, Rose, Graphite. A palette is a hue rotation plus a saturation scale over
the ONE colour field each client already draws, so every palette inherits its
structure and Violet is the identity transform — existing installs see exactly
what they see today. The maths is ported three times (Rust/Swift/Kotlin) under
one shared `ui_palette` key, with the same assertions pinned in each language.
It is presentation only, so it is a device preference and never part of a
profile.
The form screens no longer have a backdrop of their own. Settings, add-host and
pair used to sit on a still gradient; they now wear the same living field at a
calm mix — pools dimmed onto the palette's own corner colour, vignette halved so
rows that run to the edges don't get crushed. On the desktop console that
collapsed the old aurora-over-static crossfade into one shader pass with a
chased uniform. Motion speed is identical in both modes on purpose: changing it
would make the field jump mid-transition. Nothing in the gamepad UI is backed by
a static image now, and Reduce Motion (Apple) / "remove animations" (Android)
still freeze it.
Also: the settings screen had no raster coverage at all — the eyeball dump is
`#[ignore]`d — so a new test draws every tab, and the Android screenshot set
gains a console-settings scene. Both earned their keep immediately: the renders
showed the extra hint pushing "Done" off a 360 dp phone (the legend scrolls now,
and the Section cell only appears where shoulders exist) and the form backdrop
crushing its own edges.
The flatpak has not built since 35ba64ca. Every push to main fails at "Build the
flatpak", before a single build command runs:
cp: cannot overwrite non-directory
'.../build/gamescope-wsi-layer-1/subprojects/vkroots/.git'
with directory '.../git/https_github.com_Joshua-Ashton_vkroots.git'
Error: module gamescope-wsi-layer: Child process exited with code 1
vkroots was declared twice. flatpak-builder clones git sources WITH SUBMODULES by
default, and `subprojects/vkroots` is a real gamescope submodule — `git ls-tree
8c676c39 subprojects/` shows it as mode 160000 at 5106d8a0, which is byte-for-byte
the commit the explicit source pinned. So the submodule checkout already produced
the right tree and left `subprojects/vkroots/.git` as a gitlink FILE; the second,
redundant source then tried to copy the bare mirror onto that path as a DIRECTORY,
and cp refused. Source extraction died there — `buildsystem: simple` and the
hand-applied glm/stb patch_directory copies were never reached, so neither is at
fault.
Removing the redundant source is therefore a no-op on the resulting tree: the
submodule supplies that exact rev. glm and stb are NOT submodules — `subprojects/
glm.wrap` and `stb.wrap` are plain blobs at that rev — so nothing else populates
them and their explicit sources have to stay. That asymmetry is the whole trap,
and it is now written down in the manifest next to the sources, along with the
disable-submodules escape hatch for anyone who later needs to pin a subproject
away from the gamescope rev.
Why this reached main: flatpak.yml has no `pull_request:` trigger — only `push` on
main with path filters, `tags: ['v*']`, and workflow_dispatch. PR #64's checks were
green because the flatpak was never built on the PR; run 15775 was the first time
this module had ever been built in CI. Adding a PR trigger (or a manifest lint) is
the durable follow-up, deliberately not bundled here.
This blocks the release, not just main. flatpak.yml runs on `tags: ['v*']`, and the
failing step gates the bundle export, the generic-registry publish, the OSTree push
to flatpak.unom.io and the release-asset attach — all of which stay skipped. A
v0.25.0 tag cut today would ship with NO Linux/Steam Deck flatpak at all, on the
release whose headline Linux change is Deck HDR working out of the box.
NOT VALIDATED LOCALLY: this cannot be built on macOS. The reasoning is confirmed
against the upstream tree (the ls-tree above) but the green run is still owed —
dispatch flatpak.yml on this branch before merging.
HDR on a Deck needed a manual second step nobody took:
flatpak install --user flathub org.freedesktop.Platform.VulkanLayer.gamescope//25.08
documented only in a comment in this file. Build the layer ourselves
instead, so a plain `flatpak install` is all it takes.
The layer is genuinely required, not legacy. Measured on SteamOS 3.8.16
(gamescope 3.16.23.4): the gamescope-0 socket advertises
gamescope_swapchain_factory_v2 but NOT wp_color_manager_v1, with HDR both
off and on — so Mesa's Wayland WSI has no colour-management protocol to
negotiate HDR10 through, and this layer is the only thing that can append
the ST.2084 surface formats. Removing the extension gives zero
[Gamescope WSI] lines and hdr10_format=None.
Vendored rather than declared via add-extensions autodownload: the
extension is 94 MB of whole-gamescope for one 4 MB .so, its layer JSON
hardcodes a /usr library_path that an app-scoped extension mounted under
/app would not satisfy, and it would make flathub a hard install-time
dependency of an app we self-host on flatpak.unom.io.
enable_gamescope=false skips subdir('src') and every compositor
dependency, so only protocol/ and layer/ build. buildsystem is simple
rather than meson because glm and stb ship no meson.build of their own -
the wraps' patch_directory supplies it, and without that copy configure
dies with "Subproject exists but has no meson.build file".
meson generates the layer JSON from prefix+libdir, so it self-writes
library_path=/app/lib/... into /app/share/vulkan/implicit_layer.d, which
XDG_DATA_DIRS already covers. VK_ADD_IMPLICIT_LAYER_PATH is therefore
dropped - keeping it would also risk double-loading two same-named layers
for anyone who still has the flathub extension installed.
Pinned to the same gamescope rev as packaging/gamescope/PKGBUILD so the
client's layer and the host's punktfunk-gamescope come from one tree.
Verified on a Deck OLED: builds offline (--wrap-mode=nodownload) in
org.gnome.Sdk//50, and the resulting .so drives the Deck's system
gamescope to "hdr formats exposed to client: true" with
hdr10_format=Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT).
Still user-side, and not fixable in packaging: gamescope's hdr_enabled
convar (Steam's HDR display setting) must be on.
The gamescope WSI layer decides whether to engage from one signal:
isRunningUnderGamescope() reads $GAMESCOPE_WAYLAND_DISPLAY and nothing
else. flatpak does not forward host env into the sandbox, so it arrived
unset and the layer's CreateInstance early-returned before creating a
GamescopeInstance — no gamescope surface, so the HDR10/ST.2084 formats
were never appended and the surface stayed SDR.
The layer still loads and still logs its generic bits in that state, so
it reads as working. It is not: the three settings already here (layer
search path, ENABLE_GAMESCOPE_WSI, the socket bind) all sit downstream
of this gate and buy nothing without it.
Measured on a Deck OLED (Galileo, SteamOS 3.8.16), client --browse,
reading "swapchain config":
unset -> no [Gamescope WSI] Surface state block, None
set, hdr_enabled=0 -> server hdr output enabled: false, None
set, hdr_enabled=1 -> hdr formats exposed to client: true,
Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT)
Matches the field report of "HDR->SDR" in the stats overlay on a
correct HDR host. DXVK_HDR was ruled out by measurement. The remaining
gate (gamescope's hdr_enabled convar = Steam's HDR display setting) is
a user-side step, not a packaging one.
The panel captioned most rows with an IP address. The saved records were
the source: `hosts add` falls back to the address when the pairing path
knew nothing better, so `name` is literally "192.168.1.21" — and
`mergeHosts` took `s.name || s.addr` unconditionally. The fallback only
ever fired for an EMPTY name, so a name that was already a copy of the
address sailed through as if it were meaningful, and the row printed the
address twice: once as its title, once as its subtitle.
The friendly name was in hand the whole time. The row is built by joining
the saved record to the live advert, and that advert carries the host's
actual hostname — the join was already trusted for address, port, online
and OS, and only the name was read from the saved side alone.
So treat a name equal to the record's own address as the placeholder it is
and yield to the advert. A real saved name still wins, even when stale: it
may be one the user chose, and an advert must never silently overwrite it.
The comparison is against the SAVED address, so a host that moved DHCP
lease still recognises its old address as a placeholder rather than
mistaking it for a chosen name.
Checked against the Deck that reported this, over its actual store and
browse: three online rows turn into home-worker-5, ENRICOS-DESKTOP and
steamdeck, the four offline ones keep their address (nothing is
advertising a better name for them yet), and a user-chosen name survives a
conflicting advert.
`shortcutStillExists()` extracted the store method before calling it:
const get = appStore?.GetAppOverviewByAppID;
return get(appId) != null;
`GetAppOverviewByAppID` reads the store's own state (`this.m_mapApps`), so
the unbound call throws on the lost `this` — and the function's own
`catch { return true }` swallowed it. The guard therefore returned "still
exists" for EVERY appId. Not a stale-data bug: it never once answered no.
Everything downstream of it was consequently inert. A dangling appId — the
documented hazard this guard exists to catch, since the id outlives the
shortcut in Steam's CEF localStorage across a plugin reinstall — was never
dropped, so `ensureGamepadUiShortcut` always took the reuse branch and
`SetShortcut*`'d a dead id (silent no-ops). The visible library entry never
came back, `recreateShortcuts` reported success having done nothing (its
toast only checks for a non-null appId, and the dead one is non-null), and
"Open Punktfunk" ran `RunGame` on the dead id — Steam answers that with
"Game configuration unavailable".
Call it as a method so `this` survives, and guard the global with `typeof`
first: `appStore` is Steam-injected, and a bare reference to a missing one
is a ReferenceError that optional chaining does not prevent — which would
have landed in the same catch.
Verified against the live Deck that hit this: evaluated both versions over
its actual appIds, and where the old guard says alive/alive, the fixed one
says alive for the live stream shortcut and dead for the dangling UI id —
so the stale key now drops and the entry is recreated on the next mount.
The label Decky shows for an installed plugin is plugin.json "name", which
we had set to the lowercase directory name — so the one place every user
sees the plugin listed was the one place it was off-brand, while the panel
header (titleView) already read "Punktfunk".
The two were conflated because the name looked load-bearing: the zip's
top-level dir becomes ~/homebrew/plugins/<dir>, and the scripts derived
that dir FROM plugin.json "name". They are in fact independent — Decky
extracts the zip as-is and locates an installed plugin by MATCHING
plugin.json "name", never by folder name (that is how a plugin can live in
DeckWebBrowser/ and list itself as "Web Browser").
So brand-case the label and pin the on-disk dir to the literal `punktfunk`
in package.sh/deploy.sh/CI instead of deriving it. Pinning is the part that
matters: had the dir followed the label, this rename would have installed a
second `Punktfunk/` folder beside the existing `punktfunk/` and the plugin
would have shown up twice.
The self-update call passes the name Decky uninstalls before extracting, so
it moves to "Punktfunk" with it. The upgrade INTO this build still passes
"punktfunk" (the installed build's own value), which matches that build's
plugin.json — so the old folder is removed and the new zip lands in the
same lowercase dir either way. Decky's per-plugin settings dir is unused
(all state lives in ~/.config/punktfunk), so nothing is stranded.
Reported from the field (2026-08-05): a fresh Windows 11 box with a data
partition, "New apps will save to: D:", and the client installed there. It
launches, finds hosts and streams — but no setting and no profile survives a
restart. Reinstalling to C: fixes it completely. The reporter's read was "it's
in read-only mode", and that is almost exactly right.
The one clue that localises it: the client creates its mTLS identity with a
plain `fs::write` on first run and hard-exits if that fails. Their app started,
so ordinary file creation in the config directory works. Only the config stores
were being lost — and those are the three files that go through `write_atomic`,
which writes a sibling temp and renames it over the target.
The rename is what breaks. The client ships as a full-trust MSIX package, so
its `%APPDATA%` writes are redirected into the package container. When the
package lives on a secondary drive, Windows keeps that redirected state on the
package's own volume: `C:\Users\<u>\AppData\Local\Packages\<pfn>\` stays a real
directory on C:, but its children (LocalCache, RoamingState, …) are junctions to
`D:\WpSystem\<SID>\…`. Both sides of our rename still spell `C:\Users\…`, so
nothing looks unusual, but they can resolve across that junction boundary — and
`std::fs::rename` is `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` and *not*
`MOVEFILE_COPY_ALLOWED`, so a cross-volume move fails outright rather than
degrading to a copy. Creating files still works, which is why everything else
about the install looks healthy.
So the fix is not to make the rename work — it is to stop treating it as the
only way to persist. `write_atomic` now falls back to writing the target in
place when the atomic route fails. That is the same operation the identity files
already use, and those demonstrably round-trip on the affected installs, so the
fallback lands on a path we know resolves. It trades crash-atomicity for exactly
the writes that would otherwise be lost, and nowhere else: temp+rename stays the
normal route everywhere it works.
Writing into a redirected location cannot desync from reading it — Microsoft
documents one private-location-first resolution order for both, so whichever
layer a write lands in is the layer the next read finds. The fallback verifies
anyway, by reading the bytes straight back: a write that reports success and
disappears is precisely the bug being fixed, so this path does not get to claim
success on an `Ok(())` alone. It costs nothing normally — it only runs on an
install that has already shown it does something unusual.
Two things this uncovered on the way:
The temp file was a single shared `<name>.json.tmp`, but these stores have five
whole-file writers (WinUI shell, session, console UI, CLI, Decky). Two saving at
once collide on it — on Windows the second write hits a sharing violation, and
worse, one process can rename the other's half-written bytes over the target.
The scratch path now carries the pid.
And none of this was visible to anyone. Every save on this page is
fire-and-forget by design (a failed settings write must never take a stream
down), so ~15 call sites discard the error and the UI cheerfully shows the
toggle you just moved. The reporter had no log file to send either, because
"Open log folder" was handing out a phantom path — a separate bug, already fixed
in f3c0ee47 but not in the 0.24.0 they were running. `store_health` records the
last persistence failure centrally, and Settings shows an error bar naming the
path when the store is refusing writes, so a client that cannot save says so
instead of pretending.
`update.rs` had hand-rolled the same temp+rename inline, so it neither cleaned
up its temp on a failed rename nor picks up the fallback; it now goes through
the one writer. The update floor silently never rising is how a declined update
comes back forever.
Deliberately NOT done: disabling MSIX AppData virtualization in the manifest
(`desktop6:FileSystemWriteVirtualization`). It would stop the redirection at the
source, but every existing packaged install's settings, profiles and pairings
live inside the container today — turning it off points the client at an empty
real `%APPDATA%` and silently resets all of them. That needs a migration, not a
manifest flag.
Also considered and not taken: resolving the destination directory with
`GetFinalPathNameByHandleW` and creating the temp inside the resolved path, to
keep atomicity. It does not reliably close this hole — when the target file
exists only in the unvirtualized layer while its directory resolves to the
private one, the rename still straddles the boundary — and it would rest on
canonicalisation behaving through the redirection, which we have never verified
on a packaged run.
Verified on the RTX box (.173, Windows 11 26200), which is the platform that
actually has these rename semantics: `cargo fmt --all --check`, the full
`pf-client-core` lib suite (109 passed), and clippy `-D warnings --all-targets`
on both `pf-client-core` and `punktfunk-client-windows` — all clean. Also green
under linux/amd64 (116 passed). Three new tests: the pid-scoped scratch path,
the fallback actually persisting and reading back when the atomic route is
blocked, and a genuinely unwritable store surfacing its error instead of
swallowing it.
The mechanism above is established from documentation and third-party reports,
not from a reproduction on a second-drive install — that box does not exist
here. The fix does not depend on the diagnosis being exactly right: it repairs
any install where the rename fails but a direct write succeeds.
The Android HUD headlined `capture→displayed` with SurfaceFlinger's latch
and scanout inside it — pipeline depth no client can pace under. The usual
Android streaming overlays stop measuring at decode-complete, so users
comparing overlays read our honesty as latency: on a 60 Hz panel that floor
alone clears 30 ms, more than everything those overlays display put together.
Exclude it, the way the Apple clients have since the presentation rebuild
(8a40e467): shave the measured floor off the shown display and end-to-end at
every tier, and name what came off in Detailed as `os present +N excluded
(display pipeline minimum)`. The equation still tiles the headline, because
the `display` term is shaved by the same amount.
The floor is the `latch` p50 we already measure (release→OnFrameRendered),
not a modelled 2/refresh: it moves with the panel rate, tunnelled playback
and the vendor's low-latency mode, and it exists on every render path (the
release stamp is parked on all three), so it does not depend on the timeline
presenter being active. Unmeasured reads 0.0 and nothing is shaved — we
exclude only what we actually measured. With the floor out, the `display`
term is already just `pace`, so the `(pace + latch)` split now renders only
on a window where no latch sample paired, and the hardcoded 2-refresh
Apple-equivalence twin is gone with it.
Raw numbers are untouched in the 1 Hz `pf.present` logcat line, so HUD-off
A/Bs and cross-session comparisons still read unshaved values.
Design D4 promised entries that open the LAUNCHER — Steam Big Picture, Heroic,
Lutris — and the plumbing for it landed in M2/M4: the `role` field, the
`steam_ui` kind, the console's Launchers rail. But nothing could flow through it
for anything except Steam.
D4 said the other launchers would ride the `command` kind. The 2026-08-05 review
then made `launch.kind = "command"` operator-only (it is handed to a shell), so a
plugin publishing one is refused with a 403. The two changes are individually
right and jointly leave a hole: `steam_ui` was the only launcher kind a plugin
could publish, so a Heroic or Lutris tile was unreachable.
New `launcher_ui` kind, valued by store id. One kind rather than one per store
because every launcher except Steam has exactly a single UI to open; Steam keeps
its own kind because it genuinely has two. D1 is preserved — the plugin names a
launcher, the host builds the command, and no shell string crosses the wire:
heroic -> the same native-or-Flatpak resolution the `heroic` game kind uses,
minus --no-gui and minus the URI, so the window itself opens
lutris -> bare `lutris`, which opens the window (the URI form is `lutris_id`)
Platform-gated to what this host can actually resolve, and validated INBOUND: a
value naming a launcher this OS cannot open is a 400 the plugin author can act
on, not a tile that silently does nothing when a user clicks it. Windows
launchers (Epic, GOG Galaxy, Xbox app) are deliberately absent — each needs its
own verified activation and a guess would ship exactly that dead tile.
Also closes a WP4.3 item I under-delivered and did not flag: the console's
add/edit form had no way to mark an entry as a launcher, so even hand-adding one
was impossible. It now has the checkbox — and `formFrom` round-trips it, without
which editing a launcher entry would silently demote it to a game, which is the
precise bug that file's own comment warns about.
Gates on .21: punktfunk-host 435 passed / 0 failed (two new), workspace clippy
-D warnings clean, cargo fmt --all --check clean, OpenAPI drift green. Console:
orval + paraglide regen, tsc clean, check-i18n at 604 messages for en + de.
Still unproven on hardware: no launcher tile has been clicked on a real host.
The steam plugin (the first to emit one) is not built yet.
PR #58 hardened the art proxy in the same three files this branch rewrote, and
the two changes pull in opposite directions: #58 narrowed what the host will read
from disk, while WP1.2 widened what counts as a local art path so an extracted
scanner's covers can be served at all. Resolved so the widening goes through the
gate rather than beside it.
Kept from #58, unchanged: art_path_is_confined (UNC refusal, canonicalize-or-
refuse, config-dir exclusion, roots check), the image-extension whitelist,
sniff_image_type, validate_art_paths as write-time validation, the AuthLane
privileged-field check on every entry in a reconcile payload, and the launch
redaction in GET /library.
Three reconciliations:
* `local_art_bytes` converts a `file://` value to a path BEFORE calling
art_path_is_servable, so the confinement check and the read see the same
path. Ordering is the point: percent-decoding happens before
canonicalization, so a `%2e%2e` escape cannot hide from the traversal check.
Pinned by a test.
* `art_roots()` gains $HOME on POSIX. This is the one that would have bitten
silently: the list was empty on non-Windows, which was correct while
is_local_art_path was Windows-shaped (Playnite is Windows-only, so nothing on
a POSIX host was ever classified as local art and the confinement had nothing
to confine). Once WP1.2 classifies POSIX paths as local, an empty root list
is not "secure by default" — it serves NO plugin art on Linux, which is every
cover the lutris and steam plugins emit. $HOME is the exact analogue of the
Windows users base #58 already ships, and covers Steam's librarycache and
grid overrides, Lutris's coverart/banners (both copies), Heroic's caches and
all the Flatpak variants. It is not the load-bearing control: a value still
needs an image extension, must canonicalize to a real regular file inside a
root and outside the config dir, and must CONTAIN image bytes.
* The two tests that both wanted to mutate PUNKTFUNK_LIBRARY_ART_ROOTS became
one. Cargo runs tests as parallel threads of a single process, so two tests
setting the same env var race. The `file://` and confinement assertions moved
into #58's existing confined test; what remains of the WP1.2 test is the
pure classification/rewrite half, which touches neither env nor filesystem.
Also: `steam_ui` was missing from the list of host-resolved launch kinds in
privileged_field's doc comment and in the 403 a plugin sees. Prose only — the
check is a denylist (prep, launch.kind = "command"), so steam_ui was never
actually refused — but a plugin author reading that error would have concluded
otherwise.
Gates on .21: punktfunk-host 433 passed / 0 failed (including #58's H-2 tests and
the new file:// ones), full workspace tests clean, workspace clippy -D warnings
clean, cargo fmt --all --check clean, OpenAPI drift test green.
Second half of security-review-2026-08-05 H-6. The infra half (unom/infra,
runners/ci-core/) split the LAN registry in two: :5010 serves GET/HEAD only and
refuses everything else with 405, :5011 demands basic auth on every request
including the /v2/ ping. Both fronts sit on one store, and a registry keys by
repository name rather than by the host:port the client used, so an image
pushed to :5011 is the identical image every consumer pulls from :5010.
So: builds tag the write port, a docker login precedes the push, and the
release-tag manifest PUTs authenticate. Consumers are untouched — every
`container:` in every other workflow still pulls anonymously from :5010, and
ci/rust-ci-arm64cross.Dockerfile's `FROM 192.168.1.58:5010/...` still resolves.
Not doing the digest pinning the review asked for, deliberately, and the header
says why at length. Once pushes are authenticated, the people who can overwrite
a tag are exactly the people who can push to main and edit a pinned digest in
this file — a pin defends against nobody it did not already trust, and costs a
two-commit dance on every ci/ change (~3x a month) during which consumers run a
builder image predating the change they are testing.
What does close the residual gap is making :latest a checked function of the
tree. reconcile-latest.sh asserts on every run that :latest and :ck-$KEY are the
same digest, re-points it when they are not, and warns loudly. An out-of-band
overwrite is caught on the next push to main with no churn, and it fixes a
pre-existing bug on the side: reverting ci/ used to leave :latest on the newer
build forever, because the older key is a cache hit and nothing re-pointed it.
Repair rather than fail, because a legitimate revert must not red-line main.
Verified against the live registry from a runner host with the real docker
client: unauthenticated push denied, push to :5010 refused 405, authenticated
push to :5011 accepted, that same image pulled back anonymously from :5010.
reconcile-latest.sh exercised over all three cases (diverged -> repaired,
already equal -> no-op, missing key -> exit 1). All seven builder images are
consistent with their content keys today, so the new step is a silent no-op on
its first real run.
`check_entry_fields` returned `Result<(), Response>`, which trips
`clippy::result_large_err` under CI's `-D warnings`: an axum `Response` is 128
bytes and it was riding in the `Err` variant.
`Option<Response>` is the shape this always wanted. There is no error value to
propagate here — the "error" IS the response the handler sends back — so `None`
means "the payload may proceed" and `Some(r)` is the refusal to return. The call
sites read the same, one word different.
Caught by CI, not by me: I ran `cargo check` and not `cargo clippy -D warnings`.
Writing a real scanner against the kit before six repos get cut from it, rather
than after. It is the lutris pilot (M5/WP5.1) — the smallest of the six and the
one that exercises the POSIX local-art path end to end.
It earned its keep immediately: withReadOnlyDb / openReadOnly were never exported
from the parsers barrel, so the single most distinctive thing the lutris plugin
needs was unreachable from @punktfunk/plugin-kit/library. Nothing caught that,
because nothing had consumed the public surface yet.
It also caught a vacuous green in this package: tsconfig's include was
["src","test"], so anything under examples/ type-checked as a no-op. `examples`
is now in the check scope; tsconfig.build.json still narrows to src and
package.json still ships only dist + README, so nothing new is published (verified
against the built dist).
The example carries two deliberate departures from the Rust original, both
documented inline: art is emitted as file:// URLs instead of inlined data: URLs
(the host proxies the bytes, so the payload stays small — inlining covers is what
blew the 2 MB body limit at 49 titles during the playnite work, and is exactly
why the POSIX art path exists), and the untrusted-slug guard is carried over
verbatim, since the slug comes from Lutris's own database and is interpolated
into a path the host will later be asked to serve.
What it demonstrates, which is the reason one-repo-per-plugin is safe: everything
below `scan` is store-specific parsing, and everything else — store claim, sync
engine, launcher entries, __config, console registration, and the CLI verbs
including the parity gate — comes from defineLibraryPlugin.
plugin-kit: tsc clean (now including examples), 56 tests pass, build clean.
One plugin = one repo, matching the house pattern (playnite, rom-manager and
virtualhere are already each their own repo with their own biome/bunfig/tsconfig
/CI). The implementation plan's WP5.0 had proposed a single workspace repo for
all six library scanners; this is the piece that makes the split cost nothing.
Everything the six scanners share is already published rather than adjacent: the
parsers and defineLibraryPlugin live in @punktfunk/plugin-kit/library, so repo
boundaries are irrelevant to them. Fixtures are not shared in practice either —
the Rust scanners build theirs inline in code, there are no fixture files, and
the one genuinely cross-plugin builder (binary shortcuts.vdf) is already in this
package's own tests. A pga.db fixture is useless to the epic plugin.
The parity harness was the exception: generic across all six, and parked in the
shared repo the plan assumed. It moves here.
What it is: the acceptance gate for an extracted scanner. Ported unit tests pin
the PARSERS; they do not prove the plugin reproduces the scanner it replaces. A
plugin that parses perfectly and emits steam:440.0 instead of steam:440 breaks
every Moonlight pin on the host and no parser test notices.
punktfunk-plugin-steam parity --snapshot before.json # host on its built-in
punktfunk-plugin-steam parity --compare before.json # offline; exits non-zero
--compare runs the plugin's own scan rather than requiring it to be installed
first, so a mismatch is visible before anything is published and the run is
repeatable while you fix it.
Three judgement calls in the diff, each pinned by a test:
* art is compared by PRESENCE, not value. The representation legitimately
changes on extraction (a host-relative proxy path or inlined data: URL
becomes a file:// path or a CDN URL), so comparing values would fail every
run for no reason. Losing an art kind fails; gaining one does not.
* launcher entries (role: "launcher") are reported separately instead of as
unexpected extras — the built-in scanner had no concept of them, so they can
never be in a baseline. An ORDINARY title the scanner never had still fails,
which is what catches a bad tool filter.
* absent and empty are the same thing in metadata: the host omits empty lists
and nulls, so a plugin sending genres: [] has not changed anything.
plugin-kit: tsc clean, 56 tests pass (10 new).
Closes H-3 of the 2026-08-05 review, the last of its six highs. A plugin's
interface was reverse-proxied onto the console's own origin and framed with
`allow-same-origin`, so plugin JS ran as first-party code on that origin: one
`fetch('/api/**', {credentials:'same-origin'})` and the BFF attached the
operator's ADMIN bearer. That reached everything `plugin_may_access` withholds
— arm pairing, read the host PIN, approve a device, read `/hooks`. The "open
in new tab" link was the same escalation with no iframe involved at all.
The fix is not a sandbox attribute, and it is worth writing down why, because
the obvious change is the one that does not work. Dropping `allow-same-origin`
gives the frame an OPAQUE origin; its subresource requests are then cross-site;
the `SameSite=Lax` session cookie stops being sent; every plugin asset 302s to
/login and the frame is blank. Nothing about the new-tab link is helped either.
So the origin moves instead. A second listener on its own port (default
PORT + 1) serves plugin UIs and nothing else:
different ORIGIN — scheme+host+PORT — so the same-origin policy separates the
plugin from the console: it cannot read the console's DOM,
its cross-origin fetch of /api/** is unreadable (no CORS)
and cannot mutate (Sec-Fetch-Site sees same-site).
same SITE — cookie scope ignores the port and SameSite is computed on
the site, so the session cookie still reaches the plugin
listener and plugin pages keep working.
Enforcement is two refusals and both are load-bearing: the console origin
refuses /plugin-ui/**, and the plugin origin refuses everything ELSE — above
all /api/**, which would otherwise hand the admin bearer right back to plugin
JS that is now same-origin with that listener. Both are unconditional: if the
plugin port cannot be bound, plugin UIs are DISABLED and the console says so,
rather than falling back to the arrangement this exists to remove.
Two consequences that would otherwise bite in the field:
The port has to be open. Done for the Windows netsh rule, the firewalld
service and the ufw profile.
A browser stores a self-signed-certificate exception per ORIGIN, including
the port — and a certificate interstitial can never be shown inside an
iframe, so the frame would just sit blank with nothing on screen explaining
why. A `no-cors` probe distinguishes it (a TLS failure rejects; any HTTP
answer, even 401, resolves) and the console renders a card linking the
operator to open the port once in a real tab.
Also here: the health probe moved server-side to the console origin (it used
to rely on being same-origin with the plugin), the postMessage listener now
verifies `event.origin` — a real check rather than a tautology — and
plugin-kit's `postMessage(..., "*")` is documented as load-bearing, since
narrowing it to `location.origin` would now target the plugin's own origin and
silently drop every message.
Verified against a running console with a fake mgmt API and a fake plugin:
console /plugin-ui/** → 404; plugin-origin /api/v1/hooks, /, /login,
/_auth/logout → 404; plugin page loads 200 through its own origin;
unauthenticated plugin origin → 401 (not a redirect to a /login it does not
serve); a forged x-pf-listener header changes nothing on either listener; the
plugin's own Clear-Site-Data / Access-Control-Allow-Origin / Set-Cookie are
dropped by the proxy allowlist; the plugin origin's CSP names the console as
its only frame-ancestors source; and with the port squatted, ui-config reports
`unavailable`, the console still refuses /plugin-ui/**, and the console itself
keeps working.
Still wants on-glass confirmation in a real browser — the cookie and framing
behaviour is reasoned from spec, not observed.
cargo fmt --all --check clean; cargo check -p punktfunk-host --all-targets
green on Windows; web console builds and typechecks.
Acts on the 2026-08-05 host security review. 36 of its 38 findings; the two
exceptions are recorded below and in the review doc.
The review's headline is that `plugin_may_access` was the one authorization
gate in the system that was allow-by-default — a hand-maintained denylist of
route prefixes, where every sibling gate is deny-by-default. Its own doc
comment names the two capabilities it exists to withhold, and both were
reachable one route over, because ~1450 commits of new routes were added and
the list was never one of the things anyone remembered to update.
So the gate is now an allowlist, and a test walks the live route table and
fails the build for any route that has not been deliberately classified for
both non-admin lanes. That test is the actual fix: it is what stops the next
route from arriving pre-authorized.
Route reachability and field authority turned out to be different questions.
A provider plugin has to be able to reconcile its own library entries — that
is what a scanner plugin IS — but `prep` and a `command` launch inside that
payload are handed to `/bin/sh -c` as the host user, and every execution site
documents them as operator-typed. Requests now carry the lane that authorized
them, and those two fields are refused to everyone but the operator's own
token.
The art proxy read any absolute path off disk in the host process, which on
Windows is LocalSystem, from a path the plugin lane could write and then read
back — so it yielded `mgmt-token`, which is full admin. It now serves only
real images (extension AND magic bytes, so a renamed secret fails), only from
inside an allowed root, only after canonicalization, and never over UNC; and
a path it would refuse to serve can no longer be persisted in the first place.
On Windows, the config-dir hardening was skipped exactly when it was needed —
it ran only in the branch that CREATES host.env, so the case it was written
for (a local user pre-created the directory and planted one) was the one case
it never ran in. It is now unconditional and first, an existing host.env is
re-owned, and the inheritable OWNER RIGHTS ACE that kept an attacker's files
theirs after the directory was re-owned is gone. The identity and token
readers were hardening the directory only on the path that GENERATED a new
secret, so a planted cert/key or token was adopted verbatim and permanently;
they harden before the first read now.
`ensure_admin_only_source` is implemented. The 2026-07-05 audit recorded it as
FIXED and it was in no commit in this repository's history — the local EoP it
described was live, and it is the payload half of the config-dir chain above.
Also: the three input planes are bounded and lossy like the mic plane on the
same loop already was; Android's library client no longer accepts any
publicly-trusted certificate for the pinned host; the usbip vhci nodes get
their own group instead of riding on `input`, which every packaging scriptlet
tells users to join; a registry URL can no longer inject a TOML table into
bunfig.toml; the pairing cooldown is charged before the arming state is read,
so armed/disarmed is no longer a free oracle; and the whole Low tier, of which
the two worth naming are a clipboard MIME NUL that panicked the host on one
control message, and an unauthenticated global logout that let any LAN peer
sign the operator out on a loop.
NOT fixed, deliberately:
H-3 (plugin UIs framed allow-same-origin). Dropping allow-same-origin does
not work: the document's origin goes opaque, its subresource requests are
then cross-site, the SameSite=Lax session cookie is not sent, and every
plugin asset 302s to /login. The "open in new tab" link is the same
escalation with no iframe at all, so the sandbox attribute is not where this
gets fixed either. It needs a second listener — a distinct origin that is
still the same site — which changes the console's deploy model and wants
on-glass validation. The mechanism and the dead end are written down at the
iframe.
H-6 registry authentication, whose other half lives in unom/infra. The
in-repo halves are done: workflow_dispatch inputs no longer interpolate into
run: blocks (one of them in the step holding UPDATE_MANIFEST_KEY), and the
syft installer is pinned to its tag instead of main. Digest pinning is left
until the registry is authenticated, because a tag — content-keyed or not —
can simply be overwritten while anonymous pushes are accepted.
M-5 is half done: the oracle is closed, but binding the arming window needs
the console to learn the fingerprint first, which is a knock-then-bind flow
rather than an edit.
Verified: cargo fmt --all --check clean; cargo check --all-targets green on
Linux and on Windows (confirmed non-vacuous — a planted type error in
windows/install.rs fails the build); scripts/xcheck.sh windows check green;
cargo test -p punktfunk-host --bins 416 passed, the single failure being
gamestream::stream::tests::sender_delivers_batches, the known qemu-environmental
UDP-loopback flake that fails identically on clean main in the same container;
cargo test -p pf-clipboard 13 passed; web console typechecks.
WP6.1 of design/library-scanner-plugins-implementation-plan.md.
The library is a flagship surface and cannot depend on an opt-in subsystem
(design D9, closing G9): once the scanners are plugins, a host whose runner is
off comes up with an empty library and no obvious reason why. The security
posture for on-by-default was already built and shipped — LocalService on
Windows, a sandboxed systemd --user unit on Linux, the scoped plugin-token lane.
Windows (.iss): the PunktfunkScripting task is registered ENABLED and started on
a FRESH install, and left to the existing restore path on an upgrade. The
distinction is a new TaskExists probe taken before StopBunRuntimes disables
anything — TaskEnabled alone cannot tell a fresh install from an operator who
deliberately turned the runner off, and defaulting to "on" would silently switch
it back on for them.
deb/rpm: `systemctl --global enable` from the postinst/%post, guarded to first
install only so an upgrade never undoes a mask. `--global` because a maintainer
script has no user session to act on, and it is the only mechanism that makes a
--user unit on-by-default for everyone.
sysext: RPM scriptlets never run from a sysext image, so the enablement symlink
is baked in directly (/usr/lib/systemd/user/default.target.wants/). Without it
the runner would ship present-but-off on exactly the platform where an operator
is least likely to go looking for it.
Opt-out throughout is `systemctl --user mask punktfunk-scripting` — `mask`, not
`disable`, since a plain disable cannot remove a symlink under /etc or /usr. The
unit comment, both package descriptions, and the docs-site plugins page all say
so; the page also gains the Windows equivalent.
Not gated on hardware: none of this is verifiable from a Mac. The .iss change
needs an installer run (fresh + upgrade, and an upgrade with the task
deliberately disabled), and the deb/rpm/sysext changes need a package build.
M4 of design/library-scanner-plugins-implementation-plan.md, plus WP6.2.
WP4.1 — SourceToggles and ProvidersCard merge into Library/Sources.tsx. They
were two cards because they were two different things: scanners were compiled
into the host, plugins were an afterthought. After the extraction they are the
same thing — the host reports ONE list of sources whose ids match whether they
came from a built-in scanner or the plugin replacing it — so one surface is both
simpler and the only honest presentation. Each row carries its toggle, a
running/stopped badge for plugin sources, an entry count, filter, settings and
an uninstall that offers to remove the games too. An "Add a source" rail lists
uncatalogued library plugins with a "Detected" badge; `detected` is deliberately
tri-state, so only a POSITIVE probe badges — an entry with no probes for this
platform is unknown, and calling that "not installed" would be a lie.
The settings drawer (SourceSettings.tsx) renders a generic form from the
plugin's own JSON Schema over GET/PUT /__config, through the existing
session-gated /plugin-ui/<id>/ proxy — zero new host surface, and the browser
never learns the plugin's port or secret. It flattens allOf branches (effect
nests a checked schema's annotations there, so a form reading only the top level
silently loses every title and default) and falls back to a JSON editor when any
field is a shape it cannot express — partial rendering would be worse than none,
because a field missing from the form is a setting the operator cannot change.
WP4.2 — uiPlugins() now excludes category "library", which covers both the
sidebar and the mobile overflow since they share the selector. The
/plugins/$pluginId/$ route still resolves, so existing deep links keep working;
library plugins are just not advertised.
WP4.3 — LibraryGrid groups role:"launcher" entries into a rail above the grid,
and the empty state points at the sources surface rather than leaving a bare
grid (after extraction, "no games" is the expected first-run state).
WP6.2 — a migration banner offering one install per still-built-in scanner whose
plugin is catalogued. One button per scanner, never a single "migrate
everything" and never a silent auto-install: installing code stays an explicit
operator act, and per-scanner is what makes it safe to repeat (the claim
suppresses the built-in idempotently, so a half-finished migration is a valid
state).
WP4.4 — i18n en+de (kept under the existing "Game sources" label rather than
minting a third "Plugins"), Storybook stories for the sources card in three
states, the launcher rail and the banner. Gates: orval regen, tsc clean, vite
build clean, check-i18n green at 595 messages for both locales.
Still owed: the browser click-through (the store's Tabs-theme bug shipped
through green types and lint), and an AppShell nav story — that one needs the
plugins query mocked, which does not exist in this Storybook setup yet.
M3 of design/library-scanner-plugins-implementation-plan.md. Target shape: a
first-party scanner plugin is its parsers plus a scan function.
WP3.1 — a parsers module under the new ./library subpath, porting what the six
in-host scanners hand-rolled: text VDF/ACF, the BINARY shortcuts.vdf KeyValues
walker with its CRC-32 appid derivation and the 64-bit rungameid composition,
read-only SQLite (bun:sqlite, immutable=1 so a scan can never take a lock or
spawn WAL sidecars next to a launcher's live database), a reg.exe wrapper,
capped readers, the path-confinement join that keeps a crafted goggame-*.info
from pointing a launch at an arbitrary program, Steam root/library discovery,
art location helpers, and a fetch helper carrying the host's no-redirect
anti-SSRF posture. Every parser is total: a missing launcher or a truncated file
degrades to "no titles", never to a throw.
Two deliberate departures from the Rust originals, both about the Windows
runner's account: steam root discovery now also reads HKLM Valve\Steam
InstallPath (a non-default install dir was previously uncovered), and the
registry wrapper refuses HKCU outright — as LocalService that is not the
operator's hive, so reading it would silently look like "not installed".
WP3.2 — GET/PUT /__config on the kit's UI server, so a plugin with settings does
not ship an SPA (closes G8). GET answers {schema, value}: the derived JSON Schema
and the raw operator-authored config. PUT validates by decoding and only then
persists RAW, so defaults are never baked into the file. The handler is split out
as makeConfigHandler and driven directly in tests.
WP3.3 — defineLibraryPlugin wires SyncEngine (poll + fs-watch + debounce), the
store-claiming reconcile, launcher entries appended to every sync, a UI server
serving only __config under category "library" (which keeps six installed
scanners out of the console nav), and the standard detect/scan/uninstall CLI
verbs. It warns ONCE when a pre-M2 host silently ignores the store claim — that
degradation is otherwise invisible except as duplicated titles.
M0/S2 is recorded here as a committed fixture rather than prose. Two findings the
original spike missed because deriving a schema does not exercise it:
withDecodingDefaultKey takes an Effect, not a thunk — a thunk type-checks, derives
fine, and dies at decode time; and a checked schema (Schema.Int) nests its
annotations under allOf, so a form must merge those branches. Both are pinned.
plugin-kit: version 0.3.0, tsc clean, 46 tests pass (16 ported parser tests, 10
config/derivation). Publishing (WP3.4) is deferred — it needs a tag and a push.
M2 of design/library-scanner-plugins-implementation-plan.md. Everything a
library scanner plugin needs is now expressible over the API; all additive.
WP2.1/2.2 — store claims (D2). library.json gains a v2 shape ({entries, claims})
that loads the v1 bare array unchanged and is written on the first mutation.
PUT /library/provider/{p}?store=<s> claims a store for a provider: its entries
then surface with deterministic <store>:<external_id> ids and the store's own
badge instead of opaque custom:<id> ones. That identity is the whole point —
entry ids, GameStream FNV app ids, client art caches and Moonlight pins all
survive a title moving from an in-host scanner to a plugin. One provider per
store (409 otherwise); DELETE releases; an empty reconcile does NOT (a store can
legitimately have zero titles). While a claim is held, all_games() skips the
matching built-in scanner, so the two never double-list during the bridge.
WP2.3 — DetectHint gains steam_appid and env_marker, the two store-derived
signals the host used to read for itself. Without them a steam plugin's lease
tracking would drop from reaper-exact to dir-prefix, and Heroic-under-Proton
would lose the only signal that works. Malformed markers are dropped, not
honoured — this feeds a path that can end processes.
WP2.4/2.5 — role: game|launcher on the entry shapes (serde-default, skipped when
default), and a steam_ui launch kind valued bigpicture|desktop that opens the
Steam client itself. Validated inbound as well as at launch.
WP2.6 — GET/PUT /library/scanners generalizes to SOURCES: built-in scanners
minus claimed ones, plus claimed stores, plus any provider with entries. The
same library-scanners.json disabled-set backs all of them and the ids match by
construction, so a user's disabled state carries over verbatim through the whole
migration. A disabled plugin source has its entries filtered at read time,
exactly like a disabled scanner.
WP2.7/2.8 — plugin registration gains a category field (the console keeps
library plugins out of the nav); index entries gain categories and per-platform
detect probes, evaluated existence-only into CatalogEntry.detected so the host
never re-grows per-store knowledge. Index SCHEMA stays 1 — additive.
WP2.9 — OpenAPI + SDK regenerated on Linux; kit wire widened (LaunchSpec.kind is
now a plain string documented against the host's vocabulary — closes G3), and
ProviderClient.reconcile takes an optional store and returns the host's echoed
entries so a caller can detect a pre-M2 host silently ignoring the claim.
Also fixes a bug the S3 spike turned up: is_steam_launch gated on a steam:// URI,
so a steam_ui launcher entry would have skipped BOTH gamescope's --steam mode and
the B1 single-instance free — on a box autologged into game mode, the nested
second Steam would see the first and exit, crashing the spawn. It now tests the
first token.
Gates on .21: workspace tests green (punktfunk-host 425 passed), workspace
clippy -D warnings clean, cargo fmt --all --check clean, OpenAPI drift test
green. plugin-kit: tsc clean, 20 tests pass.
M1 of design/library-scanner-plugins-implementation-plan.md — behavior-frozen
groundwork for lifting the six scanners out into plugins.
WP1.1: heroic_command/heroic_launch_prefix, epic_launch_uri, gog_spawn,
valid_steam_appid and shortcut_gameid move into library/launch.rs with their
unit tests. The scanner modules beside it now do enumeration only, so they can
be deleted wholesale later without taking launch logic with them (D1).
WP1.2: is_local_art_path accepts file:// (the plugin contract) and POSIX
absolute paths, excluding the two /-leading shapes the host itself emits (its
own /api/ proxy path and protocol-relative CDN URLs). local_art_bytes
percent-decodes and converts a file:// value first. The art proxy and
fetch_box_art resolve ANY id against library.json before the legacy steam:
branch, so a plugin's entries serve art without the host knowing its store.
No API change; no user-visible change.
The Windows build of pf-client-core has been red on main since the
pad-audio merge (#23): pad_audio.rs calls
`crate::audio_wasapi::device_by_id`, but lib.rs mounts audio_wasapi.rs AS
`crate::audio` via the #[path] per-OS swap — the `audio_wasapi` module
name never exists. Windows-gated call site, so every Linux leg stayed
green while both `windows / build` targets failed E0433.
One-line rename to the mounted path (+ the comment that pointed readers
at the phantom name). Verification is the PR's own windows leg — the
crate builds on no other platform this path compiles on.
Stall program T2 (amplification kill), the phantom-latch half. A deciding
window that delivered under a quarter of the target rate (a host-side
capture stall, an outage, a mid-window pause) carries starvation-shaped
distress — a jump-to-live flush, a keyframe-ask burst — that the decode-cap
latch read as decoder evidence: under a periodic capture stall (the RDNA4
standby-sink field cases, one stall every ~5 s) every edge offers another
'backoff' at the SAME rate, and one pair latches a phantom decoder knee at
whatever rate the display driver happened to interrupt. The session then
fights the cap's re-probe ladder (+12.5% per 16-128 clean windows) for
minutes on a decoder that was never the problem.
Starved windows still back off (real damage deserves the safe response) but
take the same 'not a knee sample either way' arm as a draining backoff:
they neither latch a decode cap nor erase the reference a genuine choke
set, so a real knee's pair still finds itself around the interruption. The
¼ bar sits deliberately far under the ×¾ utilization bar climbs require.
Gates: 44 abr tests green (2 new: the stall-cycle no-latch scenario and the
reference-preservation scenario), full core lib suite 346 green
(--features quic), fmt + clippy clean.
Stall program T2 (amplification kill), the resume-burst half. The native
pace budget was min(0.9 × time-to-deadline, overflow at ~3× stream rate) —
for steady-state frames the rate term is smaller and decides, but for an
OVERSIZED frame (a capture-stall resume carrying seconds of scene delta, a
cold IDR) the deadline term clamped a multi-interval overflow into the
remainder of ONE: an instantaneous many-×-stream-rate blast that overruns
the socket tx-buffer and loses the very frame that would have ended the
freeze. Field fingerprint across three RDNA4 standby-sink cases:
WSAENOBUFS(10055) + loss_ppm spikes at stall edges, then a recovery-IDR
round trip per retry while the client shows 'current bitrate 0.1'.
The budget is now the overflow's wire time at the pace rate itself
(send_pacing::native_budget, pure + unit-tested), bounded by an absolute
100 ms ceiling so a pathological frame can't park the send thread; the
deadline stays a target, never a license to blast. Steady-state frames
produce byte-identical schedules (the rate term already decided);
PUNKTFUNK_PACE_FACTOR=0 keeps the legacy deadline-only spread; the
GameStream plane's Moonlight-pinned schedule is untouched.
Gates: host clippy --all-targets -D warnings + 9 send_pacing tests green
(linux/amd64 container), fmt clean.
audit.yml's three blocking bun-audit legs (web, sdk, plugin-kit) were all red on
main. Ten findings in sdk and plugin-kit, eight in web; every one of them a
transitive dependency, none reachable by bumping a direct dep.
web already carried the right mechanism — an `overrides` block whose `undici` and
`fast-uri` pins had simply gone stale — so it needed four bumps, not a new idea:
undici 7.28.0 -> ^7.29.0 and fast-uri 3.1.4 -> ^3.1.5 for the reported advisories,
plus postcss ^8.5.10 -> ^8.5.25 and brace-expansion ^5.0.8 -> ^5.0.9 for two more
that were published after the failing run and would have gone red on the next
audit anyway. All four stay inside their current major.
sdk and plugin-kit were harder and the fix deserves an explanation. Their single
finding is undici 8.7.0/8.8.0 pulled in by @effect/platform-node, a devDependency
pinned at 4.0.0-beta.98. That dependency already declares `undici: ^8.7.0`, which
permits the fixed 8.10.0 — the vulnerable version survives purely as a stale
lockfile resolution. Nothing bumps it in place: `bun update` only walks direct
dependencies, `bun install --force` preserves a resolution that still satisfies
its range, and every platform-node release through beta.103 declares the same
`^8.7.0`, so moving the dep changes nothing. Bun rejects the scoped form outright
("Bun currently does not support nested resolutions"), so a flat `overrides` entry
is the only mechanism available, and it necessarily also moves sdk's top-level
undici from 7.x to 8.x.
That is safe here, and was verified rather than assumed. The only source use is
sdk/src/config.ts, which does `new Agent({ connect: { ca } })` behind a dynamic
import and a try/catch with a documented plain-fetch fallback; `Agent` and its
`connect` option are unchanged between undici 7 and 8. sdk typechecks and its 72
tests pass against 8.10.0; plugin-kit typechecks and its 20 tests pass. Both trees
now dedupe to a single undici 8.10.0.
Consumers are deliberately untouched: `overrides` apply only at the root of the
tree that declares them and are not honored when the package is installed as a
dependency, so sdk's published `optionalDependencies: { undici: "^7.0.0" }` is
left alone — a consumer resolves the latest 7.x, which is the fixed 7.29.0. The
override governs this repo's own tree, which is exactly what audit.yml checks.
Worth knowing: sdk's dev tree therefore exercises undici 8 while consumers get 7.
One trap found on the way. Running `bun install` over plugin-kit's existing
lockfile emitted a lockfile with two byte-identical `@punktfunk/host` entries —
its `file:../sdk` dependency crossed with the new override — and bun then refuses
its own output with "Error loading lockfile: InvalidPackageKey". That reads as a
tooling error rather than a finding, so it would have taken the audit gate down
while looking like something else entirely. Regenerating the lockfile from scratch
produces a valid single entry; all three lockfiles are checked for duplicate keys.
Also worth recording, because it nearly shipped: deleting the pinned nested entry
from a lockfile makes `bun audit` report "No vulnerabilities found" while the
vulnerable copy is still installed on disk. bun audit reads the lockfile, not
node_modules. That is a vacuous green, not a fix, and was rejected.
Verified: `bun audit` clean in all three trees; web builds and typechecks (its
typecheck needs the build first, which generates routeTree.gen); sdk 72/72 and
plugin-kit 20/20 tests pass.
Everything the 2026-08-03 haptics sweep filed against the pad-audio branch (P2 + P3).
Four of them are the difference between a feature that works and one that fails silently.
**B6 — nothing ever un-muted the coils.** Every rumble report asserts `HAPTICS_SELECT`,
which is SDL's "disable audio haptics" bit: the firmware mutes the very voice coils the
0xD1 stream drives. No code anywhere cleared it again, so ONE rumble left tier-A haptics
silent for the rest of that pad's life — no error, nothing in a log, and the host happily
streaming into a muted actuator. `DsDevice.ds5AudioHapticsReport` is the documented undo
(flag0 with both bits clear); written EP0-direct when the stream starts and again after a
rumble stop while a stream is live, because the stop report re-mutes on its way past.
**B10 — the desktop mix could reach a controller's coils.** Pad endpoints were filtered out
inside `plan()` only. The watchdog, Follow mode and the parked default all go through
`judge_default`, which classifies by NAME — and a pad endpoint is deliberately stamped
"DualSense Wireless Controller" so games treat it as the pad's speaker. No name rule could
ever catch one. It now refuses them by identity.
**B27 — an out-of-range pad aliased onto a real slot.** The 0xCD plane's pad is the only u16
index and every consumer narrowed it with `as u8` on an assumption nothing enforced, so wire
pad 256 steered pad 0's speaker volumes. Rejected at the decoder, which makes the narrowings
lossless by construction. An existing test had pinned the bug in place, asserting that wire
pad 513 round-trips; corrected, plus a test for the 256→0 alias specifically.
**B7 — caps that arrived late were never announced.** The renderer commits the tier-A trade
only once its sink opens, which is well past the arrival burst's two 100 ms ticks, and
`set_pad_audio_caps` only stored an atomic. The client believed it had pad audio while the
host emitted nothing. The input task now compares the live registry against what the last
arrival actually carried and re-arms the burst itself — no new plumbing, and no extra traffic
when nothing changed.
The rest: `needs_aeb_kick` is finally ACTED on (R4) — a stored-but-not-served endpoint is
declined rather than opened, because `AUTOCONVERTPCM` makes it succeed and mis-route; a failed
provisioning no longer latches `PROVISIONED` for the process lifetime (R5), and `host_cap`
retries, so a host that started while the audio stack was busy recovers at the next connect
instead of the next reboot; the loopback init timeout reaps its thread instead of detaching one
per ~2 s reopen (R6); kind-change restarts are bounded (R3) since the trigger is a client-sent
arrival; the devtest uses the endpoint's real channel mask (B11) instead of letting wasapi
derive 0x0F against the endpoint's 0x33; the render loop asks `is_session_ended()` rather than
spinning at nice -16 (R12); short writes are counted and reported instead of dropping the tail
in silence (R13); and a frame addressed to another pad is dropped before it can seed the gap
tracker from a foreign sequence space (R14).
Verified: punktfunk-host clippy -D warnings **0 on a real Windows box**; Linux/amd64 clippy 0
with **589 tests** (pf-client-core 114, pf-inject 101, punktfunk-client-android 20,
punktfunk-core 345+1+8); Android :kit: tests + :app: compile green; fmt clean.
Six punktfunk-host tests fail on that Windows box. FIVE fail identically on a tree with no
pad-audio code at all (QUIC `Rejected(SetupFailed)` — the box's network environment); the
sixth passes 3/3 in isolation and only failed under the parallel run, on a locally-bound
ephemeral port. Neither is this change.
Still owed: on-glass. This is a hardware feature and none of it has been on a real DualSense
since the merge.
86 commits of main, including the whole M1-M12 haptics sweep. Twelve conflicting files;
three of them were more than textual.
**The capability bits collided.** Both branches allocated the SAME wire bits for DIFFERENT
features: `client_caps 0x04` and `host_caps 0x20` are redundant desktop audio on main and
pad audio here. Merged naively, a peer would negotiate one and get the other. Pad audio
moves to the next free bits — `CLIENT_CAP_PAD_AUDIO = 0x08`, `HOST_CAP_PAD_AUDIO = 0x40` —
and the `abi.rs` mirrors move with them (their compile-time equality assertions caught the
mismatch, which is exactly what they are for).
**Both branches also claimed ABI v15.** Main's shipped (the rumble-policy floor), so the
pad-audio surface becomes **v16**.
**`native/input.rs` would have reintroduced a fixed bug.** This branch resets
`rumble_seq[idx]` on pad removal; M1 established that the client's reorder gate is
per-connection with no reset path, so restarting the host counter strands every later
envelope until it climbs back. Took main's seq-preserving `clear_pad_feedback` and kept only
the branch's `pad_streams.stop(idx)`.
The rest: `wiring_plan::plan` now delegates to main's `plan_with_formats`, so the pad-endpoint
filter moved into that body and the predicate behind it is factored out as `is_pad_render`
(also what B10 needs); `Ds5Feedback::AUDIO` derives from main's `REPORT_ID_LEN` like its
siblings; `AudioCtl` joins the explicitly-listed unhandled variants so the guard-false case is
covered rather than swept up by a `_`; `include/punktfunk_core.h` regenerated rather than
hand-merged.
Twelve findings from the sweep's DRY/docs/dead-code tail. Most are small; three found
real defects hiding behind the duplication.
**The UHID event ABI existed five times.** Every UHID gamepad backend — DualSense,
DualShock 4, Switch Pro, Steam Controller, Steam Controller 2 — carried its own verbatim
copy of the kernel's constants plus its own `put_cstr`, and they had already drifted:
`switch_pro` was missing the SET_REPORT pair entirely, and `steam_controller` read a
FIXED 16-byte SET_REPORT window instead of the event's own `size`. That last one is a
bug in both directions — a longer report was truncated, and a shorter one had the parser
reading whatever the reused event buffer still held past the payload, i.e. acting on
rumble values the game never wrote. Now one `uhid_abi` module owns the numbers plus the
two accessors that are easy to get subtly wrong, with tests on exactly that.
**A dead force-feedback id fallback.** ff-core's `input_ff_upload` picks a free effect
slot and writes it into the effect BEFORE uinput forwards the request, so the `id == -1`
branch could never run — and allocating from a local counter would have been the wrong
answer anyway, since the kernel owns that id space. Removed, with a `debug_assert` where
it stood.
**Apple's HID path silently dropped weak rumble.** `hidByte` took the top byte with no
non-zero floor, so every amplitude below 0x0100 rendered as exactly nothing. Android has
always floored it at 1; this was the odd one out. That converter also existed twice
byte-identically inside one Gradle module — now one `wireAmplitudeToByte`.
Also: the DS5 output-report layout gets named offsets (`dualsense_proto::out_report`)
documenting all three transport bases — USB 0, SDL payload −1, Bluetooth +2 — since the
differing bases are transport-forced, not drift. `pf-client-core` cannot import them (it
and `pf-inject` do not depend on each other, and a DualSense layout has no business in
`punktfunk-core`, their only shared crate), so its copy now DERIVES its offsets by
explicit subtraction and a test pins the relationship. `PUNKTFUNK_HID_EFFECT_MAX` sizes
the struct it describes instead of a second literal 11 — the header now emits
`uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]`. The rumble policy engine's `min_pulse_ms`
and `keepalive_ms` docs stop naming cases nothing implements: no in-tree caller sets
`min_pulse_ms`, and the macOS DualSense-over-BT keepalive the doc cited CANNOT be served
by the quirk, because that renderer skips writes whose levels are unchanged and would
swallow the engine's re-emit — it keeps its own keepalive instead. `TrackpadHaptic` is
marked as staged scaffolding (the tag is on a shipped wire; removing the variant would
not reclaim it). Three ×257-vs-`<<8` doc comments corrected — the scaling itself is fine,
both round-trip to 255. `backstop_ms.max(160)` deleted as unreachable (the engine floors
at 500). New tests for `Ds5Feedback` and for the Android rumble JNI packing on BOTH sides,
with `MAX_PADS <= 16` now a compile-time assertion rather than a comment.
Closes S1-S9, S11, T2, T3 (design/haptics-sweep-2026-08-03.md M12).
S11's second half is NOT a defect and was left alone: `clients/session/src/main.rs`
calls `set_forwarding` unconditionally on every params-build (its own comment explains
why — browse mode reuses one service across launches), so `Ctl::Forwarding` routinely
arrives unchanged and that early-out is what stops a redundant `sync_open` + Valve-HIDAPI
cycle each launch.
Verified: pf-inject clippy -D warnings 0 / 91 tests; pf-client-core + punktfunk-core
clippy 0 / 437 tests (amd64 container); punktfunk-client-android 7 tests; Android :kit:
6 tests; Apple swift build + 189 tests / 0 failures; cargo fmt --all --check clean. Each
new test probed by reverting its fix — the fixed SET_REPORT window fails 3, a broken pack
shift fails 3, dropping the amplitude floor fails 1, and a wrong DS5 offset either fails
the pin or refuses to compile.
Three things a field report (Discord, upgrade from 0.1x) turned up, all on the
Windows host.
1. "It thinks I have Sunshine/Apollo running." It didn't — they were uninstalled.
Both uninstallers leave their config/log directory in Program Files behind, and
`detect.rs` counted a bare directory, or a service registered at ANY start type
(including `disabled`), as a live conflict. The installer's own probe was
narrowed to "service start type <= 2" after exactly this cried wolf on a
`winget install`, and the tray dropped its always-on warning for the same reason
in 3e782852 — the runtime probe never got the same treatment, so the one surface
the user actually looks at kept shouting. `Evidence::is_active` now draws the
line (running, or set to start on its own) and only active detections reach the
startup warning, the `detect-conflicts` exit code, and `/local/summary`. Dormant
findings still print in the full report, under a heading that says they need no
action — that report is where "why does it think I have Apollo?" gets answered.
2. The console's conflicts card hardcoded "Another game-streaming server is
**running** on this machine" regardless of what was found, so a dormant leftover
was announced as a running server. It now says "active", and each entry names
the observation — `Sunshine (running)`, `Apollo (starts automatically)`.
3. "The exclusive screen never times out going back to re-enabling the display."
`isolate_displays_ccd` deactivates the operator's panels and hands the
pre-isolate topology to the caller, which restores it at teardown — but that
snapshot is PROCESS MEMORY, and Windows deliberately never saves the isolated
topology to the CCD database. So a host that crashed, was killed, or was stopped
mid-session left the desk dark with nothing in the product to undo it. There was
one startup recovery leg already, but only for the EXPERIMENTAL
`pnp_disable_monitors` axis, which is off by default — the default Exclusive path
had none. `isolate_journal` now marks what an isolate is about to switch off
(before the apply, so dying mid-apply is covered), clears the mark on restore,
and force-EXTENDs at host startup if a mark survived. EXTEND rather than
replaying the saved blob: the blob pins the virtual display's target id, which
dies with the crashed host, so a replay would mostly fail BAD_CONFIGURATION into
the very same backstop `restore_displays_ccd` already keeps — and EXTEND stays
correct across a reboot, where saved ids would be stale.
Turn "Forward controllers" off and four rows below it stop meaning anything — nothing is
forwarded, so there is no pad type to pick and no guide button to route. GTK desensitised
them, the touch settings on both mobile clients dimmed them and the console UI refused the
step; the Windows client and BOTH controller-navigable screens left them fully live, so you
could sit there changing settings that did nothing.
Windows: `.enabled(s.gamepad_forwarding)` on the forwarded-controller picker, pad type,
guide button and hold-Select rows — the same builder the echo-cancellation row already used
to follow the mic switch.
Apple's gamepad settings had no way to say it: `Row` carried `adjustable` (which only hides
the chevrons) and nothing else. Added `Row.enabled`, dimmed the row CONTENTS only so the
glass still reads as a focusable row, and enforced the inertness centrally in `adjust(id:)`
/ `activate(id:)` rather than in each builder's closure. The hint bar drops "Adjust"/"Change"
on a dimmed row, because advertising them was the same lie the live row told.
Android's gamepad settings already had `GpRow.enabled` — documented as "dimmed + inert" —
but it only faded the label: every dimmed row still stepped and still wrote its setting. The
"No profiles yet" placeholder looked inert only because its own closures were empty. Made it
real in one named place (`liveRow`), covering all three input paths (left/right, A, and a tap
on the already-focused row), then gated the pad rows on it.
Also on that screen: the DualSense / DualShock passthrough toggle, which the touch settings
have carried beside its SC2 twin all along. It was missing exactly where it matters most —
a TV box has no touch interface to fall back to, so there was no way to reach it at all.
Apple capture, separately: with forwarding off, opening a slot still claimed EVERY element's
system gesture and powered the controller's IMU. Neither reaches the host, so the first only
took the user's screenshot/Home gestures away for nothing and the second drained the pad's
battery streaming gyro over Bluetooth. Narrowed rather than skipped — the escape chord is
read off the same slot and on tvOS is the ONLY controller way out of a stream, so the chord's
own four buttons keep their claim. A test pins the alias list against the chord mask; if they
drift the symptom is a session nobody can leave, with nothing logged.
Closes R17, R18, R19 (design/haptics-sweep-2026-08-03.md M11). R17 as filed named Windows and
"Apple"; Apple's TOUCH settings were already correct and Android's controller-navigable screen
was not — both corrected here.
Verified: Windows clippy -D warnings exit 0 on a real Windows box; Apple swift build clean +
full suite 192 tests / 0 failures (3 new); Android :app: + :kit: green (5 new); cargo fmt
--all --check clean. Each fix probed by reverting it — every probe failed the tests it should.
Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26
opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes
it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for
one press — the local one covering the stream.
Two cross-client tier-P settings, zero wire changes:
- system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto
forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the
same physical press no matter what.
- guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's
guide, down until release — held on, that's the host's long-press, which
opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered
on release with its up TAP_PRESS (50ms) behind, because per-transition
sends fold into seq'd GamepadState snapshots and a back-to-back pair can
coalesce into no press at all. A Select inside a combo (the escape chord)
passes through untouched. Auto arms it only where the raw press can't
reach the host cleanly: gamescope, iOS/iPadOS, tvOS.
The same SelectGesture rules live in pf-client-core (pure state machine +
unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and
Android's GamepadRouter. Settings rows on every surface (GTK, WinUI,
console UI, Decky, Apple x2, Android x2) with profile plumbing throughout.
punktfunk-session grows a control socket
($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one
runtime path a flatpak and the host see identically): 'guide'/'qam' verbs
inject synthetic taps. The Decky panel gains a Host menus section (visible
while the client runs) whose buttons press the host's Steam/QAM and close
the local menu so the host's shows through.
iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the
class needs the Xcode 27 SDK to compile). Docs: input, client-settings,
steam-deck. Design: punktfunk-planning design/system-buttons-routing.md.
Gates: docker clippy --all-targets --locked -D warnings + tests
(pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47),
cargo fmt --all --check, swift build (macOS), gradle kit+app compile,
decky tsc --noEmit + py_compile. clients/windows not compiled (no box).
- Six mock hosts rather than three. An iPad-13 portrait grid is three columns
wide and 2752 px tall; three cards left ~60% of the capture as black.
- Settings opens on Display, not General. Resolution, frame rate, bitrate,
HDR and codec are what someone reads a streaming app's settings shot for.
- The wake scene is the modal-over-grid variant. The gamepad-UI one is a
full-screen takeover over a bare gradient — correct, but four lines of text
on an empty aurora; the modal shows the same overlay over the host grid.
- `requestGeometryUpdate` now reports a refusal instead of failing silently.
It does not help on the simulator (an app's stdout doesn't reach the driver
through `simctl launch`) but it will on macOS and on a device.
- Documented that `.landscape` does not rotate on iPad: a multitasking-capable
iPad app is resizable, so iPadOS ignores the request and simctl cannot
rotate a simulated device. The iPad set is portrait throughout.
Uploading the 0.24.0 set surfaced a pair screen that reads as broken, and a
hero that was never the orientation it claimed.
The capture harness:
- Landscape scenes were captured in PORTRAIT. `IOSOrientationConfigurator`
asked for the geometry update from `updateUIViewController`, where
`view.window` is still nil — SwiftUI makes one update pass for a
`.background` representable, before the hierarchy is in a window, so the
guard fell through and nothing ever asked again. Both `.landscape` scenes
(the stream hero, the trust card) shipped as portrait. Now a real
UIViewController asks from `viewDidAppear` and pins
`supportedInterfaceOrientations`.
- The shot host applied `.ignoresSafeArea()` to the whole scene, so the
hero's HUD — resolution, bitrate, the latency breakdown, the entire point
of that screenshot — sat under the Dynamic Island. Only the black backing
ignores it now; scenes that want full bleed already ignore it themselves.
- `03-pair` was hand-composed into a ZStack rather than presented. PairSheet
is a bottom sheet on iOS: its detents and the system's Liquid Glass only
exist inside a real `.sheet`. Composed, the grouped Form stretched to full
screen height and the capture was a strip of content over a black void,
with a DISABLED "Pair & Connect" (empty PIN) and the capture simulator's
own name — `pf-shot-iphone-6.9` — rendered in as the device name.
- Sheets do not inherit `.environment(\.colorScheme, .dark)` across the
presentation boundary; they follow the DEVICE. The pairing sheet came out
light grey over the dark app. The simulator is now set to dark appearance.
- Discovery browsed the live LAN mid-capture, so a bystanding machine's
hostname went out on the listing and no two runs matched. `HostDiscovery`
gains a `debugSet` seam (the counterpart to `HostWaker.debugSet`); the
mock hosts advertise, so cards read ONLINE through the real `advertises`
path and the reachability probe never touches the network.
- Created simulators were named `pf-shot-<prefix>`, which the reuse regex
never matches: every run created another simulator and none was reused.
They are named after the device now — reusable, and not user-visible junk.
Two bugs found on the way, neither screenshot-only:
- HostStore/ProfileStore PERSISTED the harness's mock data. On a dev Mac
that is the same App-Group suite the real app reads, so running the
script could replace the tester's saved hosts with "Battlestation" & co.
- GamepadHomeView drew the controller chip as a trailing `.overlay`, which
reserves no width — on a portrait phone it sat on top of the centred
"Select a Host". Laid out as a row with a hidden leading mirror.
- The pairing sheet's field prompt said "How the host lists this Mac" on
iPhone and iPad.
Coverage: the listing set is six scenes in listing order, and is now the
stream, the machines it found, the couch/controller mode, waking a sleeping
host, the quality controls and pairing — the console and wake screens
already existed in `ShotScenes.all` and were simply never captured. Mock
hosts carry OS marks, Wake-on-LAN MACs and profile chips so the grid is
full rather than three offline rows over an empty half-screen. `SCENES=`
overrides the set for the dev scenes.
An adversarial review of this branch found a regression I introduced, plus three smaller
defects. All four are fixed here, each verified on .21.
**The regression.** `mergeHosts` names a host by its record's stable id, and `hosts list --json`
always emits one (`KnownHosts::load` mints ids for every record). So a launch always went out as
`punktfunk launch <uuid>` → `ConnectPlan::for_host` → `HostTarget::from(&KnownHost)`, which
copies the address stored ON THE RECORD. Meanwhile the panel deliberately renders the LIVE
advert's address. Nothing on a Deck ever writes a moved address back — `discover` and
`hosts list` are both reads, and only the desktop shells' hosts pages update one.
So after any DHCP move the row read "online" at the new address and every press dialled the old
one: a 15 s dead connect, or — if a MAC had ever been learned — a black Steam "game" for the
full 90 s wake budget. Proven with a stub session binary: `launch abc-123` emitted
`--connect 10.0.0.5:9777` for a host answering at `10.0.0.99`.
This worked on origin/main, which dialled `toHost(v).host` — the advert's address. The fix
restores that without giving up stable ids: `hosts add <new-addr> --fp <known-fp>` now MOVES the
matching record instead of filing a second one (the fingerprint is the identity — this is the
same rule that makes the verb idempotent), and the panel re-points a host it can see has moved
before launching it. Verified: `moved 10.0.0.5:9777 to 10.0.0.99:9777`, one record still, and
`launch abc-123` then emits `--connect 10.0.0.99:9777`.
**"No hosts yet" was also how a missing client looked.** `_cli_argv()` returning None becomes
`client-unavailable`, which the panel dropped on the floor — so a Deck with no client installed
was told its network was empty, under a button that launches the client that isn't there. It now
says which of the two it is.
**The browse worker never exited on a quiet LAN.** `discover_for` drops the receiver and the
doc claimed that stops the thread. It does not: the worker parks in `recv()`, and the arms that
ignore an event (`SearchStarted`, `ServiceFound`, `SearchStopped`, a v6-only advert) never touch
the sender, so on a LAN with no Punktfunk host nothing ever wakes it. Harmless today because the
only caller is a short-lived CLI process, but the function invites in-process use, where it would
leak a thread and an mDNS daemon per call. Now polled with a 250 ms tick and a check at the top
of the loop. Verified: ten back-to-back browses settle back to the baseline thread count.
**A `pair=optional` host was recorded as paired.** Every unsaved host now goes through the trust
sheet (it has no pin, so it cannot stream without one), but the sheet's only non-PIN action ran
`--request-access`, which persists `paired: true` on Ready. An optional host admits anyone who
pins its identity — there is no operator decision, so nothing was approved and the same box read
"paired" here and "trusted" in the desktop client. Such a host now gets **Connect** instead,
which pins and streams without claiming an approval, and the "approve this Deck" toast is no
longer shown to someone who has nobody to ask.
Also: `PF_CLIENT_BIN` was the one launch-option value never validated — a client installed under
a path with a space would split Steam's tokenizer.
German-first Promotional Text, Descriptions, Keywords and App Review notes,
plus the app-specific privacy text the existing website policy is missing.
Every character-limited field is checked by check-limits.py, which also catches
headings whose stated count has drifted from the real length. Three things the
brief assumed turned out not to hold, and the copy says so rather than shipping
the claim: a Mac cannot act as a host, the published privacy policy covers only
the website, and the App Review notes field caps at 4000 characters.
PF_HOST is gone; the browse branch is keyed on PF_BROWSE alone and runs the SESSION binary,
which is the one path this rework deliberately did not repoint. Comment only.
`KnownHosts::load()` mints a stable id for any record that lacks one and SAVES it — which makes
it a write, and `discover` was calling it purely to annotate what the browse found with
saved/paired. It never hands those ids back to anyone.
That matters because the Decky panel issues `discover` and `hosts list` together, in parallel.
Against a store written before ids existed, both processes read it, both mint DIFFERENT ids for
the same record, and both save. Whichever loses the race has already handed its ids to its
caller — so the panel could draw a row whose host reference no longer resolves, and pressing it
would exit 5 ("no saved host matches") until the next refresh settled things.
`KnownHosts::read()` is `load` without the mint: the store exactly as it is on disk. `discover`
uses it; every caller that dials a host by id still uses `load`, so ids are still minted the
first time anything needs one.
Verified on a fixture store with no ids: `punktfunk discover` leaves it byte-identical, and a
following `punktfunk hosts list` mints as before.
`mergeHosts` filled a row's fingerprint as `s.fp_hex || advert?.fp || ""`, so a host saved by
address — nothing pinned on disk — borrowed the fingerprint of whatever was advertising at that
address and rendered as ready to stream. The launch then refused for want of a pin, from a row
that had just shown "Stream" and "trusted".
Under the old rule the mistake was mostly hidden, because `needsPair` asked a different
question for saved and unsaved rows. This rework makes a pinned fingerprint the ONLY rule, so
the same conflation would now decide the whole thing.
The two are different facts and are now separate fields. `fp` is what the RECORD pins — the
thing the session binary requires. `advertisedFp` is what the host is offering right now, which
is what request access would pin, and moving one to the other is a trust decision the user
makes in the sheet rather than something the merge does behind them.
The trust sheet gates on and pins `advertisedFp` accordingly: a saved placeholder that happens
to be advertising can now be let in with request access, and one that isn't still gets the PIN
path with the reason.
The "a native install with no sibling CLI resolves to None" check created
/tmp/pf-test-native/bin/punktfunk and never removed it, so the assertion that the sibling is
ABSENT held only on the first run on a given machine and failed on every rerun. Caught by
running the suite twice.
The plugin's settings tab, fullscreen page, host editor and games picker are gone, and the
docs described all four in detail. Sweeps clients/decky/README.md and the docs site.
steam-deck.md gains a **Request access** section — the no-PIN path where the host's operator
approves the Deck, which is the one genuinely new thing a user gets — and says plainly where
the settings went: **Open Punktfunk → Settings**, the same rows over the same store, one tap
from the same panel. A removal that reads as a regression is worth a sentence, not a silence.
The troubleshooting table drops the rows for surfaces that no longer exist and gains the two
questions the new path will actually raise ("request access isn't offered", "the stream just
sits there").
client-settings.md claimed ~18 settings were "offered by … and Decky". None are; the console
home offers them. Its intro now names the console home's real sections (Stream, Video,
Presentation, Audio, Controller, Touchscreen, Interface, Profiles) instead of describing the
deleted sidebar.
Three claims in that file turned out to be wrong ALREADY, independent of this rework, and are
fixed here because verifying against crates/pf-console-ui/src/screens/settings.rs is what
found them:
• "Render scale — offered everywhere except the console home's list". RowId::RenderScale has
been in the console's ROWS since 2026-07-31.
• wake-on-lan.md: "Punktfunk Console has no auto-wake setting of its own". It does —
RowId::AutoWake, "Wake hosts automatically". Its Wake & Connect BUTTON is independent of
the setting, which is the true half that sentence was built on.
• The console home's Library button was documented as gated on the "Show game library"
toggle. It isn't — `library_enabled` appears nowhere in pf-console-ui outside the toggle
row itself; home.rs offers Library on any paired, saved host.
Also updated: support-matrix (Decky's Profiles and Game library go ✅/❌ → ⚠️ — the panel shows
pinned profile cards but creates none, and the library lives in the console home),
wake-on-lan (the plugin no longer fires its own packet or stretches the connect budget — the
CLI runs the real wake-and-wait), pairing, game-library, profiles-and-links, input, clipboard
and install-client.
BREAKING (C header only): constants such as MAX_PADS, TAG_LEN, ABI_VERSION,
INPUT_MAGIC and the whole BTN_/AXIS_ family are now PUNKTFUNK_-prefixed.
cbindgen emits a bare #define per `pub const`, so those names landed in the
namespace of every C program that includes the header. The rename table already
said this was the rule and already carried the handful someone had noticed —
and its own comment spells out why it matters: a clashing #define silently
takes the last definition rather than failing to compile, so the failure mode
is a wrong value, not a build error. This is the remaining 149.
Associated constants are deliberately left alone. cbindgen already qualifies
those with their type name, which is the very property whose absence makes a
bare MAX_PADS dangerous — they are namespaced, just not by us.
Nothing in this repository consumed the unprefixed spellings except one Swift
test, which sat next to lines already using the prefixed form because its
constant happened never to have been added to the table; it is updated here.
The C harness links and runs against the regenerated header.
Scheduled deliberately: the sweep flagged this for a release boundary, and
0.24.0 has shipped. External C embedders using the old spellings must add the
prefix; there is no silent breakage, since the old names simply stop existing.
Three wire and ABI faults.
An out-of-range pad index reached one rumble consumer and not the other. It
skipped the reorder gate — the per-pad seq cursor has no slot for it — and was
handed to the legacy queue, while the policy engine discarded it on its own
bounds check, so the comment promising both consumers are fed was false for
exactly these. An embedder draining the queue could be handed an index it would
use to subscript its own per-pad array. The host never emits one, so it is
malformed or hostile either way; both consumers now agree by dropping it before
either sees it.
The adaptive-trigger effect was the only variable-length wire field bounded on
neither side. Encode appended whatever it was handed and decode took the whole
tail, while its sibling raw-report field had been bounded both ways all along;
there is now one constant both sides clamp to. Worse than the missing bound was
the empty case: a body with no effect bytes decoded as an EMPTY effect, and
downstream an empty block is written as an all-zero trigger report, which is
mode 0x00 — release. A truncated datagram could therefore silently cancel the
trigger effect a game was holding. That shape is now rejected outright; a
genuine release is a full-length zero block and still decodes.
The C ABI history had a hole and a symbol nobody versioned. v11 shipped without
its line, and the rumble policy engine's C surface was added while the version
constant still read 7, with no bump at all — so every core since has exported
those symbols while advertising a number that never promised them. A shipped
binary says what it says, so that cannot be corrected backwards; v15 instead
establishes the floor that guarantees the surface, and the v11 line is written
down. No code changed for the bump and nothing moved on the wire.
What is left of the plugin is what only a Decky plugin can do: start a stream through Steam so
gamescope focuses it, and stand in front of the trust decision that gates it. One Quick Access
panel, four sections, no route.
HOSTS. One `useHosts()` calls discover and hosts-list together and merges them by fingerprint
first, address second — so a host that moved DHCP lease still matches its record, and a
different box that inherited the old address does not inherit its pairing. The CLI annotates
`saved`/`paired` by that same rule, so the two surfaces cannot disagree. Rows sort online
first, then most recently used, then by name: the host you streamed last night is the first
thing under your thumb, and a host that is off right now never is.
`needsPair` is now ONE rule: no pinned fingerprint. The session binary refuses a pinless
connect, so a row without one can offer nothing but a button that fails. The old rule also
consulted the advertised policy for unsaved hosts, which made the same box read differently
before and after being saved.
PINNED CARDS render NESTED under their host as `▸ <Profile name>`, not in a section of their
own — a card IS a (host, profile) pair, and a row floating free of its host is exactly the "a
pinned tile reads as a duplicate host" problem the desktop shells still have. The host's own
BOUND profile is deliberately not drawn as a card: it applies silently on the plain row, and
showing it twice would suggest the two do different things. This plugin creates, edits and
deletes no profile and no card — pin creation belongs where profiles are edited.
TRUST SHEET (new, trust.tsx). Request access (default) / Use a PIN instead… / Cancel, in the
GTK dialog's order and wording. Request access is not a second ceremony — it saves the host
with the fingerprint it ADVERTISED, then launches; the host parks that connect until its
operator approves this Deck, admits it, and the stream starts by itself.
No fingerprint, no request access. A host typed in by address advertises none, so the sheet
offers the PIN path only and says why, rather than showing a button that could only fail. The
sheet never TOFUs past a missing fingerprint: that pin is the only thing standing between a
185 s wait and an impostor answering for the host.
The sheet is a `showModal` portal, so it captures its callbacks once and never re-renders from
panel state — everything it acts on later is read through a ref. Reading a captured value is
precisely what made pinning a second game compute from a stale base and clobber the first.
LAUNCH PATH. The wrapper's contract becomes PF_REF / PF_PROFILE / PF_REQUEST_ACCESS /
PF_BROWSE; PF_HOST, PF_LAUNCH, PF_MGMT and PF_CONNECT_TIMEOUT are gone. A stream is now
`punktfunk launch <ref> [--profile <id>] --exec --fullscreen`, and a reference is all that ever
rides Steam's launch options — no resolution, bitrate or codec, the same rule the deep-link
grammar enforces.
Request-access launches run SUPERVISED, without `--exec`: under --exec the CLI becomes the
session, so no process survives to see the stream come up and record the approval. Safe for
gamescope because focus follows reaper's descendant tree, not a single process, and
flatpak-run/bwrap already sit in that tree on every other path.
Wake-on-LAN comes out entirely. The plugin used to fire a magic packet itself and then stretch
the connect budget to 75 s to cover the host's resume — a workaround for the CLI-less era.
`punktfunk launch` runs the real wake-and-wait loop and only dials once the host answers, which
is strictly better and deletes a backend method, a frontend call and a shell branch.
The console-home branch of the wrapper is untouched on purpose: the shell binary already execs
the session for `--browse`, so there is nothing to repoint and no reason to spend a diff there.
Everything else in steam.ts — two shortcuts sharing one name (and so one Steam Input configset
key), artwork versioning, appId verification, controller config, stopStream — is unchanged.
The Decky plugin was a second client. It had its own mDNS discovery, its own host-store
editor, its own settings UI over the entire client settings store, its own per-game pin store
and picker, and its own fullscreen route with three tabs — about 3,000 lines of TypeScript and
Python mirroring, in two other languages, things the Rust client already does. Every one of
them drifted from the original: the TXT parser fell behind each key the host advert added, the
settings screen modelled a subset of a store that kept growing.
They existed because when this plugin was written there was nothing headless to ask. There has
been since v0.22.0, so this deletes them.
GONE, frontend: page.tsx (the fullscreen route), settings.tsx (a seven-page sidebar over the
whole store), hostmgmt.tsx (add/edit/forget), library.tsx (the games picker), ui.tsx (row
primitives only the page used).
GONE, backend: get/set_settings, list/refresh_devices, library, get/set_pins, list_hosts,
add/edit/forget_host, probe_host, reset_config, wake, the avahi browse and its TXT parser, and
the direct reads of client-known-hosts.json.
WHAT REPLACES THE BACKEND is four shells, each about fifteen lines of build-argv-run-parse:
discover() -> punktfunk discover --json
hosts() -> punktfunk hosts list --probe --json
pair() -> punktfunk pair <addr:port> --pin N --name LABEL
trust_host() -> punktfunk hosts add <addr:port> --fp HEX --name LABEL
trust_host is the ONLY write this backend makes to the client's store, and it goes through the
CLI — which writes temp+rename into a user-owned directory, so a root backend driving it
cannot lock the desktop client out of its own files. Nothing here opens client-known-hosts.json
or client-profiles.json any more; `hosts list --json` returns profile bindings and pinned cards
already resolved against the catalog.
_cli_argv mirrors the deleted _session_argv exactly, pointed at `punktfunk`: the flatpak app id
stays LAST, because flatpak treats everything after it as the app's own argv. The
LD_LIBRARY_PATH repair applies unchanged — Decky's PyInstaller leak breaks the flatpak's
libcurl whichever binary inside the sandbox is being started.
A client too old for a verb now announces itself DETERMINISTICALLY: exit 5 plus
`unknown command "<verb>"`, mapped to `client-outdated`, which the panel renders as one
explanatory row plus the update button that fixes it. That replaces guessing from GTK-init
noise, which survives only where the update check still drives `punktfunk-client` directly.
KEPT unchanged in mechanism, because only a Decky plugin can do them: runner_info,
shortcut_art, apply_controller_config, check_update/update_client, kill_stream.
The settings screen is not lost, it moved: console home -> Settings has the same rows over the
same store, is gamepad-navigable, and is one tap from this same panel. Per-game pins have no
shared equivalent yet — decky-pinned.json is deliberately left ON DISK, untouched, so a later
migration can read it.
test-backend.py is rewritten against what is left — argv shape, the exit-code mapping, and the
Steam configset editor, which was untested until now and is the riskiest thing that survived:
it edits a file holding hundreds of other games' bindings, in place.
Two faults in the rich-feedback plane — the lightbar, player LEDs and adaptive
triggers — both of which leave a controller physically wrong with nothing to
put it right.
Nothing reset the pad on teardown. Rumble stops on its own the moment nothing
renews it, but the rich planes are LATCHED in the controller's firmware: they
outlive the stream, the app, and being unplugged. Ending a session while a game
held a weapon's trigger resistance left the physical trigger stiff on the
desktop afterwards, and its lightbar showing whatever the game last set, until
another game happened to set one. The Apple client already reset on teardown;
the desktop and Android halves now do too — triggers to mode 0x00, lightbar
dark, player indicator cleared. Android writes them EP0-direct like its rumble
stop, because the reader thread is stopping and the queue would never drain.
A single lost datagram stranded the pad on the previous value. The plane is
deduped AND rides unreliable datagrams, which is a bad pairing: a change is
forwarded exactly once, so when that datagram is dropped nothing re-derives it
— the game keeps sending the same value and the dedup swallows every copy. The
pad then holds the last weapon's trigger effect, or the last lightbar colour,
for as long as the game keeps that setting, which can be the rest of a level.
The dedup already remembers the current state, so it can repair itself: it now
re-emits what it has latched once a second. Slow on purpose — this is a repair
mechanism, not a transport, and every value is idempotent, so a client that did
receive the original simply re-applies it. A forward re-stamps the clock, so a
plane the game is actively driving never pays for a renewal it does not need.
One-shot pulses are deliberately excluded from that renewal: replaying a
trackpad haptic would be a new pulse, not a repair. Raw passthrough reports are
excluded too — the device's own refresh cadence already re-sends them verbatim.
Request access is not a second pairing ceremony, it is a LAUNCH: an ordinary identified
connect with the advertised fingerprint pinned and the handshake budget stretched past
the host's approval window. The host parks the connection until somebody approves the
device in its console or web UI, then admits the same connection and the stream starts
by itself. The desktop shells and the console home have had this for a while
(`SpawnOpts::persist_paired`, `screens/pair.rs`); headless callers had no door to it.
punktfunk launch <host-ref> --request-access
Two behaviours, both small:
* `connect_timeout_secs = 185`, matching the host's PENDING_APPROVAL_WAIT. Anything
shorter gives up while the approval prompt is still on the operator's screen.
* `run_plan` records the host as paired on SessionEvent::Ready. That event IS the
approval arriving, and it records the pin the session actually connected WITH rather
than re-reading the store — the handshake completed against that identity, which is
what makes the record true. Every other launch still records nothing: a plain connect
proves reachability, not a new trust decision.
Refused under `--exec` (exit 5) rather than silently downgraded. Under --exec the CLI
BECOMES the session, so no process survives to observe Ready — a quiet downgrade would
leave hosts reading "trusted" forever with nobody able to explain why.
`punktfunk hosts add <addr> --fp <hex>` against an address already in the store printed
"is already saved" and exited 0 — having done nothing at all. The --fp was silently
discarded, so a host saved by address stayed pinless and every later connect refused
for want of a fingerprint, with no line anywhere saying why.
Three outcomes now, and the difference between them is a trust decision:
• no fingerprint on the record, one offered → fill it in, print `updated <addr>:<port>`
• the same fingerprint offered again → no-op, exit 0 (a panel may retry a step
whose state is already correct without
having to invent an error to show)
• a DIFFERENT fingerprint → refuse, exit 3
The refusal is the important one. A changed identity is a decision for a person at a
surface that can show them both — the rule `upsert_trusted` exists to enforce — and
quietly overwriting a pin here would be a back door through the pinning the rest of the
client is built on.
A record still named after its own address takes an offered --name; a label the user
chose is theirs and an advert's name must not overwrite it.
The CLI could do everything with a host except FIND one, so every headless consumer
grew its own mDNS: the Decky plugin parses ~120 lines of avahi TXT escaping in Python,
which drifts from the host's advert every time a key is added and makes the plugin
depend on Avahi being the resolver.
`discovery::discover_for(timeout)` is the bounded collector beside the streaming
`browse()` the UI uses — same service type, same TXT keys, folded to one row per host.
A refreshed advert wins (it carries the newer address), a removal drops the row, and
dropping the receiver on the way out stops the worker so a one-shot call can't leak a
browse per invocation.
The verb annotates each hit against the saved-hosts store rather than handing back two
lists to join: `saved`/`paired` are answered by fingerprint first and address second —
the same rule every other surface uses. That is what stops a host that moved DHCP lease
from reading as new, and stops a different box that inherited the old address from
reading as paired.
punktfunk discover [--json] [--timeout SECS]
Default 3 s, capped at 30 — this is called from a Quick Access panel, and a typo'd
`--timeout 3000` would hang that panel with no way to cancel. An empty LAN exits 0: a
caller branching on the code is asking whether the browse ran, and it did.
Three faults on the default capture path, all of them silent.
Unplug tore nothing down. onLinkClosed() is the real unplug signal — silence
never is, an idle pad simply stops streaming — but it skipped the pad-audio
teardown that stop() performs, so the render thread went on writing to a
descriptor whose device was gone, the renderer's own UsbDeviceConnection leaked,
and because the started flag stayed set and the native tier-A registry stayed
armed for that wire index, the pad came back with neither pad audio nor wire
rumble: the next occupant of the index inherited a suppression nothing would
lift. The teardown is now one shared step and runs on both paths, before the
slot is released, since the renderer is addressed by the index the release
forgets.
The wire slot was claimed on the first parsed report. A captured pad that
reports nothing then gave the host no arrival, so no virtual pad, no pad-audio
capability, no 0xD1 — a renderer sitting at zero frames, which is exactly what a
broken pipeline looks like, and it took a physical replug to clear. A pad that
reports nothing is still a pad, so the slot is claimed when the capture engages;
the first report stays as the fallback for a claim that found no free index.
This also puts the common claim on the main thread, which is the contract
GamepadRouter.openExternal documents and the link thread was quietly breaking.
And the two settings had no UI. The model and its persistence existed but no
toggle did, so pad_speaker could only be set by hand-editing shared_prefs, and
pad_haptics — which decides whether the pad trades wire rumble at all — could
not be turned off by anyone who hit trouble with it. Both are now rows under the
DualSense passthrough toggle, gated on it, since neither does anything to an
uncaptured pad.
The padHaptics doc no longer describes the arbitration as a selection forced by
a firmware-level mutual exclusion. It is decided on evidence — the coils belong
to haptics only while haptics frames arrive — which is what 2032c48f changed it
to and why a rumble-only title keeps rumbling.
Four encoder faults, plus a note on a fifth that turned out not to be one.
A centred stick did not encode as centre on the Y axes. The mapper inverted the
already-quantised byte, and 0..255 has no exact midpoint: the forward map puts
centre at 0x80, so mirroring the output lands on 0x7F — one below the 0x80 that
DsState::neutral and the pad's own resting report use. Games idle-poll a
centred stick constantly, so a DualSense, Edge or DS4 sat under a permanent
sub-deadzone tilt. Inverting in i16 space instead maps centre to centre by
construction and keeps both extremes exact; the only cost is i16::MIN and
-32767 sharing a code, one LSB at the very end of the travel.
Force-feedback ignored replay.delay. It was decoded on upload and never read:
an effect started the moment it was played and ended replay.length later, so
anything scheduling a delayed effect — DirectInput under Wine does this
routinely — fired early AND finished early by the same amount. The delay now
shifts the whole window, with length measured from the end of the delay rather
than eaten into by it. Writing the test for that surfaced a second bug in the
same path: a waiting effect was still a candidate for the abandoned-effect
force-off, and since the play command is itself the last FF activity, an
infinite effect with a delay longer than the idle window would be killed on its
first contributing tick after sitting silent the whole time it waited. Being
abandoned now requires the effect to have been audible for the window too.
An empty serial panicked the service thread. The reply builder clamped the
length to at least 1 and then sliced that many bytes out of the string, which
asks a zero-byte slice for one byte. The kernel already has a graceful answer
for a length it rejects, so report the true one and let it fall back.
Deck triggers could not reach full pull. Scaling by 128 tops out at 32640 of a
declared 32767, leaving the last 127 counts unreachable, so no game could ever
see the axis bottom out. One multiply gets both ends exact.
The idle watchdog is left alone. It does cut finite multi-second effects that
the uinput path exempts, but only the uinput path is handed an explicit
duration; the protocols behind the watchdog are level-triggered with no
duration field anywhere in a report, so there is nothing at that layer to
exempt. The choice is between cutting a long effect and letting an abandoned
one drone forever, and only the latter has field evidence behind it. Recorded
at the constant so the next reader sees the cost rather than rediscovering it.
PyroWave sessions are gated out of mid-session renegotiation, so a
constrained path serves them through the leg-1 SESSION-START clamp. This
pins the consistency that guarantee rests on: everything chunk-aligned
derives from the one Welcome::shard_payload number — the host
packetizes at it, the client's C-ABI parse window reads it back, and
partial delivery zero-fills exact windows of it — verified at the two
clamp shapes a constrained path actually produces (1216, the
WARP/Tailscale budget, and the 512 floor) over the sealed loopback wire
with real loss.
Play production access landed 2026-08-01 and the listing is live, but the
docs still told Android users to beg for a tester invite on Discord and
warned that the Play link "only resolves once your account is on the
tester list". Both are now wrong, and the install page is the first thing
a new Android user reads.
Stable is a public Play listing. Canary is unchanged — it still goes to
the invite-only Internal testing track — so each page now draws that line
explicitly instead of describing both as test tracks.
Also corrects the release process: channels.md said CI "never
auto-publishes to the public stores" and that someone promotes alpha ->
production by hand. Since 43e3c7b6 a vX.Y.Z tag publishes to production
at 100% with no further click (android.yml resolves TRACK=production on
refs/tags/v*). Apple is still manual, so that half stands.
Touches install-client.md, clients.md, channels.md, support-matrix.md and
uninstall.md — the last one told people to ask on Discord to be removed
from a tester list that no longer gates the app.
'Created and edited in the touch interface' is dead advice on a TV box — no
touch to reach it with. Unlike tvOS the editor DOES exist on-device (same
APK), behind this screen's own Controller-optimized UI toggle, so on TV the
Profiles strings now name that route instead.
Phases 1-2 of design/shard-payload-reneg.md, on top of the Phase 0
per-frame geometry. The leg-1 watcher stops merely diagnosing the
constrained path and heals the CURRENT session; the same machinery,
inverted, takes a proven jumbo LAN up to ~8.9 KB shards.
- Messages: MSG_SHARD_PAYLOAD_CHANGED (0x08, host→client, {shard_payload
u16}) and MSG_SHARD_PAYLOAD_ACK (0x09, the echo). Asymmetric by
design: a shrink re-keys the packetizer at the next AU immediately
after sending (per-frame pinning makes ordering irrelevant; the ack is
telemetry), a grow emits nothing above the old size until the ack —
the ack is the gate even though client buffers are statically sized.
- Client: one dispatch arm in the shared pump control task (all client
families) — validate against the advertised receive bounds, ack;
out-of-bounds requests get SILENCE, not an ack, so a buggy host can
never read a granted grow out of garbage.
- Host driver: the wire_mtu watcher grows a ShardReneg arm — on a
below-ceiling verdict it still records the learned budget (session 2
starts right) and now also shrinks session 1 at the ~3-10 s verdict
mark; with the jumbo opt-in (PUNKTFUNK_JUMBO=1, or PUNKTFUNK_WIRE_MTU
> 1500 — one knob, derived) it sends the ack-gated grow after a
settled-at-sealed-jumbo proof and then stays alive as the revert
guard: quinn's blackhole detection lowering current_mtu shrinks the
wire back through the same path. The QUIC MTUD probe ceiling rises
from 1472 to the sealed jumbo size with the opt-in (per-ENDPOINT: a
few extra failed probes toward non-jumbo peers, zero cost otherwise).
- Apply point: Session::set_shard_payload drained in the send loop next
to the adaptive-FEC target, gated on no open streamed AU (a streamed
frame's shard-aligned tiling derives from the size it began with).
- Renegotiation is gated OFF for PyroWave sessions: their clients parse
chunk-aligned AUs in windows of the Welcome value pinned at session
start (read once over the C ABI), so a mid-stream re-key would corrupt
the parse — those sessions keep the leg-1 next-session clamp. This
also settles the plan's open question on the two wire_chunk consumers:
both are PyroWave-only, so the gate covers them entirely.
- Legacy peers are inert both ways: no Hello advertisement → the host
never constructs the driver; an old host never sends the message.
core: 296/296 --features quic + clippy -D warnings (macOS), fmt; the
regenerated header carries the new message ids (drift gate).
GamepadSettingsScreen gains the trailing Profiles section (per-profile rows
with live pin counts, touch-interface explainer) and a console-styled
GamepadPinHostsDialog — controller- and TV-remote-navigable pin management
writing KnownHost.pinnedProfileIds through the existing store path. Pin-add
was previously touch-only; pinned-card rendering and unpin stay as they
were.
GamepadSettingsView gains a trailing Profiles section (one row per catalog
profile, live pinned-to-N-hosts counts) and an in-place pin-to-hosts picker
driving HostStore.setPinned — the first pin management reachable from the
controller-first UI, and on tvOS the only possible one. tvOS wording drops
the 'create them in the standard interface' promise (no profile editor
exists there); other platforms keep it. Pinned-card rendering and the
connect path were already in from WP5 and stay untouched.
The Skia console now renders a pinned profile card after its host's primary
tile (KnownHost::pinned_profiles resolved by the service thread), connects
with that profile as a one-off via the existing effective_settings resolver,
and shows the bound default profile on the primary tile. The settings screen
gains a trailing Profiles section — one row per catalog profile with a live
pin count — whose activation opens a pin-to-hosts screen; toggles ride the
new ConsoleCmd::SetPin to the binary, which persists pinned_profiles (the
same field the CLI resolves for Decky's host list). Profiles themselves stay
desktop-authored (design client-settings-profiles.md §5.2a, §5.4).
Phase 0 of mid-session shard-payload renegotiation (planning
design/shard-payload-reneg.md), stacked on the leg-1 MTU resilience. All
three legs are client-side and forward-compatible: deployed clients that
carry them accept a mid-session shard change the moment a future host
sends one, and nothing changes on the wire until then.
- W0.1 — the reassembler's strict shard_bytes firewall becomes per-frame
pinning: a frame's first-arriving packet pins that frame's shard size
(bounds-checked to [min_shard_bytes, max_shard_bytes], even), later
packets must match the pin, and the per-frame block ceiling derives
from the pinned size (a session-level cap would reject legitimate
post-shrink frames). The reorder race between an ordered control
message and unordered video dies structurally: old-geometry frames in
flight complete under their own pin while new frames arrive under the
new one, and no cross-geometry splice can land in one buffer. The
in-flight budget stays byte-based and exact.
- W0.2 — MAX_DATAGRAM_BYTES 2048 → 9216: every receive path (transport
RECV_BUF, the recvmmsg ring) now accepts sealed jumbo datagrams
(9000-MTU LAN ≈ 8908-byte shards). Static buffers over resize-on-ack:
the ring delta is 128 × ~7 KiB ≈ 896 KiB per client session, lazily
allocated, hosts unaffected. Grep verdict: no embedder uses the
constant directly, so no C ABI bump — the regenerated header rides
along (drift gate).
- W0.3 — trailing Hello field max_shard_payload: u16 (0/absent =
legacy), the append-with-placeholder discipline of video_caps/
client_caps. One field is both the renegotiation capability flag and
the jumbo ceiling; core's pump advertises it for all client families,
the probe too.
- Host seam for Phase 1, dead until wired: Packetizer::set_shard_payload
(re-derives the block ceilings; construction delegates to it) +
Session::set_shard_payload (host-only, Config::validate parity).
Verification (the 0.23.0 lesson — geometry changes breed sizing bugs):
the slice-wire suite re-runs at shard 512/1216/1408/8908 (exact-multiple
sweep, lossy + reversed roundtrips, sentinel path, in-flight budget);
mid-stream shrink→grow→revert delivery; the old-geometry reorder race;
cross-geometry splice rejection; firewall bounds non-vacuous both ways;
a 48-case mixed-geometry reorder-torture proptest asserting per-frame
byte-identical DELIVERY and an exactly-zero final budget; and a sealed
loopback session test (continuous crypto/replay) delivering frames
across live re-keys — every test asserts delivered frames, never the
absence of errors.
core: 294/294 --features quic + clippy -D warnings (macOS), fmt.
Three faults in the desktop session's gamepad path.
The Steam Deck lost its built-in trackpad-mouse at the start of every session.
SDL's Valve HIDAPI driver clears the pad's digital mappings during
*enumeration*, which is part of bringing the gamepad subsystem up — so holding
the drivers off from inside GamepadService::pumped could never work: receiving
a GamepadSubsystem means the enumeration has already happened. The hint set
there detached a driver that had already done the damage, and lizard mode only
came back seconds later when the firmware watchdog restored it. The presenter
now disables them with its other pre-SDL_Init hints. The threaded worker always
had this right; only the caller-pumped path was wrong, and it could not fix
itself, hence a separate entry point its callers can place correctly.
Player LEDs did nothing at all on any pad that is not a DualSense. The match
arm handled the DualSense raw-effects path and let everything else fall through
a bare `_`, though SDL exposes set_player_index and owns the per-device
pattern. The wire carries a positional bitmask rather than an index, and the
bridge is the popcount: every convention that reaches this wire spells "player
N" as N lit LEDs — the DualSense patterns 0x04/0x0A/0x15/0x1B/0x1F and the
Switch/XInput run 0x01/0x03/0x07/0x0F alike — so counting them works for both,
where reading a bit position would only ever suit one. No lit LED means no
player, not player 0. The remaining unhandled variants are now named rather
than swept up by `_`, so a new one cannot join them silently.
A forwarded pad could be left buzzing when the session ended. detach() only
posts Ctl::Detach; the close that flushes the pad, tells the host to remove it
and explicitly zeroes the motors runs when the pump next drains that message.
Single mode broke out of the loop immediately after detaching and Event::Quit
never detached at all, so both skipped it entirely. The teardown now sits where
every exit converges instead of on the individual breaks. That still leaves the
several paths that leave by `?` on a fatal overlay or present error, so the
pump also silences its slots on Drop — the explicit call stays, because a pad
should go quiet before a long teardown rather than after it. Drop closes the
slots directly rather than draining the queue that would have done it: same
physical outcome, and it touches no lock, where draining reaches an unwrap on a
Mutex that would abort the process if it panicked mid-unwind.
Video datagrams are sealed at a shard payload sized for a clean 1500-byte
MTU (1472-byte UDP payloads). A host whose route to the client crosses a
smaller-MTU hop (a VPN/overlay adapter claiming the LAN route, a lowered
NIC MTU) delivers every small flow — QUIC control, hole punch, input,
audio — while 100% of video datagrams die: the client sits on a black
screen reporting zero loss and the host streams into the void with every
gauge green. Field-reported as 'connects fine, black screen forever'.
Three legs, none of which changes a session on a healthy path:
- PUNKTFUNK_WIRE_MTU operator override: shard payload derived from a
given on-wire IP MTU. Wire-compatible — Welcome::shard_payload is
already negotiated per session (the v4/v6 split ships two values
today) and every client follows the negotiated value.
- Detection: the QUIC MTU-discovery probe ceiling moves from quinn's
stock 1452 to exactly the sealed video-datagram size (1472), so a
control connection's settled MTU becomes a verdict on the path:
settled at the ceiling proves it carries video, settled below proves
it cannot. A per-session watcher samples after the search has settled
(live-connection guard against mid-search false learns) and logs an
actionable WARN naming the failure shape and the diagnosis commands.
- Healing: the measured budget is recorded per peer IP; the next
handshake clamps shard_payload to fit, so a reconnect self-heals. A
later session that reaches the ceiling erases the record.
Verified: core 286/286 --features quic + clippy -D warnings (macOS);
host clippy -D warnings + native:: tests 44/44 (pf-lxcheck container).
The regenerated C header picks up the new MIN_SHARD_PAYLOAD constant.
CI caught what my local harness could not: reading `huge.count` inside the closure that already
holds `huge` exclusively is an exclusivity violation, so PunktfunkKitTests failed to compile.
The blind spot is worth recording. I verified `AudioRing` by compiling it against a standalone
harness whose bodies were TOP-LEVEL code, where Swift applies DYNAMIC exclusivity — the same
statement in a function body gets the static check and is a hard error. A harness that does not
share the shape of the thing it stands in for can be green for a reason the real build does not
have. The harness now puts every body in a method and compiles with
`-enforce-exclusivity=checked`.
Length now comes off the buffer pointer (`$0.count`), which is what the closure already owns.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They were running nowhere. This workflow only assembled, and the screenshot
workflow runs the :app module's tests, so nothing enforced :kit's — the pure
parsers, migrations and feedback policies could go red without anyone
noticing. A couple of seconds against a module the build already produces.
Four faults in the Android feedback path, all of them silent.
Rumble stopped for the rest of the session if one vibrator call threw. The
poll thread called cancel() unguarded while every call around it was already
wrapped, so an unchecked throw — DeadSystemRuntimeException, or the
RuntimeException a dying service wraps a RemoteException in — unwound the
thread. `running` stayed true, so nothing noticed it was gone and nothing
restarted it. Guarding the two bare cancels is not enough on its own: the
binder calls that bind a vibrator can throw just the same, so the loop itself
now survives a failed render, and the same guard covers the hidout thread.
A rumble stop that was never written was treated as one that landed. The
DualSense capture disarmed its backstop timer *before* the write, on a queue
that discarded failed submits without saying so, so a dropped stop left the
motors running with nothing scheduled to try again — and a USB pad holds its
last level until told zero. Writes now report whether they were accepted, the
backstop is disarmed only once the stop is actually on its way, and the
backstop re-arms rather than giving up if its own write is refused.
A full write queue dropped lightbar colours, player-LED masks and trigger
effects. Its overflow rule was "drop the oldest", which is right for rumble —
re-sent continuously, so a lost frame returns milliseconds later — and wrong
for everything else, which the host sends once on change and never repeats.
Eviction is now driven by an explicit key from the caller rather than by
inspecting the bytes: rumble supersedes the pending rumble in place, and a
one-shot is discarded only if the queue holds nothing but one-shots. The key
cannot be recovered from the report itself, which is why this is not keyed by
report id — every DualSense output report carries the *same* id and differs
only in its valid_flag bytes, so an id-keyed rule would let a rumble
supersede a lightbar, which is this bug again by another route.
An unplug leaked the USB connection and the detach receiver. The link only
signalled the drop; neither capture released anything, so the interfaces
stayed claimed (the pad could not return to Android's own input stack) and a
re-plug overwrote the field holding the receiver, stranding one live for the
rest of the process. The captures now release the transport, stop() is safe
to call from the callback it arrives on — the reader thread must not join
itself — and a close is reported exactly once however many detectors see it.
A reader that could not queue a single request now reports itself down too,
instead of leaving the owner waiting on a capture that never streams.
The client-decoder knee latch (decode_cap_kbps) was unreachable in
production — zero "decode cap learned" lines across every field log, while
its own doc named the exact sawtooth it exists to end (the 2026-08-03
1440p120 field trace: 220↔450 Mbps for nine minutes, five knee backoffs,
no latch):
- The ordinary two-bad-window backoff — the knee's most common
presentation, a standing 15–45 ms decode rise below the severe tier —
carried no decode evidence at decision time, because evidence was judged
from the deciding window alone. Worse, the backoff the decode signal
itself caused then RESET the knee streak. Now the streak carries its own
attribution (streak_decode_windows): a backoff whose bad windows were
all decode-flagged is decode evidence.
- A cascade's second backoff can never agree with the first: a live host
acks the ×0.7 request in ~100 ms, so the second sample always sits at
the reduced rate — outside the ±1/8 similarity band by construction
(0.7 < 7/8). The canonical test never acked between its backoffs, which
is how the premise survived. Now a backoff only samples a rate the
controller climbed back to (climb_since_backoff, armed by any ack that
raises the rate); a drain-time backoff neither latches nor erases the
reference the real knee set.
- A keyframe-ask storm on a clean link (the Steam Deck presentation: the
overdriven decoder wedges and begs instead of queueing — 14–19 asks at
~300 Mbps with loss_ppm=0 in the field traces) is decode evidence too;
with real loss present the asks stay network-attributed.
The reworked tests model the ack round-trip (choke → ack → re-climb →
choke), including a regression test replaying the field trace's rates and
decode figures, which must latch at its second knee encounter.
Pressing Escape mid-stream on an iPad leaves the capture in a state it
could never leave: iPadOS releases the pointer lock by itself, a bare
Escape deliberately never clears `captured` (it is a game key), and the
re-lock burst added with the Escape-drop fix is the only thing that ever
asks for the lock back. That burst fires in the 0.6 s immediately after
the platform's own "let me out" gesture — precisely when it is least
likely to be granted — and once its budget is spent nothing re-asks:
`setCaptured` is the only other requester, and `captured` never went
false. The capture then spends the rest of its life on the absolute
pointer path, which is why the field report reads the way it does —
clicks still land exactly where you aim, because absolute positions keep
forwarding, but the game receives no relative deltas and camera look is
dead for the rest of the session.
Make the click the second stage of the recovery. A click into the video
while captured-but-unlocked now re-anchors the lock chain and re-asks,
which is the request the platform actually wants: a genuine user
gesture rather than an app grabbing the pointer straight back.
Asked on the button UP, so the click has fully forwarded on one
transport first — asking on the DOWN can flip `gcMouseForwarding`
mid-click and strand the release on the GCMouse path. Gated on
`pointerLockWasEngaged`, exactly as the drop path is, so a scene that
never qualifies (Stage Manager, Split View) is never bursted at, and on
no burst already being in flight, since a pending burst mutes absolute
motion and re-arming one per click would freeze the cursor between
clicks of a menu the user is still aiming around.
Worst case is now today's behaviour rather than a permanent one: a
refused burst settles, and the next click tries again.
Typechecked for arm64-apple-ios17.0 (PunktfunkKit builds clean). NOT yet
verified on glass — the premise that a click-driven re-request is
honoured is exactly what the previous fix got wrong.
The page claimed "audio is a fraction of a percent of a stream's bandwidth, so high costs
nothing worth counting". At 256 kbps plus redundancy that is 512 kbps — true of a 20 Mbps
session, wrong by an order of magnitude on a 5 Mbps one, which is why the budget now exists.
Says what actually happens on a narrow link, and points at the log line that reports the
settled tier.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Findings from the post-implementation review of design/audio-quality-and-latency.md.
**The bandwidth gap (highest).** Tier `High` (256 kbps) and the redundant `0xD2` plane were
added separately, each costed as "~1 % of the video budget", and nobody added them together:
256 kbps sent twice is 512 kbps — ~2.5 % of a 20 Mbps session but ~10 % of a 5 Mbps one. Audio
rides QUIC datagrams, OUTSIDE the ABR loop, so ABR could neither see that nor reclaim it; a
constrained link quietly handed a tenth of its bandwidth to audio while ABR carefully managed
the rest.
`plan_audio_budget` now makes tier and redundancy ONE decision against the session's resolved
video bitrate, ordered by preference rather than cost — transparent audio beats redundant audio,
since the field report was about quality and redundancy only pays under loss, so `High` alone
outranks `Standard`+redundancy even though they cost the same. It can lower what the operator
asked for, never raise it, and never goes below `Low`: a stream with unintelligible audio is
worse than one spending a few percent more.
**The Linux host kept the exact defect fixed on Windows.** `let _ = tx.try_send(samples)` —
silent, uncounted data loss, where the encoder concatenates across the hole, so every drop is a
click AND a permanent shift of everything after it. WP0.2 turned out to be Windows-only and had
not said so. Linux now shares `capture_policy::CaptureStats`: drops counted and warned, plus
per-window peak/RMS/delivered%. A Linux audio report was until now exactly as un-triageable as
the Windows one was on 2026-08-03.
**Apple's WP0.3 was half-done** — `bufferedMS` was added and wired to nothing. The drain thread
now logs buffer/target/underruns/sheds like the other three, from one locked snapshot so the
numbers in a line describe the same instant.
Also: the Linux "audio format negotiated" line now says WHICH mode produced it, because that
changes what it is worth — in stream-sink mode the host owns the sink so the mix cannot have
been narrowed upstream, but in legacy monitor mode a 16 kHz Bluetooth sink would still be
reported as a clean 48 kHz through PipeWire's resampler, the same way WASAPI's autoconvert hid
it on Windows. Reading the monitored node's own rate needs a registry lookup this stream does
not do; recorded as an open gap rather than implied to be covered.
Two stale docs: `audio_wasapi.rs` cited `clients/windows/src/audio.rs` (deleted) and still
described the pre-shared-policy "prime to ~3 quanta" behaviour. And the Apple ring's `prefill:`
parameter, dead since the depth moved into the ring, is gone.
Verified: clippy --all-targets -D warnings on Linux (docker) AND Windows (runner .133, forced
clean rebuild of punktfunk-host + pf-client-core); core 167 tests; host 57 audio tests on
Windows; Android clippy count identical to pristine (6, all documented arm64 artifacts); Apple
ring re-simulated. The host suite's `gamestream::stream::tests::sender_delivers_batches` fails
under qemu — the recorded environmental flake, unrelated to audio, green on the earlier
less-loaded run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WP0.4. The 2026-08-03 reporter had no way to know their desktop mix was being routed through
Steam's voice-carrier endpoint, and no documented way to change it — `PUNKTFUNK_HOST_AUDIO`
existed only in a module doc comment.
Two new sections: what the host actually captures (a render endpoint, not "the sound card"),
what the new `engine_hz/engine_ch/engine_bits` log line tells you, and the
`PUNKTFUNK_AUDIO_OUTPUT_MODE` / `_QUALITY` / `_REDUNDANCY` knobs — with host_and_client called
out as the quickest A/B for the endpoint question; and why audio that lags the picture should
now correct itself, plus what to check when it does not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 4 + WP3.2 of design/audio-quality-and-latency.md.
**The defect.** Every client ring primed *up* to a target and clamped at a ceiling, and none
walked the depth back *down*. Any transient — a Wi-Fi arrival burst, a host stall, or plain
host-DAC-vs-client-DAC skew of a few dozen ppm — therefore added latency permanently, until
an underrun happened to re-prime. Android, with no shed at all, converged on its 120 ms hard
cap and stayed there for the rest of the session; that is the "audio latency is too high"
report. Apple did shed, 40 ms in one go, which its own comment called "one audible blip".
All four now share `punktfunk_core::audio::JitterPolicy`: depths in MILLISECONDS rather than
device quanta (`3 x quantum` meant 15 ms at a 5 ms quantum and a silent 64 ms at a 20 ms
one), a crossfaded 5 ms shed once the depth average has sat above target for 2 s of consumed
audio, and de-prime hysteresis. Linux and Windows had never had that hysteresis — they still
carried the `if ring.is_empty()` instant re-prime that Android identified as self-inflicted
crackle, where one transient drain manufactured a whole target's worth of silence.
Android's floor drops 40 -> 25 ms: the policy grows the target on the devices that actually
underrun, instead of every device pre-paying for the worst one. The Windows ring moves from
raw bytes to interleaved f32 so it can share the policy and the crossfade helper at all.
Apple is the one client where the policy is hand-written in a second language, so it gets
its own XCTest (`AudioRingDriftTests`). Verified here by compiling `AudioRing.swift`
standalone against a simulation harness — +200 ppm for 5 minutes settles at 30 ms with zero
silent callbacks, where the old ring would have ridden its 80 ms high-water mark.
**WP3.2 — recovery lives in core, not in the clients.** The rebuilt frame is re-inserted into
the demux queue in order, so every embedder (including any C-ABI consumer) gets a complete
stream without knowing the `0xD2` plane exists, and their `AudioGapTracker` simply stops
seeing the gap. `recovery_and_the_gap_tracker_agree` pins exactly that. For the same reason
core advertises CLIENT_CAP_AUDIO_RED itself rather than making four embedders remember to.
Verified: clippy --all-targets -D warnings and the full test suites for punktfunk-core,
pf-client-core, punktfunk-host, pf-host-config under Linux/docker (163 + 61 tests);
punktfunk-client-android `cargo ndk check` for aarch64 with the gate proven non-vacuous by a
planted type error, and its 6 clippy findings confirmed IDENTICAL to the pristine file (all
are the documented arm64-only artifacts); AudioRing.swift type-checked and simulated on
macOS; fmt. The Windows client half (audio_wasapi.rs) is still not compile-verified anywhere.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phases 0-3 of design/audio-quality-and-latency.md, host side.
**WP2.1 — the 2026-08-03 root cause.** The client-only loopback preference took Steam's
Streaming *Microphone* render endpoint over real hardware unconditionally, because it is
silent on the host. But that endpoint exists to carry remote VOICE, and nothing checked
whether it could carry music: on the reporter's box it won all 31 loopback opens across 25
sessions while a clean AMD HD Audio endpoint sat idle, and the whole desktop mix went
through it before reaching Opus. A silent sink now has to EARN its preference — if its mix
format narrows the mix it drops below real hardware. It is still taken when nothing better
exists (narrow audio beats no audio), but flagged so the capture side says why.
`plan_with_formats` takes a probe rather than reading WASAPI, so all 26 wiring-plan tests
still run on every platform. An unknown format counts as fine, which is asserted:
`unknown_formats_reproduce_the_formatless_plan` proves a probe failure can never make the
plan worse than it was before formats existed.
**WP0.1 — log the endpoint's ACTUAL mix format.** Everything the old log printed ("48 kHz
f32 channels=2") was our REQUEST; with `autoconvert` WASAPI converts silently from whatever
the endpoint really runs. That is why a 3,600-line log filed over an audio-quality
complaint contained nothing that could diagnose it.
**WP0.2 — count what we drop.** The capture->encode handoff was a silent lossy `try_send`:
a stalled encode thread lost chunks, the encoder concatenated across the hole, and nothing
recorded it — a click plus a permanent shift of everything after. Now counted and warned,
alongside per-window peak/RMS/delivered% so a quiet host, a broken endpoint and a stream we
are damaging ourselves stop looking identical.
**WP2.4 — stop the default-device tug-of-war.** In Assert mode the capture is bound to the
planned endpoint EXPLICITLY, so a hijacked default changes only where apps render — the old
full reopen tore the capture down for nothing. The field log shows the cost: something
re-set the default every ~4 s and each round was a teardown, a wiring pass with
IPolicyConfig writes, and an audible dropout — seven in sixteen seconds, one ending in a
2 s error backoff. Now: put the default back, keep the stream, and after four rounds in
twenty seconds concede for a minute and say so once.
**WP1.1/1.2 — encode quality.** Constrained VBR (the hard-CBR comment justifies itself with
GameStream's audio FEC, which this plane does not have) and `AudioTier::High` by default:
stereo 128 -> 256 kbps, ~1 % of a 20 Mbps session. GameStream's encoder is deliberately
untouched — its FEC really does need fixed-size packets.
**WP3.1 — redundant `0xD2` plane**, sent when the client asked for it.
**WP2.2 — `audio.output_mode`** as a first-class setting (`client_only` / `host_and_client`
/ `follow_default`), superseding the two undocumented env vars, which stay honoured. The
enum lives in pf-host-config, which is deliberately dependency-free, so the tier table stays
in core where the codec knowledge is.
`capture_policy.rs` is split out for the same reason `wiring_plan.rs` is: both encode field
behaviour, so their tests must run on Linux CI, not only on a Windows box. That split
immediately earned itself — `capture_stats_separate_silence_from_signal` caught RMS being
divided by the FRAME count while summed over interleaved SAMPLES, which inflated it by
sqrt(channels) and made a sine report an RMS equal to its own peak.
WP4.5 (open the loopback at the minimum device period) is deliberately NOT done: in shared
mode `IAudioClient::Initialize` cannot change the engine period at all, so it would be a
no-op at best and a new failure path at worst. Recorded in the code. WP2.3 (force the parked
endpoint's volume) is deferred — `wasapi` keeps IMMDevice private, so it needs new raw COM
on a path this tree cannot compile, let alone test; its diagnostic half ships as the RMS
line above.
Verified: punktfunk-host + pf-host-config clippy --all-targets -D warnings and the audio
test suite under Linux/docker (gate proven non-vacuous with a planted type error); 26
wiring-plan tests standalone; fmt. The Windows-only halves of wasapi_cap.rs and
audio_control.rs are NOT compile-verified anywhere yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five faults in the Apple client's feedback path.
With two DualSenses attached, each pad's renderer opened "the first connected
DualSense" — taken from an unordered Set, so the choice could differ between
two calls in one process. Both renderers could land on the same device, one
pad's rumble coming out of the other while their per-instance write dedupes
fought over it, or they could split by luck. Each renderer now asks for the
device its own controller is, correlating GameController's stable ordering with
IOKit's location ids; the selection rule is a pure function so it can be tested
without an IOHIDDevice, which cannot be constructed. Without a preference the
lowest location id wins — still arbitrary, but stable, which Set.first was not.
A failed HID write was logged and swallowed, so a write that never reached the
device still counted as a successful render. That matters most for a stop,
which has nothing behind it: the renderer stamped its write clock even on
failure, the keepalive only re-writes non-zero levels, the ticker is cancelled
once the target is zero, and on USB there is no firmware timeout. A swallowed
stop therefore left the motors running with nothing scheduled to try again.
The write result now reaches the caller, which drops the handle and falls back
to CoreHaptics rather than claiming success.
A half-failed split-handle setup reported HEALTHY. Only the all-nil case
counted as failure, so one surviving handle passed silently while rendering
something wrong in a direction that depended on which handle died: lose the
right one and render falls to the combined branch, playing max(low, high) on
the LEFT handle; lose the left and the split branch discards the heavy motor
outright. A half-open split now tears the survivor down and takes the combined
path, which at least renders both motors somewhere.
Session end never put the lightbar out. This class is what turned it on, and
every DS write is valid-flag-selective, so a game's last colour stayed lit in
firmware after the stream ended — a DS4 was cleared incidentally because its
player indicator IS the lightbar, a DualSense was not.
And the renderer's stop() ran on the main actor. It is a queue.sync whose body
is a per-motor CHHapticEngine.stop() — an XPC round trip the renderer's own
notes record as able to hang — plus a blocking HID write to a device that has
just departed, and it queues behind any in-flight setup(). It runs on every
unplug and every pin change, and the main thread drives the presenter's
CADisplayLink, so it hitched the picture mid-stream. It is detached now; the
renderer is already off routing by then, so nothing observes it.
Verified: swift build clean, 188 tests pass (185 before), and the three new
device-selection tests fail if the deterministic fallback is reverted.
Note for anyone rebuilding here: the checked-in xcframework was stale (it
predates punktfunk_connection_report_phase) and build-xcframework.sh still dies
on this Mac at its macOS-floor guard. A macos-arm64 slice assembled by hand
from `cargo build --target aarch64-apple-darwin` is enough to typecheck.
From the 2026-08-03 force-feedback sweep (B14, B15, B18, B19, B20).
Foundation for the audio quality + latency plan (design/audio-quality-and-latency.md).
All three pieces are pure and unit-tested here so the four client rings and the Windows
host glue that follow stay thin.
**Bitrate tiers** (`AudioTier`). The layout table's `bitrate` becomes the `Standard`
value, so that tier reproduces the pre-tier wire byte-for-byte — the tier machinery is
provably non-regressive. `High` (stereo 256 kbps) is the default: 5 ms Opus frames are
much less efficient than 20 ms ones, so the historical 128 kbps buys roughly what
~100 kbps buys at 20 ms, while the same session carries tens of Mbps of video. Purely a
host-side encoder knob — libopus reads the bitrate out of the packet, so no client
change and no negotiation.
**`JitterPolicy`** — the ms-denominated de-jitter state machine every client will share.
Two defects it exists to fix: (1) each ring computed its target as `3 x quantum`, a sane
15 ms at a 5 ms quantum and a silent 64 ms at a 20 ms one; (2) every ring primed *up* and
clamped at a ceiling, and none walked the depth back *down*, so drift/bursts added latency
permanently — Android, with no shed at all, converged on its 120 ms cap. Here a depth EWMA
that sits above target for 2 s of consumed audio sheds ONE 5 ms frame with a crossfade.
Driven by samples consumed rather than the wall clock: allocation- and syscall-free (safe
in a realtime callback) and deterministic under test.
`every_preset_sheds_before_it_trims` pins the invariant that makes this real rather than
decorative. The first draft had `headroom_ms` <= the shed threshold on all four presets,
so the ring was trimmed back before the average could ever reach the shed point: drift
correction was dead code and the ratchet test passed for the wrong reason (the hard cap
did the work). `a_transient_burst_does_not_shed` caught it. The shed point is now derived
from `headroom_ms` so it cannot invert again.
**`0xD2` redundant audio** — each datagram carries its frame plus a copy of the previous
one, so a single lost packet is reconstructed instead of concealed. Opus in-band FEC
cannot do this job: LBRR is a SILK feature and the desktop encoder is CELT-only
(RESTRICTED_LOWDELAY, 5 ms), so `set_inband_fec` there is a no-op. Costs no latency —
the copy rides the successor, which arrives inside de-jitter slack that already exists.
Gated capable-and-agreed via CLIENT_CAP_AUDIO_RED/HOST_CAP_AUDIO_RED; every other session
keeps the `0xC9` wire unchanged. 0xD1 is left free for the pad-audio program.
cbindgen: prefix the four new exported constants. `FRAME_MS`/`SAMPLE_RATE_HZ` as bare C
macros are the same hazard the BTN_* renames already document — a clashing #define takes
the last definition silently rather than failing to compile.
Verified: 300 core tests, clippy -D warnings, fmt. (`c_abi` fails identically on a
pristine tree — this Mac has no system libopus for the C harness link.)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The button shipped in d839f4c2 opens the user's Documents folder instead of the log
directory on every packaged install. Nothing is wrong with the button — the path is.
The client ships as a full-trust MSIX package, and Windows redirects a packaged app's
%LOCALAPPDATA% writes into its private ...\Packages\<family>\LocalCache\Local\. The log
module creates and appends through that redirection without ever seeing it, so the
literal %LOCALAPPDATA%\punktfunk\logs it hands out is right to WRITE to and names a
directory that never exists on disk. Explorer runs outside the container: it resolves the
literal path, finds nothing, and — instead of failing — silently falls back to Documents.
An unpackaged dev run creates that directory for real, which is why this only ever showed
up in the field.
Two more places handed the same phantom path straight to the user, both added by the same
commit and both wrong in the same way: the "client log file" startup line, and the
failed-spawn banner's "Check <path>" — the one people are told to follow after a session
dies. Anyone who did landed in an empty or absent directory.
So the fix is one resolver, not three call-site patches. `real_dir` canonicalizes the
directory it just created, which resolves through the redirection on a packaged run and
changes nothing on an unpackaged one — no package identity to detect, no LocalCache path
to hand-assemble. `log_dir` stays as the write path and goes private so a future caller
can't reach for the wrong one; `path` now resolves too, which fixes both messages.
`canonicalize` always returns a `\\?\` verbatim path and Explorer refuses those (taking
the same silent Documents fallback), so `strip_verbatim` undoes the prefix — including
the `\\?\UNC\` form a roaming profile on a share resolves to. The button additionally
guards on `is_dir()`: if the resolve ever comes back wrong, the click does nothing rather
than landing the user somewhere misleading again.
Three faults in the shared rumble policy engine, all answered by one change of
shape: the free-running jitter phase becomes `last_emit` — the exact value last
handed to an embedder — and every emit routes through one helper. That single
field answers all three live questions: would re-sending this be a no-op device
write, is this stop redundant, and would the nudge invent a stop.
The Steam Deck declares a 40 ms keepalive with a 1-LSB nudge, because an
SDL-class layer discards a write identical to the last one. But the nudge lived
only in the keepalive branch, so every host renewal re-emitted the raw level,
collided with the last jittered write, was discarded, AND re-anchored the
keepalive timer. The gap between distinct device writes stretched to 80 ms at
the 400 ms default TTL and 100 ms at the hatch floor — two to two and a half
times the cadence the quirk exists to guarantee. Nudging on any repeat closes
it: 40 ms throughout.
Level (1, 0) turned that nudge into (0, 0) — the value the engine reserves for
"stop now" — and handed it out with a non-zero backstop, under a live lease.
It is the only such level: high must already be zero, and low ^ 1 == 0 implies
low == 1. The nudge now steps the LSB up instead, so the phase still alternates
and no stop is ever invented.
A zero for a pad the engine already believes silent is now dropped. Under the
legacy hatch the host re-sends zeros for every latched pad every 500 ms for the
rest of the session, which cost Android an unconditional log line and a binder
cancel() at 2 Hz per pad. The deliberate stop-burst heal is untouched, because
a stop that was LOST leaves the pad buzzing, and that is exactly the guard's
pass condition.
The client also now bounds the lease it will honour. RUMBLE_TTL_CEIL_MS is
sender-side only, so a modified or third-party host could stamp a long TTL and
wedge its pump, leaving Apple — whose renderer deliberately keeps no staleness
policy of its own — and a Deck slot buzzing for all of it.
Every new test was proven to fail with its own fix reverted, including the two
that guard against over-reach: a default-quirks pad must still get the level
verbatim, or an off-by-one amplitude would land in Apple's identical-target
comparison and Android's one-shots.
One suspicion from the audit did NOT survive: a v2 envelope carrying ttl_ms 0
cannot take the legacy backstop, because the expiry check preempts the relay
branch. No fix; pinned with a test so that ordering stays load-bearing.
Verified: 17/17 rumble tests, clippy --all-targets --features quic -D warnings
= 0, fmt clean, generated C header unchanged. (`c_abi_harness_round_trips`
fails on this Mac with a linker error, identically on an unmodified tree.)
From the 2026-08-03 force-feedback sweep (B12, B22, R9, T1).
Three faults that between them silence a wired DualSense.
The trade was committed without asking whether the host can send pad audio at
all. Against every released host — no HOST_CAP_PAD_AUDIO — the renderer claimed
the interface, took the pad off wire rumble, and then rendered nothing, with
`pad_haptics` defaulting on and no UI to turn it off. The capability is now
checked before `sink::open`, so nothing is claimed and nothing is traded.
Arming was unconditional, so a speaker-only setup took the motors away too. The
speaker pair is channels 0/1 and no rumble write can disturb it; only the
haptics lane arms now.
And the suppression itself was wrong for the case that matters most: a title
driving classic rumble and no haptics audio. Suppressing on "a stream is open"
assumed the game's rumble rides the haptics mix, which for such a title is
false — it renders no haptics audio at all, so the host's -60 dBFS gate emits
nothing on 0xD1 and the pad was left with neither. Ownership is now decided by
evidence: the coils belong to haptics only while haptics frames are actually
arriving, and to wire rumble otherwise. Frames are stamped on arrival rather
than after decode, so a decoder hiccup cannot hand the coils back mid-effect,
and concealment does not count as evidence. Liveness is dropped at every
teardown, because wire indices are recycled and a stale stamp would let a fresh
pad inherit the previous occupant's ownership.
Arbitrating on evidence rather than on a prediction about the hardware is
deliberate, and the module doc now says why. It used to assert that the coils
and the rumble motors are the same physical actuators — "a firmware constraint,
not a preference". Nothing establishes that: it traces to one reverse-engineered
comment in SDL, whose own modern path sets HAPTICS_SELECT alone with amplitude
on ucEnableBits3, which reads more like an independent mute than a shared-
actuator interlock. The combination that would settle it — rumble with
HAPTICS_SELECT cleared — is emitted by no code anywhere, and nothing here writes
it either. The evidence rule is correct under either hypothesis.
The liveness clock is 1-based so that 0 stays an unambiguous "never stamped":
without it a frame arriving in the process's first millisecond read as
never-arrived and handed the coils back mid-effect. Its test caught that.
Verified: clippy -p punktfunk-client-android --all-targets --locked -D warnings
= 0; 15 tests pass.
Owed: the desktop twin of the arbiter, and the coil restore — the Android stop
write still asserts HAPTICS_SELECT with zero amplitude, where SDL's all-zero
stop restores the audio path.
From the 2026-08-03 force-feedback sweep (B4, B5; B6 partly).
Main had moved 34 commits past the merge-base and 13 files had diverged.
Resolving now rather than later, since the force-feedback sweep work is landing
in the same files.
Five conflicts needed hand resolution. Four were "each side added something
different" and keep both: the Forwarding and PadAudioPrefs control variants with
their handlers and setters (pf-client-core/gamepad.rs), both of the session's
pre-attach declarations (forwarding first, so slots still declare their
pad-audio caps at open time), main's WiredPlan/fingerprint alongside the
branch's pad_render_ids (audio_control.rs), and main's judge_default signature
(wasapi_cap.rs).
wiring_plan.rs was not mechanical. Main's 652abeb3 added a flagged last-resort
loopback tier; the branch had added a fifth `plan` parameter excluding pad
endpoints from every role. Taking either side alone loses the other, and
combining them carelessly is worse than both: the new last-resort tier would
happily select the pad's own speaker endpoint, which is stamped "DualSense
Wireless Controller" with no virtual marker precisely so games read it as the
pad's speaker — routing the entire desktop mix into the controller's voice
coils. The branch's exclusion shadows `renders` before any tier runs, so the
last resort inherits it; `a_pad_is_never_the_last_resort` pins that, including
that a pad-only candidate set stays honestly unsatisfiable rather than falling
back onto the coils.
Verified: clippy -p punktfunk-host -p pf-client-core --all-targets --locked
-D warnings = 0; pf-client-core 93/93; punktfunk-host 387 passed with only the
known-environmental gamestream sender_delivers_batches UDP-loopback flake;
wiring_plan 21/21; fmt clean.
NOT verified: audio_control.rs and wasapi_cap.rs are cfg(windows), so neither
the Linux container nor xcheck.sh compiles them. Those two resolutions have had
review only and need the Windows runner before this merges.
The pad rendered nothing — not its speaker, not its voice coils — because
`uac-host` streamed into a device it never unmuted. It set the sample rate and
nothing else; the UAC Feature Unit, where Mute and Volume live, was parsed by
nobody. Every counter stayed green throughout: URBs completed, 0 short bytes,
0 URB errors, 0 short writes here, decoded peak 19345. None of them can observe
mute, so a muted device is indistinguishable from a working one.
Bumps the pin to unom-io/usbfs-iso f3de1fd, which sends SET_CUR Mute=0 and
Volume=0 dB to the Feature Unit before the stream starts.
With this in, Spider-Man Remastered's haptics reach the physical DualSense
through the virtual pad, confirmed by feel on real hardware.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pad-endpoint tone` only ever drove the BACK pair, which meant the pad's speaker
— the FRONT pair, the other half of the 4-channel split — had never carried a
signal end to end. The capture probe's verdict was shaped the same way, and
called a perfectly good front-pair run "silent".
`--pair front|back|both` picks the pair, and the verdict now reports which pair
it SAW rather than judging against an assumed one.
Measured on .173, an exact mirror in both directions and no crosstalk either way:
--pair back peak_front=0.0000 peak_back=0.5000 back only, channel-exact
--pair front peak_front=0.5000 peak_back=0.0000 front only, channel-exact
--pair both peak_front=0.5000 peak_back=0.5000 both
So the host half of the speaker path is proven to the same standard the haptics
path was. What is still unproven is the client rendering the front pair into the
pad's own speaker; that needs the phone unlocked, which it no longer is.
Host clippy clean; 360 tests pass, the one mgmt display failure reproduces on a
clean tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pad-endpoint tone` only ever proved a render client could open the endpoint.
Whether anything came back out of the loopback — and in the right channel pair —
was still taken on faith, which is exactly the gap that let a stamped-but-
unservable endpoint look healthy while a client sat on an empty plane.
`pad-endpoint capture [seconds]` opens the real PadLoopbackCapturer and reports
frames plus per-pair peaks, so the two halves together exercise render -> engine
-> loopback -> pair routing with no game and no client attached.
Run against each other on .173:
pad-endpoint capture: 157920 frames over 7s, peak_front=0.0000 peak_back=0.5000
VERDICT: PASS - back pair only, front pair silent (channel-exact).
0.5 is the tone's own amplitude and the front pair is dead silent, which is the
signal the 0xD1 framer routes to the voice coils. Same figure the program notes
recorded on 2026-08-01 and nothing has been able to reproduce since.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two loose ends from the pad-audio bring-up.
`wasapi 0.23`'s `DeviceEnumerator::get_device` passes `GetDevice` a pointer
into an `HSTRING` temporary that was already dropped, so it resolves whatever
the allocator left behind and misses ids that are perfectly valid. Only the
pad-audio path had been moved off it; the remaining four callers include
desktop loopback capture and the default-endpoint judgement, where a spurious
miss silently downgrades a capturable default to Unknown. The host now resolves
through `open_wasapi_device` (raw COM, buffer kept alive). `pf-client-core`
cannot share that helper — it pins a different `windows` revision than `wasapi`
does, so the two `IMMDevice` types are incompatible — and instead scans the
active collection by id, which touches only safe crate APIs.
Provisioning also stopped latching a transient. A stamp lands, a check run
immediately afterwards reports all seven keys served, and AudioEndpointBuilder
then reverts the three format keys behind us, leaving 4/7 for good. Since
`needs_aeb_kick` is what makes startup restart AudioEndpointBuilder + Audiosrv,
that transient meant bouncing the machine's whole audio stack on every host
start, forever, chasing stamps a re-pass lands. `ensure` now stamps, lets AEB
settle, and only then checks — repeating up to five times.
Before: fresh provisions landed 4/7 with kick=true on 3 of 4 runs. After: 4 of
4 runs settle 7/7 with kick=false in 2.8s, identity intact (Wireless
Controller / DualSense Wireless Controller / PFDS container), 4ch mask 0x33,
render and loopback capture both opening, and `pad-endpoint tone` clean.
Host clippy clean; 360 tests pass, the one mgmt display failure reproduces on a
clean tree. The client-side helper is type-checked against wasapi on Windows in
isolation — pf-client-core itself will not build on .173 (no ffmpeg/SDL3/Vulkan
toolchain there), so its module integration is unverified.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`PUNKTFUNK_PAD_AUDIO_STAMPS` narrows `ensure` to a named subset of the seven
stamps (unset keeps all of them, so the shipping path is unchanged). The
MMDevices Properties ACL denies even an elevated `reg delete`, so the only way
to ask "which stamp breaks this endpoint" was to re-provision with subsets.
Using it settled that nothing does. Once the heap corruption is out of the way
and stamping completes in ONE pass, the full set yields an endpoint that is
4ch/48k/mask 0x33 with both directions open — render and the loopback capture
that feeds the 0xD1 plane — and `pad-endpoint tone` renders without error.
The intermediate reading, that the Steam driver was stereo-only and the feature
needed a different carrier, was a confounded A/B: the "stamped" sample had
accumulated its stamps across heap-corrupted runs. Asked properly — in
EXCLUSIVE mode, which reaches the driver instead of the engine's mix format —
that driver reports 2ch, 4ch and 8ch, the same shape a real DualSense reports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects sat between the pad-audio endpoint and any sound. Neither was
where the symptom pointed.
`windows 0.62` implements `Drop for PROPVARIANT` as `PropVariantClear(self)`.
Every variant `set_store_value` builds borrows memory Rust owns — a `Vec<u16>`,
a `&GUID`, a `&'static [u8]` — so each stamp handed that pointer to
`CoTaskMemFree`. The file said the opposite in a comment, which is why it
looked safe. The damage surfaced late: `pad-endpoint ensure` died with
STATUS_HEAP_CORRUPTION (0xC0000374) partway through stamping, leaving the
endpoint with whatever subset had landed and `needs_aeb_kick` stuck true
forever. With the variants held in `ManuallyDrop`, `ensure` exits 0 and all
seven stamps read back served for the first time.
`wasapi 0.23`'s `DeviceEnumerator::get_device` builds its argument as
`PCWSTR::from_raw(HSTRING::from(id).as_ptr())`; the `HSTRING` is a temporary,
so `GetDevice` reads freed memory. That is where the `IAudioClient: 0x80070002`
came from — not from the endpoint, which activates fine. Resolving through
`open_mmdevice`, which keeps its buffer alive, retires the error in both the
tone devtest and the loopback capture.
Also adds the instrument that separated these: the tone path now reports the
raw `IMMDevice::Activate` result alongside the crate's, and `pad-endpoint tone
--endpoint <id>` can drive any endpoint, so "this process cannot activate
anything" and "this endpoint is broken" stop looking identical.
Verified on .173: ensure exit=0, 7/7 stamps served, needs_aeb_kick=false,
0x80070002 gone. Host clippy clean; 360 tests pass (the one mgmt display
failure reproduces on a clean tree).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The renderer now reports once a second regardless of traffic — frames in,
samples decoded, peak level, frames written, underruns, short bytes.
The first version reported only after a frame arrived, which made the single
most diagnostic state unreportable: an idle plane and a dead renderer looked
identical (both silent). That cost a debugging round on real hardware, where the
absence of any line had to be triangulated against usbfs interface claims and
`dumpsys input` to work out which of the two it was.
The peak is of the decoded PCM, and it is the discriminator that matters: frames
arriving with peak=0 means the host's capture is hearing silence — a routing
problem upstream — whereas a non-zero peak means real signal is reaching the pad
and anything still wrong is downstream of the write.
The self test shipped in the previous commit was gated behind a capture, which
needs a stream, which needs a host — so it depended on precisely the thing it
exists to rule out. It could not have been run in the situation that motivated
it.
It is now a "Test haptics" button on the DualSense passthrough card in
Settings → Controllers → Connected controllers, which is reachable with no
session at all. It opens its OWN connection to the pad — the same rule the
renderer follows, and the rule whose violation caused the fault this test looks
for — runs the tone on a worker thread, and reports a plain-language result:
which of open / write / no-data failed, or how many frames reached the pad.
The debug-property trigger stays for the in-session case; this is the one that
answers "can this phone drive this pad at all" before a host is even involved.
**The bug.** The renderer was handed `HidUsbLink`'s file descriptor. That link's
own comment states the hazard exactly — "only one thread may drive a
connection's UsbRequests (requestWait() returns ANY completed request; a second
waiter would steal the reader's completions)" — and it is just as true of the
usbfs reap underneath: the isochronous ring and the HID reader were reaping each
other's URB completions. The standalone harness works because it owns its
descriptor by construction, which is precisely why it could never have caught
this. `DsCapture` now opens a dedicated connection via `openAuxConnection()` and
closes it only after the render thread is joined.
**The test.** Nothing exercised the CLIENT path without a host, so the two things
most likely to be wrong were invisible: whether the descriptor handed over is
exclusively ours, and whether the claim succeeds on this kernel. Neither is
unit-testable and a harness proves neither.
`nativePadAudioSelfTest` drives the voice coils with a tone through the real
path — the same aux connection, claim, sink and write loop the renderer uses —
and is triggered by `adb shell setprop debug.punktfunk.pad_audio_selftest 3`,
matching this repo's existing debug.punktfunk.* convention. It runs INSTEAD of
the renderer for that capture, never alongside it: two engines on one descriptor
is the fault being tested for, and I nearly shipped it into the test itself.
Underruns are deliberately not a failure condition — that is producer pacing.
The pass condition is data reaching the bus.
A real bug, and the worst shape one can take here: it costs the user ALL
haptics rather than degrading.
`pad_audio::start` returned success as soon as the render thread spawned, and
`nativeStartPadAudio` then declared the pad's render capability and took it off
wire rumble. But `sink::open` runs later, on that thread. On a kernel that
refuses the interface claim — the OEM case documented as needing a clean tier-C
fallback — the pad was already suppressed and the host already streaming 0xD1 at
a renderer that never opened. No pad audio, and no rumble either.
The declaration and the suppression now happen inside the renderer, immediately
after a successful open, and are both withdrawn when it stops. A failed open
declares nothing and suppresses nothing, so the session stays on ordinary rumble
— which is what "degrades to tier C" was always supposed to mean. `PadAudio`'s
Drop clears the tier-A bit too, so a thread that dies unexpectedly cannot leave a
pad permanently mute.
The general rule this violated: never give up a working fallback until the thing
replacing it is known to work. Spawning a thread is not evidence that it will.
A gap in the previous commits, and the same silent-failure shape as the two they
fixed. There are TWO negotiations, not one: the per-pad render capabilities that
ride a gamepad arrival (bits 8/9), which those commits set, and the SESSION-level
CLIENT_CAP_PAD_AUDIO in the Hello, which they did not. Without the latter the
host never sets HOST_CAP_PAD_AUDIO and emits no 0xD1 at all — so the per-pad bits
would have had nothing to gate, and the renderer would have sat on a permanently
empty plane with every other piece looking correct.
Threaded as an explicit `padAudioOk` on nativeConnect rather than advertised
unconditionally: the cap makes a Windows host provision pad endpoints at startup,
and a user who has pad audio switched off should not pay for that.
Found by tracing what an on-glass run against a real host would actually need,
not by a test — there is no test that could have caught it, since both halves are
individually well-formed.
The Kotlin half. Turns out Android needs to claim nothing extra: `uac-host`
claims the pad's audio interface itself through usbfs on the fd, and usbfs
claims are per interface, so the HID claim `HidUsbLink` already holds is
untouched. The link therefore surrenders only its file descriptor.
Two orderings carry the whole design, and both are easy to get wrong:
- **Start on the first report, not at claim time.** The wire pad index does not
exist until the router opens a slot, and the host addresses the 0xD1 stream by
that index — starting earlier would declare capabilities for a pad that has no
index yet.
- **Stop before the link closes.** `usb.stop()` closes the connection whose
descriptor the render thread borrows, so `padAudio.stop()` runs first, at the
top of `DsCapture.stop()`. `nativeStopPadAudio` does not return until the
thread is joined, which is what makes the borrow sound rather than merely
usually-fine.
`DsCapture` decides WHEN (it owns the wire index and the link lifetime);
`StreamScreen` decides WHETHER (it owns the session handle and the settings).
The capture stays ignorant of sessions.
Settings: `padHaptics` defaults on — it is the whole point, and this client's
rumble already drives the same actuators, so tier A is a strict improvement.
`padSpeaker` defaults OFF: it is a small loudspeaker in the user's hands playing
audio they can already hear, and surprising someone with that is worse than
making them opt in.
Verified: APK builds, and both JNI entry points are exported in the shipped
arm64 .so — a missing one would be an UnsatisfiedLinkError only at runtime.
12 Rust tests, 0 clippy findings, fmt clean.
The two things that decide whether WP9 does anything at all on a device, both
failing silently rather than loudly if missed.
**Capability bits.** The host emits 0xD1 only toward pads that declared they can
render it (arrival flags 8/9). Without `set_pad_audio_caps` the renderer would
sit on a permanently empty plane and look like a decode bug. Declared when the
stream opens, withdrawn when it stops.
**Rumble arbitration.** `valid_flag0` bit 1 (HAPTICS_SELECT) *disables* audio
haptics and selects classic rumble, and `DsDevice` sets it on every rumble write
— as Linux's hid-playstation and SDL both do. One replayed rumble command would
mute the voice coils the 0xD1 stream is driving, for the rest of the session.
Tier A and tier C are mutually exclusive in the pad's firmware, so the
arbitration selects and never blends.
Suppression sits at `nativeNextRumble`, the pull point, rather than in Kotlin:
it keeps the rule next to the reason and covers every caller. The registry is an
atomic bitmask because the reader is the rumble poll thread and must not block
behind a start/stop on the JNI thread.
Order matters on teardown: the capability is withdrawn before the pad returns to
wire rumble, so the host has stopped sending 0xD1 before tier C resumes and the
two never overlap.
`nativeStartPadAudio`/`nativeStopPadAudio` now take the wire pad index, since
both the capability and the arbitration are per-pad. Out-of-range indices are
rejected rather than wrapped into another pad's slot.
12 host tests (2 new, including one pinning that an out-of-range index cannot
shift the mask into undefined territory), 0 clippy findings, check clean on all
three Android ABIs.
The Android twin of `pf-client-core`'s pad_audio: drain the host's per-pad
DualSense streams, Opus-decode haptics (kind 0) and speaker (kind 1), interleave
into the pad's own 4-channel layout, and render on the pad itself.
Every other client hands that stream to the platform's audio graph. Android
cannot: AOSP's UsbAlsaManager denylists the DualSense's output by VID/PID, so
the kernel enumerates the pad's playback node and the framework discards it —
`hasOutput: false`, nothing for setPreferredDevice to target, /dev/snd closed by
SELinux, and UsbRequest rejects non-bulk/interrupt endpoints. So this drives the
pad's isochronous endpoint directly via uac-host on the descriptor Java owns.
That is measured, not assumed. On a Nothing Phone (3): the claim succeeds
unprivileged, the gamepad and the pad's microphone both keep working, and the
underrun-free floor is 4 ms — holding under eight-core load with the SoC in
severe thermal throttling. The renderer runs at 6 ms, one step of headroom,
because the same measurement found transient events that are not depth-dependent.
Structured to the crate's own convention: the mixer and PLC are ungated so they
compile and unit-test in the host workspace (8 tests), while everything touching
an Android-only dependency is cfg'd to android. Two details worth review:
- The kinds arrive on different cadences (5 ms vs 10 ms), so each has its own
write cursor and both shift together on overflow — a haptics-only session
renders with a silent speaker pair instead of stalling on a kind that will
never arrive, and the two can never skew.
- An unrecognised kind is dropped rather than folded into the coil pair. A
`min(1)` clamp would have rendered a future kind straight into the actuators.
Lifecycle mirrors MicCapture: dropping the handle joins the thread, and
nativeStopPadAudio returns only once it has, so Kotlin may close the
UsbDeviceConnection as soon as it returns and not before.
usbfs-iso/uac-host enter as git dependencies pinned by revision — a transport
under a real-time deadline should move when we choose. They become version
dependencies once published to crates.io.
The 0xD1 pad-audio plane streams a DualSense's voice-coil haptics (back
channel pair, 5 ms Opus frames) and speaker (front pair, 10 ms) per pad from
a Windows host to the SDL clients, which render them into a USB DualSense's
own 4-channel audio device.
Wire (punktfunk-core, ABI v15): PAD_AUDIO_MAGIC 0xD1 [pad][kind][seq][pts]
[opus]; CLIENT_CAP_PAD_AUDIO 0x04 / HOST_CAP_PAD_AUDIO 0x20; per-pad render
capability rides GamepadArrival flags bits 8/9, sent only toward a host that
advertised its cap so old hosts see byte-identical arrivals; silence is a
frozen seq (mic-mute discipline), loss is a seq gap concealed via
AudioGapTracker. HidOutput::AudioCtl (0xCD kind 0x06) forwards the 0x02
report's audio-control bytes 5..=10 change-only, value-deduped, with a
once-per-pad "title asserted haptics-select" diagnosis log.
Windows host endpoint provider (audio/windows/pad_endpoint.rs): per-pad
render endpoints are additional devnode instances of Valve's Steam Streaming
Speakers driver (SetupDiRegisterDeviceInfo, NOT the class installer - it
needs an interactive window station), stamped with DualSense identity: desc
"Wireless Controller", device name "DualSense Wireless Controller",
ContainerId = the virtual pad's PFDS GUID, 4ch/48k format triplet.
IPropertyStore route first, ACL-repaired registry fallback (the MMDevices
keys deny writes even to SYSTEM; the owner's implicit WRITE_DAC + an ACE for
S-1-5-18 resolved by SID is the way in). Provisioned at host startup
(PUNKTFUNK_PAD_AUDIO, PUNKTFUNK_PAD_AUDIO_SLOTS, default 1), idempotent via
a persisted PunktfunkPadIndex marker; pad endpoints are structurally
ineligible for the mic/loopback wiring plan and guarded against default-
device theft; capture is WASAPI loopback on the stamped endpoint. Devtest:
punktfunk-host pad-endpoint ensure|remove|status.
Host service (native/pad_audio.rs): per-(session,pad) thread, loopback 4ch
-> pair splitter -> per-kind stereo Opus (48k LowDelay CBR 64k) -> per-kind
silence gate (opens at peak>=1e-3, 250 ms hangover, gated = no send + frozen
seq) -> datagrams. Spawned from the native input pump when a DualSense/Edge
arrival carries audio bits and both caps negotiated; idempotent re-arrivals;
reaped on remove and teardown.
Client tier A (pf-client-core/pad_audio.rs): settings pad_haptics (default
on) and pad_speaker (default "pad"); tier A = wired USB DS5/Edge via SDL
connection state with an audio-sibling fallback; correlation maps the SDL
HID path to the pad's own render endpoint (Windows: ContainerId match +
4ch gate via registry; Linux: Sony sink signature); renderer decodes both
kinds into a quad interleave and plays it on the pad's endpoint (WASAPI
autoconvert / PipeWire target.object, 240-2400 frame ring floor,
dont-reconnect so an unplug never re-routes haptics to the desktop
speakers). SDL's DualSense driver sets "disable audio haptics" whenever it
drives rumble emulation, so tier-A pads suppress wire rumble and send one
cleared-enable-bits effects packet to keep the actuators live; AudioCtl
bytes fold back into the effects packet at report-minus-one offsets.
Verification: punktfunk-core 265 tests (macOS) + clippy -D warnings (mac +
Linux docker); pf-inject 85 tests (Linux docker); punktfunk-host cargo
check + clippy + 19 pad tests + 46 audio-module tests (Windows box);
pf-client-core 30 tests + clippy (Linux docker CI image) + cargo check
(Windows box); punktfunk-client-session clippy (Linux) + check (Windows);
cargo fmt --all --check clean on the final tree. NOT yet verified: any
on-glass run (host deploy + real title + physical pad), the stamp-route
split at runtime, exclusive-mode Initialize isolation, Linux-host emission
(the per-pad PipeWire sink is not in this change - Windows hosts only).
Scope excluded deliberately: tier B (Apple CoreHaptics) and tier C
(haptics->rumble derivation), pad_speaker="mix", Android leg, settings UI
surfaces (keys are serde-defaulted), GameStream-plane arrivals (audio_caps
always 0 there).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:07:06 +02:00
383 changed files with 37076 additions and 6755 deletions
| tr -d '\r'| sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest: //p'||true
}
key_digest=$(digest_of "$KEY")
latest_digest=$(digest_of latest)
if[ -z "$key_digest"];then
echo"::error::$IMAGE:$KEY has no manifest — the build or push above did not land"
exit1
fi
if["$key_digest"="$latest_digest"];then
echo"$IMAGE:latest == :$KEY ($key_digest)"
exit0
fi
echo"::warning::$IMAGE:latest did not match its content key :$KEY — re-pointing it. If ci/ was not just reverted, someone overwrote this tag out of band: check the registry access log on home-ci-core."
"summary":"Fetch one cover-art image for a library entry",
"description":"Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what theuser's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which casethis 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.",
"description":"Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact —it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URLconvention second\n(newer titles' CDN assets can live at a per-asset-hash path the hostcan't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
"operationId":"getLibraryArt",
"parameters":[
{
@@ -1307,7 +1307,7 @@
"library"
],
"summary":"Replace a provider's library entries (declarative reconcile)",
"description":"Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.",
"description":"Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
"operationId":"reconcileProviderEntries",
"parameters":[
{
@@ -1318,6 +1318,15 @@
"schema":{
"type":"string"
}
},
{
"name":"store",
"in":"query",
"description":"Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)",
"required":false,
"schema":{
"type":"string"
}
}
],
"requestBody":{
@@ -1348,7 +1357,7 @@
}
},
"400":{
"description":"Invalid provider id or payload",
"description":"Invalid provider id, store id, or payload",
"content":{
"application/json":{
"schema":{
@@ -1367,6 +1376,16 @@
}
}
},
"409":{
"description":"That store is already claimed by another provider",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"500":{
"description":"Could not persist the catalog",
"content":{
@@ -4159,7 +4178,8 @@
"tier",
"platforms",
"compatible",
"update_available"
"update_available",
"categories"
],
"properties":{
"author":{
@@ -4172,6 +4192,13 @@
],
"description":"A revocation covering the catalogued version — do not offer this without shouting."
},
"categories":{
"type":"array",
"items":{
"type":"string"
},
"description":"What kind of plugin this is — the console filters Browse by these, and the Game sources\nsurface's \"Add a source\" rail shows exactly the `library` ones (design D5/D6)."
},
"compatible":{
"type":"boolean",
"description":"Can this host install it?"
@@ -4179,6 +4206,13 @@
"description":{
"type":"string"
},
"detected":{
"type":[
"boolean",
"null"
],
"description":"Whether the launcher this plugin scans looks **installed on this host** (design D8), from the\nindex's own existence probes. `null` = the entry declares no probes for this platform, which\nthe console renders as \"unknown\" rather than \"not installed\"."
},
"homepage":{
"type":[
"string",
@@ -4365,6 +4399,17 @@
],
"description":"The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
},
"role":{
"$ref":"#/components/schemas/GameRole",
"description":"Whether this entry is a game or the launcher itself — see [`GameRole`]."
},
"store":{
"type":[
"string",
"null"
],
"description":"The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten."
},
"title":{
"type":"string"
}
@@ -4409,6 +4454,10 @@
},
"description":"Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"role":{
"$ref":"#/components/schemas/GameRole",
"description":"Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)."
},
"title":{
"type":"string"
}
@@ -4467,6 +4516,17 @@
"type":"object",
"description":"What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.",
"properties":{
"env_marker":{
"oneOf":[
{
"type":"null"
},
{
"$ref":"#/components/schemas/EnvMarker",
"description":"A launcher-stamped environment marker (D3) — see [`EnvMarker`]."
}
]
},
"exe":{
"type":[
"string",
@@ -4487,6 +4547,15 @@
"null"
],
"description":"The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]."
},
"steam_appid":{
"type":[
"integer",
"null"
],
"format":"int32",
"description":"The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**\nsignal that exists — Steam wraps every launch, native or Proton, in\n`reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a\nsteam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.",
"minimum":0
}
}
},
@@ -4715,6 +4784,27 @@
}
}
},
"EnvMarker":{
"type":"object",
"description":"An environment variable a launcher stamps onto the game's process, identifying it.\n\nSerializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin\nthat knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to\nbe able to say so, since after extraction the host no longer reads that launcher's files itself.",
"required":[
"key"
],
"properties":{
"key":{
"type":"string",
"description":"The variable name (e.g. `HEROIC_GAME_ID`).",
"example":"HEROIC_APP_NAME"
},
"value":{
"type":[
"string",
"null"
],
"description":"The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time."
}
}
},
"EventKind":{
"oneOf":[
{
@@ -5165,6 +5255,10 @@
],
"description":"The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
},
"role":{
"$ref":"#/components/schemas/GameRole",
"description":"Whether this entry is a game or the launcher itself — see [`GameRole`]."
},
"store":{
"type":"string",
"description":"Which store surfaced it: `\"steam\"` or `\"custom\"`.",
@@ -5296,6 +5390,14 @@
}
}
},
"GameRole":{
"type":"string",
"description":"What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big\nPicture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,\nleases and lists exactly like a game (design D4), and clients that don't know the field render it\nas a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged\nfor every entry that doesn't opt in.",
"enum":[
"game",
"launcher"
]
},
"GameSession":{
"type":"string",
"description":"How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
@@ -6334,6 +6436,13 @@
"title"
],
"properties":{
"category":{
"type":[
"string",
"null"
],
"description":"What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`\nbecause it describes the plugin, not its surface. The console knows one value today —\n`library` — which it filters **out of the nav**: six installed scanner plugins would otherwise\nflood the sidebar, and their real entry point is the Game sources surface (design D5). A\nlibrary plugin that genuinely wants its own page (rom-manager, which is much more than a\nscanner) simply omits the category."
},
"title":{
"type":"string",
"description":"Human-readable title for the console nav entry (1–64 chars; control chars stripped)."
@@ -6366,6 +6475,13 @@
"title"
],
"properties":{
"category":{
"type":[
"string",
"null"
],
"description":"The plugin's kind — see [`PluginRegistration::category`]."
},
"id":{
"type":"string"
},
@@ -6604,6 +6720,10 @@
},
"description":"Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"role":{
"$ref":"#/components/schemas/GameRole",
"description":"Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`."
},
"title":{
"type":"string"
}
@@ -6780,26 +6900,46 @@
},
"ScannerInfo":{
"type":"object",
"description":"One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a togglefor. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
"description":"One **game source** on this host, with its enable state — the unit theconsole renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible.",
"required":[
"id",
"label",
"enabled"
"enabled",
"origin"
],
"properties":{
"enabled":{
"type":"boolean",
"description":"Whether this host runs the scanner (default true)."
"description":"Whether this host runs the source (default true)."
},
"entries":{
"type":[
"integer",
"null"
],
"description":"How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle.",
"minimum":0
},
"id":{
"type":"string",
"description":"Stable scanner id — the same string the scanner's entries carry in their `store` field.",
"description":"Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin.",
"example":"steam"
},
"label":{
"type":"string",
"description":"Human-facing name for the console toggle.",
"example":"Steam"
},
"origin":{
"$ref":"#/components/schemas/SourceOrigin",
"description":"Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
},
"provider":{
"type":[
"string",
"null"
],
"description":"The provider id backing a `plugin` source — absent for a built-in scanner."
}
}
},
@@ -6962,6 +7102,14 @@
}
}
},
"SourceOrigin":{
"type":"string",
"description":"Where a [`ScannerInfo`] comes from.",
"enum":[
"builtin",
"plugin"
]
},
"SourceView":{
"type":"object",
"description":"A configured catalog source and how its last refresh went.",
"a single short read must not force a full re-prime")
}
}
#endif
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.