Two field reports (2026-08-21), one shape: a GameSir G8+ and an Xbox Elite
Series 2 ("Xbox Wireless Controller" over Bluetooth) with X answering Y, Y
answering LB, and the two shoulders answering menu buttons — everything else
correct. That is not a stray mapping, it is exactly what `GENERIC_XBOX` does to
scancodes `0x133`/`0x134`/`0x136`/`0x137`, so the correction added yesterday was
firing on pads whose buttons were already where `Generic.kl` says they are.
It fired because it asked the wrong question. `hasKeys(BUTTON_C, BUTTON_Z)`
answers for what a device DECLARES, not what it reports: `hid-input` allocates
`BTN_A + n` straight through for every button in the descriptor, so BTN_C
(`0x132`) and BTN_Z (`0x135`) are set on ANY pad declaring six or more —
including a standard-layout pad that never presses either. The signal is
therefore identical on the pad that needs correcting and the pad that does not,
and no amount of tightening it could have separated them. It is the same pad
model in both reports: an Elite Series 2 needed the correction on a Fire TV and
another Elite Series 2 was broken by it here.
What does separate them is the axes. A HID gamepad describes its triggers either
as the Accelerator/Brake usages — which become `ABS_GAS`/`ABS_BRAKE`, names
Android has words for — or as two more generic axes on `ABS_Z`/`ABS_RZ`, which
it does not. A descriptor well-formed enough to name its triggers puts its
buttons at the standard positions too. It is also the firmware line on the pad in
the report: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE after
its firmware update and Z/Rz before it, and only the older one was ever wrong.
`padButtons` now takes `namedTriggers` and answers NATIVE whenever it is set —
no correction of any kind, on buttons or axes, for a pad Android already reads.
`padMap` computed that fact one line below and only ever spent it on the axes;
it now decides both. `hasKeys` stays for the narrower question it can answer —
WHICH straight-through order, once the axes have established there is one — where
a false positive costs nothing.
Moonlight decides it on the same fact (`ControllerHandler`, `gasRange == null`
beside the `"Xbox Wireless Controller"` name); yesterday's commit cited its
tables and then replaced its discriminator, which is where this came in.
Verified: `:kit:testDebugUnitTest` and `:app:testDebugUnitTest` green (16 cases
in PadButtonsTest, 3 new: the gate holds for every vendor/declaration
combination, the four reported buttons stay themselves, and the report-order
choice past the gate is unchanged), `:app:compileDebugKotlin` clean. The
DualSense report filed alongside these — Triangle dead in the client UI and in
the stream — is NOT explained by this and is not fixed here: a button that
reaches neither is one `buttonBit` maps to nothing, which no branch of the
correction produces for Triangle. The Controllers screen prints the raw scancode
and keycode of every press; that line off the reporter's pad will pin it.
Merged with `apple / swift` red: the Mac runner's volume is out of space (594 "No space left on device" lines; the only distinct error is "You can't save the file because the volume 'Macintosh HD' is out of space"). It passed on the parent commit 1280f697 and the only delta since was one line in scripts/ci/docs-undocumented-env-baseline.txt — Swift compilation of the Apple client cannot be affected by a Rust host firewall rule, a core control message, or a CI text baseline. A rerun hit the same wall, so the mac-mini-1 disk needs the owed cleanup.
Every check that can validate this change is green: ci/rust (480 core tests), ci/rust-arm64, android, docs-drift, web, docs-site, bun-nix, and both windows-client legs. The Windows HOST build — which the PR checks never run, and which is the only thing that compiles the cfg(windows) firewall function — was dispatched separately on the branch and passed, including "Clippy (host + tray, Windows)".
`api/openapi.json` (and its docs-site copy) gain exactly the new route and its
three schemas — nothing else moved, which is the check worth doing on a
regenerated spec.
The test fix is one the tests found themselves, on the first run in an
environment that actually executes them: all three shared the provider id
`playnite` and cleared the whole process-global table between cases, so under
parallel scheduling they flipped each other's answers — `omitted_is_not_running`
read `None` for a title another test had just wiped. Each now takes ids only it
uses and forgets only its own row, which also retires the blunt `reset()` that
made the collision possible.
`reportRunning` and its two types were reachable only through the deep
`./reconcile.js` path — `index.ts` re-exports an explicit list, not a star — so
no plugin could import them from the package root the way it imports every
other provider symbol.
Version bumped because it is a published package and the addition is what a
consumer would depend on; the playnite plugin deliberately does NOT, calling the
route through the untyped host seam instead so it is not gated on this publish.
The host derives liveness by scanning, which needs something recognizable on
disk. A Playnite-launched emulated game, a manually added one, or a library
plugin that records no install directory has none — and its launch is a
`playnite://` hand-off, so the host holds no process either. The lease went
`Untracked`: the exit was never noticed, `session_on_game_exit` could not fire,
and `POST /game/end` had nothing to aim at. Playnite knew the whole time.
New `PUT /library/provider/{provider}/running` takes a provider's complete
running set (with the pid where it knows one) — declarative and idempotent like
the reconcile beside it, so a missed event or a plugin restart self-corrects
rather than drifting. `crate::runstate` holds it and expires it after 90s
unless restated, which is what makes it safe for a live provider to hold a
session open for a game the host cannot see: a plugin that dies stops counting
and the host falls back to scanning, exactly as today.
`LeaseKind::Reported` is the lease that follows from it. `open` reaches it when
the spec is empty and a provider speaks for the id, and — the load-bearing part
on Windows, where every launch is a hand-off by construction — the three shim
reclassification paths now fall back to it where they fell to `Untracked`.
Phase 1 takes "running" as the game appearing; phase 2 takes "stopped" as the
exit. Unlike `procscan::running_hint`, which may only ever delay an exit because
Steam's registry flag survives an unclean one, a fresh report is decisive in
both directions. A reported pid joins the termination ladders on the same terms
as a spawned one: re-resolved and start-time-pinned at the moment of use.
The route is the plugin lane's, like the reconcile. No new authority — the host
maps `external_id` through the catalog, so a provider can only speak about
entries it published; an unknown id is counted, not refused, because a report
legitimately races its own reconcile and 400-ing the batch would throw away the
liveness of every other running title.
plugin-kit gains `ProviderClient.reportRunning`; a 404 from an older host means
"this host tracks games by scanning".
`check-docs-drift.sh` scans for `PUNKTFUNK_*` identifiers and asks that
each be documented in docs-site or explicitly baselined. It cannot tell
an env knob from a cbindgen-exported `#define`, so the new
`PUNKTFUNK_MSG_DELIVERY_REPORT` header constant tripped it.
It is a wire message type byte, not a knob an operator can set — same as
every other `PUNKTFUNK_MSG_*`, all of which are already in the baseline.
Added in sorted position beside them.
Two field reports from one Android user, with one shape between them: a
decision taken once, at a moment when the answer was still wrong, and never
revisited.
The console UI could not be dismissed. "With a controller" asks whether a pad
is attached, and the client answered that with `isPad` — does this device's
source class include gamepad or joystick. That is the right question for
ROUTING an event and the wrong one for presence: devices publish inputs that
claim the source class while being no such thing (OEM game-mode overlays, the
gaming-phone shoulder triggers), and one of them is enough to pin the console
UI on forever, because a pad that was never there can never disconnect.
`pads()` now filters on `looksLikeController`: the source claim AND hardware
behind it — a stick, a HAT, or the A/B face buttons — on a device the platform
did not synthesize itself. The claim is cheap; the hardware is not. `isPad`
keeps its looser meaning for the event lane, where it is correct. It is not a
complete defence (an OEM device that declares BTN_GAMEPAD and two axes is
indistinguishable from a pad at this layer), so the master switch stays the
guaranteed way out — and the Controllers screen still lists everything real in
one column or the other, which is where someone looks when the client's idea of
"a pad is attached" disagrees with the room.
The picture sat in the top-left corner. The ASurfaceControl layer composites
into the SurfaceView's on-screen rectangle, read once at `surfaceCreated` — but
the stream screen hides the system bars and switches the window to draw into
the display cutout a frame or two later, and each of those grows the view under
a surface that is never recreated. The layer went on painting at the size it
started with, anchored at the origin. It passed on glass because a device whose
bars were already hidden when the surface arrived never sees the gap.
The size is now live: a packed atomic on the session handle, seeded by
`nativeStartVideo`, re-reported by `nativeVideoSurfaceSize` from every
`surfaceChanged`, and read by the layer before each present. One atomic load per
frame, and rotation and multi-window come along for free.
Verified: `:kit:cargoNdkClippy` (arm64 + armv7, deny warnings), `:kit:` and
`:app:` unit tests, and the native crate's own suite. The new JNI symbol is
exported in the built `.so`.
The managed takeover stopped stopping the display manager and started idling the
box's autologin session instead (c2f5e91b). That commit also deleted the two lines
the old DM stop carried — `record_session_select_baseline()` and
`STOPPED_DM = Some(dm)` — and 38a0f54b then removed every remaining writer of that
static. The in-stream switch gate in `create_managed_session` still reads it, so
`honor_session_select_switch` became unreachable code on every box.
Bazzite never noticed, for two reasons measured on `.41`: its `os-session-select`
is a thin wrapper over `steamosctl` D-Bus calls and writes NO sentinel (verified —
`~/.config/steamos-session-select` does not exist before or after a completed
switch), and `is_steam_htpc_platform()` defaults the mid-stream session watcher ON
for bazzite/steamos, which follows the switch by itself.
Nobara gets neither. `ID=nobara` matches no HTPC default, so no watcher; and its
ChimeraOS-layout `os-session-select` DOES write the sentinel, which was the only
thing the host had to see the switch by. So on Nobara the user's "Switch to
Desktop" went entirely unhandled: the capture loss it causes drives a rebuild, the
rebuild relaunches game mode over the booting desktop, and the stream is thrown
straight back in — the 2026-07-24 field report, reopened by a user 2026-08-20.
Arm the gate on the takeover that actually exists now. `takeover_idled()` reads
IDLE_DROPIN_ARMED, the idle drop-in re-baselines the sentinel when it goes in, and
STOPPED_DM is documented as what it has become: adoption-only state for a takeover
stranded by a pre-0.31.0 host. The hand-back keeps that host's DM restore + switch
replay (now `replay_switch_under_restored_dm`) but runs it only for such an adopted
takeover — a 0.31.0 takeover leaves the DM up precisely so the OS does that work
itself, and on the VM it does: `Updated user selected session to plasma`, then
Plasma.
Both hand-back paths also give the box its own Game Mode back, which neither did.
The takeover replaces that session's ExecStart with a sleep, and a mid-stream
switch is the one exit that leaked it — the disconnect restore sweeps it, but a
switch is not a disconnect. Measured on Bazzite `.41`: after a completed switch to
KDE the unit still carried `ExecStart=/usr/bin/sleep infinity`, so "Return to
Gaming Mode" would have started a unit that does nothing. That is the same barred
way back `release_autologin_mask` already exists to prevent for the mask this
drop-in replaced, so it goes there — covering the watcher-driven distros — and in
`honor_session_select_switch`, covering the sentinel-driven ones.
Verified on the Nobara VM (123 on home-node-3) by driving the real path against
live systemd: takeover -> real `steamos-session-select plasma` -> rebuild. The
rebuild refuses to relaunch game mode, the idle drop-in is gone, and the box
arrives in KDE. Backed out to the 0.31.0 shape on the same box it fails at "the
switch did not advance the sentinel" — the blindness itself. The extended
`the_mask_comes_off_only_when_the_box_takes_itself_back` covers the watcher path
against a live user manager, and fails without the hand-back.
A field host streamed 1919 frames into a black screen while its own log
blamed the client. Four faults, each of which alone makes the failure
invisible.
The Windows firewall rules are `localport=`-scoped (47998-48010, 9777,
5353), but the media data plane binds an EPHEMERAL port per session. No
such rule can ever cover it, so Windows Firewall drops the client's
hole-punch on EVERY session on EVERY Windows host — `punched=false` on
the "data plane bound" line, in all six sessions of two field logs. The
punch then never opens the return path and video is sent blind to an
address the client merely reported. `service install` now also adds a
program-scoped inbound UDP rule for the host executable, which covers
whatever port a session picks. Program-scoped rather than a pinned port:
pinning into 47998-48010 would collide with Sunshine/Apollo.
`LossReport` carried only `loss_ppm`, which is ambiguous at zero — loss
is a ratio over the packets that arrived, so a flawless link and a link
delivering NOTHING both report 0. The host read total silence as perfect
and decayed adaptive FEC to its floor. Clients now also send a
`DeliveryReport` with the session's received-packet count. It is a new
type byte, NOT a field appended to `LossReport`: that message is
length-checked exactly, so lengthening it would make every shipped host
reject the loss reports its FEC runs on. Sent every window while the
count is zero, once when the first packets land, then never — an older
host warns per unknown message and must not be flooded on a good
session.
`NO_VIDEO_RETRY` (client got nothing) and `FLUSH_COOLDOWN` (client
drowning) were both 2000 ms, so the host's cadence classifier could not
tell two opposite faults apart and picked the wrong one out loud. The
no-video cooldown moves to core beside `FLUSH_COOLDOWN` at 2600 ms, and
both sides compare against the shared constant rather than a copy.
The diagnosis now leads with the delivery count: zero is an error naming
the data plane, a confirmed count keeps the old confident wording, and an
old client that cannot answer gets a warning that says so instead of
guessing. A punch that never arrives is also its own warning now, rather
than a debug field on an info line.
Reported from a Fire TV Stick 4K Max: a DualSense and an Xbox Elite Series 2,
both over Bluetooth, both identified correctly but with buttons on the wrong
actions ("L1 being L2"). Both work in Moonlight on the same box.
Android names a pad's buttons through a KEY LAYOUT file matched on VID/PID. A
pad with no matching file falls back to AOSP's `Generic.kl`, which assigns
keycodes by SCANCODE POSITION — 0x130→BUTTON_A, 0x131→BUTTON_B, 0x132→BUTTON_C
and up. That is only right if the pad's buttons sit where the file assumes, and
a HID gamepad with no kernel driver numbers its buttons 1..n straight through in
its OWN report order, so every keycode past the first divergence is somebody
else's button. Neither pad has a layout there: AOSP ships none for the Elite
Series 2 over Bluetooth (045e:0b05) on any version, and the DualSense's
(054c:0ce6) both postdates Fire OS and carries `requires_kernel_config
CONFIG_HID_PLAYSTATION`, which a Fire TV kernel has not. A DualSense reporting
straight through puts L2 on 0x136, which `Generic.kl` calls BUTTON_L1 — the
reported symptom exactly, and Circle and R1 (BUTTON_C / BUTTON_Z) mapped to
nothing at all and were dropped.
This client read `KeyEvent.keyCode` only, so it inherited every one of those
errors — in the stream, in the console shell's navigation, and in the
Controllers screen's own tester, which is why the tester agreed with the bug.
Resolve buttons from the SCANCODE instead, which is the pad's own report
position and immune to the layout file — the same reason `Keymap.toVk` reads
`scanCode` for keyboards. Two things keep it off pads that already work:
1. the correction applies ONLY where the delivered keycode is what
`Generic.kl` would have said, so a device-specific layout always wins;
2. which report order to read is decided from what the DEVICE declares —
a pad numbering straight through claims BUTTON_C and BUTTON_Z, keycodes
no real controller has a button for — never from a model table.
`Gamepad.padKeyCode(event)` is a drop-in for `event.keyCode` and every pad
reader now goes through it: the streaming branch, the Skia console shell's
probe, the older Compose navigation, and the Controllers tester. The console and
the game must not disagree about which button a user pressed.
Axes get the same treatment: a pad declaring none of LTRIGGER/RTRIGGER,
BRAKE/GAS or BRAKE/THROTTLE is one Android never mapped, and its triggers are on
raw axes. A Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry, so
only the triggers (RX/RY) were missed; every other such pad puts the right stick
on Rx/Ry and the triggers on Z/Rz — the shape that makes pulling a trigger swing
the right stick. Whether those idle at −1 is measured off the device's own range
rather than assumed, so a pad reporting an honest 0..1 is not rescaled to a
permanent half-pull.
Also: the Controllers screen now reports each press as its raw scancode and
keycode plus the correction, names the resolved layout on the pad's card, and
reads axes through the same map the stream does — so it can be trusted again,
and so an unmapped pad can be pinned from a field report without the hardware.
The Xbox Bluetooth product ids (One S, Elite Series 2 and its Core) join the
identity table; without them an Elite streamed under the Xbox 360 identity.
Moonlight carries the same two report-order tables (`ControllerHandler`'s
`isNonStandardDualShock4` / `isNonStandardXboxBtController`), which is why both
pads work there on the same box.
Verified: :kit:testDebugUnitTest and :app:testDebugUnitTest both green (13 new
cases in PadButtonsTest covering the scancode tables and the axis resolution),
:app:compileDebugKotlin clean. NOT yet tested on glass — the reporter's Fire TV
is the confirmation this needs. The Xbox pad's Guide button stays unreachable
either way: it arrives as KEY_HOMEPAGE, and Android never delivers KEYCODE_HOME
to an app.
Reported from the field: gamepad input does nothing on GameStream clients, and it
reproduces across every client and device a user tries (Artemis on a Steam Deck and
on an Android phone both). That breadth is the tell — it is not a client at all.
On Windows there are two virtual Xbox backends and they are not interchangeable to a
game. The XUSB companion registers only GUID_DEVINTERFACE_XUSB and exposes no HID
collection (pf_xusb.inx says so in its own header: "a non-HID UMDF2 driver", Class =
System), so Steam's hidapi enumeration, SDL, RawInput, DirectInput, joy.cpl and
WGI/GameInput cannot see it at all — only classic XInputGetState can. The native plane
moved to the real HID pad as its DEFAULT on 2026-08-09 (bd5735b8) for exactly that
reason; its doc comment records the reporter who lost a controller for two weeks to it.
The GameStream plane never got that change. It has bound `crate::inject::gamepad`
since the first gamepad commit, when that name meant uinput and Windows had no second
backend; Windows later gave the same name the XUSB companion, so this plane inherited
it by module-name coincidence rather than by any decision. bd5735b8 did touch
control.rs — but only to widen the rumble closure's arity, and its note reasons about
"the uinput backend", not about the Windows one sitting behind the same import. So
every Moonlight session since has presented a pad most games cannot enumerate, while
native punktfunk sessions on the same host got the good one.
There was also no way out: `windows_xbox_hid` was `pub(super)`, i.e. unreachable from
this module, so PUNKTFUNK_XBOX_BACKEND did nothing here — and the Windows manager
ignores the arrival `kind` byte, so a client could not ask for a DualSense either, the
escape that saved the earlier reporter.
- native.rs / native/gamepad.rs: `mod gamepad` and `windows_xbox_hid` become
pub(crate). The knob keeps ONE definition and one name; widening visibility breaks
no existing caller. Its doc now records that both planes read it, and why being
`pub(super)` was itself the bug.
- gamestream/control.rs: a `SessionPads` enum is the one place this plane picks a
backend — the HID pad when the shared knob says so, the XUSB companion otherwise,
and on every other platform the single backend that exists.
`PUNKTFUNK_XBOX_BACKEND=xusb` now reverts both planes together.
The HID pad's rich-feedback plane is dropped rather than plumbed: an Xbox pad has no
lightbar or adaptive triggers, and GameStream has no vocabulary for one — its rumble
message (0x010B) carries the two handle motors and nothing else, which is why the
trigger levels were already dropped at the call site.
No test: a `cfg(target_os = "windows")` #[test] would compile NOWHERE. ci.yml excludes
it by target, and windows-host.yml lints the host with `cargo clippy -p punktfunk-host`
without `--all-targets`, which does not build `cfg(test)` modules at all — the same
blind spot that workflow's own comments blame for letting the Linux twin's tests rot to
the wrong arity. It would be dead weight, not coverage.
VERIFIED
* ON WINDOWS (.133, the only box where this arm compiles at all):
`cargo check -p punktfunk-host` clean in 57s, then
`cargo clippy -p punktfunk-host -- -D warnings` clean in 42s — both on default
features, which include `gamestream`. This compiles BOTH enum arms: `Xusb` and
`Hid` are Windows types alike.
Non-vacuous by construction: `cargo clean -p punktfunk-host` ran first (a scp'd
tree plus this box's lagging clock and a warm shared target dir otherwise yield a
fresh `Finished` having compiled nothing), and both passes logged
`Compiling punktfunk-host v0.31.0`. The only warning is punktfunk-core's
header-write notice — benign and pre-existing.
* `cargo fmt --all --check` clean.
NOT VERIFIED — owed
* Linux: no box could run it (.25's root filesystem is 100% full; .21/.136/.138 were
down; OrbStack here hangs even on `docker run alpine echo`). Exposure is small —
the Linux arm is three calls on a `GamepadManager` whose Windows XUSB sibling has
the identical method surface and just compiled — and ci.yml DOES run on
pull_request, so opening a PR closes this.
* On glass: no Moonlight session has driven the HID pad through this plane yet. That
is the real acceptance test, and the log line to look for is
"virtual Xbox pad created (Windows UMDF HID)" where it used to say
"virtual Xbox 360 created (Windows XUSB companion)".
* windows-host.yml has NO `pull_request` trigger (push to main, v* tags,
workflow_dispatch only), so CI will not re-check this arm on a PR — the .133 run
above is deliberately standing in for it.
Re-measured every version-table row against the tree rather than
trusting the earlier cut: ABI 25, wire 2, driver 6/min 3, gamepad 3,
index schema 1, event schema 1, edition 2024, MSRV 1.85, 27 crate dirs,
39 workspace members, SDK 0.1.5, plugin-kit 0.4.3, openapi 0.31.0 with
both copies byte-identical, gamescope +pfhdr8 with 10 patch files and
no diff against the tag. All match what the table claims. The C header
diff against v0.30.0 removes exactly two things — the old ABI define and
the ex11 doc paragraph that stated the superseded hi-res rule — so
'addition only' holds.
Three fixes to the text itself:
* The commit count is now taken at the tip being released (170/113 at
d7fa5847) instead of at this branch, where it moved every time I
committed to it.
* The two NixOS items in Before-you-update are one item. They were
separated by three unrelated bullets, so the audience that has to
act on both — builds are failing AND there is now a cache — had to
find them in two places.
* A TL;DR line read 'it repaired itself on no retry', which says the
opposite of what it means.
Checked and left alone: the Gaming Mode 'Switch to Desktop' entry is
presented as a bug users hit, and that is right — v0.30.0's takeover
does stop the display manager on mask-fragile flavors
(gamescope.rs:259 at the tag), so it shipped. Only the mask-vs-stop
detail moved inside this cycle.
Gates: fmt clean, punktfunk-core --lib 273 passed, cargo metadata ok,
Play notes 456/500 and unique, voice scan finds no internal names in the
notes body.
170 -> 173 commits since v0.30.0 (115 non-merge, counted on this branch).
One is user-facing and is in the notes: the web console's Virtual
displays page rendered the Streamed-screen and session-lifetime cards
below the tab shell, so both tabs showed them and the Live tab read as
a duplicate of Configuration. They are policy surfaces and now sit
inside Configuration.
The other three are CHANGELOG-only: the guides trim's second round, the
fifth get-started screenshot (a client's host list), and the workflow
change that made it reachable — linux-client-screenshots now publishes
its PNGs to the generic registry as well, because the v3 artifact store
is browser-only and nothing could pull the shots out of it.
The CI bullet also records this branch's own retry.sh wrapping, since a
reader hitting 'Fail extracting tarball' deserves to find the diagnosis
rather than repeat it.
Gates re-run on this tip: fmt clean, punktfunk-core --lib 273 passed,
Play notes 456/500, both openapi copies identical at 0.31.0.
docs-site died on `error: Fail extracting tarball for
"@rolldown/binding-linux-x64-gnu"` (run 19630, 2026-08-20). The message
points at the package; the package is fine.
MEASURED, because the message invites the wrong fix:
* The tarball's sha512 matches docs-site/bun.lock exactly, and it is
an ordinary 3-entry npm tgz — same gzip framing, same modes, no pax
headers — as the 1.2.0 one that installs fine. Only the payload
differs in size (20.6 MB vs 19.0 MB of .node).
* bun 1.3.13 AND 1.3.14 both extract that exact tarball from disk in
under 80 ms. So it is not the bun bump the floating oven/bun:1 tag
brought in, and not a format bun stopped accepting.
* In the SAME run, the web job installed the same registry over the
same network and passed — it was 25 s ahead of docs-site.
* Run 19632, seven minutes later, installed the identical lockfile
and passed.
So: a transient truncation, not a bad package. bun streams
download-and-extract, so a tarball cut off mid-stream surfaces at the
extract step and names the package it was reading — which is why this
looks like `@rolldown/binding-linux-x64-gnu` is broken and why the
obvious fixes (bump rolldown, pin bun, refresh the lockfile) would all
have "worked" by changing which bytes were in flight, and none of them
would have fixed anything.
scripts/ci/retry.sh already exists for precisely this and its header
already diagnosed it: "the runner box executes many jobs in parallel and
its network drops packets under that load … Wrap every single-shot
network command in CI with this instead." `bun install` is a single-shot
network command and was the one class still unwrapped, so it is wrapped
now at all nine Linux sites — ci.yml (web, docs-site), arch, deb, rpm,
web-screenshots, sdk-publish and plugin-kit-publish (both installs).
3 attempts, not retry.sh's usual 5: a genuinely stale lockfile fails
deterministically under --frozen-lockfile, and 10s+20s of backoff is
enough to outlive a load burst without making that honest failure wait
a minute and a half.
The two windows-host.yml installs are left alone: pwsh, and a Windows
box that is not the contended runner.
Verified: all seven workflows still parse; the helper resolves from
web/, docs-site/ and sdk/ (the three working-directory shapes used);
the wrapper recovers a command that fails once and succeeds on the
retry; and `bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile
--ignore-scripts` in docs-site installs all 1138 packages, so the
lockfile is sound and the wrapper does not change the command.
Not done, deliberately: docs-site's lockfile still pins rolldown 1.1.2
where web has 1.2.0. That difference is real but it is not this bug,
and refreshing a lockfile to chase a network flake would have buried it.
An Android TV's only input is its remote — a D-pad, OK and Back — and the
console bound both Settings and the game library to face buttons that
remote does not have. X opened Settings, Y opened the shelf, and neither
event could be produced: the Kotlin bridge emits Move/Confirm/Back for a
remote and nothing else, the carousel's only action tiles are Add Host and
Rescan, and the hint chips that also carry those actions need a finger on
a touchscreen. So on a TV the two were simply unreachable. This is the
gap the deleted Compose home used to paper over with its own "no face
buttons" mapping, and the same wall Apple hit on the Siri Remote.
Down on the carousel now opens Settings — the other free direction beside
up, which already opens the host menu. It is a second route on a pad and
the only route on a remote, so the hint bar names the one the device in
hand actually has: ▼ with no pad attached, X with one. That is the whole
extent of the legend change; Y still opens the library and still says so.
The shelf gains a Library row on the host's own options menu, which up
reaches, offered on exactly the terms Y offers it (saved AND paired). It
replaces the menu rather than stacking on it, so Back from the shelf lands
on the carousel and not on a menu about the host just left. This is also
the route `docs/game-library.md` has been describing all along — "open its
options and choose Library" — which until now did not exist.
▼ is a new hint glyph: the ▲ triangle stood on its head rather than a
second drawing routine, and pressable in the hint bar exactly as ▲ is.
ROOT CAUSE of every gamescope symptom chased today. nixpkgs WRAPS this package:
makeWrapper leaves the real compositor ELF at bin/.gamescope-wrapped and
installs a small launcher at bin/gamescope that sets PATH (xwininfo) before
exec'ing it. Our prune kept only `gamescope`:
find $out/bin -mindepth 1 ! -name gamescope -delete
so it deleted the compositor and shipped the launcher pointing at a path that
no longer existed. MEASURED (run 19622): $out/bin held one 16 KB file, and
`strings` on it showed the PATH prologue and '/bin/.gamescope-wrapped'.
That single line explains all of it:
* `--version` printed NOTHING — the launcher exec'd a deleted target. I had
attributed this to the build sandbox, and to upstream's PrintVersion()
landing after 3.16.25. Both were wrong.
* the +pfhdr marker was 'missing from the binary' — a wrapper carries no
version string, so it never could have been there.
Keep the target through the prune, and assert on the WRAPPED ELF rather than
the launcher (which would otherwise pass or fail by accident). The launcher
references its target by absolute path, so renaming the launcher stays safe.
The guards were right on every run; they were reporting a genuinely broken
artifact, not a flaky check.
The Interface section owned two facts this changes: which surfaces carry
"Gamepad-optimized browsing" (the Android console home carries it now, so
that home can be left from inside it) and what an Android TV sees — the
mode row used to be described as present-but-inert there, and both rows are
simply absent now.
The Android console's settings screen had a picker for WHEN it fronts the
app but no switch for WHETHER it does — that lived only in the touch
settings, which a user already inside the console has no way to reach.
The console UI became the only UI on Android, so the way back out was a
setting you could only change from the thing you were trying to get to.
Adds the "Controller-optimized UI" row over the same `gamepadUiEnabled`
the touch switch writes, folded through `extra` under
`android.gamepad_ui_enabled` like the other Android-only rows. Off
returns to the touch home immediately: the console's save bumps the
snapshot generation, the host emits its settings event, and Kotlin's
`applySettings` now folds the flag back into the App state that
`gamepadUiActive` reads.
Shown only where "off" has somewhere to land. `ConsoleOptions.fallback_ui`
(new, threaded to `Ctx` beside `deck`) is true only for the Android touch
shell — false on a TV and for the desktop session, where this console IS
the interface and an off switch would strand the user in nothing.
The mode row follows the same rule and moves under it: it decides nothing
on a TV (the tv term satisfies `gamepadUiActive`'s OR on its own) or while
the switch is off, so it is hidden in both cases rather than offered as a
control that changes nothing. Renamed to the touch screen's own word for
it, "Show it" — "Controller UI" directly under "Controller-optimized UI"
was two rows told apart by their tails.
The marker is not in the installed ELF, and patch 0005 applies cleanly to a
src/meson.build that still carries the exact line it rewrites. Those two facts
cannot both be reasoned from the log as it stands, so stop guessing and
instrument the two stages separately:
* postPatch now asserts +pfhdr is in src/meson.build after our vcs_tag
substitution — the same gate build-punktfunk-gamescope.sh applies. If it
fires, patch 0005 or the substitution lost the marker, and it prints the
version block as patched.
* the installCheck failure branch now prints evidence instead of an
assertion: $out/bin, anything under $out mentioning pfhdr, and the
version-shaped strings actually present in the binary.
Whichever fires, the next run says which stage drops it. Each guess here costs
a full compositor build, and I have now spent three on this one symptom.
The Streamed screen and session-game lifetime cards sat BELOW the tab
shell, so they rendered on both tabs — from the Live displays tab the
page read as the Configuration tab's content duplicated (reported the
other way round, same sight). Both are policy surfaces, so they ride
inside the Configuration tab as siblings of the config card; the Live
tab is now only the live list + arrangement.
`--version` produces EMPTY output under nix's build sandbox — measured on BOTH
nixpkgs' 3.16.25 and the pinned 5fb8dce4 (runs 19551 / 19573 / 19594). It is a
sandbox property, not a defect: gamescope calls PrintVersion() before the getopt
loop (src/main.cpp:721), so `gamescope --version` does print the banner on a
real system, which is what the host's capability probe reads. My earlier claim
that the probe itself was broken was wrong.
packaging/gamescope/build-punktfunk-gamescope.sh already avoids running the
binary, asserting on src/meson.build instead. Grepping the installed ELF is
strictly stronger than either: the version string reaches .rodata via
GamescopeVersion.h's k_szGamescopeVersion, so this proves the marker survived
patching, meson configuration and compilation into the artifact we ship — and it
cannot be defeated by the binary being unable to start.
The src pin from the previous commit stays. It is still right for its own
reason: the patches are authored against 5fb8dce4, nixpkgs' 3.16.24 could not
take patch 0009 at all, and every other channel ships this exact commit.
Saved hosts with their pairing state + an unpaired host found on the network, from
linux-client-screenshots run 19593 via the new generic-package publish (fixed 'ci' version,
anonymous GET), cropped to the cards. Completes the handoff's screenshot list: console login,
Waiting for approval + PIN, the Approve dialog, live status, and a client's host list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#320 merged but v0.31.0 was never tagged, and 69 more commits (41
non-merge) landed on top of it. The version stays 0.31.0 and Cargo.toml
does not move; the notes, Play notes and CHANGELOG section are
re-measured on the new tip.
THE NUMBER IS NOW FORCED, where the first cut's was a judgement call.
The C ABI moves 24 -> 25: punktfunk_set_log_callback plus the
PunktfunkLogCb typedef (#338's core half), so an embedder can hear the
core's tracing lines. Addition only — no declaration moved, no repr(C)
struct grew a field, and an embedder that never calls it is byte-
compatible with v24. include/punktfunk_core.h is therefore NO LONGER
byte-identical to the v0.30.0 tag, which the first cut's table and both
"nothing versioned moves" paragraphs claimed.
Table re-measured on d801cb72. Two rows changed since the first cut and
two were already corrected in-tree by their own commits:
* C ABI 24 -> 25 (above).
* api/openapi.json 0.29.0 -> 0.31.0 — the STAMP only. No management
surface moved; the file had been left two versions stale and #337's
regenerate-and-diff caught it. Both copies cmp identical.
* SDK 0.1.5 and plugin-kit 0.4.3 were cut during the cycle (#348,
#334), so the rows the first cut wrote as "owed" are now past tense.
The registry still skips 0.4.2 on purpose.
Everything else holds: wire 2, driver protocol 6 / min 3, gamepad
channel 3, plugin index schema 1, host event schema 1, edition 2024,
MSRV 1.85, 27 crate dirs, gamescope +pfhdr8 with no new patches. The
PKGBUILD pfhdr7 discrepancy is unchanged and still pre-existing.
REWRITTEN RATHER THAN APPENDED TO, because the intervening work never
shipped and a user must not read a within-cycle correction as a second
bug:
* The Gaming Mode takeover section. The first cut described "stops the
login manager first, then steps in" — which #342 replaced before
shipping, after a field report that a stopped display manager leaves
the box unable to service Steam's own Switch to Desktop (it hung
until reboot, and could not even be detected around: every trace of
that switch is written by the component we had stopped). End state:
idle the autologin over the gamescope-session-plus@ template, leave
the DM running. #343 then deleted the whole 142-line stop/linger
chain the old approach needed. Two shipped facts became false with
it — the takeover no longer stops the DM and no longer needs the
punktfunk group (that group still gates the usbip pad nodes) — and
the notes' Before-you-update says so for Deck/Bazzite operators.
* The Android ASC entries. HDR seeded from the codec's echo, untagged
SDR raising blacks to grey, and the console's dispose re-showing the
system bars over the stream are all regressions against THIS cycle's
unreleased presenter, so they are folded into the Android story, not
listed as fixes users hit.
NEW USER-FACING WORK, in rough order of how much it changes:
* Windows client (#349): the default download becomes a signed
per-user Inno Setup installer + portable zip; MSIX stays for the
Store. This is the Steam fix — MSIX puts the exe under WindowsApps,
which Steam's picker cannot browse, and alias activation defeats
overlay injection. Before-you-update carries the one action in the
release: an MSIX user who wants it must Remove-AppxPackage first and
will re-pair once, because pairing identity lives with the package.
* install.sh (#345/#346): one command from nothing to a pairable Linux
host, running platforms.json's lines verbatim, with Sunshine
coexistence, --dry-run, --uninstall and env twins. Shipped PREVIEW
on purpose and the notes say so.
* Send logs to host completes on Apple (#338) and Android (#339) — the
two legs 0.30's notes named as follow-ups.
* KWin 6.6 (#344/#331): it refuses to stream an output it created
disabled, in the session's language, logged nowhere, and persists
the disable against the stable per-client name so it repeats
forever. Enable and retry; and a translated refusal no longer burns
all 8 retries.
* Deck Wake-on-LAN (#347, closes#322), portable Playnite covers
(#330), plugin-kit republish on startup/manual (#334), console touch
gestures and idle gates (#333), cancel-connect (#329), NixOS host
builds failing outright (#328 + #332 + #336).
* Docs overhaul (#337/#340/#343/#346): get-started track, Switching
from Sunshine, screenshots, and data/platforms.json as the single
source for install facts with seven CI drift gates over it.
Gates on this tree: cargo fmt --all --check clean (no drift this time —
the first cut needed a whitespace commit, which is already in main);
cargo metadata --offline ok; cargo test -p punktfunk-core --lib 273
passed; Play notes gate verbatim 456/500 and unique; voice scan clean
outside For developers; both openapi copies cmp identical at 0.31.0.
⚠ The C ABI harness still did not run here — no libopus on this MacBook
— and this time the header DID change, so ABI 25's C compile is owed to
CI. Named in Verification status rather than left to be found.
⚠ Owed outside this repo: platforms.json changed (the Windows download),
so punktfunk-website needs `bun run sync-platforms` committed — the step
#346 added to docs/releases/README.md, and this is its first exercise.
The category sacrificed, per page (RFC 'screenshots over prose' / phase-2 handoff item 3):
- client-settings.md (4368→3144 words): the why-rationale narrative around each setting; every
default, number, availability list, env var and link stays.
- virtual-displays.md (3724→3015): status/history notes (the 'What's live today' tracker
paragraph, 'Windows has always lingered 10 s') and design rationale; compositor-specific
operational facts stay.
- plugins.mdx (2835→2418): narrative duplication — restart instructions stated twice, story
asides the linked plugin repos carry.
- input.md (2499→2403) and wake-on-lan.md (2286→2186): rationale asides and historical notes
('the plugin used to fire a packet itself…'); these two are per-platform fact tables end to
end, so only a light pass was honest.
15712→13166 words over the five biggest Guides (−16%). Zero heading changes (anchors stable),
docs-drift + docs-links green, docs-site build + lint green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nix was the ONLY channel not pinning the compositor — the RPM spec, the
PKGBUILD, build-punktfunk-gamescope.sh and packaging/gamescope/README.md all
ship 5fb8dce4 (3.16.25-11), while gamescope.nix patched whatever version
nixpkgs happened to carry. That produced two failures in two days, both the
same bug:
* nixpkgs shipped 3.16.24, where patch 0009's context does not exist, so the
build died at patchPhase. host.gamescopeHdr defaults true, so every
`services.punktfunk.host.enable = true` failed the same way.
* bumping the lock to 3.16.25 fixed the patches, and then --version printed
NOTHING. Upstream's `gamescope::PrintVersion()` landed AFTER the 3.16.25
tag; in that tag `--version` is `return 0;` with the comment "We always
print the version to stderr anyway". The host reads that banner to decide a
session's bit depth and cursor compositing BEFORE the virtual display
exists, so a silent banner is a silent fall back to SDR.
The installCheck was right to fail — this was not a check problem.
Also in this commit: the +pfhdr guard now prints the banner it read. That is
how the empty output was identified at all; without it the failure is
indistinguishable from the binary not starting, at ~15 min per guess.
vcs_tag now substitutes the PINNED version (old.version is the pre-override
attr and would still say 3.16.25).
nix-instantiate --parse clean. Hash from nix-prefetch-git --fetch-submodules.
A user report: launching via Big Picture doesn't work and the Steam overlay never appears.
Not because the app is UWP (it's full-trust Win32 under MSIX too) but because of the MSIX
install SHAPE: the exe lives under the ACL'd WindowsApps dir Steam's non-Steam-game picker
can't browse, and alias/shell:AppsFolder activation defeats the overlay's injection — Steam
must spawn the exe itself from a normal path.
- punktfunk-client.iss: per-user (no UAC) install to %LOCALAPPDATA%\Programs\Punktfunk.
Re-creates the manifest's grants in HKCU (punktfunk:// scheme, Start entries, {app} on the
user PATH for the punktfunk CLI) and fetches the Windows App Runtime when missing.
- pack-client-installer.ps1: consumes pack-msix.ps1's layout (one assembly, three artifacts),
signs the four exes individually, emits setup.exe + a portable zip. Same signing backends
and fail-closed-on-tags rule as the sibling scripts; no .cer (an exe runs untrusted).
- windows-client.yml: pack step after the MSIX, publish + release-attach the new artifacts
(canary/latest aliases punktfunk-client-setup_<arch>.exe, ..._<arch>-portable.zip).
- deeplink.rs: write_shortcut targets the app-execution alias only under package identity;
unpackaged installs have no alias but a stable path, so they target current_exe().
has_package_identity() now shared with main.rs's AppUserModelID probe.
- docs: install-client (installer default + a 'Launching through Steam' section + MSIX/portable
as alternates), channels, uninstall, clients, platforms.json (both copies, drift check green).
v0.31.0's changelog recorded this cut as owed and said why: plugins resolve
`@punktfunk/host` from the registry, so a fix sitting in `sdk/` reaches nobody
until a version carries it. Four commits have touched the SDK since sdk-v0.1.4,
the headline one being the mgmt-port fix — a moved `PUNKTFUNK_MGMT_BIND` left
every plugin and the tray dialing 47990.
`SDK_VERSION` moves with it. It is a hand-maintained constant (the bundled
runner has no package.json to read at runtime), and the runner compares it
against the SDK actually installed in the plugins tree to decide whether to
reinstall — so shipping 0.1.5 with the constant still reading 0.1.4 would have
published the fix and then never delivered it. `version.test.ts` exists for
exactly this and caught it.
Gates: 83/83 SDK tests pass.
The memory raise got gamescope building, and it now reaches installCheckPhase
and fails there:
punktfunk-gamescope: the +pfhdr marker is missing — the patches did not take
Patch 0005 applied cleanly to src/meson.build in that same run, so the message
is misleading: the patch DID take. Two very different causes are
indistinguishable from the log as written —
* the binary never ran (shrunk RPATH, missing loader dep), so --version
printed nothing at all; or
* it ran and printed a version without +pfhdrN, meaning upstream no longer
builds the banner from VCS_TAG.
A guard that reports 'missing' without showing what it read cannot be acted on,
and each guess costs a full compositor build. Capture the output and print it on
failure, with a note on how to read the two cases apart.
No behaviour change: same assertion, same exit. Also verified in this run: the
chmod fix works (prune, rename and layer install all completed) and the WSI
layer is installed.
Phase 2 of the docs-and-onboarding overhaul (items 1-partial, 2 and 4 of the handoff):
install.sh: --uninstall reverses step 1 + step 6 per family (user units off first, only the
punktfunk packages actually installed, then the repo; config/groups/firewall stay, as
/docs/uninstall states) — smoke-tested as a new installer-smoke step on all three families.
The end-of-run check now catches the two NVIDIA silent failures on every family: no driver at
all, and a module the kernel refused to load (Secure Boot) via an nvidia-smi probe pointing at
the troubleshooting anchor; the Fedora ffmpeg-libs/NVENC warning folds into the same block.
check-docs-drift.sh gate 7: the manual 16-file os-release matrix PR #345 was verified with,
committed — every family's detection, its install line, its removal line and the four
unsupported pointers run through the real script under --dry-run on every push (docs-drift's
container gains curl, the script's own prerequisite).
Screenshots (RFC: "screenshots over prose"): four console shots captured from the same
storybook-fixture pipeline web-screenshots.yml runs — login and the armed Pairing page into
quickstart.md, the Approve dialog (access level + expiry + guest fast-path) into pairing.md
replacing the prose that described it, live status into web-console.md. Files under
docs-site/public/img/, dark-theme, bundled+preloaded by the docs build (verified served).
Still missing: a client host-list shot — linux-client-screenshots run 19546 built it, but its
artifact isn't API-downloadable; add it when a browser session can fetch the zip.
WP5 rider: the release-flow docs-freshness step now includes the website content look-over.
NOT flipped: installer stays preview — the handoff gates the default flip on real-box mileage
(Bazzite above all), which a Mac can't provide.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WP5 of the docs-and-onboarding overhaul, the two riders the RFC attaches to WP2–WP4:
- .gitea/PULL_REQUEST_TEMPLATE.md — one question: did a user-facing fact change, and is the
docs-site page that owns it updated in this PR (install/repo/port facts in data/platforms.json).
CI's docs-drift only catches the mechanical half; this is the reminder for the rest.
- docs/releases/README.md step 1 — while the release diff is in front of you, check docs freshness,
and if platforms.json changed, run `bun run sync-platforms` in punktfunk-website and commit,
because the download page vendors that file and only refreshes when someone does.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WP4 of the docs-and-onboarding overhaul (punktfunk-planning design/docs-and-onboarding-overhaul.md).
scripts/install.sh — plain POSIX sh (dash-clean), ~380 lines, `curl -fsSL https://punktfunk.unom.io/install.sh | sh`:
detect the distro from os-release (apt / dnf / pacman / rpm-ostree→sysext; NixOS, SteamOS, Windows
and unknown distros get a one-line pointer and stop; Debian 12 / Ubuntu 24.04 / Mint 22 / Fedora
45 hit the documented floors with the right docs link) → install with the platforms.json lines
VERBATIM (channel and the Fedora group are edited into the string at run time; `--yes` rewrites
them non-interactive, a tty hands the package manager its own prompt; stdin is never read, because
under `curl | sh` stdin is the script) → `punktfunk-host detect-conflicts` (exit 1 = active
Sunshine-family host) → offer to keep both by moving the management API port (PUNKTFUNK_MGMT_BIND,
default 47991, the firewall step opens it) → input group (ujust on Bazzite; no-op if already in) →
optional punktfunk group, GameStream compat, shared clipboard (all default no) → firewalld/ufw
profiles → enable host + console (+ the plugin runner where it isn't) → optional linger → verify
(unit active, UDP 9777 bound) and print the console URL, the password command and the pairing
steps. `--dry-run` prints every command and changes nothing; every prompt has a PUNKTFUNK_INSTALL_*
environment twin; re-running is safe (install skipped when the binary exists). Running under sudo
is refused (host.env and the units belong to the user); root without sudo gets a shim so the
verbatim lines still work.
Decisions: the canonical URL is punktfunk.unom.io/install.sh, a 302 on the website to the script at
raw/branch/main (versioned with the code it installs; precedent: the Bazzite sysext bootstrap) —
the website half is punktfunk-website PR #4. GPU drivers stay the docs pages' job; the one silent
failure (Fedora + NVIDIA without RPM Fusion's ffmpeg-libs → no NVENC) is called out at the end.
Gates: check-docs-drift.sh gate 6 — every apt/pacman/dnf/sysext install line in data/platforms.json
must appear verbatim in the script, and the script must parse (shown to fail on a planted drift).
New path-filtered workflow installer-smoke.yml runs the script unattended in debian:trixie,
fedora:44 and archlinux:base against the real registry, then `punktfunk-host --version`,
`detect-conflicts`, and a re-run that must say "already installed".
Docs: install hub gains "Guided install (preview)" rendered from platforms.json's new `installer`
block via an <Installer/> component (one-liner + inspect-first form + flags); CONTRIBUTING names
the new gate. Verified locally: sh/dash -n, both docs gates, docs-site build + lint, and a
--dry-run matrix over 16 faked os-release files (all four families, every floor, canary, every
option, piped stdin). The container run itself is the CI job's to report — Docker on this machine
was wedged under another session's emulated build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On KWin >= 6.6 `streamVirtualOutput` creates the output on the backend and then
passes `workspace()->findOutput(output)` to the stream, which is null for an
output the workspace does not manage (`wantsToManage` = `isEnabled() &&
!isNonDesktop()`). So an output KWin creates DISABLED is refused with
"Could not find output" — translated into the session's language, and logged
nowhere, because disabling an output is a perfectly valid configuration that
applies successfully. 6.4/6.5 passed the backend output straight through and
streamed it either way.
It repeats forever. The host asks for a STABLE per-client output name precisely
so KWin persists that client's scale and mode against it, so a stored setup
naming it `enabled: false` is reapplied to every future session for that client
— and the user cannot fix it in System Settings, because the output only exists
for the few milliseconds the request is alive.
Repair it instead. On a refusal, enable the head over kde_output_management_v2
and let the retry go again. Two properties of KWin make that possible, both read
off Plasma/6.7 rather than assumed:
* `sendFailed` only sends the event — it does not emit `finished`, and
`removeVirtualOutput` is wired to `finished`. The disabled output therefore
stays alive for as long as we hold the failed stream open, which is the
window the repair runs in (and why it must run inside the worker thread,
before the connection drops).
* `WaylandServer::handleOutputAdded` offers EVERY backend output to the
output-device registry, gating only placeholders and non-desktop ones. Only
`wl_output` is gated on being enabled, so a disabled output is invisible to
`stream_output` but fully addressable over output management.
Enabling it is a user-applied configuration, so KWin persists it against that
output's identity: the retry's fresh request finds a stored setup that enables
it. The repair therefore fixes attempt N+1, never attempt N.
`REPAIRED_HINT` keeps that retry reachable. The opener wraps every worker error
with "KWin virtual output failed", which is the phrase the host's
`is_permanent_build_error` matches to short-circuit the retry loop — so a
repaired refusal carrying it would be classified permanent and the retry that
consumes the repair would never run, making the whole path dead code. A repaired
refusal is reported verbatim without that wrapper; an unrepairable one keeps it
and still fails fast, because nothing about the box changed. The host-side test
pins both halves.
`cargo check` on Linux flagged six functions the previous commit orphaned. The
linger machinery existed for one reason — stopping the display manager ends the
user's last login session, and logind would take the host down with it about 10 s
later — and nothing stops a display manager any more, so the whole chain goes:
`try_stop_display_manager`, `ensure_host_survives_dm_stop`,
`host_is_under_user_manager`, `cgroup_under_user_manager`, `linger_enabled`, and
the one test that covered only the cgroup predicate. 142 lines out, 17 in.
`mask_unit` becomes `#[cfg(test)]` rather than going with them. Its only caller
now is the mask-lift test, and that test still guards live code:
`lift_autologin_mask` cleans up a takeover adopted from a host old enough to have
laid a mask, so the state it undoes has to stay constructible.
`pf-dm-helper` keeps its `stop` and `linger` verbs even though no caller is left —
only `restore` is still reached — because removing them changes the shipped polkit
action's surface, which is a packaging decision rather than a cleanup.
WP2 of the docs-and-onboarding overhaul (punktfunk-planning design/docs-and-onboarding-overhaul.md),
stacked on #337 (WP0+WP1).
Nav (meta.json) is now Get started / Guides / Switching from Sunshine / Reference / Troubleshooting.
Get-started track, rewritten for someone with no Linux expertise — one task per page, happy path,
under two minutes each: quickstart (5 steps), install (a pick-your-system hub), ubuntu, debian,
fedora, arch, bazzite (+ a firewall step it was missing), a new nixos page (moved out of install),
pairing. Every distro page quotes its install snippet through a new <Install platform="…"/> MDX
component that reads docs-site/src/data/platforms.json — a byte-identical snapshot of
data/platforms.json (the Docker build context is docs-site/ alone, same arrangement as
openapi.json) which scripts/ci/check-docs-drift.sh now gates. <Ports/> renders the port table the
same way on a new reference page, ports.mdx, so the four distro pages stop restating port lists.
platforms.json itself was fixed while wiring it up: the apt snippet lacked the keyring lines, the
winget one lacked `winget source add`, the Arch repo-add is now idempotent (grep guard — the
documented double-append gotcha disappears at the source), names match the real floors (Ubuntu
26.04+, Windows 11 22H2+), a web-console port entry and the firewall profile names were added, and
client platforms (Windows MSIX, macOS, TestFlight, Play, Steam Deck) so the website can render its
cards from the same file.
Dense material moved down rather than deleted: build-from-source.md (the three distro appendices),
the Mint/LMDE/Cinnamon analysis → requirements.md, TOFU/`--open` → security.md, Secure Boot MOK
paths and the Arch libavcodec soname refusal → troubleshooting, sysext channels/rollback/rebase →
updating.md, the 0.28.1 cert note → windows-host.
New page switching-from-sunshine.md: coexistence via the 47990 move (PUNKTFUNK_MGMT_BIND), the
Windows display-topology overlap, detect-conflicts exit semantics (1 only for an active host — the
troubleshooting page said "non-zero if any"), what maps to what, migration steps. The
troubleshooting Sunshine section is tightened and points there.
The three facts whose only home was a README now live in troubleshooting — the ffmpeg-libs weak
dependency (no NVENC on Fedora), the pacman double-append error, the ds_inhibit SELinux storm with
DualSense pads on Bazzite — and packaging/bazzite + packaging/arch READMEs are pointers.
Stale claims corrected against the tree: the packaged unit runs the native-only `serve` and
GameStream is opt-in on every route (kde, hyprland, security, steamos-host said otherwise);
host.env is optional (EnvironmentFile=-); Fedora 43 uses the `bazzite` group; the iOS clipboard
bridge exists; PUNKTFUNK_GAMESCOPE_SDR_NITS defaults to 203.
Guides trimmed for verbosity (~5% by words — they are fact-dense; every heading, command, number,
env var and link target is preserved, verified by token-inventory diffs). Tooling: docs-site README
and CONTRIBUTING describe the platforms snapshot; input/uninstall/support-matrix links retargeted.
Verified: check-docs-drift.sh and check-docs-links.sh green, docs-site `bun run build` + `bun run
lint` green, and the built site served locally to confirm every cross-page anchor the rewrite
links to (37) renders, plus the <Install/> blocks and the <Ports/> table.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two reds on #337's first CI run, both self-inflicted:
- Gate 3 greps every quoted "PUNKTFUNK_*" literal outside docs-site — including the gate's own
explanatory comment, which spelled out ("PUNKTFUNK_X") as an example and so reported PUNKTFUNK_X
as a new undocumented knob. Reworded the comment; the regex is unchanged.
- The `rust` job's regenerate-and-diff found `api/openapi.json` still saying `"version": "0.29.0"`
while the crate is 0.31.0 — the only difference in the whole spec. Regenerated with
`cargo run -p punktfunk-host -- openapi` (in the punktfunk-rust-ci image) and copied the
docs-site snapshot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reverts the --max-jobs 1 from the previous commit. The hypothesis it encoded —
that punktfunk-web and punktfunk-scripting bundling concurrently exhausted the
box — is DISPROVEN: run 19444 failed identically with it, and the Environment
step it added shows why the premise was wrong:
Mem: 125Gi total, 48Gi available Swap: 8.0Gi nproc: 16
overlay 172G, 108G avail
Nothing was scarce. Serialising also would have cost real wall-clock on the
publish tier, which builds six derivations including an hour of Rust, so it does
not stay on an unproven basis.
Also ruled out: bun is 1.3.13 in BOTH the old and new nixpkgs, so the lock bump
did not change the bundler.
What remains is what those numbers cannot see. Inside a container /proc/meminfo
and free(1) report the HOST, so a cgroup memory cap is invisible to them — and a
cap is the only explanation left for a SIGKILL with 48Gi apparently free. So
read it directly: cgroup v2 memory.max, falling back to v1
memory.limit_in_bytes.
No fix in this commit, deliberately. Two guesses have already cost a day of red
runs; the next change should follow the number.
The managed takeover freed Steam by stopping the box's gaming session and,
because a display-manager autologin puts that session straight back, by stopping
the display manager too. That worked until the user asked for a desktop session:
with no DM there is nothing on the box able to start one, so Steam's own "Switch
to Desktop" sat on its modal until a reboot (field report 2026-08-18, .41).
It also could not be detected and worked around. On a steamos-manager box the
switch is a D-Bus call whose every trace is written by the display manager we had
just stopped — measured on .41: the `~/.config/steamos-session-select` sentinel is
never written (that is the ChimeraOS/Nobara layout), `/var/lib/sddm/state.conf`
only advances when sddm actually STARTS a session, `get-default-login-mode` stays
`game` for a non-persistent switch, and `graphical-session.target` going inactive
fires at takeover time too. There is nothing to watch, because the component that
would produce the signal is the one we disabled.
So stop disabling it. The takeover now idles the box's autologin session for the
stream's duration — a drop-in over the `gamescope-session-plus@` template that
replaces `ExecStart` with a process that sleeps — and leaves the DM running. The
autologin still SUCCEEDS, so there is no failed unit to relogin against (a masked
one fails in milliseconds, which is the storm's engine); the session runs nothing,
so Steam is free; and the DM is alive, so the box can service the switch itself.
No privilege, no DM-flavor matrix, and no detection.
Measured on .41, both directions: takeover leaves `steam` down, `sddm` active and
the unit `active (running)` with NRestarts=0; the switch that used to hang brings
Plasma up in ~10 s; the restore puts Steam back within 5 s.
The drop-in lives under $XDG_RUNTIME_DIR for the same reason the bind one does —
it replaces the box's game-mode ExecStart, so a copy outliving the host would be a
box whose Game Mode silently does nothing — and is swept unconditionally at
startup. Its removal sits above every early return in the restore, next to the
bind drop-in's, because the desktop-active return is exactly the path that would
leak it. The restore restarts rather than starts: the unit is active-but-idle, and
`start` on an active unit is a no-op that would log success over it.
Docs and the shipped Bazzite host.env said the takeover has to stop the display
manager and needs the `punktfunk` group; both are now false. The group still gates
the usbip nodes the virtual Steam Deck pad attaches through, which is what the
advice narrows to.
The core logs through tracing; the desktop and Android shells install a subscriber/logger and
see those lines, the Swift client installed none and saw nothing — every transport warning,
quinn connection event and rustls handshake note vanished, and a client log bundle sent to the
host carried the shell's half only.
- punktfunk_set_log_callback(max_level, cb, user): a log::Log backend behind a C callback
(level, target, message, user), level-gated by log::set_max_level so anything above the
ceiling costs no formatting; NULL detaches; Unsupported when another log backend already owns
the process (android_logger). tracing's `log` feature declared explicitly by the core — it was
on transitively via quinn, which an ABI promise must not rest on. ABI_VERSION 24 → 25, header
regenerated, embedding doc §2.6.
- Apple: CoreLog.install() at app start routes the lines into ClientLog under core.<crate>
(os_log + the send-to-host ring), info ceiling by default (PUNKTFUNK_CORE_LOG_LEVEL raises it).
- Test: a log record and a tracing event both reach the callback with level/target/message,
interior NUL dropped not truncated, ceiling honoured, detach silences.
The nix job's recurring red is not a broken bundle, it is memory:
punktfunk-web> error: script "build" was terminated by signal SIGKILL (Forced quit)
Reason: builder failed with exit code 137
MEASURED 2026-08-19: five runs across main and two branches died exactly there,
while other runs on the SAME commits passed — the signature of pressure, not of
a defect. nix builds punktfunk-web and punktfunk-scripting concurrently, and
each one runs a memory-hungry bundler; that pairing is what the runner cannot
afford.
`--max-jobs 1` caps how many DERIVATIONS build at once, not the cores inside
one, so two bundles that each pin a core simply run in sequence. Applied to the
publish tier too, which builds six derivations including those same two.
This step sits BEFORE the publish tier, so every one of those failures also
stopped the binary cache from filling — it has blocked more runs today than
every real bug combined.
Also prints memory in the Environment step. An exit 137 cannot be explained by
the disk numbers that step used to gather on their own.
WP5b of punktfunk-planning design/console-ui-sweep-2026-08-19.md (the last open item):
- pf-client-core: the logring's RING half (note/render/wallclock — std only) is
Android-enabled; `send_to_host` stays desktop-gated with the rest of the ureq
fetches. `wallclock` moves in from the session's ring_layer so every ring feeder
stamps lines the same way.
- Android native: JNI_OnLoad installs a RingTee — every `log` record goes to logcat
AND into the ring, in the desktop ring_layer's line shape. `nativeRenderLogs(header)`
hands Kotlin the rendered bundle.
- Kotlin: `SkiaConsole.sendLogs` replaces the not-available stub — renders the ring
and POSTs it to /api/v1/client-logs over `mtlsHttpClient` (the library/art path),
noticing the desktop wording on success/failure. The upload deliberately stays on
the Kotlin side: OkHttp already owns HTTPS-to-the-pinned-host on this platform,
and pulling ureq+rustls into the .so for one POST would be a dependency change,
not a feature.
- console-ui: the host menu's "Send logs" desktop-only gate is gone — paired and
reachable is the whole condition again; the pinning test flips to assert both
platforms offer it.
The Gaming Mode console got "Send logs to host" in 0.30 (pf_client_core::logring +
POST /api/v1/client-logs); the Apple client had no ring to send and no POST on its mgmt
transport, and its own session lifecycle was never logged at all — a field report from an
Apple TV carried only the host's half.
- ClientLog: drop-in for Logger(subsystem: "io.unom.punktfunk", category:) — same call
shape (privacy:/format: interpolations accepted), writes os_log AND a process-global ring
bounded by 4096 lines / 768 KiB (under the host's 1 MiB cap), stamped with wall-clock
ISO-8601 so a bundle lines up with the host log. .debug stays out of the ring (per-key
input chatter would flush it — the Steam Deck DPB lesson). 13 Logger declarations swapped.
- MgmtTransport/MgmtConnection: POST with a length-framed body on the same pooled, pinned
mTLS connection; LibraryClient.sendLogs posts the ring and returns the bundle id.
- SendLogs.toHost (app): identity + pinned fingerprint required, same gates as the library.
- UI: "Send Logs to Host" in the host card's context menu (paired hosts) with an alert for
the outcome; a "Send logs to host" row on the gamepad host options screen whose label and
detail band report Sending…/Logs sent/Couldn't send in place, like Copy link.
- SessionModel logs connect asked/landed/refused, disconnect and session-ended reason.
- ClientLogTests: ring bounds + eviction note + truncation, logger format, POST framing.
WP0+WP1 of the docs-and-onboarding overhaul (punktfunk-planning
design/docs-and-onboarding-overhaul.md):
- CONTRIBUTING.md + docs-site/README.md state the ownership rule (docs-site
owns user-facing facts; READMEs keep rationale + pointers; the website
deep-links) and the get-started vs reference audience split.
- ci.yml gains a docs-drift job (scripts/ci/check-docs-drift.sh +
check-docs-links.sh): the OpenAPI snapshot must be a byte copy of
api/openapi.json, PUNKTFUNK_* vars the docs mention must exist in the tree,
the undocumented-var set is ratcheted against a named baseline file,
host-cli.md commands must exist in the host crate, data/platforms.json must
parse, and internal doc/README links must resolve. The rust job additionally
regenerates the spec from the built host and diffs it — closing the 'not
gated' hole CONTRIBUTING itself confessed to. All seven gates were shown to
FAIL on planted violations before landing (the gate-of-the-gate rule).
- data/platforms.json: single source for install commands, repo URLs, ports
and the Sunshine/Apollo/Vibeshine conflict facts — the docs rewrite (WP2),
website download page (WP3) and guided installer (WP4) consume it.
- READMEs (root, bazzite, debian, arch, rpm, flatpak) stop restating docs
walkthroughs (net -444 lines); facts whose only home a README was (the
ds_inhibit SELinux storm, the ffmpeg-libs weak dep, the pacman.conf
double-append) stay put, marked for the WP2 migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A 0.30.0 user reported the setting does nothing for ⌘Space. It never could:
the macOS implementation (0.29, b2146f33) is an NSEvent local monitor, which
only sees the keys AppKit delivers to the app — ⌘Q, ⌘W and their like. The
shortcuts macOS itself owns (⌘Space → Spotlight, ⌘Tab → the Dock, ⌃↑ →
Mission Control, everything under System Settings › Keyboard › Shortcuts) are
consumed by WindowServer before any app is asked. The SDL clients take those
via the private CGSSetGlobalHotKeyOperatingMode, which SDL only compiles in
outside the sandbox; this app is sandboxed on both channels.
The sandbox-legal way is a session-level CGEventTap, which needs Accessibility.
`InputCapture` now installs one while forwarding (and only then — it comes
down with setForwarding(false)/stop(), and capture already releases on any
focus loss, so the tap is never live with another app frontmost). The tap
forwards nothing itself: it takes each keyDown/keyUp off the system and
re-posts it into this app's own queue, addressed to the key window, so it
arrives exactly where the same key would have had macOS not claimed it — the
monitor first (client chords, ⌘ chords → host), then StreamLayerView (the
rest). One key path, no second VK table, no second release bookkeeping; and
the ⌘-chord keyUps macOS used to swallow now arrive too.
Two things verified in standalone harnesses rather than assumed: a reposted
event does reach a local monitor, and a windowless NSEvent(cgEvent:) does NOT
reach the first responder — NSApp.sendEvent routes key events by
event.window — hence the re-stamp onto the key window's number. The
intercept half (tap ahead of Spotlight, inside the sandbox) needs a granted
Accessibility switch this machine doesn't have; that is the live test left.
Gating per event: forwarding, capture mouse model (⌃⌥⇧M flips it
mid-capture, so it is read live rather than at install), app active. Any
other state passes the key through untouched — a tap that swallows keys for
the whole Mac is the failure mode designed against. Installed on the main
run loop on purpose: a hung main thread trips the tap timeout and macOS hands
the keyboard back; the callback re-arms on kCGEventTapDisabledBy* otherwise.
The Accessibility prompt is asked only from Settings — on a genuine off→on
flip of the toggle, or an explicit "Allow Accessibility access…" button that
also opens the pane — never at stream start, and never for the default-on
users this update lands on. Without the grant the setting keeps doing what it
did in 0.29, and its caption now says exactly which half works. App Review
notes carry the justification.
Two changes have been sitting in `sync-engine.ts` with no way to reach a
plugin: `minInterval` (the fs-change rate cap), which v0.31.0's changelog
already recorded as owing a cut, and the always-apply sync reasons — `startup`
and `manual` publish even when the fingerprint matches, so an operator who
fixes a host-side art-root problem recovers by restarting the runner instead
of deleting the plugin's cache file.
The registry skips 0.4.2 deliberately. `plugin-kit-v0.4.2` was tagged at the
Steam cover-art commit but its publish never landed — the registry's newest kit
is still 0.4.1 — and the tag is left exactly where it is rather than moved onto
newer code. Every consumer's range is a caret (`^0.4.1`, `^0.4.2`), so 0.4.3
satisfies all of them, and the plugin that asked for `^0.4.2` gets the
cover-art scan it was waiting for in the same package.
With the WSI layer building, the derivation reaches the prune and dies on every
reshade file it tries to remove:
rm: cannot remove '.../share/gamescope/reshade/Shaders/lilium__tone_mapping.fx':
Permission denied
gamescope's own default_extras_install.sh installs those under read-only
DIRECTORIES (mode 555), and rm needs write permission on the CONTAINING
directory rather than on the file. The compositor is fully built and installed
by that point, so the log reads as finished right up to the failure.
Only reachable now that #332 got the build past the layer assertion — the third
latent fault in this install path, each one having masked the next: patch drift
(#328), enableWsi defaulting off (#332), and now this.
Nix seals $out read-only after the builder exits, so widening it mid-build costs
nothing and changes nothing in the output.
The 2026-08-19 console-ui sweep (punktfunk-planning design/console-ui-sweep-2026-08-19.md),
WP1-WP6:
- WP1 — touch deferred-tap + drag-to-scroll. PointerInput::Down grows a touch flag
(SDL Finger* arms and Android toolType feed it; SDL's touch-synthesized mouse events
are dropped); the shell tracks the gesture: within slop a lift is a tap delivered at
the anchor, past it drags emit one synthetic scroll tick per 56dp of dominant-axis
travel. Fixes the on-glass defect where any swipe across the settings list cycled the
value it landed on (MenuList presses focus AND activate). Mouse behavior unchanged.
Fling deliberately not included; the Release edge stays for it.
- WP2 — Controller haptics + Controller speaker rows (trust::Settings::{pad_haptics,
pad_speaker}) in the Controller tab, forwarding-gated like their siblings; the
speaker row speaks the GTK switch's dialect over the stored string. Detail strings
are platform-aware now (the Stats/Mouse desktop chords are not taught on Android).
- WP3 — Send logs was already desktop-gated in actions(); pinned by a test.
- WP4 — ConsoleCmd::BindProfile lands (the port design's WP5 leftover): a Default
profile… host-menu action opens a pin_hosts-style chooser; desktop arm writes
KnownHost::profile_id, Kotlin arm mirrors it.
- WP5 — per-host Shared clipboard toggle on the host menu (HostRow::clipboard_sync,
ConsoleCmd::SetClipboard, both service arms). Platform defaults stay divergent on
purpose; the console shows and writes the stored value only.
- WP6 — Android idle gates: the reachability sweep only probes while the console is
attached, and the render thread drops to half rate after 60s without input.
Every wake gate in the codebase reads `!host.mac.is_empty()` against the saved
record — `ConnectPlan::wake`, the console's `can_wake`, `punktfunk wake`. That MAC
only ever reached the store through `trust::learn_mac`, and `learn_mac` had exactly
two callers: the GTK hosts page and the WinUI one.
Neither runs on a Steam Deck. Gaming Mode has only the Decky panel (which drives the
headless CLI) and the console home — and those learned the management port alone,
never the MAC. So a Deck's records stayed MAC-less forever, every wake gate stayed
false, and Wake-on-LAN was skipped silently: no packet, no error, nothing to see.
It worked on desktop purely because those two hosts pages learn on each discovery
tick. (#322)
Rather than add the missing call twice, collapse the three per-field learners
(`learn_mac`, `learn_os`, `learn_mgmt_port` — three `pub fn`s, three load/save
cycles) into one `learn_from_advert`, and call it at every site where an advert
meets a saved record: both desktop hosts pages, the console home, and the CLI's
`discover`. Remembering one call is not a thing a front-end can half-do; remembering
three is what produced this. It takes the three fields rather than a `DiscoveredHost`
because there are two of those — core's and the WinUI shell's verbatim port.
`discover` is where the panel-only flow is fixed: it is the one verb the Decky panel
runs that ever sees an advert. It keeps `KnownHosts::read()`, so it still mints no
ids and cannot join the race that comment warns about, and `learn_from_advert` writes
only when an advert genuinely taught the record something — a steady-state panel
refresh touches no disk.
Two things fall out of the same root cause: the console home now persists the OS
chain too, so a Deck host's icon stops vanishing the moment mDNS goes quiet; and
`punktfunk wake`'s "connect to it once while it's awake" is replaced, since a MAC
comes from an advert and never from a connect — that wording sent this diagnosis
looking in the wrong place.
The magic-packet sender itself was never at fault (`punktfunk-core::wol` passes its
7 tests) and neither was the flatpak sandbox (`--share=network`). Nothing reached
them.
Closes#322
With the lock bumped, all ten patches apply and the compositor compiles, links
and installs cleanly. It then fails our own postInstall assertion:
punktfunk-gamescope: this nixpkgs' gamescope built no WSI layer, so no game
under the compositor could ever obtain an HDR10 swapchain
nixpkgs takes `enableWsi ? false` and feeds it to
`mesonBool "enable_gamescope_wsi_layer"`, so the plain derivation ships the
compositor and no layer at all; nixpkgs gets its own layer by instantiating a
SECOND copy inside the wrapper. `enableWsi` is a FUNCTION ARGUMENT, so
overrideAttrs cannot reach it — it needs `.override`, applied before
`.overrideAttrs` so the latter is not applied to the derivation being replaced.
The assertion did its job: it caught a compositor that would have installed
under our name and then denied every nested game an HDR10 swapchain, which is
the one outcome the header calls the worst. But it only fires after a full
build (MEASURED: run 19323), which is an expensive way to learn a default.
`build-rust` / `build-gamescope` were compared only against the STRING 'true'.
A checkbox from the Gitea UI arrives that way, but an API dispatch can deliver a
real JSON boolean, and the comparison then misses — the step is skipped, the job
still reports success, and the log is indistinguishable from a run that had
nothing to do.
MEASURED 2026-08-19: dispatched with build-gamescope=true to verify the flake.lock
bump actually fixes the gamescope patch set. Run 19320 went green with 'Build the
patched gamescope' SKIPPED — a green that proved nothing about the one package
being fixed, on the exact check the header recommends before merging a lock bump.
Accepts both shapes now. Deliberately still no `inputs.*`: that context is what
Gitea's parser is least reliable about, which is why this file used
github.event.inputs in the first place.
The publish tier went red on punktfunk-gamescope-3.16.24:
patching file src/steamcompmgr.cpp
Hunk #1 FAILED at 9321.
1 out of 1 hunk FAILED -- saving rejects to src/steamcompmgr.cpp.rej
0009 rewrites the pipewire call site so dead capture textures are reaped on the
compositor thread, and its context is upstream's vblank-driven form:
// Drive on vblank, not the timer: under VRR the timer starves (page flips re-arm it).
if ( vblank && pipewire_is_streaming() )
MEASURED against three upstream trees: that context is absent from the 3.16.24
tag and present in both 3.16.25 and master. packaging/gamescope/README.md pins
upstream at 5fb8dce4 (3.16.25-11) and gamescope.nix's header says it was checked
against 3.16.25 — but the lock still carried a 2026-07-15 nixpkgs shipping
3.16.24, five weeks behind. The earlier patches were already only just holding
on: 0004 and 0008 applied at offsets of -51, -48, -35, and one hunk at fuzz 2.
This is not a CI-only failure. gamescope.nix patches whatever gamescope the
pinned nixpkgs carries, and host.gamescopeHdr defaults true, so every
`services.punktfunk.host.enable = true` build was failing the same way. The
cache tier is what surfaced it — exactly what nix.yml's header predicted would
happen, and the reason that tier compiles gamescope on every main push.
Only the nixpkgs node moves (verified: 8 inputs compared, 1 changed).
NOTE: this fixes the instance, not the class. The nix channel is the only one
that does not pin gamescope's src — the RPM spec, the PKGBUILD and
build-punktfunk-gamescope.sh all ship 5fb8dce4 — so the next nixpkgs move can
break it again in either direction.
The connect takeover could only be dismissed by a session phase coming back from the
embedder, and nothing guaranteed one would. Pressing B latched "Canceling…" and waited:
- Android's console shell never sent a phase at all on the canceled path — `launch()`
returns early when `Dial.cancelled` is set — so the takeover stayed up until the app
was killed.
- The desktop shell waited for the pump's `Failed`/`Ended`, but the pump was parked
inside the blocking `NativeClient::connect*`, which had no abort. That is 15 s on a
normal dial and 185 s on a request-access connect the host holds pending approval.
Two halves, both at the point every caller routes through:
`pf-console-ui` drops the takeover itself on Back. Cancel is the user's decision and
needs no confirmation from the wire; the `CancelConnect` action still goes out, and
every embedder already handles a dial that lands afterwards (quit-close the connector,
route the end back silently). With no waiting state left to render, `Connecting.canceling`
and the "Canceling…" card go with it.
`NativeClient::connect_with_audio_format` takes an optional abort switch, polled while
the call is blocked, and the session pump passes its stop flag — so the embedder's cancel
now reaches a dial that has not landed yet instead of being answered whenever it does.
Taking it is the same give-up as running out of budget (quit close + shutdown), so the
worker stops re-dialing and the host tears down rather than lingering for a reconnect
nobody wants. Deliberately NOT aliased onto the client's own `shutdown`: the pump reads
that to mean "this connection died" and derives the session's end reason from it, which a
caller-set flag would race — a dropped link would have reported no reason at all.
The sync engine's fingerprint says we would compute the same entries again. It does
NOT say the host still holds them — and the host may accept a payload and store less
of it than was sent: an art path outside its allowed roots is stripped and the games
kept (deliberately, a cover must not cost a library), a launcher tile it cannot open
is dropped the same way.
Once that happens the fingerprint is a permanent "no changes". The operator fixes the
host side, nothing republishes, and the only way out is to delete the plugin's cache
file — which is exactly the advice a portable-Playnite library with 70 dropped covers
was given.
So the two triggers with a person behind them always apply: `startup`, the restart
everyone reaches for, and `manual`, the console's Sync-now button and the CLI's
`sync`. Both mean "publish my library NOW", and "no changes" is the wrong answer to
that. The loop reasons keep the short-circuit, which is where it earns its keep —
they are what would otherwise PUT the whole library every few minutes.
A Playnite unzipped outside the users base (D:\Apps\Playnite) keeps its whole
library beside the exe, so every cover it exports lives at
`<PlayniteDir>\library\files\…` — outside every default art root. The games
synced and all 70 covers were dropped, with only PUNKTFUNK_LIBRARY_ART_ROOTS
as a way out.
The Playnite install dirs are now art roots too, exactly as Steam's install
root already is, and `playnite_install_dirs` learned to find a portable copy at
all: it registers no uninstall entry and sits under no profile, but it does
register the `playnite://` handler — the very registration this host's launch
path already follows to start a Playnite title. So the same probe also gives a
portable install its Fullscreen launcher tile, which it never had.
The confinement is not loosened: the roots come from the host's own registry
and filesystem probes, never from the plugin lane that supplies the art path,
and the extension, regular-file, magic-byte and config-dir gates all still
apply.
KWin sends the `failed` reason on zkde_screencast localized. The retry
short-circuit matched the English "could not find output", so a pt-BR session
("Não foi possível encontrar saída") fell through to 8 attempts over ~11 s and
then reported "out of retries" — a config fact dressed up as a flaky one.
Match "KWin virtual output failed" instead: our own prefix, never translated.
Every `failed` KWin sends on this path is a backend/config fact (unsupported
compositing type, a backend without createVirtualOutput, an output the
workspace declined to enable), none of which a retry 500 ms later changes.
The message itself now says what KWin's untranslatable reason means, and the
KDE docs gain the KWin 6.6+ shape of it: KWin creates the output, leaves it
disabled, and workspace()->findOutput() returns null — reported identically to
a backend that cannot create one at all.
A closure cannot be generic over the lifetime in its return type, so the
`Ctx<'_>` this one built could never be tied to the `&mut Settings` it
borrows — `lifetime may not live long enough`, and the lib test target
failed to compile.
Same shape the add_host and library test modules already use: a plain
`fn ctx<'a>(…) -> Ctx<'a>` where the lifetime is written down once.
90 commits since v0.30.0 (65 non-merge), was 88/64. #318 installed the
Nix cache signing key (public half now pinned in the docs and served by
the cache) and moved its DNS provisioning into unom/infra's OpenTofu; the
CHANGELOG's Nix paragraph says so instead of listing the PR as absent.
The notes needed no change: their NixOS line already pointed at the
install guide and the cache-served key. Version table unaffected — #318
touches docs and the setup wizard only.
88 commits since v0.30.0 (64 non-merge). Cut from origin/main 8a4eac4c
(PR #317 merged; PR #318, the Nix cache signing-key follow-up, is open
and NOT in this cut).
THE NUMBER: 0.31.0 is a judgement call, not forced. Nothing versioned
moved — WIRE_VERSION 2, C ABI 24 with include/punktfunk_core.h byte-
identical to the v0.30.0 tag, driver protocol 6 / min 3 (pf-driver-proto
has no diff against the tag), gamepad channel 3, plugin index schema 1,
host event schema 1, gamescope +pfhdr8 with no new patch files,
api/openapi.json still stamped 0.29.0 and unchanged, SDK 0.1.4,
plugin-kit 0.4.2. On that table alone this could be 0.30.1. It is a
minor because the cycle is feature-shaped and one commit carries a `!`:
the Android Compose console is deleted outright and pf-console-ui over
Skia/GL becomes the console on all three ABIs (#301, #317); the Android
present path moves to ASurfaceControl by default (#290); the Linux
desktop-audio capture flips to a host-owned null-audio-sink by default
with PUNKTFUNK_STREAM_SINK=stream as a one-release escape (#305);
`topology: exclusive` on Hyprland/sway goes from silently-extend to
genuinely disabling the operator's outputs, which is the default policy
on every auto-detected box (#304, closes#284); a Nix binary cache
ships (#313); and the Linux host package grows three new system files
(udev rule, WirePlumber policy, ALSA UCM drop-in) that the DualSense
audio path depends on (#291, #302, #315). scripts/ci/pf-version.sh
already derives the canary base as latest stable + one minor, so
canaries have sat at 0.31.x since the v0.30.0 tag and move to 0.32.x
after this one.
Version table re-measured on 8a4eac4c, not carried forward. Two rows
carry a warning rather than a change: sdk/ and plugin-kit/ both have
real code changes (the mgmt-endpoint follow in config.ts/runner-cli.ts;
SyncSettings.minInterval in sync-engine.ts) with package.json unbumped.
They version off their own sdk-v*/plugin-kit-v* tags and this commit
does not touch them; the CHANGELOG names sdk-v0.1.5 and
plugin-kit-v0.4.3 as owed so they are cut deliberately.
The release branch also carries one preceding whitespace-only commit:
crates/pf-console-ui/src/screens/controllers.rs and
crates/punktfunk-host/src/audio/linux/pad_card_volume.rs had landed on
main formatted differently from rustfmt 1.96.0 (the pinned toolchain),
so ci.yml's Format step was red on the tip this is cut from. `cargo fmt
--all` and nothing else.
Gates run on this tree (this MacBook, rustc/rustfmt 1.96.0): cargo fmt
--all --check clean; cargo metadata --offline ok; Cargo.lock diff
versions-only (36/36 lines); cargo test -p punktfunk-core unit suite
272 passed; the android.yml Play notes gate run verbatim — 498/500
characters and not byte-identical to any prior release's; notes voice
scan finds no internal names outside the For developers section (the
two hits, `nixpkgs` and Flatpak, are things those users configure by
name); both openapi copies cmp identical and unchanged since the tag;
include/punktfunk_core.h regenerated by the build and git-diff clean.
⚠ NOT run here: the C ABI harness (tests/c_abi.rs) — it links -lopus
and this machine has no libopus (`ld: library 'opus' not found`; the
Mac Studio has it). The header is byte-identical to v0.30.0's, where the
harness passed, and nothing in punktfunk-core's C surface changed; the
CI runner is its first execution for this tag. Named in the CHANGELOG's
Verification status rather than left to be discovered.
No outside contributor is credited by name: every attribution in the
64 bodies is a dated field report or "a reporter", and issue #284 was
filed by the maintainer. The Thanks section thanks the field reports
without naming anyone.
The SurfaceView path never tagged SDR because MediaCodec tags its own window
buffers; with AImageReader → ASurfaceControl the transaction is the only
carrier, and dataspace 0 meant setBufferDataSpace was never called. An
untagged limited-range BT.709 buffer read as full range shows black (16) as
gray. SDR now maps to ADATASPACE_BT709 (limited-range video) in
color_dataspace — every ASC buffer is tagged.
Console → stream rides an AnimatedContent cross-fade, so the outgoing
console shell stays composed until the fade ends: its
onDispose { show(systemBars()) } fired AFTER StreamScreen's hide, parking
the status bar and the gesture bar over the video for the whole session.
The hide/show now lives once in App.kt, keyed on the resolved intent
(streaming or console fronting = immersive; touch shell = bars back), and
both screens' per-screen bar management is deleted.
The negotiated ColorInfo is the wire contract's authority on the stream's
colour, but the ASC backend seeded a hardcoded BT2020_ITU_PQ guess and then,
on the first output-format change, overwrote it with whatever the codec
echoed — and a decoder that omits color-transfer (common) echoed None, which
clobbered the dataspace to 0 before the first present. Untagged P010 buffers
composited as sRGB: broken HDR colours. An HLG stream was also mis-seeded PQ.
Now the initial dataspace derives from client.color (PQ vs HLG, range), and
a format change only refines it when the codec actually reports an HDR
transfer — never resets it, matching the SurfaceView path's semantics.
Two files landed on main formatted differently from rustfmt 1.96.0's
output, so ci.yml's Format step fails on the tip the release is cut
from. Whitespace only; no behaviour change.
Two corrections and one thing actually done.
DNS here is not a dashboard click. unom/infra owns the unom.io zone in OpenTofu
(terraform/cloudflare/records.tf, applied by dns-cutover.yml), and that file's
`local.hostnames` set carries its own invariant: "a name here with no vhost 404s,
a vhost with no name here never cuts over." A record added by hand in Cloudflare
is out-of-band and risks the duplicate-record round-robin the file documents a few
lines further down — the same class of trap as hand-editing ~/caddy/Caddyfile on
the box. The setup steps said "in the unom.io Cloudflare zone" as though it were a
manual change; they now name both files, the workflow that applies them, and the
one-added-record check to expect from `plan`. unom/infra#20 makes the change.
The signing key is generated and `NIX_CACHE_SIGNING_KEY` is installed as a repo
Actions secret, so its public half is no longer a placeholder:
punktfunk-cache-1:yhOJmHxzg6tzXpxSFzlYn6Pc6r0jHprsWqt8MZC654o=
pinned in both docs. The publish step still writes the same value to
/punktfunk-cache.pub, so the docs can always be checked against the cache itself —
and the wizard now compares the two and warns on a mismatch, because docs that
disagree with the cache mean users reject everything it serves.
The wizard drops to four stages. DNS and the vhost were separate stages when they
looked like separate manual steps; they are one PR against one repo, so they are
one stage. The key stage now detects the installed key, prints it, and refuses to
casually regenerate — a new key invalidates every signature already published and
breaks every user pinning the old one.
Verified: shellcheck + `bash -n` clean, 4 stages against TOTAL_STAGES=4, and the
already-installed path's key extraction tested against the real README.
Four phone-sized fixes, one theme — a small screen was paying for chrome it
never asked about:
- The Android shell hides the system bars while it fronts the app (transient
by swipe, restored on dispose), the same contract as the stream. This is
also the safe-area fix: hidden bars report zero insets, so scrolled content
no longer slices off at the visible gesture-bar line with bare backdrop
below — only the display cutout remains a real inset.
- The design-unit scale's phone density floor rises 0.6 -> 0.75: on a 460 dpi
panel the floor is what sets the scale (the couch term only wins on tablets
and TVs), and 0.6 read a step too small in the hand. Still the documented
on-glass knob.
- The library's sort/view bar appears only while it holds the pad (up from
the field / the legend's 'Sort & view'), the Apple client's behaviour; the
field takes the band's height back as it fades.
- The store/platform subtitle under the focused title is gone — the cover
badge already says it — and the detail band shrinks 84 -> 64 units, most of
a grid row on a phone.
Plus a guard the new scale makes necessary: the grid's two-column minimum
shrinks its covers to fit rather than clipping at the edges when a narrow
viewport times a high density leaves less width than two full cells.
The bring-up instructions were copied from packaging/flatpak/README.md, which
still describes an edge proxy on `home-reverse-proxy-1` forwarding to
192.168.50.50. That home-lab topology is gone. packaging/winget/server/
compose.production.yml — the newest of the three and the only one written since
the move — says so outright: "the sibling docs/flatpak compose files still carry
stale comments … the public hostnames resolve straight to the hcloud box and are
served by Caddy there — no local proxy is involved." flatpak.unom.io resolves to
167.233.145.172, which is unom-1 itself, confirming it.
So the steps now match how docs and winget were actually stood up:
* DNS in the unom.io Cloudflare zone, DNS-only, straight at the hcloud box.
* The vhost in unom/infra `caddy/Caddyfile`, proxying to localhost:3250 —
NOT 192.168.50.50, and NOT hand-edited on the box. ~/caddy/Caddyfile there
looks like the config but is an rsynced copy with no .git to warn you; a
vhost added only on the box lasts until the next deploy. That is how the
winget source vanished on 2026-07-26, and it is now called out here too.
* `caddy_target_ports` + terraform is dropped. It was the home-lab firewall
allowlist; winget's setup, written post-move, has no such step.
Also adds the SNI diagnostic winget's README hard-won: Caddy 308s every Host on
:80 to https, including names it has never heard of, so probing port 80 proves
nothing — check the certificate by SNI instead.
scripts/setup-nix-cache.sh walks the five steps interactively (built from the
/wizard template): it opens each page, says exactly what to click, and verifies
each stage before moving on, because the failure signatures are easy to confuse
— a TLS handshake failure means the vhost is missing, a 502 means the container
is down, and a 404 means the cache is healthy and empty.
It also closes the loop the first version left open: it generates the signing
key locally (a local nix, or the nixos/nix image — MEASURED: both produce the
`name:base64` line, and convert-secret-to-public round-trips), then writes the
PUBLIC half straight into the two docs that carried a `<fill-in>` placeholder.
Nobody has to wait an hour for the first publish to print a value we can derive
up front. The secret half is shown once for pasting into Gitea and never
touches disk. Re-running detects an installed key and refuses to silently
replace it, since that would invalidate every signature already published.
Verified: shellcheck clean, `bash -n` clean, 5 stages against TOTAL_STAGES=5,
and the doc substitution tested against a real generated key — public keys are
base64 and contain `/`, so the sed uses `|` as its delimiter.
Three packaging conflicts, all the same shape: #307 added a
`60-punktfunk-dualsense.conf` install at the exact line this branch added the
ALSA UCM install to. Both sides kept — they act on different layers and neither
subsumes the other:
* the WirePlumber rules govern how the pad's nodes BEHAVE once they exist
(`node.always-process` so GE-Proton's raw open cannot race itself,
`priority.driver = 0` so a pad never clocks somebody else's graph);
* the UCM drop-in governs WHICH nodes exist at all (a `SpeakerHaptic` device
at priority 200, so the 1-channel sink games overrun is never minted).
Checked rather than assumed: the drop-in's node-name matchers
(`~alsa_output.usb-Sony_Interactive_Entertainment_DualSense.*`) still match the
sink the UCM change introduces — `…DualSense_Wireless_Controller-00.HiFi__
SpeakerHaptic__sink` — so the policy follows the pad onto the new profile. And
neither touches volume, so the 0 dB pin on this branch is untouched by both.
The Android side of #301 deleted the Compose gamepad mirror, not
`SettingsScreen.kt`, so the "Controller speaker" subtitle survives; the Skia
console that replaced it carries no speaker row of its own (it opens Android's
connected-controllers view instead), so there is no second place to say it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Controllers row used to raise the D7 platform-screen mechanism: Android drew
the Compose ConsoleControllersScreen over the surface and suspended the console's
input until it closed. Now the page is a shared Skia screen
(screens/controllers.rs) pushed like any other settings sub-screen, so the console
keeps its own navigation, transitions and haptics on it — and a future desktop
build gets it for free (the row itself stays Android-only in row_on).
What genuinely cannot move into Rust stays with the host, asked for by ONE
parameterised command, ConsoleCmd::PadAction { action, pad_key }: the USB and
Bluetooth grant dialogs (sc2_bluetooth / sc2_usb / ds_usb), the rumble pulse on
the real InputDevice, and the DualSense pad-audio self test. SkiaConsoleShell
handles them with the same helpers the touch Controllers screen uses (testRumble,
the grant intents, nativePadAudioSelfTest), reporting through the notice toast, so
the support answer cannot drift between interfaces. PadInfo carries the three
fields the screen needed and the aggregated list already lacked (detail line,
forwarded, rumble), filled by ConsoleJson.pads from the same padInfoOf reader the
touch screen renders from.
PlatformScreen::Controllers is gone; the mechanism itself stays for Licenses,
which still suspends the console's input correctly (the probes gate on platformUp
as before). The Compose console variant and its screenshot scenes are deleted;
the touch ControllersScreen keeps the full page INCLUDING the live input test,
which deliberately did not move — the console only receives the aggregated
MenuSample, nowhere near a per-device axis/trigger readout (ponytail note at the
top of controllers.rs records the upgrade path).
This job is now the heaviest on the fleet — a full workspace build plus
gamescope fills the store with tens of GB, and this fleet ran a runner out of
disk on 2026-08-06. The pre-existing Environment step reads df before any of
that happens, which is the less useful of the two moments.
A field session spent an evening measuring the HOST — sinks, tone injection,
per-channel peaks — for a pad speaker that was switched off at the other end.
A disabled toggle and dead hardware are indistinguishable from the pad, and
nothing said which one it was.
Two hints, at the two places somebody actually looks:
* `punktfunk-session --pad-audio-test` now says, before the tone, whether
`pad_speaker` / `pad_haptics` would stop a real session rendering the very
thing the tone is about to prove works. The devtest drives the pad DIRECTLY
and is otherwise blind to the settings, which is exactly what makes "the tone
plays here but the game is silent" so confusing. It has to be said up front:
the capability is never advertised when the toggle is off, so no later log
line can catch it.
* The Android settings row now states its default in the subtitle. Android is
the one client that defaults this off, and that is a deliberate form-factor
choice (a small, easily-startling loudspeaker in the user's hands, unlike
haptics duplicating audio they already hear) — so the default stands and the
row stops being silent about it. `pf_client_core`'s `default_pad_speaker` is
`"pad"` and always has been, so the desktop clients were never the ones
hiding it.
Worth recording for the next time: the host ALREADY had the signal. `native/
pad_audio.rs` logs `pad audio streaming … haptics=<bool> speaker=<bool>` at
stream start, and a client with the toggle off shows up there as
`speaker=false`. It was being printed on the host that was being measured all
evening.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Field report 2026-08-18, confirmed: the operator had moved the management API off 47990
(`PUNKTFUNK_MGMT_BIND` in host.env — the supported way to share a box with Sunshine/Apollo). The
web console followed, because it reads `<config_dir>/mgmt-endpoint`, the one line the host
publishes on every start with the port it REALLY bound. Nothing else did:
- The plugin runner / SDK resolved `PUNKTFUNK_MGMT_URL` → literal `https://127.0.0.1:47990`.
The runner is a scheduled task (Windows) / systemd unit that inherits nothing from host.env —
on Windows it cannot even read it — so every plugin, and the runner's own log shipper, dialed a
dead port forever. Task Running, plugins never registering, empty library, and "no logs at all".
- The tray defaulted `--mgmt-port` to 47990 and told the operator to edit the autostart command
line if they moved the bind. Nobody knows to do that; the tray reports a running host as
unreachable.
One source, two readers, no new file:
- `sdk/src/config.ts::publishedMgmtUrl` reads `mgmt-endpoint`; `resolveConfig` uses it after the
env override and before the 47990 default. Every plugin `connect()` follows, on every platform,
with no unit/task changes. `runner-cli.ts` additionally exports it into `PUNKTFUNK_MGMT_URL`
before any plugin loads, so a plugin still carrying an older vendored `@punktfunk/host` follows
too (on Windows `reconcileSharedSdk` cannot refresh the read-only tree, so old copies can
outlive several host upgrades). An explicit PUNKTFUNK_MGMT_URL still wins.
- `pf_paths::published_mgmt_port` (std-only leaf; the tray now depends on it) parses the same
line. The tray's `mgmt_port` becomes `Option<u16>`: `--mgmt-port` pins, `None` re-reads the file
on every poll tick, so a host restarted on a new port is picked up without relaunching the tray.
Swept the rest: the web console (`windows::service::spawn_web`, the systemd unit, NixOS module)
already sourced the file; the host CLI, plugin-kit (goes through the SDK), gaming-mode console
and native clients derive the port from discovery / the Welcome — no other literal remained on a
loopback path. The console's web port (47992) is not operator-configurable, so the tray's
literal there is not the same bug.
Verified: SDK 83 tests pass (4 new: absent file → default, published line followed, env wins,
blank = unset), `tsc` clean, biome clean; `pf-paths` unit test; `cargo fmt --check` clean;
`cargo clippy -p pf-paths -D warnings` clean; `cargo check -p punktfunk-tray -p pf-paths` on
Linux (docker rust:1.96) — the tray is cfg-gated off macOS. Not built on Windows from here.
WirePlumber starts every new card's sink at `device.routes.default-sink-volume`.
That is 0.4, and 0.4 is a CUBED number: what a mixer shows as 40 % is 0.4^3 =
0.064 of linear amplitude, -23.88 dB. The setting is global — it cannot be
scoped to one device from configuration — so there was no config file to ship,
and it fires again on every fresh card, which for a usbip pad is every attach.
Reasonable for a laptop speaker somebody is about to turn up. Wrong here twice:
nobody chose it and nobody would think to look for it (a pad's sink is not a
listening volume anyone reaches for, so it reads as weak hardware), and BOTH
ends of a session mint one. The game's samples cross this sink on the host and
the pad's own sink on the client, so the two multiply: 0.064^2 = -47.8 dB by the
time haptics reach a voice coil, which matches the -48 dB measured in the field.
That is the difference between "the haptics are subtle" and "I am not sure the
haptics are connected".
Both ends now set `channelVolumes` to unity — 1.0, which is unity in the linear
units the pod speaks AND in the cubed scale a mixer displays, the one value that
is unambiguous.
* Client (`pin_sink_volume`, called from `correlate_pad_sink`): at every pick,
so a card that re-minted its nodes after a profile change or a replug is
re-pinned without anything having to notice. Skipped for the `split_parent`
pick — that is a name lifted off another node's proplist, with no object of
our own behind it, and pinning the sink that named it would pin the wrong
node.
* Host (`audio/linux/pad_card_volume.rs`, started when `PadUsbCapturer::open`
succeeds): the host half matters because `pad_usb` captures at the pad's
isochronous OUT endpoint, DOWNSTREAM of this sink — PipeWire applies the
volume when it mixes into the ALSA device, so what we encode and send is
already attenuated and no client-side fix can recover it. It retries for 15 s
rather than firing once: the USB device is live well before its ALSA card is,
and pinning before WirePlumber has applied the default we are undoing would
simply be overwritten. Only sinks belonging to a DualSense CARD are touched —
`device.id` is what keeps it off a host's own minted pad sink, which
publishes the full DualSense identity on purpose.
Neither end restores on the way out, deliberately: the profile swap next door
overrides a choice the user made, this overrides a default nobody made, and
putting -24 dB back would be restoring the bug. `PUNKTFUNK_PAD_SINK_VOLUME=0`
disables both, for bisecting a box where something else attenuates.
Both pods are unit-tested for the shape that actually matters — one unity float
per channel. PipeWire ignores a `channelVolumes` whose length does not match the
port count, and an ignored pod looks exactly like the pin silently not working,
which is the -23.88 dB back again and just as invisible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A wired DualSense on Fedora 44 / Bazzite / Arch presents exactly one playback
sink: the 1-channel `…Default__Speaker__sink`. GE-Proton mints its synthetic
"Sony controller speaker" endpoint from that lone mono sink, and Marvel's
Spider-Man Remastered overruns it — reliably, ~74 s in:
73.846 render_GetBuffer (…)->(5034, …) <- GE's mono endpoint
73.846 EXCEPTION_ACCESS_VIOLATION info[0]=1 (WRITE) info[1]=5CB9A000
Not a format mismatch: `GetMixFormat` and the game's `Initialize` both agree on
mono float32 `nBlockAlign 4`, and pulse sized `maxlength: 20136` = 5034 x 4
correctly. At the fault `rsi=rbp=0x13aa` (5034, the frame count) while
`rcx`/`rdx` are 5206/5207 — the copy loop had already run past the count. It is
a game/GE bug on a code path that ONLY EXISTS WHEN THE MONO SINK DOES.
So delete the mono sink rather than chase the overrun. `alsa-ucm-conf` describes
the pad as Speaker / Headphones / Mic / Headset and has never carried a
`SpeakerHaptic` device — the DualSense profile arrived upstream in 1.2.15
(36a111a) already without it, and the Deck's is a Valve downstream patch they
still carry on their own 1.2.16.1. With `SpeakerHaptic` at `PlaybackPriority
200` against `Speaker`'s 100 the card takes `HiFi (Mic, SpeakerHaptic)`, the
sink is the 4-channel one, and the mono sink — with the crash path — never
exists. The voice coils reach their own channels as a bonus.
Shipped WITHOUT replacing a file `alsa-ucm-conf` owns, which is what made this
awkward to package. `USB-Audio/USB-Audio.conf` ends with an unconditional,
optional include of `USB-Audio/conf.d/{vid}-{pid}.conf`, placed after its device
table has chosen `${var:ProfileName}` and before it includes the profile that
name resolves to — so a two-line drop-in keyed by 054c:0ce6 / 054c:0df2 swaps
the profile with no diversion, no `Conflicts`, and no `%config` fight. Verified
against alsa-lib rather than assumed: `ucm_cond.c` makes `Condition` optional
for a syntax-v8 `If` carrying `Append`, and `uc_mgr_evaluate_include` evaluates
each included subtree in place before moving to the next include, so the
`Define` lands before the profile include substitutes the variable. The hook and
the DualSense profile shipped in the SAME release (1.2.15), so every tree that
has the bug has the hook.
Host packages only (rpm — and therefore the Bazzite sysext, which unpacks the
RPMs — deb, Arch). The client already has a working fallback in
`ensure_pro_audio`, and a shared file in two co-installable packages is a file
conflict for a nicety. NixOS is not covered: it has no /usr/share/alsa/ucm2 to
drop into and needs a package override instead.
`scripts/ci/check-dualsense-ucm.sh` runs the whole chain on a real distro tree
with no hardware, via UCM's card-less `conf.virt.d` path with only the four card
built-ins stubbed. Against pristine Fedora 44 alsa-ucm-conf 1.2.16.1: baseline
`Headphones/Headset/Mic/Speaker`; with the drop-in, `SpeakerHaptic` and
`HeadphonesHaptic` too, `PlaybackPriority/SpeakerHaptic=200` over `Speaker`'s
100, `PlaybackPCM/SpeakerHaptic=…dualsense_haptic_out:…,1,1,2,3`. It exists
because this fix hooks another project's dispatcher: an upstream rename would
neuter it silently, and what comes back is the crash, not a quieter pad.
Negative-tested both ways (typo'd ProfileName, hook deleted).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every other channel ships prebuilt binaries; Nix was the exception — `nix build`
meant the whole Rust workspace *and* a gamescope build from source, roughly an
hour, and `host.gamescopeHdr` defaults true so that compositor build is on the
critical path of every `services.punktfunk.host.enable = true`.
nix.yml grows a third tier: on a push to main it builds the Rust packages plus
gamescope, signs them, and publishes to https://nix.unom.io. No new trigger is
needed for releases — a release bumps the workspace version in Cargo.toml, which
is already in the path filter.
Gitea cannot host this: it has 23 package registry types and none is Nix, and the
protocol wants fixed anonymous paths at a URL root (/nix-cache-info,
/<hash>.narinfo, /nar/…) that /api/packages/{owner}/generic/… cannot express.
The RustFS at storage.unom.io would work mechanically — nix speaks
s3://…?endpoint= and the sccache credentials already exist — but it is a local
box on the home uplink with no CDN, so every user download would compete with CI,
and S3 answers 403 for a missing key unless the bucket policy grants anonymous
ListBucket. Nix treats anything other than 404 as a hard error rather than a
cache miss, so that would break users' builds for packages the cache never held.
So it goes on unom-1 beside the flatpak repo, as a caddy:2-alpine container
serving a static tree — which is all a binary cache is.
Three decisions worth keeping:
* Only punktfunk's own store paths are published. The rest of a runtime closure
is stock nixpkgs, already on cache.nixos.org behind a real CDN; mirroring it
would spend disk and home-to-cloud bandwidth to serve a worse copy. That is
~300 MB per publish instead of several GB. The step asserts every built output
is matched by the name filter, so a future pname change fails the build rather
than silently dropping the most expensive package from the cache.
* NARs upload before narinfos, and rsync runs without --delete. A narinfo whose
NAR has not landed is a hard download failure for whoever fetches it in that
window; a NAR nothing points at is merely invisible.
* prune.sh from the first publish, not after the box fills. The flatpak repo next
door reached 3.84 GB publishing this same way with no sweep, on a box that has
run out of disk before. It ages out narinfos, then sweeps unreferenced NARs —
that order is the correctness argument, and it carries a self-check.
Verified locally: the Caddyfile serves a fixture cache with 200s on hits, 404 on
misses (the assertion the whole design rests on), and immutable cache headers;
prune.sh passes its self-test on Debian including empty and all-stale caches;
both workflows parse; the new run blocks pass shellcheck and dash -n. `set -eu`
rather than `-euo pipefail` — dash dies on the latter — with the two pipelines
whose left side must be able to fail rewritten as redirects.
Docs: README gains the substituter snippet, a maintainer runbook, and a warning
that inputs.punktfunk.inputs.nixpkgs.follows disables the cache entirely (every
store path changes, so every package rebuilds). The install guide gains the same
in short form.
The public key is a fill-in until the first publish prints it — see the setup
steps in packaging/nix/README.md.
Connecting a controller could swap in the console shell over a SurfaceView
nothing would ever paint: the native create failing, the render thread dying,
or a GL context Android reclaimed all left the app on a gray screen for the
rest of the process — Kotlin only logged the Dead event.
SkiaConsole now exposes an observable [healthy] flag (false on create failure
or a Dead event) and App folds it into the gamepad-UI gate, so the touch UI
takes over. On the native side, a run of consecutive GL setup failures
(window surface / Skia wrap) — previously logged and retried forever, a hot
spin with a live surface — now ends the render thread through the same
release order as Quit, which raises Dead and hands the screen back.
The MainActivity pad probes were one last-writer-wins slot. The Skia shell
installs its probes once (its effect keys never change); a Compose screen the
console opens over itself (Controllers, Licenses) overwrote that slot, and on
its way out nulled it — the shell never re-installed, so every gamepad press
after closing the page was silently dropped until the process died.
The slot is now a stack: each holder pushes its claim on install and removes
it BY IDENTITY on dispose, and dispatch consults the top. Whatever ordering
Compose produces — cross-fades composing both screens at once, non-LIFO
disposal — a leaving screen takes only its own entry, and the one underneath
resurfaces the moment it pops.
A managed takeover on the .41 SDDM-autologin box entered a ~4-5 logins/s
relogin storm and every udev consumer drowned in the fallout. It presented
as "my DualSense is not detected in the game, or only with an insane delay":
the pad enumerated perfectly and then delivered input at ~1.4 Hz instead of
250 Hz, because `winebus` re-enumerates udev on every event and therefore
stops reading `hidraw`. An evening went into disproving the pad stack, the
ALSA UCM, PipeWire and GE-Proton before the display manager was suspected.
The storm was ours. Measured on .41 (2026-08-18):
/usr/share/wayland-sessions/gamescope-session-ogui-steam.desktop
Exec=gamescope-session-plus ogui-steam
/usr/share/gamescope-session-plus/gamescope-session-plus, last act:
systemctl --user --wait start gamescope-session-plus@${CLIENT}.service
so the runtime mask this takeover lays sits *directly in sddm's relogin
path*. Every autologin then fails in milliseconds instead of taking the
seconds a real gamescope + Steam start costs, and sddm's `Relogin=true` has
no backoff: a slow, survivable relogin loop becomes a fork storm. 962 logind
sessions in 3.7 min, `Watching system buttons` re-scanned 5,688 times, a
box-wide udev `change` storm at ~20/s, iio-sensor-proxy crash-looping at ~16
starts/s as a udev-activated amplifier, load 26 on 12 cores.
The 2026-07-31 reading of the same box recorded the storm but concluded the
sddm helper "execs the session script directly, so the masked unit never
enters the picture". It does — one `systemctl` call further down. That is
why masking looked inert, and why it was left as the *degraded* takeover for
SDDM when the DM stop could not be achieved. Masking without the stop is not
a weaker defense. It is the storm's engine.
So the mask no longer substitutes for the DM stop, on any flavor:
* `dm_plan` loses its `mask` input, and with it `dm_survives_masked_unit` —
the SDDM/plasmalogin split existed only to pick a degraded mode, and there
is no longer one to pick. A planned DM stop that does not land now fails
the takeover and the caller degrades to ATTACH, which is a fully working
stream at the session's own mode. Fighting an autologin we cannot stop is
strictly worse than not taking over: it costs the user their input plane.
* The mask is laid only after the stop has LANDED. Both failure arms bail,
so reaching the mask proves no DM is up to relogin through it. That is
also what keeps mask-fragile flavors safe — a stopped plasmalogin cannot
trip its own start limit, and every restore path unmasks before restarting.
* `skip` is now `!any_live` on every flavor: killing loaded-but-inactive
leftovers frees no Steam, and masking them under a running DM is the storm.
Which of the two candidates in the handoff this was is now settled, on the
box: not (b). `pkexec /usr/libexec/punktfunk/pf-dm-helper linger` run from a
sessionless `systemd --user` context — the host's own context — exits 0;
lingering is on, bazzite is in group `punktfunk` (965), helper and polkit
action are installed and correctly annotated. The privileged path works
there. It is (a), and the fix is the invariant above rather than a better
`any_live`, because the DM ending up alive next to our mask is the fault
whatever put it there.
`any_live` is tightened anyway, stated as the negative: systemd has exactly
two not-running ACTIVE states and the other four all mean the unit still
owns Steam. The old list missed `deactivating` (and `reloading`), so a unit
caught mid-teardown read as a dead leftover — a box that IS in gaming mode
sampled as idle, its Steam left holding the single instance our own launch
then collides with. Small window on an idle box, wide open on a churning
one, which is exactly when this is sampled.
Two things the handoff asked for that are about never spending that evening
again:
* `DmHelperError::shape()` — the four shapes need four different fixes
(package it / install polkit / fix the action / join the group), so they
ride along as a greppable `shape` field, and a planned-but-unachieved DM
stop now logs at ERROR naming it instead of vanishing into a WARN.
* `watch_for_relogin_storm()` — logind names its session files after a
monotonic id in `/run/systemd/sessions`, so the max is a free login
counter. Two `read_dir`s five seconds apart on a detached thread; above
1/s it says so at ERROR, and says what it means: no audio, input or
PipeWire measurement taken during a storm is valid. Detect-and-report
only — self-mitigation would tear down a live stream on a detector that
has never been wrong in the field because it has never run there.
Finally, `systemctl_system` captures its stderr and logs it at DEBUG. On an
unprivileged host that verb is *expected* to fail — it is the cheap probe
before the pkexec helper — so systemctl's own "Access denied ... requires
interactive authentication" went to the journal on the normal, successful
path: two of them immediately before `INFO restored the display manager`.
That shape cost two sessions on its own, each spent explaining a failure
that had already succeeded one line later.
Checked in the CI image (linux/amd64): `cargo fmt --check`, `cargo clippy
--all-targets -D warnings`, and `cargo test -p pf-vdisplay --lib gamescope`
— 52 passed, 1 ignored (needs a live `systemd --user` manager).
Refs: punktfunk-planning design/sddm-relogin-storm-starves-input-handoff.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Field report 2026-08-18, Windows host on 0.30: PunktfunkScripting task Running, Playnite and
Steam plugins installed, library empty, and "no logs at all for plugins" — nowhere on the box.
That is by construction, not by accident. The runner's only log door is the log shipper, which
tees console output to `POST /plugins/logs` over the mgmt API; the scheduled task itself had no
console and no file. So every failure that stops the runner reaching the host — LocalService
lost its read grant on plugin-token / native-cert.pem, a moved mgmt bind, a TLS pin miss, a
401 — is exactly the failure the shipper cannot report, and it leaves the same picture:
task Running, plugins never registering, an empty grid, and nothing to send when asked for logs.
`scripting-run.cmd` now redirects the runner's stdout+stderr to
`%ProgramData%\punktfunk\plugin-state\runner.log`, keeping the previous run as `runner.log.1`.
plugin-state is the one directory `plugins enable` makes writable for LocalService, and it
inherits Users-read from the config dir, so the operator can `type` it from any prompt.
Writability is probed with `copy /y nul` first; if the dir is not writable (the task was started
by the installer before `plugins enable` ever ran) the runner starts unlogged as before rather
than not at all. No `goto`: the file is stored LF and cmd's label scan is unreliable there.
The console's empty-Plugins hint (en/de) and the plugin docs now name the file; the log-ship
header no longer claims the task writes no file. Verified by reading only — no Windows box
reachable from here; the cmd semantics used (`copy nul` as a write probe, `if defined` blocks,
leading redirect on `echo`) are the boring ones.
The mattpocock engineering skills (/triage, /to-tickets, /to-spec, /wayfinder,
/diagnosing-bugs, /code-review) all assume a per-repo configuration that this
repo never had: they need to know which tracker to talk to, which label strings
carry the five triage roles, and which docs to read before exploring. Without it
each of them guesses, and the guess is GitHub.
Which is wrong here in a way that fails quietly. This repo's remote is
git.unom.io — Gitea. `gh` is installed on the machine and will happily run
against github.com and find nothing; `glab` and `tea` are not installed at all.
So the tracker doc points at the connected `gitea` MCP server instead, and says
so in the first line, because the failure mode is an agent confidently reporting
an empty issue list.
Two traps in that MCP surface are worth the words they take:
- `issue_write` applies labels by numeric ID and `remove_label` takes a
`label_id`, while `list_issues` filters by label *name*. Applying a label
therefore needs a `label_read` lookup first — passing the name silently
gets you nowhere.
- unom/punktfunk has no labels defined at all, on the repo or on the org
(checked both). The first triage run has to create the five before it can
apply any of them.
Gitea writes are outward-facing — a shared instance that mails on activity — so
the doc encodes the standing rule as a gate: reads are free, every write waits
for a go-ahead, subagents included.
Domain docs are single-context: one CONTEXT.md and one docs/adr/ at the root.
Neither exists yet and neither should be created pre-emptively; /domain-modeling
writes them when a term or a decision actually gets resolved. Twenty-seven
crates and nine client platforms is the shape of a repo that eventually wants a
CONTEXT-MAP.md, so the switch path is written down — but they serve one domain
today, and a glossary split four ways before it has a single entry is just four
empty files.
AGENTS.md rather than CLAUDE.md because neither existed, and the cross-tool
convention costs nothing here.
Reverts the primary-action swap from 22fdea66: a host card's tap ran
`onBrowseLibrary ?? onConnect`, so on Mac, iPad and Apple TV clicking a paired
machine dropped you on its game shelf and streaming the desktop had moved into
the context menu. That inverted the pattern every other surface still uses.
Back to: tap the card = connect, "Browse Library…" = a context-menu action
(both on the host's own card and on a pinned host+profile card, where its shelf
still launches with that card's profile).
No other client had drifted — the Rust console shell (Linux + the Android Skia
port) keeps A = connect and Y = library, the GTK card's activate emits
`CardOutput::Connect` with "Browse library…" in the overflow menu, Android's
Compose grid taps to connect with the same menu item, and the Apple console
shell's tiles activate into connect. Only the touch/desktop `HostCardView` had
the swap, which is why one fix covers all three Apple platforms.
Everything else the library round-trip landed stays: the wake-on-open retry, the
per-host cached catalog, the Resume badges, the scroll memory.
Verified on the Mac: swift build clean, swift test 375 tests / 0 failures.
`UsbDevice::default` in the vendored server fills iSerialNumber with the string
"Serial". A real DualSense reports none — this file's own doc comments assert that
twice — and ALSA bakes the value into the card id, so the pad presented as
`…DualSense_Wireless_Controller_Serial-00` where the hardware gives
`…DualSense_Wireless_Controller-00`. PipeWire then carries the difference into
every node name and into `device.serial`.
Clearing it makes every name a matcher can key on byte-identical to a physical
pad's.
Scope, honestly: this is fidelity, not a fix for anything currently broken. It was
written when the `_Serial` infix was a suspect for the dead haptics; it is not the
cause. GE-Proton's winepulse leg has since been observed matching
`alsa_output.usb-Sony_Interactive_Entertainment_DualSense_Wireless_Controller_Serial-00.Default__Speaker__sink`
with the placeholder present, so nothing depends on this today. It removes a
gratuitous difference from real hardware rather than fixing a symptom.
Also NOT a fix for UCM profile selection, though that was my first reading:
`alsa-ucm-conf` keys on `${CardComponents}` (`USB054c:0ce6`), so `DualSense-PS5.conf`
matched with the placeholder still present. Which profile a card lands on is decided
by verb priority, not by its name. The comment records that so it is not re-derived.
Gated with `cargo clippy -p pf-inject --all-targets -- -D warnings` in
`punktfunk-rust-ci:latest` (linux/amd64): exit 0.
The WirePlumber DualSense policy landed with its %files line duplicated into
%install, one line above the real install. rpm ran it as a command, and the
%install scriptlet died on 'No such file or directory' before it reached
anything else.
The comment claimed the daemon republishes node props "whenever the graph is
recalculated". It does not, quite: pw_impl_node_set_driver writes the key and marks the
props changed, but leaves the flush to the node's next info emission — in practice the
state change that accompanies the same recalculation. Near enough for a diagnostic, and
exactly the kind of near-enough that a later reader deserves to be told about rather than
discover. Also retires four "in stream-sink mode" phrasings that now describe both sink
modes.
Both wlr-family backends accepted the topology axis, the management API reported
it as the session's effective topology, and the backend logged a warning and did
nothing (sweep 13.18 shipped the warning, never the behaviour). Because
`resolve_topology` sends `auto` — the default — to `Exclusive` on any host without
a `PUNKTFUNK_COMPOSITOR` pin, and both compositors are auto-detected, the default
policy on every such box was an Exclusive that behaved as Extend. Closes#284.
`exclusive` now disables the operator's heads for the session and restores them
when the display GROUP's last member is torn down, via the same
`take_topology_restore` hand-off KWin uses — so the registry runs the restore
before the last output is reclaimed and the compositor never sees zero enabled
outputs, and a sibling session never has the desk re-enabled under it.
The disable filter is group-aware (design §6.1): enabled, not ours, and not
managed. On Hyprland `managed` is `PF-<pid>-<n>`, which covers a second host's
outputs too; on sway it is the `HEADLESS-` prefix, which also spares a headless
sway's own bootstrap output — the harmless failure, versus blacking out a live
sibling.
`primary` stays treated as extend, which is the honest answer rather than a gap:
Wayland has no primary-output concept and these compositors have only a focused
output, which the streamed head already holds since #283. It now says so
distinctly instead of sharing a warning with `exclusive`.
🛑 The Hyprland restore is `hyprctl reload`, and that is measured, not chosen.
Re-applying the head's own mode/position/scale — what design §5.2 and the issue
both assume — does NOT undo a disable: it answers `ok` and leaves `disabled:
true`. Probed 2026-08-18 against 0.56.2 (hyprlang) and 0.55.4 (Lua); every
targeted form was accepted and changed nothing, including `,enable` (answers
`invalid resolution`), `preferred,auto,1`, `monitorv2 disabled=false`, `keyword
unset monitor`, the Lua `disabled = false`, `dispatch dpms on` and
`forcerendererreload`. A runtime monitor rule is additive and the `disable` keeps
winning; only re-reading the config clears it. The headless output survives the
reload, so the issue's worry about losing it does not hold. Side effects are
documented at the call site and in the docs: other runtime `keyword` overrides are
dropped, and a hyprlang config re-runs its `exec =` lines. It runs only when a
session actually disabled something.
Disable is spelled per config era and confirmed by read-back, mirroring
`set_monitor_rule`: `keyword monitor <n>,disable` under hyprlang, `hl.monitor{
output = "<n>", disabled = true }` under Lua. Both eras reject the other's form at
exit 0, so the read-back — not the exit status, not the `ok` — is what decides.
Also fixes a marker gap that made one of those rejections read as success:
`hyprctl keyword` under the Lua config manager answers "keyword can't work with
non-legacy parsers. Use eval.", and `hyprctl_dispatch` matched "couldn't" but not
"can't". `set_monitor_rule` was covered by its own mode verification; nothing else
was.
⚠ The sway half is NOT exercised on a live sway — no box in the fleet runs one,
the same gap #283's `focus output` shipped with. The argv is sway's documented
surface, both shapes are pinned by tests (this file uses `output <name> <verb>`
AND `focus output <name>`, so getting one backwards is the live risk), and the
read-back turns a wrong guess into a warning naming the outputs rather than a
screen that silently stays dark.
Six new unit tests cover the group-aware filter on both backends, the headless
no-op case, both disable spellings and the marker set. 246 pass on Linux.
The shipped WirePlumber policy sets `priority.driver = 1` on a DS5's ALSA sink and says it
"keeps the pad from ever driving the graph". Read against PipeWire's own recalc, it does not:
`priority_driver` is unsigned and `pw_context_recalc_graph` skips a driver only when it is
`<= 0`. At 1 the pad is merely LAST in the ordering — and last is still elected whenever
nothing above it qualifies, which on a punktfunk host is the ordinary in-session state,
because claiming our own sink as the default output leaves the box's real card idle. With
`node.always-process` on the same node it is also permanently runnable, i.e. permanently
eligible.
Zero is the value that means excluded. The pad keeps driving the streams actually linked to
it — a driver always drives its own group, priority orders the election and nothing else —
so GE-Proton's haptics are unaffected.
The second rule covers the capture side of the same cards. That node is what clocked a
reporter's desktop audio for a whole session: in the Pro Audio profile it carries
`priority.driver = 2600`, never suspends, and had nothing linked to it at all — its only
function on that machine was to clock other people's graphs. The `alsa_output` matches never
touched it. Only the priority is set there; holding a device open is about the playback node
GE opens raw, and an always-processing microphone is not something this host should ask for.
Both of these are belt to the braces of the host-side fix — a capture group that carries its
own driver cannot be handed one — but they are worth having on their own: they are what stops
a pad from clocking anything else on the box, including a build that predates it.
`PUNKTFUNK_STREAM_SINK` has existed since 0.29 and appeared in no documentation at all, which
was tolerable while it had two values and one of them was "don't". It has three now, the
default changed shape, and the change is visible to anyone who opens their audio settings
mid-session: there is a virtual output *and* a recording stream named `punktfunk-audio-…`,
and someone will want to know whether that is a leak. It is not.
Troubleshooting gains the section the 2026-08-14 investigation should have been able to
start from: audio broken up while video is steady, one WARN line naming the node that took
our clock, what that means for the sound (its stalls are now our holes), and the two ways
out — remove the loopback, or turn off the profile of a sound card that is reached over the
network and cannot be clocked at all.
On 2026-08-14 a reporter's audio stuttered. Answering "what is clocking desktop audio on
that box?" took four field logs, a purpose-built probe script, and finally a pw-top DRIVER
column — and the answer was a DualSense's sound card, attached over the network, that
nothing was linked to.
Every part of that answer was already in the daemon, one bind away. `node.driver-id` on our
own node names the driver of the group we are scheduled in; it is deliberately not in the
registry's announce set, so it needs the node bound and its `info` event read, and the
daemon republishes the props whenever the graph is recalculated.
So bind our node, keep a map of node id to name from the registry, and log the driver by
name whenever it changes. In null-sink mode there is exactly one right answer — our own sink
— so anything else is a WARN that names the culprit and says what it means: the holes in
this stream are that node's scheduling, not ours. The legacy topologies have no driver of
their own and borrow one by design, so there the same fact is an INFO that names it without
judging it.
On change rather than per window, deliberately: the driver moves a handful of times in a
session, and the 30 s capture summary is written from the RT callback while this arrives on
the main loop — a shared field would need cross-thread state for a value that a line of its
own carries better (`grep 'graph driver'`).
Our desktop-audio sink was a pw_stream wearing media.class=Audio/Sink. A stream is
structurally a follower — it never drives — so the group it forms with the game's output
streams has no driver of its own, and PipeWire's recalc assigns every such group to the
highest-priority *running* driver anywhere on the box.
On a reporter's host that was the DualSense forwarded over VirtualHere: its USB-Audio card
sat in the Pro Audio profile (priority.driver=2600, never suspends), nothing was linked to
it, and it clocked our capture and the game's four streams for a whole 15-minute session.
Its frame counter is a kernel stub — vhci_get_frame_number() logs "not yet implemented" and
returns 0, ~1900 times a second — so snd-usb-audio could not recover a device clock for an
async endpoint arriving over the network. Not xruns: every cycle that happened was healthy
(ERR 11 in 15 min, WAIT never past 111 µs); the loss was in the interval *between* cycles.
3.9 delivery holes a second, worst 142 ms, and 15.4 % of the audio that user heard was
silence this host synthesized over the gaps.
So mint a real sink instead: a support.null-audio-sink adapter, created on our own
connection, captured through its monitor. That node IS a driver — the null sink publishes
node.driver=true and the audio adapter forwards its props — with a timerfd inside the
daemon's realtime data loop, so the group carries its own clock and no hardware (or
network-attached) device can be elected for it. It is the same object pactl load-module
module-null-sink creates, which is the most exercised virtual-sink path on Linux.
Three properties carry the design, and each is a decision rather than a default:
* node.passive on the monitor tap. A link is passive when the input port is passive and
the output node can suspend (any Audio/Sink), and a passive link makes neither end
runnable by itself — so between sessions the group is idle and the null sink's timer
parks with it. That answers, by construction, the "200 callbacks a second forever on a
parked host" objection that kept node.always-process off the old stream sink. While a
game plays, its own non-passive link makes the sink runnable and run_nodes() walks that
through the monitor to us, so pause/resume accounting keeps today's meaning exactly.
* node.force-quantum, not node.latency. A driver's quantum is the smallest node.latency
among its followers, clamped — and then rounded DOWN to a power of two, because
default.clock.power-of-two-quantum is true by default. That is why our 240-frame ask has
silently been served as 128 on every stock Linux host since the capture was written: the
callback runs at 2.67 ms, not the 5 ms it is designed around. force-quantum skips the
rounding, and because this sink drives only its own group it forces nothing on anyone
else's device — which is exactly why the same key would have been the wrong answer while
we were borrowing somebody's hardware clock.
* node.dont-fallback WITH node.linger, never one alone. WirePlumber 0.5 reads
dont-fallback on its own as licence to destroy the stream the moment its target is not
visible ("defined target not found"); linger turns that into "wait for it". Together
they mean the tap can only ever record our sink — never, not even for the moment before
ours registers, a hardware sink's monitor.
PUNKTFUNK_STREAM_SINK grows a third value: unset is the new topology, =stream is the 0.30
one (a one-release escape hatch, so a field A/B needs no build), =0 is still the legacy
default-sink-monitor follower. The routing claim, the capture callback, the stats line and
everything downstream are untouched — the sink node changed owner, not the pipeline.
The channel map now has one source and two views (pod and audio.position string), because a
created node is configured by properties while a stream is configured by a format pod, and a
map that disagreed with itself between them would swap channels silently.
Gated in punktfunk-rust-ci amd64: fmt, clippy --all-targets -D warnings (non-vacuous —
"Checking punktfunk-host" present), 607 tests. On-glass validation is still owed and is what
the plan gates the merge on: pw-top must show our sink at the top of its own group with the
game's streams and our tap under it, and 5 min of loud audio at delivered_pct=100 gaps=0 on
a box where a hardware sink is also running.
GE-Proton's DS5 haptic router opens the pad sink's backing hw: device RAW whenever it is
free — then its own path re-probe EBUSYs against its own handle, invalidates the stream,
and spins a 100 Hz "device generation" refresh loop: haptics dead, speaker dead, and in
one game a buffer race in the same machinery crashed the title outright. On SteamOS,
where that code was developed, PipeWire always holds the device, so GE lands on its
well-tested Pulse-routing fallback immediately and none of this fires.
Ship the SteamOS-shaped environment: node.always-process + no suspend keeps PipeWire
holding the device from the moment the card appears, and priority.driver = 1 keeps the
pad — whose USB audio clock (virtual or physical) is nobody's idea of a house clock —
from ever driving the graph. Installed by rpm/deb/arch/nix into
/usr/share/wireplumber/wireplumber.conf.d/. Matches both DS5 product-string spellings;
covers physically plugged pads on a headless host identically.
Both 0xD1 lanes encoded with Application::LowDelay at 64 kbps CBR — right for voice-coil
rumble (felt latency, band-limited content), audibly wrong for the pad speaker, which
carries real programme audio: on glass it "sounded insanely compressed". The speaker lane
now uses the full Application::Audio coder at 96 kbps (~120 bytes per 10 ms frame, still
far under one MTU); its few ms of extra algorithmic delay are inaudible on a speaker.
Haptics unchanged.
The simulator slept `service_interval × packets` per URB, measured from "now" at handling
time — so tokio timer slop, socket I/O and handler lock waits all added ON TOP of the
nominal period, every URB. The virtual pad's audio clock ran measurably slow under load
(~26 %: hw_ptr advanced ~35.7k frames/s against a 48 kHz stream), the PCM backed up into
xruns, and — because snd-usb-audio's clock IS URB completion — anything clocked off the
device dragged with it. On the test box the pad sink became the PipeWire graph driver and
pulled the desktop capture down to 50 % delivery ("insanely distorted" stream audio).
Pace against a per-endpoint absolute deadline ledger instead: each URB advances the
endpoint's deadline by exactly its nominal duration and sleeps until that instant, so
overhead eats into the next sleep rather than accumulating. A stall beyond 20 ms
re-anchors to now instead of fast-forwarding a burst. Measured after: 48005 frames/s.
Two paused-clock tests pin the exact rate and the re-anchor.
The isochronous endpoint carries the DualSense's own channel map — ch0 = headphone LEFT,
ch1 = headphone RIGHT and the built-in mono speaker, ch2/3 = the voice coils — because
everything a game writes has already been folded to hardware channels by the host's UCM
split (or written there directly) before it reaches the endpoint. The 0xD1 wire contract
instead puts the *speaker pair* on ch0/1. Forwarding the quad verbatim shipped headphone-
left (silence) as wire speaker-left and the actual speaker as wire speaker-right, which
the client renders onto the one split-sink channel that current PipeWire never wires to
the physical speaker. Field-diagnosed on glass end-to-end: haptics felt, speaker dead,
a test tone measured on exactly one channel at each hop.
Duplicate the hardware speaker channel across the wire's speaker pair; pass the coils
through; drop headphone-left deliberately (a remote pad's jack is not a wire surface).
The stream-sink capture path (uhid pads) already emits the logical layout and is
unchanged.
The Skynet field log carried `plugin:steam sync (fs-change): reconciled
16 entries` 102 times in 27 minutes: Steam writes to its dirs the whole
time a game runs, and every write re-walked the library. The 3 s
`Stream.debounce` collapses a BURST, but a debounce extends on every
event and so cannot bound the RATE under sustained churn.
`SyncSettings.minInterval` (default `DEFAULT_FS_CHANGE_MIN_INTERVAL` =
30 s; `LibraryPluginDef.minInterval` to override) sits on top of the
debounce: debounced events land in a sliding queue of one, and a drain
loop syncs, then holds for the interval. Changes inside the hold coalesce
into exactly one trailing sync, so nothing is lost and a launcher in a
writing frenzy costs one re-walk per interval instead of one per quiet
gap. Optional on `SyncSettings`, so plugins built against the older kit
keep compiling.
Test drives real `fs.watch` on a temp dir: 28 writes at 25 ms clear a 20 ms
debounce every time and would be ~28 syncs; with a 400 ms interval they
are three, at +32 / +433 / +835 ms.
Not here: narrowing the Steam plugin's watch set (`steamapps/` +
`libraryfolders.vdf`, not `userdata/`/`logs/`) lives in the steam plugin
repo; and the host-side reconcile coalescing during a session is optional
— the kit cap is the lever.
The device callbacks already run where the OS puts realtime audio (the
PipeWire playback callback on the graph's data loop since #292, WASAPI's
event-driven loop woken by the engine). The threads that FEED them were
plain: the decode leg (`punktfunk-audio-rx`), the pad-audio renderer and
its PipeWire loop, the Linux mic loop, and on Windows the render and mic
loops themselves. On a Steam Deck the same four cores decode 1440p120 and
present it, and a decode thread descheduled past the ring depth is a
drought the callback then has to conceal. #292's `setpriority(-10)` was
a no-op there: the user's RLIMIT_NICE is 0.
New `audio_rt` module, one call at the top of each of those threads:
- Linux: `setpriority` where RLIMIT_NICE allows; else, INSIDE A FLATPAK,
the xdg-desktop-portal Realtime portal (`org.freedesktop.portal.Realtime`
on the session bus); else rtkit (`MakeThreadHighPriorityWithPID` on the
system bus). The split is PipeWire `module-rt`'s, and it is not
optional: verified on the Deck (rtkit 0.14) that rtkit-daemon has NO
PID-namespace translation — it looks up `/proc/<pid>/task/<tid>/stat`
with the numbers it is given — so a direct call from a sandbox is
answered with ENOENT (and there is no `busctl` in the GNOME 50 runtime
either, which rules out the shell-out). The portal maps the sandboxed
pid/tid and calls rtkit on the app's behalf, and portals need no
`--talk-name`. polkit gates both with the TARGET process as subject:
verified on the Deck by renicing a live active-session thread and a
`steam` user-service thread (what a Steam-launched client is) through
both rungs, and restoring them; only remote (ssh) sessions are refused.
Via zbus, exactly as `pf_frame::thread_qos` does on the host (same
version, features and backend — one lock line). Never setcap/SCHED_RR:
the cap_sys_nice route is the one that killed KDE sessions in the field.
- Windows: MMCSS "Pro Audio" + THREAD_PRIORITY_HIGHEST, raw `extern
"system"` declarations as `pf_frame::session_tuning` spells them; the
MMCSS handle is leaked (thread-lifetime, the OS reverts it).
Every rung is best-effort and logs at debug what it got (`audio thread
priority raised via=…` / `refused why=…`); a refusal leaves the thread
exactly as it was. Acceptance on the Deck: `ps -eLo cls,rtprio,ni,comm |
grep punktfunk` shows the decode thread at nice −10 after connect.
The Swift mirror of core's `JitterPolicy` change, line for line: `underRun`
and `insertOneFrame()` next to `overRun` and `shedOneFrame()`, `hollow`
judged against the ADAPTIVE target (`adaptiveTarget(lift:)`, never the
sync-inflated one), `syncWantsMore` arming the insert, and the same
constants (`insertSustainMS = shedSustainMS`, `insertMarginMS =
AvSync.deadbandMS / 2`). `Stats` gains `inserts` and the 1 Hz `audio:`
line logs `drift_inserts=` next to `drift_sheds=`.
`insertFront` is index-based where core's is a `VecDeque`: the copy lands
in the slots just before `readIdx` (free exactly when the ring has that
much spare capacity) and `readIdx` steps back over it; both offsets are
shifted forward by one capacity first when `readIdx` is too small, so
neither can go negative into `%`.
`dropFront` gets the same seam fix as core's `crossfade_drop`: the
fade-out source is now the head of the discarded region — the
continuation of the sample just played — not its tail. Driven through the
hard-cap trim, the old fade stepped by 2 688 samples on a ramp where the
new one stays under 17.
`AudioRingDriftTests` carries the same vectors as core (`…NeverDeprimes`,
`…DeepensWithoutADeprimeOnACleanLink`, the insert seam heard end to end,
the drop seam against the sample played before it); both regression tests
were run against the backed-out fixes and fail there. 42/42 on macOS.
The three `VecDeque<f32>` rings apply `JitterStep::insert_front` with
`crossfade_insert` right where they apply `drop_front` today. The PipeWire
callback runs on the graph's realtime loop; the insert stays inside the
ring's reserve (hard cap plus 64 frames) because the policy only inserts
below its target. Same on AAudio (`hard_cap_max + RING_CHUNKS × frame`).
Concealment must stay visible in both directions: `PlaybackVitals` gains
`inserts`, the 10 s `audio playback` line gains `drift_inserts=` next to
`drift_sheds=`, and the Android 10 s line gets the same field. On glass
the tell that this works is `underruns` no longer stepping in lockstep
with `av_offset` swings while `drift_inserts` climbs instead.
`JitterPolicy` could lower its depth gently — one crossfaded frame per
sustain window — but could only RAISE it by de-priming: a full
`target − depth` of inserted silence plus the priming wait. `hollow` was
judged against the sync-inflated effective target, so the moment the A/V
sync loop asked for ≥ 10 ms more depth (audio early against a picture
whose latency wandered — a 53–74 fps KWin source, an ABR retarget, a
keyframe burst) the ring read as hollow on the very next callback, and
the next single late packet cost a 15–60 ms gap. Every client runs this
policy; it is the "started at 0.24/0.25" shape.
Now the ring moves toward its target in BOTH directions with the same
instrument:
- `JitterStep::insert_front` mirrors `drop_front`: when the sync loop
wants more than the adaptive target and the depth EWMA has sat more
than `INSERT_MARGIN_MS` below the request for `INSERT_SUSTAIN_MS` of
consumed audio, duplicate ONE frame at the front, crossfaded. Sync-only
(an un-wired ring is bit-identical to before), primed-only, and
below-target-only, so it can never fight the trim.
- `hollow` is judged against the ADAPTIVE target — the one underrun
evidence proved — never the sync request. Growth that was never banked
still re-primes on the click it already paid; sync never de-primes.
- `crossfade_insert` is the RT-safe twin of `crossfade_drop` (push_front
inside reserved capacity, seam blended in place).
The margin is HALF the sync loop's ±10 ms deadband, not the shed's
`shed_excess_ms`: the loop only speaks once the offset has left the
deadband, so a margin at or above it would leave every request it is
allowed to make permanently unanswered.
Also fixes `crossfade_drop`'s seam, found while mirroring it: the
fade-out source was the LAST `fade` discarded samples, which is adjacent
to the survivors — but the sample the device had just played was
adjacent to `ring[0]`, so the seam still opened with a step of
`drop − fade` samples of waveform (3 ms of a 5 ms shed). The old test
only bounded steps INSIDE the faded region. Both helpers now fade out
from the continuation of what was just played, and the tests check the
seam against that sample.
Simulated (the bunching harness now applies inserts and counts
re-primes): a clean link asked for +20 ms deepens in 6 s with 3 inserts,
zero audible, zero re-primes; a −50 ppm host clock over ten minutes is
absorbed by 9 inserts instead of a re-anchor click; the bunching link
asked for +25 ms lands at 4 audible / 0 in the tail / 1 re-prime where
the unsynced run pays 7 / 2 / 4. The two existing convergence tests keep
their bounds. No new `pub const` (cbindgen); the C header is unchanged.
2026-08-18 13:50:36 +02:00
292 changed files with 18522 additions and 5733 deletions
@@ -12,6 +12,758 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.31.0
170 commits since v0.30.0 (113 non-merge), counted at the tip this was cut from.
One versioned surface moves, additively: the **C ABI goes 24 → 25**, a single new symbol
(`punktfunk_set_log_callback`) that lets an embedder hear the core's own log lines. Nothing else
does — `WIRE_VERSION` stays **2**, the driver protocol, gamepad channel and plugin index schema are
unchanged, and no `trust::Settings` field, capability bit or control-message type byte was added.
No existing C function changed its signature or behaviour and no `#[repr(C)]` struct grew a field,
so an embedder that adopts nothing rebuilds against the new header and is done. Every 0.30.x host,
client, driver and plugin keeps interoperating in both directions, with no re-pairing.
Beneath the versioned surfaces, four things are worth a packager's or embedder's attention: the
**Windows client's default download changes** to a per-user installer plus a portable zip, with the
MSIX kept for the Store; the Linux host package installs **three new system files** (a udev rule, a
WirePlumber policy and an ALSA UCM drop-in) that the DualSense audio path depends on; the Linux
desktop-audio capture **flipped topology by default** (`PUNKTFUNK_STREAM_SINK` unset now means a
host-owned `null-audio-sink`, with `=stream` a one-release escape hatch to the 0.30 shape); and the
Android app's Compose console is **deleted** — `pf-console-ui` over Skia/GL is now the console on all
three ABIs, which removes the Compose screenshot scenes.
### Versions
| | v0.30.0 | v0.31.0 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged |
| C ABI | 24 | **25** | one additive step: v25 adds `punktfunk_set_log_callback` and the `PunktfunkLogCb` typedef (below). No existing declaration moved and no struct grew a field. Also new in `punktfunk-core`, Rust-only: three RT-safe DSP helpers (`crossfade_insert`, `pcm::raised_cosine_tail`, `pcm::raised_cosine_head`) |
| `api/openapi.json` | 0.29.0 | **0.31.0** | **the stamp only** — no management-API surface moved this cycle. The file had been left at 0.29.0 while the crate was already 0.31.0; #337's regenerate-and-diff caught it and it was regenerated, which is a one-line change to both copies. `api/` and `docs-site/public/` are byte-identical to each other |
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files. ⚠ `packaging/gamescope/PKGBUILD` still says `pfhdr7` — pre-existing at v0.30.0, not a regression this cycle, but the Arch package builds a binary the host's `>= 8` probe rejects for the keymap path |
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.5** | cut — `sdk/src/config.ts` and `runner-cli.ts` carry the `mgmt-endpoint` fix below, and plugins resolve the SDK from the registry, so it could not reach them until it shipped |
| `@punktfunk/plugin-kit` | 0.4.2 | **0.4.3** | cut, for the two `sync-engine.ts` changes that cannot reach a plugin any other way: `minInterval` (below) and the always-apply sync reasons (`startup`/`manual` publish even when the fingerprint matches, so a host-side art drop is recoverable by restarting rather than by deleting the plugin's cache). Note the registry skips 0.4.2: `plugin-kit-v0.4.2` was tagged but its publish never landed, and the tag is left where it is rather than moved |
⚠ The SDK and plugin-kit version independently of the app (`sdk-v*` / `plugin-kit-v*` tags,
`sdk-publish.yml` / `plugin-kit-publish.yml`), so their rows record what the registry holds, not
what this tag ships. Both were cut during this cycle rather than left owed — a plugin resolves them
from the registry, so a fix that never ships there never reaches one.
### ⚠ Breaking changes
**None that break a build.** No wire change, no driver-protocol change, no plugin-contract change.
The C ABI moves 24 → 25 by **addition only**:
- **v25 — `punktfunk_set_log_callback(max_level, cb, user)`.** The core logs through `tracing`; an
embedder that installs no Rust subscriber hears none of it — transport warnings, connection events,
handshake notes — and a client log bundle carries the shell's half alone, which is exactly what an
Apple TV field report turned out to be. The call registers a `log::Log` backend behind a C callback
(`PunktfunkLogCb`: level, target, message, user), gated by `log::set_max_level` so anything above
the ceiling costs no formatting; `NULL` detaches, and it answers `Unsupported` when another log
backend already owns the process (`android_logger`). Both strings are borrowed for the call only,
and an interior NUL drops the line rather than truncating it. `punktfunk-core` now declares
`tracing`'s `log` feature explicitly — it had been on transitively via quinn, which an ABI promise
must not rest on. An embedder that never calls it is byte-compatible with v24; see
`docs/embedding-the-c-abi.md` §2.6.
- **One header comment was wrong and is corrected, with no signature change:**
`punktfunk_connect_ex10`'s summary still stated the pre-2026-08-16 rule that only a format other
than 48000/16 requests the lossless plane. Any non-zero format at all does, 48000/16 included —
which is what its own warning already said and what the code always did. Embedders reading the
summary were reading the old rule.
Five more things are worth attention; none breaks a build:
- **`refactor(android)!` — the Compose console is deleted.** `pf-console-ui` (the Skia shell the
desktop session binary draws) is now Android's console on arm64-v8a, x86_64 **and** armeabi-v7a;
the gate is simply "does the native host exist", and where it does not a controller drives the
touch UI through focus. ~6.5 kLOC of `GamepadHome`, `GamepadSettingsScreen`,
`GamepadAddHostScreen`, `GamepadDialogs`, `HomeTiles`, the console halves of `LibraryScreen`,
`ConnectOverlay`/`ConnectTakeover`, the `gamepadUi` branches of `ConnectScreen`/`ConnectPrompts`/
`AdaptiveDialogs`, `App.kt`'s `GamepadShell`/`GamepadScreen` and their tests are gone. The `!` is
for the **store-screenshot surface**: the Compose console's marketing scenes cannot be rendered by
Roborazzi any more (the shell draws over native GL); its shots come from the desktop screenshot dump
or a device capture. Sysprop `debug.punktfunk.console_backend=compose` is meaningless; `=none`
still forces the touch UI on glass.
- **Linux desktop-audio capture topology flipped by default** — see the audio section. `=stream`
restores 0.30 for **one release only**.
- **Hyprland / sway: `topology: exclusive` now does what it says.** Both backends accepted it,
echoed it as the session's effective topology, and dropped it with a warning; because `auto`
resolves to Exclusive on any unpinned host, the *default* policy on every auto-detected Hyprland
or sway box was an Exclusive that behaved as Extend. Operators who relied on that get their
monitors disabled for the session now (closes #284).
- **Three new system files in the Linux host package** — the DualSense audio path does not work
without them. Downstream repackagers: see the packaging section.
- **The Windows client's default download is a per-user installer, not the MSIX.** The MSIX stays,
for the Store; the installer and a portable zip are what the download page now offers, and the
release carries `punktfunk-client-setup_<arch>.exe` and `..._<arch>-portable.zip` alongside it.
Anyone scripting against the MSIX asset name is unaffected; anyone scripting against "the Windows
client download" gets a different artifact. See the Windows client section.
### DualSense audio and haptics on Linux: five faults, and the files they needed
The whole in-game path — GE-Proton's haptic router → the pad's ALSA card → the voice coils — had
never once worked against our virtual pad. In wire order:
- **`usbip`: the calibration feature report was 42 bytes; `hid-playstation` asks for 41.** On a USB
backend an over-long reply is not truncated: the kernel treats it as hostile and tears down the
connection, not the transfer — the pad vanished ~400 ms after enumerating, and the dmesg order made
the teardown look like the cause. Three changes so the trap is not left set: the constant is 41 and
all three feature-report sizes are pinned by test; `clamp_reply` clamps every reply to the requested
length in the transport and drops any payload a handler returns on an OUT (the kernel never reads
one; those bytes would misframe every following PDU); `DualSenseUsbip::open` waits for the kernel
to actually bind a HID driver before reporting success (vhci attach succeeds immediately and
enumerates asynchronously), so bring-up faults return `Err` and the uhid fallback catches them.
New `PUNKTFUNK_USBIP_TRACE` (both socket directions to disk) and `scripts/usbip-trace-analyse.py`.
- **`usbip`: every non-ISO OUT was answered with an empty buffer, i.e. `actual_length = 0`.** vhci
copies that field verbatim into the URB's actual length; the driver returned 0 as the write's byte
count; Wine's bus driver reads 0 as failure and prints the thread's *stale* errno — so the ENOENT /
EINVAL / EAGAIN in the GE logs were never kernel verdicts. New
`UsbIpResponse::usbip_ret_submit_out_success(header, accepted)`; the debug assertion now pins
"OUT carries no buffer", not "OUT claims 0"; two wire-byte tests pin both directions. **The Steam
Controller 2 shares this handler.** `usbip-trace-analyse.py` had flagged *any* nonzero OUT
actual_length as a desync — the rule that would have hidden this bug — and now flags an OUT reply
claiming more than it was sent, or 0 against a non-empty write.
- **`usbip`: ISO completions were paced by relative sleeps**, so timer slop, socket I/O and lock waits
accumulated per transfer: the pad's clock ran ~26 % slow (~35,700 frames/s against 48 kHz), its PCM
backed up into dropouts, and because completion *is* the pad's audio clock, on the test box the pad
sink became the graph driver and pulled desktop capture to 50 % delivery. Now a per-endpoint
absolute deadline ledger (a stall > 20 ms re-anchors instead of fast-forwarding a burst); measured
after: 48,005 frames/s. Two paused-clock tests pin the rate and the re-anchor.
- **`usbip`: the capture forwarded the pad's hardware quad as the wire's speaker pair.** Hardware
is HP-L, HP-R+mono-speaker, coil-L, coil-R; the wire puts the speaker pair first. Now: the speaker
channel duplicated across the wire's speaker pair, coils passed through, HP-L dropped. The
stream-sink (uhid) capture path already emitted the logical layout and is unchanged.
- **`usbip`: `iSerialNumber` was the literal `"Serial"`.** A real DualSense reports none, ALSA bakes it
into the card id (`…Wireless_Controller_Serial-00` vs `…Wireless_Controller-00`) and PipeWire
carried it into every node name and `device.serial`. Cleared. Explicitly *not* a fix for anything
observed broken — GE's winepulse leg matched the placeholder — and *not* a UCM-selection fix
(alsa-ucm-conf keys on `${CardComponents}`, `USB054c:0ce6`).
- **The pad's ALSA card was root-only.** It is created mid-session-bringup with no seat session
active, so logind's ACL never materialises; WirePlumber's probe got EACCES and the card never
appeared in PipeWire at all. `scripts/60-punktfunk.rules` gains two `SUBSYSTEM=="sound"` rules for
"summary":"Report which of a provider's titles are running",
"description":"The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.",
"operationId":"reportProviderRunning",
"parameters":[
{
"name":"provider",
"in":"path",
"description":"The provider id ([a-z0-9._-], `manual` reserved)",
"description":"How many reported titles matched an entry this provider currently publishes.",
"minimum":0
},
"ttl_s":{
"type":"integer",
"format":"int64",
"description":"Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.",
"minimum":0
},
"unknown":{
"type":"integer",
"description":"How many were ignored because no such entry exists (a report that raced a reconcile).",
"minimum":0
}
}
},
"ProviderRunningInput":{
"type":"object",
"description":"Request body for `reportProviderRunning`.",
"properties":{
"running":{
"type":"array",
"items":{
"$ref":"#/components/schemas/RunningTitle"
},
"description":"Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped."
}
}
},
"ReleaseDisplayRequest":{
"type":"object",
"description":"Request body for `releaseDisplay`.",
@@ -7846,6 +7949,28 @@
}
}
},
"RunningTitle":{
"type":"object",
"description":"One running title in a provider's liveness report.",
"required":[
"external_id"
],
"properties":{
"external_id":{
"type":"string",
"description":"The provider's own stable id for the title — the same key its reconcile payload uses."
},
"pid":{
"type":[
"integer",
"null"
],
"format":"int32",
"description":"The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.",
@@ -733,7 +744,9 @@ from the config directory for a true factory reset."
};
lethost=&known.hosts[i];
ifhost.mac.is_empty(){
eprintln!("no Wake-on-LAN address known for {} — connect to it once while it's awake so the client can learn it",host.name);
// A MAC is learned from the host's mDNS advert, never from a connect — say so, since
// "connect to it once" sent at least one Deck owner looking in the wrong place (#322).
eprintln!("no Wake-on-LAN address known for {} — run `punktfunk discover` while it's awake (the Deck panel does this every time it opens) so the client learns it from the host's advert",host.name);
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.