cc8eb7df0875db336891c33a4acb35b0753b9de7
1623
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f5fa9649b7 |
fix(console): a pinned card's library launches with that card's profile
Pinning a profile onto a host gives it its own card on the console home, and pressing A on that card has always connected with the pinned profile as the one-off the resolver prefers over the host's binding. Y on the same card opens a library — it is paired and saved, which is the only thing the hint bar asks — and every title launched off that shelf went out with no profile at all, so the host's default binding won. Connecting straight from the card honoured the profile; going through its library did not, which is the shape a user reads as "the pin works until I pick a game". The screen was the leak: LibraryScreen copied the row's address, port, fingerprint and mgmt port, and dropped `pin` — so its launch had nothing to send and hardcoded `profile: None`, under a comment asserting that game launches follow the binding. They should follow the card: a launch off a pinned card's shelf is that card's connect with a title attached. The screen now carries the row's pin and sends its id as the one-off. It also says so, in the card's own `host · profile` shape: the shelf's title names the profile, and so does the connecting card, so which of a host's cards you came in on is legible from the screen rather than inferred from the tile you pressed two screens ago. Off the host's primary tile there is no pin and nothing changes — `None`, and the binding decides. Console-only. The Apple and Android consoles keep Y off pinned cards outright (`hasLibrary: profile == nil`), the GTK client clones the card's whole ConnectRequest — profile included — into its library, and the Windows client offers "Browse library" from the primary tile only. The console's own copy-link and wake-then-connect paths already carried the pin. Both directions are tested, and the pinned one was checked against a reverted fix: it fails with exactly the reported symptom (left: None, right: "hdr"). |
||
|
|
21f43d7f48 |
Merge pull request 'Games ran black in Nobara's Game Mode — the session script re-exports the WSI variable #144 cleared' (#165) from worktree-nobara-wsi-layer-clobber into main
apple / swift (push) Successful in 1m39s
ci / bun-nix (push) Successful in 55s
ci / web (push) Successful in 1m13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
ci / rust-arm64 (push) Successful in 1m43s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
ci / docs-site (push) Successful in 2m22s
deb / build-publish-client-arm64 (push) Successful in 2m8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m22s
ci / rust (push) Successful in 4m57s
android / android (push) Successful in 8m21s
deb / build-publish-host (push) Successful in 10m37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
arch / build-publish (push) Successful in 9m36s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Failing after 2m39s
apple / screenshots (push) Successful in 6m30s
deb / build-publish (push) Successful in 7m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m35s
windows-host / package (push) Successful in 15m1s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 19s
Reviewed-on: #165 |
||
|
|
9e28cd101c |
fix(pf-vdisplay): the WSI opt-out never reached the games — the session script re-exports it
ci / docs-site (pull_request) Successful in 1m23s
ci / web (pull_request) Successful in 1m24s
apple / swift (pull_request) Successful in 1m46s
ci / bun-nix (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m27s
android / android (pull_request) Successful in 5m53s
ci / rust (pull_request) Successful in 11m10s
A Nobara 44 field box on 0.27.0 got a working Game Mode takeover — right mode, right refresh, perf overlay in the stream — and then every game it launched played sound and took input over a BLACK PICTURE, with no error anywhere. #144 disabled the distro's `VkLayer_FROG_gamescope_wsi` with `--setenv=ENABLE_GAMESCOPE_WSI=0` on the transient unit, because that layer speaks the distro gamescope's `gamescope_swapchain` protocol and ours rejects its `swapchain_feedback`, killing the client. `gamescope-session-plus` then runs an unconditional `export ENABLE_GAMESCOPE_WSI=1` near the top of the script, before it launches anything — so the opt-out lived exactly as long as it took the script to start, and gamescope, Steam and every game got the layer back. The host logged that it had disabled it, which is what made this cost a field round-trip: the claim was true of the unit and false of everything in it. Nothing else looked wrong because the casualty is Vulkan clients specifically. Steam's Big Picture UI is not one, so the session came up looking perfectly healthy right up until a game started. Send `DISABLE_GAMESCOPE_WSI=1` as well. The Vulkan loader resolves an implicit layer's two manifest knobs in a fixed order (`loader_implicit_layer_is_enabled`): `enable_environment` must equal `"1"` to switch the layer on, and `disable_environment` is consulted last — "has priority over everything else" — where the mere PRESENCE of the variable, at any value, forces it off. The session script never mentions that one, so it is the only one that survives it. Both spellings now go out through one place, so the transient unit (`launch_session`) and the box's own session drop-in (`write_session_plus_dropin`) cannot drift apart — the same shape `SessionBind` already uses for the bind. `ENABLE_GAMESCOPE_WSI=0` stays alongside for a layer built without a `disable_environment`, and because it is what an operator reads the unit for. |
||
|
|
332a81b077 |
Merge remote-tracking branch 'origin/main' into worktree-android-console-visual-refresh
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m30s
ci / rust-arm64 (pull_request) Successful in 6m15s
android / android (pull_request) Successful in 6m58s
ci / web (pull_request) Successful in 1m16s
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m35s
ci / rust (pull_request) Successful in 15m56s
nix / flake (pull_request) Successful in 14m8s
|
||
|
|
d715225960 |
Merge pull request 'Launcher tiles get their launcher's logo — a brand token on the wire, the vector in every client' (#162) from worktree-launcher-icons into main
apple / swift (push) Successful in 1m33s
android / android (push) Successful in 7m5s
ci / rust (push) Successful in 7m54s
arch / build-publish (push) Successful in 8m5s
ci / rust-arm64 (push) Successful in 1m43s
ci / web (push) Successful in 58s
ci / bun-nix (push) Successful in 16s
ci / docs-site (push) Successful in 1m13s
release / apple (push) Successful in 10m37s
deb / build-publish (push) Successful in 3m57s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
deb / build-publish-client-arm64 (push) Successful in 3m46s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
deb / build-publish-host (push) Successful in 7m0s
docker / builders-arm64cross (push) Successful in 30s
docker / deploy-docs (push) Successful in 51s
apple / screenshots (push) Successful in 5m48s
windows-host / package (push) Successful in 17m50s
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m5s
flatpak / build-publish (push) Successful in 9m30s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m0s
windows-host / canary-manifest (push) Successful in 22s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m23s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m4s
Reviewed-on: #162 |
||
|
|
f62a48d4a9 |
feat(library): launcher tiles get their launcher's logo — a brand token on the wire, the vector in every client
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 1m52s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m15s
ci / bun-nix (pull_request) Successful in 18s
android / android (pull_request) Successful in 3m56s
ci / rust (pull_request) Successful in 5m40s
A launcher tile (role: "launcher", design D4) shipped no art on purpose:
a launcher's own icon is square, every client cover-crops a 2:3 poster,
and the crop turns a mark into a strip. So the tiles were the launcher's
name on a flat accent face — legible, and the blandest thing in the grid.
Entries now carry an optional `icon`: the NAME of a brand mark, never
image bytes and never a URL. `[a-z][a-z0-9-]{0,31}`, shape-validated by
the host on every lane (a client interpolates the value into a resource
name or an asset lookup, so the guard belongs upstream of all of them,
and each client re-checks rather than trusting the peer).
A token rather than art because the alternative is closed by
construction, and deliberately: the art proxy serves what the bytes ARE
(sniff_image_type) and SVG is not on that list — it is script-capable
XML and the console renders library art in a browser. Widening that
sniff would trade a rendering nicety for a stored-XSS surface. Naming
the mark keeps the refusal intact, keeps the glyph vector at whatever
size a tile happens to be, lets it take the tile's ink, and adds nothing
to a reconcile payload that is already body-limited. The cost is that a
third-party plugin cannot ship a mark no client bundles; its tile falls
back to the launcher's name, exactly as before, and the fix is a PR
adding the master.
assets/launcher-icons/ holds seven monochrome masters with per-mark
provenance and licensing (Simple Icons CC0: lutris, heroic, epic, gog;
Font Awesome CC BY: steam, xbox; Playnite's own logo, MIT). steam is
generated FROM assets/os-icons/steam.svg so the SteamOS host badge and
the Steam launcher tile can never drift.
scripts/gen-launcher-icons.sh bakes the three derivatives that cannot
consume a master (GTK symbolic SVG, Windows PNG, Apple template PDF)
and — unlike gen-os-icons.sh, which prints path data for a human to
paste — GENERATES the three inline registries (web console, Android
ImageVector, pf-console-ui Skia). Three clients x seven paths of up to
3 kB is a transcription error waiting to happen, and a mangled character
is a silently wrong logo rather than a build failure. The generated Rust
goes through rustfmt, since `cargo fmt --all --check` is a CI gate and a
generated file that fails it would fail every regeneration.
All six renderers draw the mark CONTAINED, never cover-cropped: the
masters' viewports are not square (steam 496x512, playnite 1024x1024)
and filling a 2:3 frame would reproduce the strip this exists to avoid.
Every one keeps its old fallback for a token it has no art for.
Epic, GOG and Xbox marks ship dormant. Those plugins' launcher switches
are off by default and emit nothing, because the host has no verified
launcher_ui activation for them yet — shipping the art now keeps turning
one on the one-line plugin change those plugins promise, instead of also
needing a release of all six clients.
api/openapi.json and the SDK are regenerated (the spec's version field
was stale at 0.25.0 and now reads 0.26.0, which is the crate's actual
version — an unrelated line that regeneration necessarily corrects).
Verified: host cargo check, clippy -D warnings across pf-client-core /
pf-console-ui / punktfunk-client-session / punktfunk-client-linux, plain
build, pf-console-ui tests (77, including a new one asserting all seven
masters parse under Skia and one asserting the letterbox stays inside
its box), pf-client-core tests (188), cargo fmt --all --check, Apple
swift build, Android compileDebugKotlin, web tsc + vite build,
plugin-kit tsc, biome. The Windows client is NOT compile-verified — it
cannot be built from a Mac (scripts/xcheck.sh covers only the capture
stack by design) and CI does not build it either; its tile change needs
a real box before it ships.
|
||
|
|
c817e4bec0 |
fix(pf-vdisplay): a CAPPED compositor refuses /proc/exe — NixOS+KDE still undetectable
apple / swift (pull_request) Successful in 1m55s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 3m34s
android / android (pull_request) Successful in 4m27s
ci / rust-arm64 (pull_request) Successful in 1m57s
ci / web (pull_request) Successful in 1m4s
ci / bun-nix (pull_request) Successful in 19s
ci / docs-site (pull_request) Successful in 1m18s
v0.27.0 resolved the nixpkgs wrapper decoration (`.kwin_wayland-w`) through `/proc/<pid>/exe`.
On the box that fix was written for, the kernel refuses that link.
Reading `/proc/<pid>/exe` is not gated on owning the process: `cap_ptrace_access_check` requires
the reader's effective set to be a superset of the target's PERMITTED set. NixOS's own Plasma
module ships `security.wrappers.kwin_wayland = { capabilities = "cap_sys_nice+ep"; }`, and our
host must stay uncapped — a capability is precisely what makes it unidentifiable to KWin (#136).
So the two traps compose: the name NEEDS `exe` because nixpkgs wrapped the binary, and `exe` is
DENIED because NixOS capped it. `detect_active_session` went back to `ActiveKind::None`, and every
connect died `no usable compositor` with `wayland="-"` — the identical signature to the bug #158
fixed, which is why a box worked around with a decoy process broke again when the decoy went away.
`match_name` now falls through to `argv[0]` (`/proc/<pid>/cmdline`) when `exe` is unreadable. It
reads correctly for the same reason `ps` does: make-wrapper's wrapper `exec -a "$0"`s the hidden
binary, so `argv[0]` survives the decoration `comm` does not. It is consulted LAST and never
overrides a readable `exe` — it is the process's own claim about itself, and a same-uid process can
set it to anything; the worst a spoof achieves is aiming detection at a backend that then fails its
own availability probe.
MEASURED (Linux 6.x, same-uid reader, target holding cap_sys_nice), for a file capability and for
the ambient form `security.wrappers` actually uses — identically:
/proc/<pid> owner ✅ real uid (so the uid filter upstream was never the problem)
comm ✅ readable (decorated + truncated, unusable alone)
exe ❌ EACCES
cmdline (argv[0]) ✅ readable
End-to-end against a LIVE capped, `.kwin_wayland-wrapped` process, old resolver vs new, both
extracted verbatim from their own sources: v0.27.0 answers `.kwin_wayland-w` (no match) in both
capped scenarios and `kwin_wayland` uncapped; the fixed one answers `kwin_wayland` in all three.
Also reached by the same rung: gamescope carries `cap_sys_nice` on a number of distros, so a
wrapped-and-capped gamescope was equally invisible to the foreign-gamescope probe.
Tests are fixture-driven (a temp dir with `comm`/`exe`/`cmdline`), for the reason #158 documented:
a renamed `/bin/sleep` stand-in dies instantly on multi-call coreutils and reads exactly like the
resolver being broken. 11 tests extracted verbatim and executed on Linux as a non-root user —
0 failures. `cargo fmt --check` and `scripts/xcheck.sh linux clippy` (`--all-targets`) clean.
Refs #158, #136.
|
||
|
|
61dfc3dadc |
Merge remote-tracking branch 'origin/main' into worktree-nixos-wrapped-comm-detection
ci / bun-nix (pull_request) Successful in 40s
ci / web (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 1m46s
android / android (pull_request) Successful in 5m22s
ci / rust (pull_request) Successful in 6m47s
apple / swift (pull_request) Failing after 13m41s
apple / screenshots (pull_request) Skipped
nix / flake (pull_request) Successful in 13m48s
|
||
|
|
0b550dad84 |
feat(android): the console tables stop drifting in silence, and the stats overlay gets a pad route
WP8 and WP9 of `punktfunk-planning/design/android-console-ui-visual-refresh.md`, in part. **WP9.3 — shared parity vectors.** The console's background palettes, its settings section names and its screen-transition motion each existed in three hand-written copies (`pf-console-ui`, this client, the Apple client) held together by a comment asking the next person to keep them in step. `clients/shared/console-vectors.json` now holds them, read the way `deeplink-vectors.json` already is: `include_str!` in Rust, a relative path in Kotlin, `#filePath` in Swift — never a copy, because a copy is a fourth contract free to go stale. It carries the DERIVED tables too, the 16-cell mesh and the 4 blob colours per palette, which is the half that reaches the screen and the half Android never checked: `GamepadPaletteTest` only ever measured the `stops` they are computed from. Two drifts it immediately caught, both now closed: * **The easing was the wrong curve.** `ConsoleMotion.EaseOutCubic` shipped as `cubic-bezier(0.215, 0.61, 0.355, 1)` while claiming to be the desktop's `ease_out_cubic`. It is not: that is the Penner/Ceaser table's curve, ~0.80 at the midpoint where `1 − (1−t)³` is 0.875 — visibly slacker over a 260 ms transition. Compose's `Easing` is a plain function, so it now evaluates the real thing analytically rather than approximating it at all. (Apple approximates with a different bezier only because SwiftUI's `timingCurve` cannot take a closure; the vectors sample the curve with a tolerance so all three can meet it.) * **The desktop has a seventh tab.** Input — touch mode, mouse, invert-scroll, shortcuts — with nothing to set on a phone or a TV. `settings.rs` claims in prose that a setting is found under the same word on every client; that was true modulo an omission nobody could see. The vectors model it with `desktop_only` rather than picking a side, so neither client has to be wrong. Rust reads it from three tests placed in the files that own the constants, so nothing had to be made `pub` to be checkable. Verified green under Linux (the crate is `cfg(linux|windows)` throughout — `cargo test` on a Mac compiles nothing and passes vacuously): 77 passed, 0 failed. Android's side gates in CI as a FILTERED task; a plain `:app:testDebugUnitTest` would drag the ~20 Roborazzi screenshot scenes into every push, and those are a release-artifact job. **WP8.1 — a pad route to the stats overlay.** The tier could only be cycled by a three-finger tap, which does not exist on a TV, on a gamepad-only session, or under touch passthrough — while the settings row promised a live cycle. `Select + X` now cycles it, byte-identical to the Apple client's `GamepadWire.back | GamepadWire.x`, implemented as the mic chord's twin in `GamepadRouter` and edge-triggered on the button that completes the mask. The buttons still reach the game, as both existing chords do. `GamepadChordTest` pins eight cases the kit had no cover for at all, including that the three chords intersect only on Select and that none is reachable through another. **WP8.5 — a start-of-stream banner.** The desktop's `skia_overlay` banner, ported with its timing (opaque 5.4 s, then a 0.6 s fade) and its rule of naming only shortcuts that exist: pad chords when a pad is present, the touch gesture when there is a touchscreen and the mode can use it. Nothing `Ctrl+Alt+Shift` is advertised, because Android has none of it. It yields to the motion-unreachable notice rather than stacking with it — that one reports something broken about *this* session. **WP8.6 — the home card says which profile it connects with.** `HomeTile` carried a `pinnedProfileId` the card never drew, so a pinned host+profile card was distinguishable from the host's own only by a subtitle that had been quietly repurposed to hold the profile name. Both now show the address like every other card and wear a tinted profile chip — the touch grid's own convention and the Apple client's, inked from the console palette. Unsaved tiles (discovered, Add Host) take a dashed edge, which is what the other two surfaces already use to say "not yours yet". ⚠ Not a detail panel: the Apple client REMOVED its own and moved the status onto the card, which is where the lock and the online pip already were here. **WP8.7 — accessibility, in part.** The console screens carried three `contentDescription`s and no `semantics`, `Role` or `stateDescription` at all. A settings row now announces once, merged — label, value, and the description that lives in the floating band far from it — with `Role.Switch` and a real toggle state, because a toggle row's on/off string was drawn by nothing at all: the switch replaces the value text, and the switch was two undescribed `Box`es. Decoration is silenced rather than labelled (the chevrons were read aloud as punctuation on every focused row). The hint bar's glyphs, the tab strip and the home tiles are done; `GamepadAddHostScreen` and `LibraryScreen` are not yet. |
||
|
|
002702bcec |
fix(pf-vdisplay): NixOS sessions were undetectable — comm is the WRAPPER's name
The session probe decided "is a desktop live?" by reading /proc/<pid>/comm for
every process of our uid and exact-matching it against "kwin_wayland" /
"gamescope" / "gnome-shell" / "Hyprland". comm is the kernel's name for the
executed FILE, truncated to 15 bytes — not argv[0].
nixpkgs wraps essentially every graphical binary: wrapProgram moves the real
ELF aside to `.<name>-wrapped` and installs a wrapper under the original name,
which then `exec -a "$0"`s the hidden file. So on NixOS the kernel reports
`.kwin_wayland-w` (15 bytes of `.kwin_wayland-wrapped`) while ps/pgrep -a show
a perfectly ordinary `kwin_wayland`, because they read argv. Measured against a
live kernel: `.kwin_wayland-w`, `.kwin_wayland_w` (KWin's own
kwin_wayland_wrapper), `.gamescope-wrap`, all 15 bytes.
Nothing downstream could recover from that one string comparison:
- detect_active_session returned ActiveKind::None on a *running* KDE desktop;
- wayland_display is only resolved for a detected kind, so the connect log
reported wayland="-" even though WAYLAND_DISPLAY was correct;
- pick_compositor's Auto arm returns the DETECTED backend, so a live, fully
working KWin sitting in available() was never chosen — every connect died
"no usable compositor";
- and PUNKTFUNK_COMPOSITOR could not rescue it: pinned_at_a_dead_session
consults the same probe, turning the miss into a hard error instead.
No environment variable reached the comparison — the XDG_CURRENT_DESKTOP
fallback in detect() is only on the pinned path. Capture itself was never at
fault: a decoy process merely NAMED kwin_wayland satisfied the probe and the
stream came up against the real KWin.
Resolve the name through /proc/<pid>/exe (the full, untruncated file name) and
strip the nixpkgs decoration. Both the leading `.` and the trailing `-wrapped`
are required before anything is stripped, so KWin's own real
`kwin_wayland_wrapper` binary keeps its name rather than collapsing into
`kwin_wayland` and handing the probe the parent's PID. The comm fast path is
kept for every ordinary distro — one read, no readlink, and no name that
matched before can stop matching.
Also applied to foreign_gamescope_running, which had the same defect: nixpkgs
wraps gamescope too, so the attach-vs-spawn ladder saw no foreign session.
Tests are fixture-driven rather than spawn-driven on purpose: a stand-in has to
be a real ELF that tolerates being renamed, and /bin/sleep is not one — modern
coreutils is a multi-call binary that dispatches on the executable's own name,
so a copy called `.kwin_wayland-wrapped` exits instantly and /proc/<pid>/exe is
gone before it can be read. That failure looks exactly like this resolver being
broken; it cost one debugging round here and the same trap is already recorded
in punktfunk-host's /proc matcher.
|
||
|
|
657e82cd29 |
fix(gamescope): a takeover's mask no longer bars the box's own way back into Game Mode
ci / bun-nix (pull_request) Successful in 17s
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m29s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m47s
android / android (pull_request) Successful in 4m24s
ci / rust (pull_request) Successful in 6m24s
A managed takeover runtime-masks the box's `gamescope-session-plus@*` unit so its
session supervisor cannot restart it underneath our Steam. The only unmask ran in
`do_restore_tv_session`, on client disconnect — so for the whole stream the mask
stayed on, and it silently barred the door the user was most likely to walk
through next.
`mask_unit`'s own doc said the mask "blocks nothing" on images whose sddm helper
execs the session script directly. That is half right, and the half it gets wrong
is this bug: on f43 bazzite-deck the script's last act is
systemctl --user --wait start gamescope-session-plus@${CLIENT}.service
(verified on the .41 VM). What the mask fails to stop is the RELOGIN LOOP — sddm
keeps trying regardless, which is why stopping the DM is the real defense. What it
very much does stop is the unit, and with it every entry into game mode, including
the user's own deliberate "Return to Gaming Mode" after a mid-stream switch to the
desktop. Steam then sits on its "Switch to Desktop…" modal forever. `--runtime`
lives in tmpfs, so a reboot cleared it — hence "it works right after a reboot" —
and a plain `unmask` does not (measured: still `masked-runtime`).
So the mask's sound lifetime is shorter than the takeover's: it ends the moment the
box stops being ours. The mid-stream session watcher already detects exactly that,
so it now lifts the mask on a confirmed switch to a desktop session — ahead of the
`compositor_for_kind` arm, because a switch we cannot follow still has to unbar the
return. `Gaming` and `None` deliberately do not lift: a takeover's own managed
session reads as `Gaming` and one momentarily down between relaunches reads as
`None`, and lifting on either would void the mask for the whole stream, in exactly
the SDDM-storm window it exists for.
Fixes a second, worse leak on the way: `honor_session_select_switch` consumed
`STOPPED_AUTOLOGIN` — the only record of what carries a mask — without unmasking,
so under a DM-stop takeover the disconnect restore found an empty list and lifted
nothing. That mask outlived not just the stream but the boot. It is also what let
that path's own step 1 work at all, since the DM's autologin heads back into game
mode through precisely this unit.
The lift is idempotent, keeps the restart list intact (the disconnect restore still
owes those units a `start`), and every hand-back path now routes through it.
Verified on Linux: `switch_ends_mask_window` decision table, plus an ignored
end-to-end test driving real `systemctl --user` (masked → survives Gaming/None →
lifted by a desktop switch → restart list intact → idempotent). Proven non-vacuous
by planting "Gaming also lifts", which fails it on the during-stream assert.
|
||
|
|
7a8f63e906 |
Merge pull request 'The console UI answers the iPad field test — and the screen that would never open again was a UIKit read mid-render' (#154) from worktree-apple-gamepad-relanded into main
apple / swift (push) Successful in 1m37s
release / apple (push) Successful in 4m9s
ci / rust-arm64 (push) Successful in 4m48s
ci / web (push) Successful in 1m2s
ci / bun-nix (push) Successful in 17s
ci / docs-site (push) Successful in 2m6s
apple / screenshots (push) Successful in 3m2s
android / android (push) Successful in 11m1s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 52s
deb / build-publish-client-arm64 (push) Successful in 3m48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m41s
docker / builders-arm64cross (push) Successful in 6s
deb / build-publish-host (push) Successful in 8m48s
ci / rust (push) Successful in 14m25s
docker / deploy-docs (push) Successful in 31s
arch / build-publish (push) Successful in 15m35s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m43s
flatpak / build-publish (push) Successful in 7m12s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m54s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m14s
deb / build-publish (push) Successful in 15m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m49s
Reviewed-on: #154 |
||
|
|
35b5ee6a36 |
Merge pull request 'punktfunk-encode-worker: GPU priority via a capability-carrying worker, with WP3 on-glass complete' (#153) from worktree-worktree-encode-worker into main
audit / bun-audit (plugin-kit) (push) Successful in 20s
audit / bun-audit (web) (push) Failing after 20s
audit / bun-audit (sdk) (push) Successful in 20s
audit / pnpm-audit (push) Successful in 9s
audit / docs-site-audit (push) Successful in 20s
audit / cargo-audit (push) Successful in 1m9s
apple / swift (push) Successful in 1m42s
ci / web (push) Successful in 1m21s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m48s
ci / docs-site (push) Successful in 1m19s
ci / bun-nix (push) Successful in 17s
android / android (push) Canceled after 5m0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 5m1s
ci / rust (push) Canceled after 4m17s
ci / rust-arm64 (push) Canceled after 4m8s
deb / build-publish (push) Canceled after 54s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 3s
release / apple (push) Canceled after 3m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 2m10s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
decky / build-publish (push) Successful in 26s
audit / license-gate (push) Successful in 6m39s
windows-host / package (push) Successful in 13m21s
windows-host / winget-source (push) Skipped
nix / flake (push) Successful in 15m53s
windows-host / canary-manifest (push) Successful in 25s
Reviewed-on: #153 |
||
|
|
84faeb1bf1 |
docs(pf-encode): Linux Main10 is live — the 'inert until Phase 5.1' comment outlived the code
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m36s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 1m15s
ci / web (pull_request) Successful in 1m52s
ci / docs-site (pull_request) Successful in 1m58s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m26s
ci / rust (pull_request) Failing after 4m39s
ci / rust-arm64 (pull_request) Successful in 5m35s
android / android (pull_request) Successful in 5m44s
nix / flake (pull_request) Failing after 18m37s
The bit_depth field said '8 on Linux until Phase 5.1 lands a P010 capture path'. The code outran it: the gamescope HDR capture patches offer 10-bit BT.2020/PQ, nvenc_fmt maps X2Rgb10/X2Bgr10 to ARGB10/ABGR10, and is_ten_bit_input flips bit_depth and hdr from the negotiated input. Verified on home-nobara-1: 'resolved session plan ... bit_depth: 10, hdr: true' on the direct backend. A 10-bit frame deliberately takes neither the NV12 nor the YUV444 convert (both compute CSCs write 8-bit planes) and rides packed RGB to the encoder, which does its own BT.2020 CSC — pf-capture/src/linux/pipewire.rs owns that gate. So Main10 needed no P010 path to arrive, and P010 is now a perf follow-up (skip NVENC's internal CSC, as NV12 does for SDR), not the thing that makes 10-bit work. |
||
|
|
ad63994cb9 |
fix(pf-encode): the 10-bit probe was the last ffmpeg NVENC open on a direct-SDK host
ci / bun-nix (pull_request) Successful in 24s
ci / web (pull_request) Successful in 1m3s
apple / swift (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m14s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m59s
android / android (pull_request) Canceled after 3m11s
ci / rust (pull_request) Canceled after 3m13s
ci / rust-arm64 (pull_request) Canceled after 3m13s
nix / flake (pull_request) Canceled after 2m4s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 1m9s
`can_encode_10bit`'s Linux NVIDIA arm answered "can this GPU encode 10-bit?" by
opening an ffmpeg `hevc_nvenc` encoder. On a host that then streams over the
direct SDK, that is the LOG-3 field bug: one ffmpeg NVENC open in a direct-SDK
process wedges every later open process-wide with `NV_ENC_ERR_INVALID_VERSION`
until the host restarts.
`can_encode_444` was moved off the ffmpeg probe for exactly this reason on
2026-07-27. The 10-bit one was deliberately left behind, on the reading that
"Linux HDR genuinely rides the libav P010 path". `open_video` contradicts that:
if cuda && nvenc_direct_enabled() { // no 10-bit exclusion
… NvencCudaEncoder::open(…, bit_depth, …)
A CUDA capture goes to the direct backend at whatever depth was resolved, and
`is_ten_bit_input` already accepts the packed 10-bit RGB (`X2Bgr10`) that a
gamescope HDR capture negotiates. So on a default NVIDIA host the probe was
loading ffmpeg's NVENC client for a session that never uses it.
Observed on home-nobara-1 2026-08-10, gamescope + RTX 5070 Ti, client HDR on:
resolved session plan … bit_depth: 10, hdr: true
pipewire format negotiated … xBGR_210LE mapped=Some(X2Bgr10) modifier=0 hdr=true
encoder submit failed — encoder rebuilt in place … NV_ENC_ERR_INVALID_VERSION
encoder did not recover after repeated in-place rebuilds — ending the video session
and with `PUNKTFUNK_NVENC_DIRECT=0` (nothing mixes, libav serves everything) the
same HDR session streams clean: 0 errors, bit_depth=10, hdr: true.
The 10-bit cap now rides `nvenc_cuda::probe_support()`'s existing throwaway
session — the same place the 4:4:4 cap already rides, queried per listed GUID
with `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE`, which is what the Windows NVENC arm has
always done (`enc/windows/nvenc.rs`). Unanswered fails CLOSED: an 8-bit session
beats a wedged one. A host that will really serve over libav
(`PUNKTFUNK_NVENC_DIRECT=0`, or a build without `--features nvenc`) keeps the
ffmpeg probe, where it validates the actual path and ffmpeg's client is loaded
anyway.
⚠ NOT YET VALIDATED ON GLASS. Gates are green — clippy `-D warnings` with
`--features nvenc,vulkan-encode,pyrowave` on linux/amd64, 67 pf-encode tests,
fmt — but the end-to-end HDR run is still owed. This branch is 42 commits behind
main and its build cannot complete a punktfunk/1 handshake on home-nobara-1 at
all (it stalls between "audio channels resolved" and "encode bit depth" and
times out at 10 s, on EVERY attempt). That stall is NOT this change: a control
build with only the routing reverted stalls identically, and the released
0.27.0 RPM on the same box handshakes fine and reaches `bit_depth=10`. Rebase
onto main before re-testing.
|
||
|
|
a23c028492 |
fix(host): the console reported the resolution the client asked for, not the one it got
`/api/v1/local/summary` (and the console card behind it) read the live-stats mode
slot, which bring-up seeded from the NEGOTIATED mode:
let live_mode = Arc::new(AtomicU64::new(pack_mode(
mode.width, mode.height, interval_hz(interval))));
The refresh was already corrected there — the comment says so, because KWin caps
a virtual output's rate — but the SIZE was still the request. Only a mid-stream
resize ever fixed it: the rebuild path below publishes `delivered_mode(frame..)`,
and bring-up never did.
Attach is what makes this matter rather than being pedantry. On a box with a
physical display the gamescope backend logs
gamescope: box drives a physical display — attaching at its own mode (no
re-mode) client_w=5120 client_h=1440
and streams the panel's size. Measured on home-nobara-1 with a 1080p HDMI panel
attached: the capture negotiated 1920x1080 and NVENC opened 1920x1080@240, while
the summary reported 5120x1440 — the console confidently naming a resolution
nobody was watching, which is exactly the shape of the stale attach-path report
noted on .41 in July ("reusing w=5120 h=1440" while the session was really 1080p).
Seeding the slot from `delivered_mode(frame.width, frame.height, interval)` uses
the same helper the rebuild path already trusts, and changes only the two fields
that were wrong — its refresh term IS `interval_hz(interval)`, so that half is
bit-for-bit what it was.
This publishes the STATS slot only. It deliberately does not send the client a
corrective `Reconfigured`: that remains owed exactly where it already was, under
`adopted_at_bringup`, because an ordinary connect's mode came from the Welcome
rather than from an accept the client has already acted on.
Verified on home-nobara-1, attach session against a 1080p panel:
summary session: {"width":1920,"height":1080,"fps":240}
actually captured: pipewire format negotiated width=1920 height=1080
Before the change the same session reported 5120x1440.
|
||
|
|
5b3ea6e8db |
fix(pf-encode): one NVENC open failure could kill every session on the box
`punktfunk-host` died twice on home-nobara-1 with the same stack:
__strlen_evex <- av_vbprintf <- format_line <- av_log_default_callback
<- ff_cuda_check <- ff_nvenc_encode_init <- avcodec_open2
<- NvencEncoder::open <- NvencEncoder::reset <- virtual_stream
once as an outright SIGSEGV mid-session, and once as a thread wedged in that
stack so the service never answered SIGTERM and systemd escalated to SIGABRT
("State 'stop-sigterm' timed out. Aborting."). Both times a client's session was
rebuilding its encoder. The blast radius is the whole host process — every other
client's session goes with it.
The fault is in libav, not here. `ff_cuda_check` logs the failing CUDA call as
`"%s failed -> %s: %s"` using an `err_name`/`err_string` pair the error lookup
does not always fill, and glibc then walks whatever was on the stack. We cannot
patch the distro's FFmpeg, so the fix denies it the chance to format: the guard
already used by the 4:4:4 probe drops the level to AV_LOG_FATAL across the open,
and `av_log_default_callback` returns on the level check before `format_line` —
these messages are AV_LOG_ERROR. The failure is not swallowed; it still comes
back as `Err(e)` and is reported with our own context, which now says the libav
text was deliberately silenced so nobody hunts for a message that will not come.
Scoped to the `open_with` call ALONE. The ENOSYS arm immediately below recurses
into `Self::open`, and `QuietLibavLog` holds a non-reentrant global mutex —
wrapping the whole `match` would have deadlocked the intra-refresh retry.
Verified on home-nobara-1 (fc44, libavcodec 62). With CUDA made unavailable so
the open fails inside the CUDA layer, the old binary prints
[hevc_nvenc @ ..] cuInit(0) failed -> CUDA_ERROR_NO_DEVICE: no CUDA-capable
device is detected
— that line IS `ff_cuda_check` formatting the two `%s` — and the fixed binary
does not; both exit 1 with our error instead. A successful open is unaffected on
both the direct-SDK and the libav paths (90/90 frames, identical output size).
What this does NOT claim: the uninitialized-pointer condition itself was not
reproduced on demand — it depends on the CUDA error lookup failing to fill the
strings, and in the forced case above it filled them fine. What is demonstrated
is that the formatting call which faulted is no longer reached during the open.
|
||
|
|
beb639f067 |
fix(ui): the OLED palette is called Eclipse now
Every other background reads as a place or a material — Violet, Nebula, Abyss, Ember, Moss, Graphite, Holo, Sunset, Bloom, Dawn, Mint, Opal — and "OLED" was a spec sheet sitting among them, naming the panel it suits rather than what it looks like. It looks like black with a thin violet corona at one corner, which is an eclipse, and that puts it beside Nebula and Abyss. The ID stays "oled" in all three clients: it is the stored ui_palette value AND the cross-client key, so renaming it would orphan every saved choice and silently desync Apple, Android and the console UI. Only the label moved. No test or doc pinned the old string. Palette tests pass; macOS builds. |
||
|
|
6695300b67 |
fix(pf-vdisplay): the gamescope bind took a user namespace that broke Xwayland, and left a drop-in that bricked the next boot
ci / bun-nix (pull_request) Successful in 24s
ci / docs-site (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m24s
ci / rust-arm64 (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 1m37s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Canceled after 4m4s
ci / rust (pull_request) Canceled after 4m13s
Field-diagnosed on Nobara (fc44, canary g13179011), where Game Mode became unstartable and the box was handed to plasma. #144's bind works — the patched build genuinely reaches a session script that hardcodes /usr/bin/gamescope — but a mount namespace in a systemd USER unit is also a USER namespace, and only this uid is mapped in it. Measured on the box: on disk / in a unit without the bind : drwxrwxrwt 2 0 0 /tmp/.X11-unix in a unit WITH the bind : drwxrwxrwt 2 65534 65534 /tmp/.X11-unix uid_map inside : 1000 1000 1 wlroots checks that /tmp/.X11-unix is "owned by root or us", sees nobody, and refuses: wlserver: [xwayland/sockets.c:100] /tmp/.X11-unix not owned by root or us wlserver: [xwayland/sockets.c:217] No display available in the first 33 -> SIGSEGV in run_pipewire Three ~10 s failures then feed chimeraos' short-session tracker, session-plus stops even trying, and steamos-session-select rewrites the user's session to plasma. So the symptom an operator reports is "thrown onto KDE and I can't get back" — two removes from the cause. Two further bugs found while fixing it, each worse than the one reported: * THE BIND WAS ARMED EVERYWHERE. The condition was only `gamescope_bin() != /usr/bin/gamescope`, so every box with punktfunk-gamescope installed took a namespace it has no use for — Bazzite, SteamOS-likes, the Deck. The blast radius was every gamescope box, not just the hardcoded-path ones the mechanism exists for. Now the host READS the session script and arms only where it never mentions GAMESCOPE_BIN and names /usr/bin/gamescope outright; everything else is bit-for-bit pre-#144, no namespace at all. An unreadable script does not arm. * THE DROP-IN OUTLIVED ITS SOURCES. It was written to ~/.config/systemd/user/ on the TEMPLATE, so it also applied to the box's OWN autologin unit at every boot — while both paths it binds live in tmpfs. After a reboot the drop-in survives and its sources do not, and BindReadOnlyPaths= with a missing source fails the unit outright. THAT is why the field symptom survived a reboot. It now lives in $XDG_RUNTIME_DIR (dies with the login session), removal covers both the runtime and the legacy $HOME path, and restore_takeover_on_startup does that removal unconditionally at host start — which is the upgrade path for every box already running canary g13179011. Without it, updating the host would not un-brick them. * A bind was armed even when gamescope_bin() fell back to the bare name "gamescope". The wrapper execs `gamescope` through PATH inside the unit — onto the path we just bound the wrapper over. Fork bomb. Refused ahead of even the operator's force. Where the bind IS armed it now carries its own compensation: a user-owned $XDG_RUNTIME_DIR/punktfunk-x11 bound read-WRITE over /tmp/.X11-unix (Xwayland creates the socket there), so the ownership check sees "us". Skipped when that directory is already ours or absent — neither is the hazard. Stale sockets are pruned by connect-test so a SIGKILLed session cannot walk the 33 display slots away. And rather than trust that reasoning, the host now ASKS THE BOX before arming: it runs the field reproduction with the real property set — `systemd-run --user --wait --collect --property=<the same args> -- stat -c %u /tmp/.X11-unix` — and arms only if the answer is our uid. Anything else (65534, a rejected property, no user manager, a blown 10 s budget) means no bind, and the session runs stock gamescope: no HDR, no in-node cursor, but it STARTS. A runtime backstop disarms and relaunches if a session launched with the bind armed produces no node in its window, latching one-way per process. The XFixes-cursor concern that argued against relocating the socket does not hold: the only host-side X client is spawned under `plan.gamescope_cursor`, which is `gamescope && !gamescope_composites_cursor()`, and our shipped +pfhdr4 build is patch level 4 — so on the very route where the bind arms, that reader is never constructed. |
||
|
|
5d7091bf87 |
Merge pull request 'The Windows Xbox pad: make games actually see it' (#149) from worktree-xbox-pad-wgi-visibility into main
apple / swift (push) Successful in 1m35s
windows-drivers / probe-and-proto (push) Successful in 26s
windows-drivers / driver-build (push) Failing after 1m40s
android / android (push) Successful in 6m12s
ci / rust-arm64 (push) Successful in 5m5s
ci / bun-nix (push) Successful in 21s
ci / web (push) Successful in 5m31s
arch / build-publish (push) Successful in 8m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / docs-site (push) Successful in 4m14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
release / apple (push) Successful in 10m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 45s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 3m4s
docker / deploy-docs (push) Skipped
ci / rust (push) Successful in 12m22s
deb / build-publish-client-arm64 (push) Successful in 4m27s
deb / build-publish-host (push) Successful in 7m16s
docker / builders-arm64cross (push) Successful in 12s
deb / build-publish (push) Successful in 9m58s
apple / screenshots (push) Successful in 6m11s
windows-host / package (push) Canceled after 12m58s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Successful in 9m46s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m48s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m50s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m23s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m31s
nix / flake (push) Failing after 21m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m26s
Reviewed-on: #149 |
||
|
|
e19f11bb0d |
feat(abi/apple): carry the trigger motors to non-Rust clients — ABI 18, next_rumble_cmd2
windows-drivers / probe-and-proto (pull_request) Successful in 25s
apple / swift (pull_request) Successful in 1m37s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Failing after 1m43s
ci / web (pull_request) Successful in 2m57s
ci / bun-nix (pull_request) Successful in 18s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m36s
android / android (pull_request) Successful in 4m0s
ci / rust-arm64 (pull_request) Successful in 4m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m32s
ci / docs-site (pull_request) Successful in 4m36s
ci / rust (pull_request) Failing after 9m15s
nix / flake (pull_request) Successful in 15m3s
The `0xCA` wire already carries the two Xbox impulse-trigger motors (v3), and the Rust decode path
already parses them; `datagram_task.rs` dropped them on the floor with a comment naming exactly this
work as what remained. The blocker was the C ABI: every non-Rust client pulls rumble through
`punktfunk_connection_next_rumble_cmd`, whose out-params cannot carry two more channels.
PunktfunkStatus punktfunk_connection_next_rumble_cmd2(
PunktfunkConnection *c, uint16_t *pad, uint16_t *low, uint16_t *high,
uint16_t *left_trigger, uint16_t *right_trigger,
uint32_t *backstop_ms, uint32_t timeout_ms);
⚠️ ADDED, not widened. `_cmd` keeps its signature and its values bit-identical for handle-only
traffic — out-of-tree embedders depend on it and `docs/embedding-the-c-abi.md` documents it, so
silently changing an exported symbol would break every consumer at once. `nm` on the staticlib shows
all four rumble entry points still exported. `ABI_VERSION` 17 → 18; every other site reads it
dynamically, so there are no hardcoded mirrors to drift.
⚠️ ONE HONEST BEHAVIOURAL DELTA, documented in `abi.rs` and pinned by a test: against a
trigger-driving host a `_cmd` caller now receives commands with `low == high == 0` where the demux
previously dropped the update entirely. They are idempotent handle stops, and the redundant-stop
suppression cannot fold them because the command as a whole is not silent. Zero cost today —
nothing sources non-zero triggers.
The dedupe-jitter proof was RE-DERIVED rather than widened, which is the kind of thing that quietly
rots when a tuple grows: the nudge touches only `low` by ±1 LSB and `emit` is only reached with a
non-silent level, so the nudged tuple can collide with the four-field stop sentinel only at
`(1,0,0,0)`. A test pins both directions — refuse at `(1,0,0,0)`, flip freely at `(1,0,lt,0)`.
Apple renders them: `RumbleRenderer` gains `Motor?` slots at `GCHapticsLocality.leftTrigger` /
`.rightTrigger` beside the existing handles. A controller without trigger actuators degrades
silently — a nil engine yields a nil slot and `reconcile` no-ops — and absent localities are never
logged, because on most pads that is the normal case rather than a fault. The macOS DualSense
raw-HID branch stays a deliberate no-op: a DualSense has ADAPTIVE triggers, not trigger rumble
motors, and inventing a mapping there would buzz the wrong thing.
🛑 BUILT AHEAD OF A PRODUCER, DELIBERATELY, AND NOTHING HERE CLAIMS OTHERWISE. Nothing can currently
source trigger rumble on Windows and that is measured, not assumed: `XINPUT_VIBRATION` has two
members, and GameInput — the only four-motor API — does not enumerate an xinputhid-promoted Xbox pad
at all, verified against a REAL Microsoft Elite which is equally invisible to it while classic
XInput reads it live. So this path has never been exercised end to end and the comments say so.
VERIFIED
* `cargo test -p punktfunk-core --features quic --lib` 378 passed on macOS, 203 on Windows;
clippy `-D warnings` clean with and without default features; `cargo fmt --all --check` clean.
* The generated header is regenerated and idempotent on re-run (CI diffs it).
* SWIFT ACTUALLY COMPILES AND RUNS: `swift build` clean and `swift test` 262 passed / 0 failures
in `clients/apple`, against a locally built xcframework. (Editor SourceKit errors about
`PunktfunkCore`/`DualSenseHID` are index noise from that gitignored artifact — a real build
resolves both, and the `DualSenseHID` references are untouched by this change.)
* `cargo build -p punktfunk-host` clean on Windows.
NOT VERIFIED
* End to end — see above; there is no producer.
* Whether a real Xbox pad on Apple actually reports the two trigger localities. The degrade needs
no code, but the positive case is untested.
* `pf-client-core` (the SDL renderer) does not build on macOS at baseline and is unbuilt here. It
only reads `RumbleCommand` fields and never constructs one, so added fields cannot break it, but
it still calls `_cmd`; wiring `SDL_RumbleGamepadTriggers` is separate work.
ANDROID: NOT DONE, and it should stay that way for now. `pack_rumble` packs pad/backstop/low/high
into bits 0..52 of a `jlong` with `-1` reserved as a sentinel — two more `u16` do not fit. The right
fix if ever wanted is the direct-`ByteBuffer` shape `nativeNextHidout` already uses in the same file
(zero-allocation, caller-owned, the established idiom), not a second `jlong` (racy across two calls)
nor `long[]` (an allocation per pull). But no Android device exposes trigger actuators at all, so
there is nothing to render. Separately stale and also not fixed: `NativeBridge.kt`'s KDoc still
documents the v2 `ttl_ms` layout rather than `backstop_ms`.
|
||
|
|
7f1f7ba87c |
fix(pads/windows): say WHY a pad index is taken, and stop the devtest lying when it is
Debugging the on-glass session, a devtest run died with
error=create gamepad bootstrap mailbox Global\pfds-boot-0: Zugriff verweigert (0x80070005)
(install/repair: punktfunk-host.exe driver install --gamepad)
and then — this is the part that cost real time — kept printing "virtual Xbox One S Controller up",
streamed frames into nothing, and let the operator measure the INCUMBENT pad on that index. The
XInput packet count sat frozen and read as "the pad is dead", which was a wrong conclusion drawn
from a harness that had already failed and not said so.
WHAT IT ACTUALLY WAS. Pad lifetime is deliberately tied to the SESSION (native/input.rs: "the
gamepads are created and torn down with the session"), and a live session's pad legitimately owns
`Global\pfds-boot-0`. The mailbox's SDDL is `D:P(A;;GA;;;SY)(A;;GA;;;LS)` — SYSTEM and LocalService
only — and the host service runs as LocalSystem while a hand-run devtest runs as an elevated
Administrator, which is in neither ACE. `CreateFileMappingW` over an existing name is really an
OPEN, access-checked against the incumbent's DACL, so it returned ACCESS_DENIED and bailed at the
`?` BEFORE reaching the `ERROR_ALREADY_EXISTS` branch that already had the right sentence. That
branch only ever fires when both processes run as the same account.
The name is per-index on purpose and stays that way: `Global\pfds-boot-{index}` is the rendezvous
the driver polls, and its existence doubles as host-liveness. Making it per-process would let two
hosts build two devices on one wire index — the "the game sees two controllers" bug. The collision
is correct; only the diagnosis was wrong.
* `gamepad_raii.rs` classifies the failure: on ACCESS_DENIED it probes with `OpenFileMappingW`,
which separates what the OS collapsed — object-manager lookup precedes the access check, so
absent gives FILE_NOT_FOUND and present-but-forbidden gives ACCESS_DENIED. It now says the
mailbox belongs to a live session's pad and that nothing is wrong with the drivers.
* `pad_slots.rs` carries that as a typed `PadCreateFault` through the anyhow chain, so `ensure`
prints the fault's remedy instead of the per-backend reinstall hint, plus the pad index.
* `devtest.rs` now BAILS when no pad was actually built, instead of announcing success. This is
the fix that matters: every probe an operator runs next will still find a device on that index.
* `native.rs` names what a detached input thread still holds, since that is one of the ways a pad
can outlive its session.
DELIBERATELY NOT CHANGED, with reasons: the session-scoped pad lifetime (intentional and
documented); the mailbox naming (load-bearing, above); the retry/backoff (latching would resurrect
the `broken` flag `PadGate` exists to kill); the 10 s thread-detach in `serve_session` and the
service's `TerminateProcess` shutdown — both are real ways a devnode can outlive its owner, but
neither is evidenced in the field case and inventing a fix for an unobserved path is how you get a
regression instead of a bugfix.
`pf-inject/lib.rs` drops the `cfg(any(linux, windows))` gate on `pad_gate`/`pad_slots`. Neither
touches an OS pad API, and the gate meant a classification whose entire subject is a `cfg(windows)`
failure could not be tested on a dev machine at all.
VERIFIED
* ON WINDOWS (.173): `cargo test -p pf-inject --lib` 109/109; `cargo build -p punktfunk-host`
clean. Both agents' Windows code was compile-UNVERIFIED before this run.
* macOS: 5 new tests, including one that pins the anyhow downcast through the exact three-layer
context chain the Windows code builds — the assumption that could not otherwise be checked.
* `cargo fmt --all --check` clean.
NOT VERIFIED
* That a LocalSystem-owned mailbox really answers `OpenFileMappingW` with ACCESS_DENIED rather
than FILE_NOT_FOUND from an Administrator token. That is reasoned from the object manager's
lookup-then-access-check order, not measured. Repro on .173: hold a session pad on index 0, run
the devtest from an elevated console, and check the new sentence appears.
|
||
|
|
fb309e0262 |
fix(pf-vdisplay): the takeover blamed polkit for a group it never named, and offered two remedies that cannot work
ci / bun-nix (pull_request) Successful in 33s
ci / web (pull_request) Successful in 1m11s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m9s
ci / rust-arm64 (pull_request) Successful in 3m25s
android / android (pull_request) Successful in 4m28s
ci / rust (pull_request) Successful in 19m29s
Field triage on Nobara, 2026-08-09. Every connect degraded to ATTACH — which on that box mirrors a
game-mode session the host never configured, and looked like a black screen on every connect. The
host said:
the packaged pf-dm-helper polkit action is missing or was denied (reinstall the punktfunk
package, or install the display-manager polkit rule from the docs)
Every clause of that was wrong. The action was installed, `allow_any`, and its exec.path annotation
matched the installed helper; pkexec authorized it and RAN the helper. The helper refused, and said
exactly why:
pf-dm-helper: user 'nobara-user' is not in the 'punktfunk' group — refusing.
Grant it with: sudo usermod -aG punktfunk nobara-user (then re-login)
That text never reached the log, because `dm_helper` ran the helper with `.status()` — which
discards stderr and collapses the exit code to a bool. The one thing that would have ended the
investigation in seconds was thrown away at the call site, and the caller then guessed. Neither
suggested remedy adds anyone to a group, so a reader who followed both stayed broken and learned the
docs were useless. It fails soft, with no error and no failed unit, so nobody finds it on purpose.
Now: `.output()`, and four failure modes that stay distinguishable because they need different
fixes — helper not installed, pkexec could not run it, polkit denied it (pkexec's own 126/127), and
the helper ran and refused, whose stderr rides through VERBATIM rather than being re-described. Null
stdin too, so a pkexec that decides to prompt gets EOF instead of parking a stream thread on a tty
read.
The same gate gates the `linger` verb, so on a sessionless host an unjoined user fails there first —
carrying the reason there as well, or the misdiagnosis just moves one message earlier.
A new startup preflight says it before a stream is being built rather than during one, gated so it
cannot nag a box that would never attempt a takeover: not root, a display-manager alias exists, a
managed session launcher exists, a packaged helper exists, and the user is not in the group. It reads
membership from the user database rather than this process's groups, deliberately: that is what the
helper reads (it runs as root and resolves the caller from the database), so `usermod -aG` satisfies
the DM gate immediately and the warning stops. Using `getgroups()` would keep warning on a box where
the takeover already works.
Packaging said the group was for "the virtual Steam Deck pad (usbip)" — so anyone without a Deck pad
correctly skipped it and landed here by following instructions properly. All three scriptlets now
lead with Game Mode, name both grants, and record that creating the group is necessary and NOT
sufficient. Docs get the same treatment: the group is an admonition above the DM-flavor list in
gamescope.md, a black-screen entry in troubleshooting.md that tells the reader to read the quoted
reason FIRST, and the per-distro install pages no longer frame it as pad-only.
|
||
|
|
2b1843ed1c |
fix(drivers/pf-gamepad): the right stick is Z/Rz — as declared, it was dead
Found on glass, first real streaming session: everything worked except the right stick, and Steam correctly showed "Xbox One S Controller". `XBOX_RDESC` declared the right stick as `Rx`/`Ry`. `xinputhid`, which translates our HID collection into XUSB, maps `Z`/`Rz` to the right stick and does not treat `Rx`/`Ry` as one, so those two axes reached nothing. Two usage bytes. Left and right were declared identically here — same collection, same globals, same size and count — so the usages were the entire difference, which is what makes the diagnosis airtight rather than plausible. Note `DUALSENSE_RDESC`, a real capture, also uses `Z`/`Rz` for its right stick and puts the TRIGGERS on `Rx`/`Ry`; that is most likely where the original mistake came from. ⚠️ Byte offsets are unchanged — still 16×2 at bit 5.0 — so `xbox_proto`'s layout tests and the host-side packing are untouched. This is a pure relabelling. 🛑 THE REAL LESSON IS THE HARNESS, AND IT IS FIXED HERE TOO. This survived every bench measurement because `dualsense-windows-test` drove LS-X and the A button and left the other five analogue axes at zero. `XInputGetState` read `RX [0..0]`, which I read as "the devtest doesn't move it" — true, and useless: a harness that exercises one axis cannot tell "this axis is not mapped" from "nothing is driving it", and the two are indistinguishable in every consumer. The devtest now sweeps all six axes on distinct phases and ramps both triggers, so one run shows which axes arrive AND that they are not crosstalking onto each other's bytes. MEASURED ON .173, same run shape before and after, devtest sweeping all six axes: before: LX [-11264..24576] LY [-32768..31744] RX [0..0] RY [-1..-1] LT [0..248] RT [7..255] after: LX [-8192..26624] LY [-32768..31744] RX [-32768..31744] RY [-24576..10240] LT [0..248] RT [7..255] VERIFIED * `cargo test -p pf-inject --lib` 104/104 on Windows; `xbox` subset 11/11 on macOS — the layout tests still pass because nothing moved. * Driver rebuilds and signs; the descriptor is still 223 bytes so the `wReportLength` const assert is undisturbed. * `cargo fmt --all --check` clean. NOT VERIFIED * Not yet re-tested in a real streaming session — that is the next on-glass run. * ⚠️ A leftover finding from the same session, unrelated to this fix and NOT investigated: the session's pad devnode SURVIVES client disconnect and keeps the `Global\pfds-boot-0` bootstrap mailbox, so a devtest run afterwards fails with `Zugriff verweigert (0x80070005)` and silently measures the stale pad instead. Restarting the service releases it. Worth its own look. |
||
|
|
4f9071b980 |
feat(pads/windows): three Xbox identities — Wireless, One S and Elite Series 2
Until now there was one Xbox identity, `device_type = 4` / `045E:0B13`, and Windows folded a client's `XboxOne` request onto it because the only Windows Xbox backend was the XUSB companion, which presents one fixed 360 identity and cannot vary it. The HID backend can, so the fold goes and two identities join it: devtype 4 045E:0B13 pf_xboxwireless Xbox Wireless Controller devtype 5 045E:02FD pf_xboxones Xbox Wireless Controller (One S) devtype 6 045E:0B22 pf_xboxelite Xbox Elite Wireless Controller Series 2 `GamepadPref::XboxElite` takes wire byte 11 — the first unassigned one, and the round-trip test previously asserted `from_u8(11) == Auto` with a comment saying assigning it must update that; the sentinel moved to 12. The C ABI mirror and the generated header moved with it. ⭐ ALL THREE SHARE ONE REPORT DESCRIPTOR, deliberately. In HID terms they are the same pad; the descriptor is the report shape, not the identity. §3 of the handoff records that our single hand-written descriptor already cost three separate bugs, and inventing two more would multiply that debt for no measured gain. They differ in VID/PID, product string, hardware id and Device Manager description only. ⚠️ All three install `pfGamepadXbox`, the section that attaches the `xinputhid` bus filter. That was the open risk: Microsoft's `xinputhid.inf` promotes by an explicit hardware-id allow-list containing `02D1, 02DD, 02E3, 02EA, 0B00, 0B0A, 0B13, 02FF` — and NEITHER `02FD` NOR `0B22` is on it. Measured on .173: promotion does not care, because it comes from our own AddReg rather than from matching Microsoft's ids. All three gain `IG_00`, register an XUSB interface, and are read live by classic XInput. Had this gone the other way the two new identities would have been strictly worse than the one they joined. The XUSB escape hatch needed a runtime degrade to stay honest. `pick_gamepad` is compile-time only, so with `PUNKTFUNK_XBOX_BACKEND=xusb` the host would have resolved and echoed `xboxelite` in its `Welcome` while actually building a 360 pad. `degrade_xbox_identity` folds the identity back at runtime, mirroring `degrade_if_no_uhid`. VERIFIED ON WINDOWS (.173 — none of this compiles on macOS; the driver needs the WDK and the rest is `cfg(windows)`): * `cargo test -p pf-inject --lib` 104/104 — including `hwid_matches_inf`, `hwid_devtype_table_matches_the_driver` and `only_the_xbox_identity_installs_the_xinputhid_section`, all now sweeping the whole identity set and asserting the section split in both directions. * `cargo test -p punktfunk-core --lib gamepad` 7/7; `cargo check -p punktfunk-host` clean. * Driver builds and signs; the descriptor/`wReportLength` const asserts still hold with the descriptor shared three ways. * ON GLASS, per identity, via the new `--xboxones` / `--xboxelite` devtest legs: each gets its own devnode (`PF_XBOX_0` / `PF_XBOX_ONES_0` / `PF_XBOX_ELITE_0`), each HID child gains `IG_00`, each registers an XUSB interface, and XInput reads each live (packets advancing, `buttons=0x1000`). * macOS: `cargo fmt --all --check` clean in both workspaces. NOT VERIFIED / NOT DONE * **Elite paddles are NOT implemented.** `BTN_PADDLE1..4` would need descriptor buttons, and once `xinputhid` promotes the pad it claims the HID collection exclusively — XInput has no paddle fields and the HID consumers that do may be locked out, so the buttons would likely reach nobody. The decisive measurement is cheap and named in the code: hold a paddle bit set and see whether a user-mode HID reader still gets reports. Until then the Edge remains the only virtual pad with native back-button slots and nothing should be advertised otherwise. * **No client picker offers the Elite**, and none can auto-detect it — SDL3's `GamepadType` has no Elite variant. It is reachable today only via `PUNKTFUNK_GAMEPAD=xboxelite` or a hand-edited client setting. All five clients ship the same curated six options by deliberate parity, so adding one is a cross-client UX change, not part of this. * Nothing here has run in a real streaming session; every measurement came from the devtest. |
||
|
|
1317901122 |
Merge pull request 'Uninstalling the Windows host left every audio device it minted behind forever — and the installer script documented that as a decision' (#145) from worktree-win-audio-uninstall-cleanup into main
android / android (push) Failing after 1m32s
ci / rust-arm64 (push) Successful in 1m53s
apple / swift (push) Successful in 1m34s
ci / bun-nix (push) Successful in 22s
ci / web (push) Successful in 1m48s
ci / docs-site (push) Successful in 1m44s
deb / build-publish-client-arm64 (push) Successful in 1m44s
deb / build-publish (push) Successful in 4m5s
ci / rust (push) Successful in 7m24s
apple / screenshots (push) Successful in 5m54s
arch / build-publish (push) Successful in 9m40s
deb / build-publish-host (push) Successful in 7m28s
windows-host / package (push) Successful in 13m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders-arm64cross (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m4s
docker / deploy-docs (push) Failing after 6m14s
Reviewed-on: #145 |
||
|
|
bd5735b803 |
feat(pads/windows): make the HID Xbox pad the default, and carry the trigger motors on the wire
Three changes that only make sense together: the HID backend becomes the default now that it is a
superset of the XUSB one, the rumble datagram grows the two Xbox impulse-trigger motors, and the
INF-shape tests learn about the Xbox identity's own install section.
WP-E — `PUNKTFUNK_XBOX_BACKEND` now defaults to `hid`; `=xusb` is the escape hatch.
The knob existed for exactly one reason, recorded in its own doc comment: the HID pad could not
reach classic XInput, so defaulting to it would trade a known-working path for an unproven one.
That objection is gone — with the `xinputhid` bus filter the INF now attaches, the HID pad is
promoted like real hardware and keeps classic XInput while gaining everything XUSB never had
(Steam, SDL, RawInput, DirectInput, joy.cpl, WGI) plus rumble, which XUSB could not source at all.
The escape hatch stays because promotion leans on Microsoft's inbox `xinputhid.inf`; if a servicing
update changes it, one env var restores the old behaviour with no reinstall. An unrecognised value
takes the DEFAULT rather than the opt-out, so a typo cannot silently drop a user onto the path with
no HID collection.
WP-D — the `0xCA` rumble datagram gains a v3 form:
v1 7 B: [0xCA][u16 pad][u16 low][u16 high]
v2 10 B: … [u8 seq][u16 ttl_ms]
v3 14 B: … [u16 lt][u16 rt]
v3 is built FROM v2's bytes rather than restating the layout, so the prefix relationship is
structural instead of a convention two encoders have to keep agreeing on, and every reader gates
with `>=`. The four levels share one seq and one ttl on purpose: they are one statement of the
pad's feedback at one instant, and sharing means the whole v2 apparatus — renewal cadence, stop
burst, the client's seq gate, the lease clamp — governs the triggers with no new code. The new
`RumbleUpdate` fields are plain `u16`, not `Option`: on a level-triggered plane "absent" must mean
zero, because "absent → keep the previous value" is the stuck-rumble bug in a new costume.
Only one backend can ever source them — the Windows HID Xbox pad, whose output report 0x03 carries
them. `XINPUT_VIBRATION` and evdev `FF_RUMBLE` have two members and no third, so every other
producer sends `lt = rt = 0`.
⚠️ The two TRIGGER `enable`-mask bits remain CONJECTURE. Bits 2/3 = left/right handle are measured;
bit 0/1 = the triggers are inferred from field order and nothing else. `parse_xbox_output` says so
inline, and no test asserts them — every test vector uses masks (0xFF, 0x00, 0x0C, 0xF3) whose
expectations hold whichever bits turn out to be right. XInput cannot settle this: it has two
motors.
The INF tests — `hwid_matches_inf` matched the install section by the exact string `=pfGamepad,`
and so stopped seeing the Xbox hardware ids the moment that identity moved to its own
`pfGamepadXbox` section. It failed loudly, which is the good outcome; it is now prefix-matched and
tolerant of further per-identity sections. Added
`only_the_xbox_identity_installs_the_xinputhid_section`, which asserts the split in BOTH
directions: the Xbox line must not install the shared section, and no other line may install the
Xbox one. Merging them back is a one-line edit that looks like tidying and would hand a DualSense
to Microsoft's Xbox translator.
VERIFIED
* ON WINDOWS (.173, the only place this code compiles): `cargo test -p pf-inject --lib` 104/104,
including the new trigger tests and both INF tests; `cargo check -p punktfunk-host` clean.
* macOS: `cargo fmt --all --check` clean; `cargo test -p punktfunk-core --features quic` rumble
suite 22/22, including v3 round-trip and v3<->v2 cross-version parsing.
* The pre-existing `c_abi_harness_round_trips` failure on macOS is `ld: library 'opus' not found`
and reproduces with these changes stashed.
NOT VERIFIED
* No trigger rumble has ever been observed end to end — nothing can drive it yet (see the
conjecture note above), and no client renders it.
* The default flip has NOT been exercised in a real streaming session; every measurement so far
came from the devtest harness. That is the on-glass run.
* Non-Rust clients do not decode v3. They are blocked on a C ABI entry point first
(`punktfunk_connection_next_rumble_cmd` has fixed out-params, ABI_VERSION 17); Apple could
render it via GCHapticsLocality.leftTrigger/.rightTrigger, Android structurally cannot (its
packed jlong is full) and has no trigger actuators anyway.
|
||
|
|
d87a8df28d |
fix(windows): uninstall removes the audio devices the host mints
ci / bun-nix (pull_request) Successful in 25s
ci / web (pull_request) Successful in 1m21s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m37s
android / android (pull_request) Successful in 6m12s
ci / rust (pull_request) Successful in 7m37s
The field report: uninstalling punktfunk left "Punktfunk Speakers", "Punktfunk Microphone" and the per-pad "Wireless Controller" endpoints sitting in Windows' Sound settings forever. They have no installer payload behind them, which is why nothing in the uninstall touched them. The host mints them at RUNTIME as extra devnodes on Valve's streaming-audio drivers, and both providers deliberately re-resolve their devnode across restarts instead of re-minting it — so they persist by design. Persistent across restarts must not mean permanent: the .iss even documented leaving them behind as a decision. New `driver uninstall --audio` leg (a third Inno [UninstallRun] entry, after the two driver legs and well after `service uninstall`, since a live host re-mints on its next wiring pass): * restores the default playback device first, if a host that died mid-stream left it parked on our loopback sink — otherwise Windows re-picks by its own ranking rather than giving the operator back the device they had; * removes every MEDIA-class devnode carrying one of our three durable owner markers (pad slot, minted role, probe), phantoms included; * deletes each endpoint's MMDevices record, resolved through the devnode link BEFORE the devnode goes. Marker-matched, never name-matched: our instances are name-identical to Steam's own, and Steam's devnodes, its drivers, and a VB-CABLE from the era when we bundled one carry no marker and stay untouched. A ROOT\ enumeration guard means a marker-shaped value on a real sound card can never cost the user their hardware. The registry half is best-effort: those keys are SYSTEM-owned and the uninstaller runs elevated but as a user, so on a stock box the record survives as an inert NOTPRESENT entry that Sound settings only shows behind "Show Disconnected Devices". The device itself is gone either way, and seizing ownership of SYSTEM registry keys from an uninstaller is a worse thing to ship than that scrap. |
||
|
|
46390739d8 |
fix(pf-vdisplay): the box's OWN session unit needs the gamescope bind too
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 5m40s
nix / flake (pull_request) Failing after 19m15s
ci / bun-nix (pull_request) Successful in 20s
ci / docs-site (pull_request) Successful in 1m1s
ci / web (pull_request) Successful in 1m11s
ci / rust-arm64 (pull_request) Successful in 1m28s
ci / rust (pull_request) Successful in 6m26s
`launch_session` spawns a transient unit and can hand `systemd-run` the `BindReadOnlyPaths` directly, but a box that owns an autologin `gamescope-session-plus@<client>.service` is RESTARTED IN PLACE instead — no `systemd-run`, so that path kept running Nobara's hardcoded `/usr/bin/gamescope` and the previous commit fixed only half the problem. Found on the box: after a reboot the host took the `ensure_box_gamescope_mode` path (the autologin unit was live) rather than the managed one. Deliver the same two fixes as a drop-in on that unit — the bind, and the WSI opt-out when the box's layer was built for a different gamescope — plus `PF_HZ`/`PF_HDR_ARGS`, which the wrapper reads and would otherwise default to 60 Hz. `daemon-reload` before the restart or systemd runs the old unit. Best-effort: a failure to write it must not block a restart that would otherwise work, and it is a no-op on a box already resolving to `/usr/bin/gamescope`. ⚠ REMOVED on restore, deliberately. Leaving it would put the patched build — and our HDR and cursor flags — under the user's ORDINARY game mode, which is exactly what `packaging/gamescope/README.md`'s "sits BESIDE the distro package" rule exists to prevent. The bind is ours only for as long as we are driving the session. `ensure_box_gamescope_mode` grows an `hdr` param to build those args; both call sites already had it in scope (`self.hdr`, and `create_managed_session`'s parameter). Gate: `scripts/xcheck.sh linux clippy` clean (0 warning/error lines), `cargo fmt` clean. |
||
|
|
3500e95660 |
fix(pf-vdisplay): make Nobara's session run the patched gamescope, and stop the WSI layer killing every client
Two independent reasons a Nobara box could never stream from a gamescope session,
both found on glass (VM 123, Nobara 44, RTX 5070 Ti).
**1. The session ran a stock gamescope, so the host refused it.**
Nobara's `gamescope-session-plus` builds its command as
GAMESCOPECMD="/usr/bin/gamescope \
and reads `GAMESCOPE_BIN` NOWHERE. All three of our spawn levers miss at once: the env
var is ignored, and an absolute path cannot be redirected by a PATH shim. So the session
ran stock gamescope, the capability probe rejected it, and every session died with
"pipeline build failed (out of retries) … it ignored GAMESCOPE_BIN / the PATH shim".
`~/.gamescope-cmd.log` — which the script writes with the exact command it ran — settles
that in one line, and is the first thing to read on any such report.
Fixed by binding our wrapper over `/usr/bin/gamescope` inside the transient unit's mount
namespace (`BindReadOnlyPaths`). Deliberately a bind, not a replacement: punktfunk-gamescope
ships under its own name precisely so it sits BESIDE the distro package, and the bind is
scoped to the session — nothing outside it sees the redirect and nothing is written to
`/usr`. Skipped when the resolved binary already IS `/usr/bin/gamescope`.
**2. With the patched gamescope finally running, every Vulkan client died — black screen.**
The box's `VkLayer_FROG_gamescope_wsi` ships with the DISTRO's gamescope and speaks its
`gamescope_swapchain` protocol. Ours disagrees, so the compositor rejects the client's
`swapchain_feedback` ("message too short") and drops it. Steam never paints; there is no
other symptom, which is what makes it expensive to find.
Measured with `vkcube` under each build, layer on:
ours 3.16.25-17 ON -> 1 rejected client
ours 3.16.25-17 OFF -> 0
OLD pin 3.16.25-4 ON -> 1 rejected client
stock 3.16.23.2 ON -> 0
⭐ The upstream protocol XML is BYTE-IDENTICAL between the distro's commit (5cdb5b0) and
our pin — same interface version, same `uuuuuus` signature — so this is the distro patching
gamescope, not a version bump. Hence the gate is "do the upstream triples differ", not a
floor, and an unreadable version on either side leaves the layer alone rather than degrading
a box that works (Bazzite/SteamOS, where it has always been fine).
⚠⚠ The old pin fails identically, so REVERTING the pin bump fixes nothing here — this is
pre-existing, not a regression from 5fb8dce4.
Verified against the UNPATCHED distro script, reproducing exactly what this code emits:
the session's own log reports `punktfunk-gamescope version 3.16.25-17-ga87390d+pfhdr4`,
with 0 swapchain_feedback errors, 0 client-communication errors and 0 aborts.
Gate: `scripts/xcheck.sh linux clippy` clean (0 warning/error lines), `cargo fmt` clean.
Non-vacuity re-verified per the xcheck note — a planted type error in the new function
produced 3 errors, and removing it went back to Finished.
Still open, deliberately NOT addressed here: a 10-bit HDR stream aborts gamescope in
`destroy_buffer` (upstream `pipewire.cpp:88`), which is a separate defect.
|
||
|
|
003ce8bea7 |
Merge pull request 'Every NVIDIA gamescope HDR stream had red and blue swapped — and a sysext step added in a release was unreachable forever' (#143) from worktree-hdr-rb-swap-nvidia into main
arch / build-publish (push) Failing after 3s
ci / rust (push) Failing after 2s
ci / rust-arm64 (push) Failing after 2s
deb / build-publish (push) Failing after 3s
deb / build-publish-host (push) Failing after 0s
deb / build-publish-client-arm64 (push) Failing after 1s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
ci / bun-nix (push) Successful in 31s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 22s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
apple / swift (push) Successful in 1m40s
ci / web (push) Successful in 1m8s
ci / docs-site (push) Successful in 1m16s
docker / builders-arm64cross (push) Successful in 16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m28s
docker / deploy-docs (push) Failing after 1m41s
android / android (push) Successful in 5m38s
apple / screenshots (push) Successful in 5m53s
windows-host / package (push) Successful in 16m59s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m1s
Reviewed-on: #143 |
||
|
|
d2a2bcc25d |
feat(drivers/pf-xusb): answer the async input wait, and put xinputhid on the stack
The two things this driver's README has always listed as the missing WGI/GameInput work, both user-mode, neither needing a bus driver: `IOCTL_XUSB_WAIT_FOR_INPUT` is now pended on a manual queue and completed by the periodic timer on a dwPacketNumber edge, answering with the same 29-byte GET_STATE payload the synchronous path serves. Declining it was enough for classic xinput1_4, which just falls back to sync GET_STATE polling — that is why the pad has always worked there. It is not enough for WGI/GameInput, which poll asynchronously: to them a decline is a refusal, not a fallback. Completion is edge-gated because releasing a waiter on an unchanged packet spins its caller at timer rate. WAIT_GUIDE_BUTTON stays declined — we have no state to signal on. The INF adds UpperFilters=xinputhid on the XUSB devnode. Note the earlier attempt put that filter on the HID child of the *other* backend, which was simply the wrong devnode: XInput does not read HID at all, it enumerates GUID_DEVINTERFACE_XUSB, which is what this driver registers. Verified on .173: build + sign + catalog exit 0; infverif "INF is VALID"; the devnode starts Status OK with UpperFilters=xinputhid readable back from its enum key; and XInput still sees the pad (slot 1 live alongside the box's real Elite in slot 0), so the async queue is no regression to the path that already worked. NOT yet measured: whether WGI/GameInput now admit the pad. `IG_` is the wrong probe for this driver — it is a HID-path artifact and pf-xusb is System-class with no HID child, so its absence says nothing either way. That needs a real WinRT/GameInput enumeration test. |
||
|
|
4e04c2bbf8 |
feat(host/pads): route the Xbox pad to the HID backend behind PUNKTFUNK_XBOX_BACKEND=hid
Wires `xbox_windows` into the per-pad router so an Xbox-family pad can be built as a real HID device instead of the XUSB companion, and adds the knob that selects between them. Opt-in rather than the new default, deliberately. XUSB is what classic-XInput games read today; the HID pad buys the Steam / WGI / GameInput / DirectInput visibility XUSB can never have, but whether Windows promotes it into an Xbox-profile device that XInput and WGI Gamepad accept is still the open question. Flipping the default before that is settled would trade a known-working path for an unproven one. The two backends are mutually exclusive per pad by construction — one match arm or the other — because presenting both hands a game two controllers for one pair of hands. Verified on .173: cargo check -p punktfunk-host exit 0, clippy -D warnings clean, `cargo test -p punktfunk-host gamepad` 8/8 green, fmt clean. |
||
|
|
97928516a0 |
fix(pf-capture): every NVIDIA HDR stream had red and blue swapped
gamescope's capture textures are mappable, hence linear-tiled, and NVIDIA does not implement linear-tiled STORAGE for A2R10G10B10_UNORM_PACK32. Upstream says it plainly in rendervulkan.cpp: "imageStore lands in XBGR order there, swapping R/B". So the composite writes XBGR bytes into a buffer still LABELLED XRGB2101010, and our patch's spa_format_to_drm() derives that label from the negotiated SPA format alone, never asking the hardware what it can actually write. The host then believed the label, correctly at every step: xRGB_210LE -> PixelFormat::X2Rgb10 -> NV_ENC_BUFFER_FORMAT_ARGB10. DRM XRGB2101010 really is "B in the low 10 bits" and NVENC ARGB10 really is "B in the lowest 10 bits"; the Windows twin (R10G10B10A2 -> ABGR10) is correct by the same rule. Every mapping audits clean because the label was right and only the CONTENT was wrong -- which is why this survived a full trace of both ends. Fix the preference host-side: offer xBGR_210LE FIRST. The first compatible consumer pod wins, so that is what a gamescope session lands on, and an XBGR2101010 texture is one NVIDIA writes in its own order -- label and content agree. It costs nothing elsewhere: A2B10G10R10_UNORM_PACK32 is the universally supported packed-10 format, it is what upstream's own fallback picks, and X2Bgr10 has a first-class encoder path (NVENC ABGR10, VAAPI X2BGR10LE). xRGB_210LE stays as the second pod so a producer offering only it can still negotiate HDR instead of dropping to the SDR downgrade. Doing it here rather than in the patch set is deliberate: the real fix is for spa_format_to_drm() to offer only what vulkan_get_rgb10_capture_format() reports, but that function landed after 3.16.25 and the pin is 3.16.25-7-g60561e2+pfhdr4 (0 "2101010" strings in the shipped binary), so the deployed gamescope cannot self-correct. This ships in the host binary with no gamescope rebuild. Field-confirmed on the RTX 5070 Ti Bazzite host with 0.26.0, and confirmed host-side rather than client-side by reproducing the identical swap from two unrelated clients (16" MacBook Pro and Mac Studio). SDR was never affected -- it takes no packed-10 path. Gate (pf-lxcheck2, linux/amd64): fmt clean, clippy --all-targets -D warnings clean, cargo test -p pf-capture 60 passed / 0 failed incl. the new hdr_offers_xbgr_before_xrgb order pin. |
||
|
|
4a4118e3ce |
feat(pf-encode): encode PyroWave in a capability-carrying worker, so the host never holds a capability
PyroWave encodes on the same GPU shader cores the game saturates, and an elevated VK_KHR_global_priority queue is the compute-preemption lever for it — measured on .21 (RTX 5070 Ti, GRID 2 loop): encode p99 6.4 -> 4.4 ms. Every driver refuses every priority class without CAP_SYS_NICE, on NVIDIA and on RADV alike, so the lever is decoration on a packaged host. 0.26.0-1 granted that capability to punktfunk-host and killed desktop streaming on every KDE box: KWin identifies a client by resolving /proc/<pid>/exe and matching an installed .desktop's Exec=, the kernel refuses that readlink to a reader whose effective set is not a superset of the target's PERMITTED set (cap_ptrace_access_check), and KWin holds no capabilities. #136 revoked it everywhere. The capability therefore cannot live in the process that fronts KWin. It lives in a new, deliberately small binary — punktfunk-encode-worker — which owns the priority-elevated Vulkan device and talks to nothing but the socket its parent spawned it on: no Wayland, no D-Bus, no network, no plugins. It is a SEPARATE FILE and must stay one; a hardlink or a hidden host subcommand shares the inode, hence the capability, and silently re-creates the incident. That rule is written where someone would break it, in the worker crate's own Cargo.toml. `open_inner` is reused verbatim in the worker — the same REALTIME->HIGH->none ladder, the same refusal-never-fails-open invariant, the same PUNKTFUNK_PERF split — so the A/B stays comparable with PW1. The only in-process change is a flag for whether THIS process prints the INERT warn, plus an out-parameter reporting the class that was granted. Three things the design did not anticipate: * An AU cannot ride in the message body. MAX_MSG is 64 KiB and bodies are serde_json, which renders a Vec<u8> as one decimal per byte: a 1080p60 AU is ~333 KB of JSON and 4K ~3.3 MB, and the minimum per-frame budget is already 64 KiB. So the AU crosses on a memfd the worker creates once and pwrites each frame; the fd crosses once, in Ready. A test pins the arithmetic so nobody "simplifies" the memfd away. Cursor bitmaps take the same route, only when their serial changes. * set_wire_chunking has to cross the wire even though poll_chunk does not. Chunking changes the AU BYTES, not merely how they are handed out — it feeds rate_budget()'s deflation and build_au's windowed framing — so a proxy-local copy would have the host cutting dense AUs at boundaries that are not window boundaries. Forwarded and mirrored. poll_chunk itself needs no protocol: the identical AuChunker runs host-side on the whole AU the worker returns. * CPU-backed frames really do reach this encoder (force_cpu_for_nvenc_444, and the raw-dmabuf degrade latch), and a 1080p BGRA frame is ~8 MB. The first non-dmabuf frame pins the session in-process with one warn rather than putting 480 MB/s on a socket. Every rung falls back to the in-process encoder exactly as today with one warn and never a dead session: PUNKTFUNK_ENCODE_WORKER=off, binary missing, spawn failure, handshake timeout, proto or workspace-version skew (host and worker are different files now, so that check is load-bearing), InitErr, a refused frame, and socket EOF mid-session — which respawns once, then pins inline. Also: recv retries EINTR with the REMAINING deadline, not a fresh one. With SO_RCVTIMEO the kernel returns EINTR rather than restarting, so a signal would otherwise read as a dead worker; re-arming with the full budget would instead let a steady signal rate defer a real hang forever. |
||
|
|
dcfba07803 |
refactor(pf-zerocopy): split the worker rails out of the zerocopy vocabulary so a second worker can reuse them
The encode worker (design/gpu-priority-capability-worker.md) needs exactly what the zerocopy worker
already has — SEQPACKET framing, fds as SCM_RIGHTS, a pinned-exe spawn that survives an on-disk
replacement, and a reaper that never blocks session teardown on a wedged child — but it must NOT
inherit the zerocopy protocol. Its messages are its own and version independently.
So `imp/proto.rs` keeps the vocabulary (PROTO_VERSION, ImportKind, Request, Reply, BufferDesc) and
all transport moves to `imp/ipc.rs`, reachable as `pf_zerocopy::ipc`. No behaviour change for the
zerocopy worker: client.rs now calls `ipc::self_exe()`/`ipc::spawn_worker()` and keeps the same fd-3
dup2 slot, PR_SET_PDEATHSIG, kill-then-reap-outside-the-lock, bounded reap with a D-state re-park,
and per-generation zombie sweep it had before.
Two real changes underneath the move:
* The cmsg store was sized for exactly one fd (CMSG_SPACE(4) = 24 B). A multi-planar dmabuf can
carry up to four, so it is now CMSG_SPACE(4*4); `send_fds`/`recv_fds` take a slice while `send`
and `recv` keep their single-fd shapes as the fast path. An over-long fd list is rejected with
io::Error rather than asserting — that is how MAX_MSG overflow is already handled — and the
receive cap is enforced by the kernel through msg_controllen, so a 5-fd peer trips MSG_CTRUNC.
* The old recv loop read only the FIRST i32 of each SCM_RIGHTS control message. Nothing sends two
fds yet so it never fired, but every descriptor after the first in a multi-fd message would have
leaked into the process. It now reads all of them.
Spawn takes the executable path as a parameter instead of assuming /proc/self/exe. The zerocopy
worker keeps self-exec; the encode worker passes its own binary, which must be a separate FILE and
never a subcommand — a shared inode shares the file capability.
|
||
|
|
99f2130b28 |
feat(host/pads): the Windows Xbox backend, compiled and tested on Windows
Adds `xbox_windows` — the host half of the HID Xbox pad: the sealed-channel open under the Bluetooth identity (SwDeviceCreate `pf_xboxwireless` + `USB\VID_045E&PID_0B13`, so hidclass derives the real-pad `HID\VID_045E&PID_0B13` child ids), device_type 4 stamped before the magic, and the `PadProto` impl that publishes through `xbox_proto`. No rich plane: an Xbox pad has no touchpad, lightbar, adaptive triggers or IMU in its HID contract, so apply_rich/clear_rich/neutralize_gyro are deliberately no-ops. Rumble comes back off the driver's republished output reports. The Bluetooth rumble report carries magnitudes on a 0..100 scale, not 0..255 — assuming otherwise silently costs 60% of the range — and the enable mask gates each motor independently. The two INF/driver guard tests now cover the new identity. `hwid_devtype_table_matches _the_driver` caught the addition on its vacuity count, which is exactly what it is for. Verified on the Arc laptop (.221, Win11 26200): `cargo test -p pf-inject --lib` 100/100 green, `cargo clippy --lib --profile test -- -D warnings` clean, fmt clean. Note `clippy --all-targets` fails there on a PRE-EXISTING issue unrelated to this change — tests/motion_contract.rs imports the linux-gated `switch_proto`. Still unbuilt: the driver itself (.221 has no WDK) and the host routing that would send an Xbox pad here instead of to XUSB. The report descriptor remains constructed rather than captured — diff it against a real pad before shipping. |
||
|
|
f266636392 |
feat(host/pads): an Xbox pad on Windows becomes a real HID device, so Steam can see it
`pf-xusb` registers only GUID_DEVINTERFACE_XUSB and exposes no HID collection, so Steam's hidapi enumeration, DirectInput, joy.cpl and WGI/GameInput cannot see the pad at all — only classic XInputGetState via xinput1_4's interface walk ever does. A reporter spent two weeks on a dead controller for exactly that reason; switching the client to DualSense, a real HID pad through the pf-gamepad UMDF driver, fixed it in seconds. This gives the Xbox pad that same footing: a new device_type 4 on the existing HID minidriver, identified as a Bluetooth Xbox Wireless Controller (045E:0B13). The wired ids the tree already uses (045E:028E, 045E:02EA) are vendor-class XUSB/GIP devices with no HID interface on real hardware, so a HID child claiming one is a device that has never existed and has nothing for Windows to promote. Driver: identity, a constructed 132-byte Game Pad report descriptor, neutral report, strings and the pf_xboxwireless hardware id. Host: `xbox_proto`, the byte-exact codec mirroring that descriptor, with 11 layout tests. One shared-path fix falls out. The timer completed every pended READ_REPORT with the full 64-byte slot, and `copy_to_output` REFUSES a source longer than hidclass's buffer rather than truncating it — so a pad declaring a shorter report would have failed every read and looked dead. Report length is now per-identity; it returns 64 for all four pre-existing pads, so their behaviour is provably unchanged. NOT BUILT AND NOT RUN ON WINDOWS — no box was reachable. The Rust codec and its tests pass on macOS; the driver, the INF and the report descriptor have never been compiled, infverif'd, or seen by a real pad. The descriptor is constructed rather than captured, which matters because we claim a real Microsoft VID/PID and SDL/Steam/Windows carry stock mappings keyed off it — diff it against a capture before shipping. |
||
|
|
e8a4f54c07 |
fix(pf-vdisplay): the capability-hint test asserted the environment, not the code
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 5m0s
ci / bun-nix (pull_request) Successful in 42s
ci / docs-site (pull_request) Successful in 1m21s
ci / web (pull_request) Successful in 1m48s
ci / rust-arm64 (pull_request) Successful in 2m47s
ci / rust (pull_request) Successful in 6m57s
`silent_without_capabilities` called the real `capability_denial_hint()` and
asserted it returns "", on the strength of a doc comment that read "The test
process has no capabilities."
That is true on a dev box and false in CI, where the runner container is root
with a full permitted set. main went red on
|
||
|
|
42ee6c5628 |
fix(packaging): the host's CAP_SYS_NICE made it invisible to KWin, killing every KDE session
0.26.0-1 setcap'd `cap_sys_nice=ep` on /usr/bin/punktfunk-host so the encoder could open an
elevated global-priority Vulkan queue. On every KDE box that ended desktop streaming outright:
KWin virtual output failed: KWin does not expose zkde_screencast_unstable_v1 to this client
reported from CachyOS on NVIDIA and on AMD, surviving a clean reinstall of host and client, and
worked around only by KWIN_WAYLAND_NO_PERMISSION_CHECKS=1.
The two cannot coexist. KWin hands out its restricted protocols — zkde_screencast_unstable_v1,
which mints our virtual output, and org_kde_kwin_fake_input, which injects input — only to a client
it can IDENTIFY, by resolving that client's /proc/<pid>/exe and matching it against an installed
.desktop's Exec=. The kernel refuses that readlink to any reader whose effective set is not a
superset of the target's PERMITTED set (cap_ptrace_access_check), and KWin holds no capabilities.
So the instant the binary carries one, KWin's executablePath() is empty, nothing matches, and the
global is never advertised — presenting exactly as a missing or mis-installed .desktop file.
Measured on CachyOS (kernel 7.1.6), same-uid reader, cap_sys_nice=ep on the target:
no capability .............................. readlink /proc/<pid>/exe OK
capability ................................. EPERM
capability + prctl(PR_SET_DUMPABLE, 1) ..... EPERM <- dumpable is NOT the gate
capability dropped + PR_SET_DUMPABLE(1) .... OK <- only an uncapped process works
The third row also rules out the reflex fix of moving the grant to systemd AmbientCapabilities=,
which lands CAP_SYS_NICE in the very same permitted set. Nothing short of not holding the
capability restores identification, so the host does not get one.
The cost is pacing only. pf-zerocopy's device create already walks REALTIME -> HIGH -> default when
a priority class is refused, and pf-frame's thread nice is a documented best-effort no-op without
the capability — so this is 0.25.0's behaviour exactly, which is the behaviour that worked.
* packaging/arch/punktfunk-host.install: grant -> revoke. post_upgrade strips the capability from
boxes that already ran 0.26.0-1's scriptlet. A pacman upgrade writes a new inode and file
capabilities do not survive that, so this is belt-and-braces for reinstall/downgrade paths.
* pf-vdisplay kwin.rs: all three "KWin does not expose zkde_screencast" errors now read
/proc/self/status and, if this process holds ANY capability, name it with its CapPrm mask and
the `setcap -r` that repairs it. The failure stays impossible to diagnose from the Wayland side
otherwise, and it is not unique to our own packaging — a hand-rolled setcap does it too.
Verified on 192.168.1.21 (CachyOS): the capability/dumpable matrix above; cargo check and
cargo clippy --all-targets -- -D warnings clean for pf-vdisplay; both new unit tests pass; and the
hint itself exercised end-to-end, silent uncapped and firing with CapPrm=0x0000000000800000 under
cap_sys_nice=ep. The shipped punktfunk-host-0.26.0-1-x86_64.pkg.tar.zst was unpacked to confirm its
.INSTALL carries the setcap on both post_install and post_upgrade.
Ships as 0.26.0-2 — packaging plus one crate, no version bump.
|
||
|
|
2079411f4f |
fix(pf-encode): the Windows host could not compile — a Linux-only reader tripped dead_code
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m24s
android / android (pull_request) Successful in 5m55s
ci / rust-arm64 (pull_request) Successful in 4m5s
ci / bun-nix (pull_request) Successful in 33s
ci / docs-site (pull_request) Successful in 1m32s
ci / rust (pull_request) Successful in 21m44s
The v0.26.0 tag went red on windows-host at the clippy step, after a clean
build:
error: function `wire_sequence` is never used
--> crates\pf-encode\src\enc\pyrowave_wire.rs:68:15
= note: `-D dead-code` implied by `-D warnings`
`pyrowave_wire` is cfg'd for linux OR windows and is genuinely shared —
`packet_boundary` and `stamp_color_bits` each have callers on both backends.
`wire_sequence` does not: every call site is in `enc/linux/pyrowave.rs`, which
is `#[cfg(all(target_os = "linux", feature = "pyrowave"))]`. Alternating
encoder handles are a Linux-side concern (PW5); the Windows backend drives
pyrowave's compat device with a single handle and never needs the counter. The
module's own `#[cfg(test)]` block does not reference it either, so on Windows
the item has zero callers in every target and dead_code is correct — it is the
`-D warnings` promotion to a hard error that stops the lib compiling.
Scoped to the one item rather than the file, and expressed as
`cfg_attr(not(target_os = "linux"), ...)` rather than a bare `allow`, so
dead_code stays LIVE on Linux — where the caller lives, and where this function
quietly losing its last caller would be a real finding rather than noise.
⚠ Not reproducible off a Windows box: cross-compiling to
x86_64-pc-windows-msvc from macOS dies in openh264-sys2's build script
(clang++ rejects `-fPIC` for that target) long before the lint stage. The
mechanism is nonetheless exact — one item, one cfg, zero callers behind it —
and the windows-host and windows-msix legs are the proof.
No behaviour change on any platform: this adds a lint attribute and eight
lines of comment.
|
||
|
|
ebf61cb448 |
Merge branch 'worktree-wave2-pw5-encode-overlap' into worktree-wave2-pyrowave
apple / swift (pull_request) Successful in 1m34s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m9s
ci / bun-nix (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 1m57s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
ci / web (pull_request) Successful in 3m25s
android / android (pull_request) Successful in 5m4s
ci / rust-arm64 (pull_request) Successful in 6m29s
ci / rust (pull_request) Successful in 11m37s
nix / flake (pull_request) Successful in 18m17s
# Conflicts: # crates/pf-encode/src/enc/linux/pyrowave.rs |
||
|
|
4a92c64144 | Merge branch 'worktree-wave2-pw7a-jumbo-shard' into worktree-wave2-pyrowave | ||
|
|
2426056465 | Merge branch 'worktree-wave2-pw6-streamed-au' into worktree-wave2-pyrowave | ||
|
|
d3aaa16a7d |
Merge branch 'worktree-wave2-pw3-dmabuf-latch' into worktree-wave2-pyrowave
# Conflicts: # packaging/arch/punktfunk-host.install # scripts/steamdeck/install.sh |
||
|
|
2dd65bdd41 |
Merge pull request 'Opening the Steam menu on the Deck moved the game too — the pad is now held neutral while an overlay owns it' (#131) from worktree-deck-overlay-input-mask into main
apple / swift (push) Successful in 1m38s
audit / cargo-audit (push) Successful in 1m45s
audit / bun-audit (plugin-kit) (push) Successful in 37s
audit / bun-audit (sdk) (push) Successful in 30s
audit / bun-audit (web) (push) Failing after 26s
audit / docs-site-audit (push) Successful in 26s
audit / pnpm-audit (push) Successful in 11s
arch / build-publish (push) Successful in 9m2s
ci / rust-arm64 (push) Successful in 3m6s
android / android (push) Successful in 10m27s
audit / license-gate (push) Successful in 5m59s
ci / web (push) Successful in 1m30s
ci / bun-nix (push) Successful in 58s
ci / docs-site (push) Successful in 1m56s
release / apple (push) Successful in 9m51s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m52s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
windows-host / package (push) Successful in 12m31s
windows-host / winget-source (push) Skipped
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
apple / screenshots (push) Successful in 5m48s
deb / build-publish-client-arm64 (push) Successful in 6m51s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m35s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m45s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m13s
deb / build-publish-host (push) Successful in 11m34s
ci / rust (push) Canceled after 18m48s
deb / build-publish (push) Canceled after 12m54s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 8m0s
nix / flake (push) Canceled after 8m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 8m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5m37s
windows-host / canary-manifest (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 1m30s
Reviewed-on: #131 |
||
|
|
5cbaca7789 |
feat(client/pads): stop forwarding the pad while the Steam overlay owns it
ci / bun-nix (pull_request) Successful in 32s
ci / docs-site (pull_request) Successful in 1m21s
ci / web (pull_request) Successful in 1m37s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m19s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 5m50s
android / android (pull_request) Successful in 7m56s
ci / rust (pull_request) Successful in 15m55s
nix / flake (pull_request) Failing after 16m33s
On a Deck in Gaming Mode the Steam menu and the QAM are driven by the SAME physical controller the client forwards, so opening either one moved the game on the host as well as Steam's UI — a second, invisible player. Steam Input masks a normal game here; it cannot mask us, because masking happens on Steam Input's virtual pad and we deliberately forward the REAL one (28DE:1205 — the virtual pad has no gyro, trackpads or paddles). SDL ships the exact behaviour we want and it is on by default: presses are dropped while the process has windows but no keyboard focus, releases still get through. It CANNOT fire on a Deck. gamescope resolves focus per Xwayland ctx and the client sits alone in its own, so the Steam overlay — which lives in the root ctx — never takes our X focus away and no FocusOut is ever generated. Measured on glass: with the QAM open, X input focus inside the client's ctx stayed on its window for the whole 4 s, while GAMESCOPE_FOCUSED_APP flipped to 769 (Steam) and GAMESCOPE_FOCUSED_APP_GFX stayed on the app. So the signal is explicit. `overlay_focus` watches those two atoms on the gamescope root ctx — which is NOT our own $DISPLAY under `--xwayland-count 2`, hence the socket-directory walk and the flatpak filesystem line — and the presenter ORs it with window focus into one `set_masked`. Masking is deliberately not `set_forwarding`: that closes the slot and sends GamepadRemove, so the game would see a controller UNPLUG every time somebody opened the QAM. This keeps every slot open and only stops the transitions, after flushing what the host believes is held so a stick deflected at overlay-open stops steering instead of freezing at its last value. On the way back, held buttons are adopted rather than replayed — the A that picked a QAM row must not fire in the game as it closes — while axes are re-sent, since a stick has no press to ghost and SDL only speaks on change. Fails open throughout: no gamescope, no X, or an unreadable signal all leave forwarding exactly as it was. `PUNKTFUNK_OVERLAY_MASK=0` opts out. |
||
|
|
78ba2342b5 |
Merge pull request 'rom-manager has been putting 0 games in the library since 08-05 — a plugin launch kind, so a scanner can publish tiles the host cannot name' (#129) from worktree-rom-manager-plugin-launch into main
apple / swift (push) Successful in 1m39s
ci / web (push) Successful in 1m45s
ci / docs-site (push) Successful in 1m35s
ci / bun-nix (push) Successful in 42s
ci / rust-arm64 (push) Successful in 2m45s
deb / build-publish-client-arm64 (push) Successful in 1m4s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
deb / build-publish (push) Successful in 3m55s
android / android (push) Successful in 6m31s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 30s
apple / screenshots (push) Successful in 6m8s
ci / rust (push) Canceled after 8m47s
deb / build-publish-host (push) Successful in 8m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 5m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m57s
windows-host / package (push) Successful in 11m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
arch / build-publish (push) Successful in 13m1s
Reviewed-on: #129 |
||
|
|
7d37fe450d |
test(pf-encode): run PyroWave at depth 2 on real hardware — without shipping depth 2
Wave-2 PW5, the stage-6 experiment. Shipped behaviour is UNCHANGED: `max_inflight` is still 1. Stage 6 is the frame-corruption stage, and its gate is an on-glass tear-hunt with a live compositor, a real client and ten minutes of moving content. That is not runnable from here. But the depth-2 risk has two halves, and one of them lives entirely in this crate — the per-slot resources (`cmd`/`fence`/`csc_set`/y/uv/cursor) and the alternating encoder handles — so that half can be answered now, on the GPU, and the answer is worth having before anyone attempts the other. The experiment drives the backend with two frames genuinely in flight (submit N+1, then poll N) and compares the result against the encoder's OWN synchronous output over the same 16 moving frames. Its own depth-1 decode is the honest reference: pyrowave's raw AU bytes are not reproducible run-to-run (see the stage-3 commit), but its decoded planes are. RESULT, .21 / RTX 5070 Ti (GPU idle at 180 MHz of 3090 — the slow-clock worst case on this card): depth-2 vs depth-1 over 16 frames: worst-case PSNR identical (inf) Bit-identical luma, every frame, in order. So stages 4 and 5 between them are sufficient for the encoder side: doubling the six single-slot resources and alternating two `pyrowave_encoder` handles under one monotonic wire sequence really does make overlap invisible to the decoder. The test is built to fail rather than to pass. Content MOVES every frame (flat fills are the documented false-green trap — a torn frame stitched from two halves of a static card is invisible), it asserts two frames were ACTUALLY in flight rather than silently proving nothing, it asserts the AU count is unchanged, and it carries an off-by-one discriminator that raw PSNR would miss: each overlapped frame must match its own reference BETTER than it matches the previous one, so a pipeline delivering frames one position late fails even though every individual PSNR looks fine. It reaches `max_inflight` directly instead of through a shipped knob, precisely so the shipped value stays 1. ⚠ WHAT THIS DOES NOT COVER, stated here so the next person does not read it as a green light for stage 6: the CAPTURE side. `.process` hands the SPA buffer back to the compositor at callback return while the encode thread holds only a dup of its dmabuf fd, so a second frame in flight widens the window in which the producer may overwrite a buffer we are still reading by a full frame period. Nothing in this crate can test that — it needs a live producer. Stages 1 and 2 are what make it answerable (the pool census says how deep the producer's ring is; the Choice range asks for headroom), and the on-glass hunt is what would settle it. Gates green at CI parity. |
||
|
|
077db416ec |
feat(pf-encode): two PyroWave encoder handles, and the 3-bit landmine that makes them work
Wave-2 PW5 stage 5. Depth is STILL 1 — the handles alternate per frame, one in flight. PyroWave's `Encoder` cannot hold two frames. Not "probably not" — structurally not. `Encoder::Impl` owns ONE each of `wavelet_img_high_res`, `bucket_buffer`, `meta_buffer`, `block_stat_buffer`, `payload_data` and `quant_buffer`, and `Impl::encode` OPENS by discarding them: an image barrier with `VK_IMAGE_LAYOUT_UNDEFINED` as the old layout — a written promise that nothing else is reading it — plus three `fill_buffer` clears. Two encodes recorded into two command buffers and submitted to one queue have no execution dependency in Vulkan (submission order orders the START, not the completion), so N+1's DWT would overwrite the wavelet bands and zero the RDO buckets while N's block packing still reads them. Content-dependent, silent. So overlap means TWO handles on one device, alternated — one per slot. Every resource above is then private per handle, and within a handle the encodes stay strictly serialized (a slot's next frame is recorded only after that slot's previous one retired), which leaves patch 0004's scratch-pool invariant intact without touching it. THE LANDMINE, and it is the reason this stage is its own commit: `sequence_count` ALSO lives on `Impl`, and it is the 3-bit counter stamped into every block header. Two handles each count 1,2,3... alone, so the wire sees 1,1,2,2,3,3.... The decoder restarts a frame only when the value CHANGES (`diff = (hdr.sequence - last_seq) & 0x7; restart = diff != 0`), so a repeat reads as MORE BLOCKS OF THE SAME FRAME: `clear()` never runs, `decoded_frame_for_current_sequence` stays true, and the second frame of each pair is swallowed. Half frame rate, occasional mixed-frame blocks, no error anywhere — on every client, since pf-client-core and the Apple Metal hand-port parse the same field. `patches/0007-encoder-sequence-override.patch` (new, ~38 lines) exposes `Encoder::set_next_sequence` + a `pyrowave_encoder_set_next_sequence` C entry + a `PYROWAVE_SEQUENCE_MASK` define, so ONE monotonic counter on the Rust side is stamped regardless of which handle encodes. The setter stores `(seq - 1) & mask` because `Impl::encode` pre-increments — its contract is about the next ENCODE, not the next store. Inert when unused, so the whole Windows backend is untouched. No `.def` change: the C API is a static archive. PREDICTED, THEN OBSERVED. A negative control on .21 (the override call removed, nothing else) reads the wire out at exactly: [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 0, 0, 1, 1, 2, 2] which is the analysis's prediction character for character, and with the override: +1 mod 8, all 20 frames, through the 3-bit wrap. THE GATE, `wire_sequence_increments_across_alternating_handles`, checks three things over 20 frames because any one alone could pass while the stream is broken: the wire counter advances by 1 mod 8; ONE persistent decoder (its `last_seq` carried across every push, exactly like a client's) reports every AU decodable; and consecutive decoded pictures DIFFER. Content moves every frame — and the first run caught a trap in the harness itself rather than the encoder: `test_card` starts its LCG at `seed | 1`, so seeds 2 and 3 build a byte-identical card and the test faked the very repeat it hunts. Odd seeds only now, with the reason written down. A runtime self-check backs the test up where the test cannot reach: after packetize, the stamped sequence is compared against what we asked for, and a mismatch logs once per process naming patch 0007. A re-vendor that loses the patch would not fail to build — it would fail on glass, subtly, and this makes it loud instead. Two byte reads per frame. `reset()` rebuilds both handles and `Drop` destroys both, each with the same null-immediately discipline the single handle had (`pyrowave_encoder_destroy` is a bare `delete` with no null check, so a stale pointer left in the field is a double free). Vendored-patch discipline: patch 0007 re-applies clean to a pristine vendor checkout (verified by stashing the vendor tree and re-applying), and `git diff crates/pyrowave-sys/vendor/` touches exactly the four intended files. VERIFIED ON GLASS (.21, RTX 5070 Ti, GPU idle at 180 MHz of 3090): all 8 `#[ignore]`d GPU tests pass, including the new gate and the 4:2:0 / 4:4:4 / 24-bpp PSNR smokes. Gates green at CI parity. |