The cross-platform half of the gap sweep:
* Phase-locked capture reaches the DESKTOP clients (it shipped on
Apple/Android only, though the desktop presenter has the best latch
signal of all — true on-glass stamps via VK_KHR_present_wait). The
presenter's 1 Hz fold publishes a latch grid (anchor = last on-glass
instant; period = min positive present spacing, capped by the display
mode's refresh so an arrival-paced sub-panel-rate stream can't claim a
slower grid); the session pump folds every AU's arrival stamp against
it with the SHARED `phase::circular_latch` statistic and sends the
~1 Hz PhaseReport (1 ms uncertainty — reference-client parity). The
cap is advertised only when present timing is real
(`VulkanDecodeDevice::present_timing` gates `SessionParams::phase_lock`),
and the host's applied grid offset from the 0xCF tail is logged so an
on-glass run can watch the controller engage.
* `Hello::display_hdr` stops being hardcoded `None`: Windows reads the
panel's colour volume from DXGI (`IDXGIOutput6::GetDesc1`, the
`--window-pos` output else the primary, advanced-color outputs only,
gated on the HDR setting) so the host's virtual-display EDID matches
the real glass. Linux keeps the EDID defaults — no portable
Wayland/X11 query exists — and the comment now says exactly that.
* The console settings screen (the ONLY editor in Gaming Mode) learns
the rows it was missing: render scale, full chroma 4:4:4, invert
scroll, capture system shortcuts, fullscreen-on-stream, auto-wake and
the game-library toggle.
* A spec-run session's device picks (GPU adapter, speaker, microphone)
now come from the `--resolved-spec` instead of a raw Settings load —
the last store read the spec path still owed (§5), and what would
make those fields profileable.
* `session_args()` documents why the GTK/CLI spawners pass no
`--window-pos` (Wayland exposes no global coordinates to read and SDL
can't apply them).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rest of the client-gaps handoff, plus what a parity sweep surfaced:
* Every whole-file settings save rebases on the file first — the GTK
dialog close and its two speed-test apply arms, the Windows
per-control commit, the console screen. The file has five writers and
no merge (profiles.rs documents the debt); saving a shell-lifetime
snapshot silently reverted whatever another writer stored meanwhile,
most visibly the spawner-persisted match-window size (item 4).
* Windows honors the Speaker/Microphone picks: `audio_wasapi` grows
endpoint enumeration (on its own MTA thread — UI threads are STA) and
resolves PUNKTFUNK_AUDIO_SINK/SOURCE as endpoint ids, falling back to
the default when the picked device is gone; the settings page gets
the two rows (defaults scope, "(not detected)" like the GPU row).
`speaker_device`/`mic_device` stop being Linux-only fields (item 5).
* A punktfunk:// link with `launch=` opened a plain desktop session on
Windows — the id was parsed, validated, planned, and dropped at the
last hop. It now rides `initiate_launch` (plus a waking variant for
the dial-first path). Linux always forwarded it.
* The console UI offered "VAAPI" on Windows — a dead option there that
also hid d3d11va, the actual Windows hardware path. The decoder list
is per-OS now.
* The Windows gamepad picker learns "Steam Deck" (the GTK picker had
it; the host-side pad has always existed).
* Doc fixes: gpu.rs named a nonexistent env var (PUNKTFUNK_ADAPTER →
PUNKTFUNK_VK_ADAPTER), and session/Cargo.toml claimed video decode is
Linux-only while explaining what the ARM64 leg drops.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two halves of the same honesty problem (client-gaps handoff items 1+2,
done together as the handoff asked).
The presenter learns full chroma: `vkframe_plane_views` accepts the
2-plane 4:4:4 pool formats (8-bit, and the 10-bit 3PACK16 sibling) —
what NVIDIA's Vulkan Video reports for HEVC RExt decode — with the
accepted set extracted into `vkframe_plane_formats` and pinned by a
decision-table test. The CSC shader needed nothing: its 4:2:0 siting
correction already self-disables when the plane widths match. The VAAPI
leg gets the same treatment (NV24 in `drm_fourcc_for` and the dmabuf
import, full-size chroma plane), and the Vulkan decoder's sw-format
gate admits NV24/P410. 3-plane 4:4:4 stays rejected — it needs a third
CSC binding — and demotes cleanly like every other unsupported format.
Design call (the handoff's fork, argued here as requested): (B)+(C),
not (A). No capability probe gates VIDEO_CAP_444 — software decode is
the guaranteed display floor on both OSes (swscale → RGBA), the decoder
ladder demotes on its own, and a probe-gated bit would turn the switch
inert on exactly the boxes that rely on the fallback. What (A) wanted
from a prediction, the overlay now delivers as ground truth:
The Detailed tier prints the encoder's target next to the measured
rate — `19.4 Mb/s · target 20 Mb/s (auto)` — and the resolved chroma,
`4:4:4→4:2:0` when the host declined the ask (mirroring `HDR→SDR`).
The target is live: `NativeClient::current_bitrate_kbps()` mirrors
every BitrateChanged ack, so an Automatic session's ABR re-targets are
visible as they move. This is the figure whose absence let the
settings-drop bug (9c5af8d7) ship four releases — measured goodput
alone cannot distinguish "the encoder is capped at 20" from "my
200 Mb/s grant met a cheap scene".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three silent console outages in one week (0x1 / 0xFFFFFFFF / 0x41306),
each a different proximate cause of the same structural defect: the
console's lifecycle was owned by Task Scheduler — one best-effort start
per boot/logon/install, no retry on a plain non-zero exit, no watchdog —
while the product already shipped a real supervisor.
The service now supervises the console as a second child slot: plain
session-0 spawn (suspended → own no-breakaway kill-on-close job →
resume), started only once the host has written mgmt-token + cert.pem +
key.pem (the cert race dies by construction), secrets read from their
files at every respawn, bun's stdout finally captured in logs\web.log,
doubling backoff 0.5s→60s that never gives up. Session switches never
touch it; a service stop takes it down via the job.
The PunktfunkWeb task is retired: web setup slims to password + legacy
task delete + firewall, the 127-line web-run.cmd batch supervisor is
deleted, an [InstallDelete] entry reaps the stale copy, and service
install now sets SCM crash-recovery actions (restart 1s/5s/60s) since
the console rides on the service process. StopBunRuntimes stays for the
scripting runner + the one migrating upgrade.
Design: punktfunk-planning design/windows-web-console-lifecycle.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Since spec mode (`f32c3aaa`, shipped in 0.22.0) the GTK shell has handed the session
a `--resolved-spec` built from `Settings::default()`. A session running from a spec
performs ZERO store reads by design, so every stream since has run at the defaults:
`bitrate_kbps: 0` reaches the host, which reads it as its 20 Mbps fallback — the
field report this starts from, "my bitrate seems to be stuck at 20 MBPS" on 0.22.3 —
and with it the resolution, refresh, render scale, codec, decoder, HDR, 4:4:4, audio
channels, mic, touch/mouse mode, invert scroll, stats tier, match-window and gamepad
type. Profiles never applied at all: the spec's `profile` was None, and spec mode
ignores `--profile`. The tell in the wild is that the GPU and audio-device pickers
kept working — the session reads those three off disk into env vars before it reads
the spec.
The plan carried a comment explaining that `settings` "carries only what the argv
needs (the fullscreen flag)". That was true when it was written and stopped being
true one commit later, in another file. `ConnectPlan::for_target` answers that shape
of bug: a front-end holding its own request type resolves through the same helper the
session's compat path uses, so there is no hand-built `Settings` left to go stale.
The GTK shell's `fullscreen_on_stream` parameter goes with it — that is a tier-P
field, and a shell-read global was beating a profile that set it. The Windows shell
was never affected (it spawns with no spec) but had the same fullscreen-vs-profile
bug, so it moves onto the resolver too.
The audit that followed found four more settings stored, rendered in two UIs, and
read by nothing:
- `enable_444` never became `VIDEO_CAP_444`. "Full chroma (4:4:4)" is announced in
the 0.22.0 notes for Linux and Windows and did nothing on either; only Apple
advertised the bit. The host already gates it on its own policy, its capturer,
HEVC and a real GPU probe, and answers the resolved chroma in the Welcome before
we build a decoder — the client only has to ask. There is no desktop decode probe
(Apple has `Stage444Probe`), but the presenter bails cleanly on a plane format it
doesn't know and demotes to software, so the downside is a slow decode, not a
broken one.
- `session_params` picked the HEIGHT fallback off `settings.width == 0`.
- `cli::exec_session` dropped `--profile` from its forward list, so
`punktfunk --connect … --profile Work` from a script or a Decky wrapper streamed
with the host's binding instead.
- `ResolvedSpec::write_temp` named the spec by the SPAWNER's pid alone, so a
cancelled connect and the retry behind it shared one path — and the first child's
exit deleted the file the second was still starting up to read.
The regression test asserts the plan carries the profile-overlaid bitrate and that
the spec equals it. On the old code both are 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`PUNKTFUNK_VIDEO_SOURCE` had no default, and unset fell through to the synthetic
test pattern. That was invisible while the systemd unit made host.env mandatory —
you could not start the host without the file, and every copy of the file sets
`virtual`. Now that the unit treats host.env as optional (020306b5), unset is
reachable, and a packaged Linux install with no host.env would serve a test
pattern to Moonlight clients.
Default it to `virtual`, the flagship per-client virtual output — which is what
every setter in the tree already says: packaging/bazzite/host.env,
packaging/kde/host.env, both host.env.example files, the host.env the Windows
installer generates, and scripts/bench/gpu-stream.sh. Nothing sets `synthetic`
and nothing relied on unset. `synthetic` stays reachable by naming it, since any
unrecognised value lands in that arm.
Only one of the three readers changes behaviour: stream.rs's
`== Some("virtual")` now matches when the variable is absent. gamestream/mod.rs
already folded None and Some("virtual") into the same `_` arm for the HDR
capability probe, and rtsp.rs only tests for `portal`.
The native punktfunk/1 plane never read this — it always builds a virtual
display — so this only ever affected GameStream/Moonlight sessions.
host.env.example's header no longer claims this key is the one non-optional
setting; the file is now overrides only, and the host runs without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toggle has been stored, profileable and rendered in two settings UIs since
profiles landed, and nothing read it. Windows grabbed the keyboard whenever input
was captured, setting or no setting; Linux never grabbed at all, because the grab
sat behind `#[cfg(windows)]` with a comment deferring the compositor story to "the
shells" — which never picked it up.
`Settings.inhibit_shortcuts` now reaches the presenter and gates the grab, on both
platforms. SDL3 already maps `SDL_SetWindowKeyboardGrab` onto
`zwp_keyboard_shortcuts_inhibit_manager_v1` on Wayland and `XGrabKeyboard` (plus
`_XWAYLAND_MAY_GRAB_KEYBOARD`) on X11, so dropping the cfg is the Linux fix.
Capture state still gates it, so releasing input hands the chords straight back,
and the desktop mouse model never grabs. A compositor with no shortcuts-inhibit
global says so once instead of failing silently — at debug under gamescope, which
has no shortcuts to inhibit in the first place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fallout from the documentation sweep: verifying doc claims against the code
turned up defects in the code and the shipped templates. Mostly comments that
describe behaviour we no longer have — which is how the docs went wrong in the
first place, since someone reads the comment and writes the page.
The one that mattered: a fresh deb/RPM/Arch install could not start the host at
all. The unit's `EnvironmentFile=` had no `-`, making host.env mandatory, and no
package creates it — all three ship only the templates under /usr/share and the
postinst merely prints the copy command. So `systemctl --user enable --now
punktfunk-host` died on "Failed to load environment files". Every field in
HostConfig::from_env resolves through unwrap_or/filter/None, so absent means all
defaults, exactly like a hand-run `serve`; the Nix module already wrote it as
`-${environmentFile}`. The Deck installer's own generated unit gets the same
prefix for the case where an operator later removes the file.
Shipped templates: PUNKTFUNK_SECURE_DDA is read by nothing (DDA/WGC are gone;
IDD-push is the sole Windows capture path and the secure desktop is
unconditional), so it stops being written into a fresh host.env;
PUNKTFUNK_INPUT_BACKEND offered a `uinput` value that does not exist and omitted
`kwin`, which is what a KDE session actually resolves to; PUNKTFUNK_RENDER_ADAPTER
no longer claims to pick a "Desktop-Duplication" GPU.
Comments corrected rather than deleted, since each explains a real why:
PUNKTFUNK_10BIT is default-on with explicit-off grammar, not an operator opt-in;
GNOME reaches EIS through Mutter's direct RemoteDesktop API, so it needs no portal
approval and is headless-capable; and the host does not run in session 0 — the
service is the session-0 supervisor and the host runs as SYSTEM in the interactive
console session, which is why game_term has to bind the input desktop at all.
packaging/bazzite/update-punktfunk.sh is now installed to /usr/share/punktfunk/,
so the command the docs promised exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The managed takeover masked gamescope-session-plus@*.service and trusted
the mask to hold SDDM's Relogin=true loop at bay. On images whose sddm
session helper execs the session script directly (/etc/sddm/wayland-session
gamescope-session-plus steam — f43 bazzite-deck), the relogin never touches
the unit: SDDM relogged in ~3x/s for the whole stream, each attempt a full
bash --login session start failing against the managed instance. Live on
the .41 VM (2026-07-31): 328 forks/s, load 6+, 1481 logind sessions in
8 minutes, the journal flooded past its own rotation — and a 5070 Ti that
"could not hold 240fps".
A DM that drove a LIVE gaming session is now stopped for the stream's
duration on every flavor (the fragile-flavor machinery — lingering guard,
pkexec dm-helper, restore via reset-failed+restart, crash-persisted state —
already existed and is reused as-is; a pure dm_plan() derives the decision
and is unit-tested). The flavors now differ only in the degraded mode when
the stop is impossible: SDDM falls back to mask-only (the storm tax, but
Steam stays protected — never attach), fragile DMs still fail the takeover
into the attach degrade. The mask stays as belt-and-braces for the window
before the stop and for images that do route the relogin through the unit.
Verified: scripts/xcheck.sh linux clippy (-D warnings, --all-targets)
clean; pf-vdisplay suite 125/125 on Ubuntu 26.04.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase-locked capture had no client half on Apple — the host's v3 controller
(grid-locked submits, coherence-gated engage) shipped with only the Android
reporter feeding it. Now the stage-4 link thread flushes the same v2
circular arrival-phase statistic at ~1 Hz:
- PhaseReporter (Stage2Pipeline): the decode callback deposits per-AU
reassembly-completion stamps, the CAMetalDisplayLink update deposits the
latch grid (period = window-min of update spacing), and the flush ports
punktfunk_core::phase::circular_latch verbatim — a period-smeared Wi-Fi
link reads coherence ≈ 0 and the host correctly never engages; a wired
link opens the gate. Binds/unbinds per session like DecodeReport.
- PunktfunkConnection.reportPhase wraps the existing ABI entry point.
- Hello honesty: iOS/tvOS advertise CLIENT_CAP_PHASE_LOCK (macOS stays
without — the stage-2 arrival presenter has no latch grid), Android now
sets the bit its reporter already earned, and the ABI grows the
PUNKTFUNK_CLIENT_CAP_PHASE_LOCK mirror const (header regenerated).
Advisory in v1: the host arms on report receipt.
- Stage-3 doc comment no longer calls itself the tvOS default (stage-4 took
that over in the 2026-07 rebuild).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
clippy -D warnings only lints this cfg(target_vendor = "apple") file on a
Mac, which CI never does — the three recv/recvmsg_x blocks predate the
undocumented_unsafe_blocks sweep and kept every local Apple-side clippy run
red.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Windows direct-NVENC session gets the Linux backend's sub-frame
chunked poll: a doNotWait sampling lock cuts each completed slice out of
the bitstream while the frame is still encoding, so the send loop's
streamed slice wire starts shipping an AU's front before its tail
exists. The default retrieve mode on Windows is synchronous, which is
exactly the mode the sampler requires - the async opt-in keeps its
whole-AU path and the chunked latch drops away when it is active.
The session's slice count now honors the negotiation instead of pinning
one: the direct-NVENC default of 4 clamped by the client's ceiling, so a
single-slice client (Amlogic-class decoders that never advertised
multi-slice) keeps today's wire shape and a multi-slice client gets real
boundaries to cut at. The pending queue carries a submit-time IDR hint
because an AU's early chunks ship before the driver reports the picture
type; sub-frame write itself stays env-gated (PUNKTFUNK_NVENC_SUBFRAME=1)
until the Windows on-glass A/B validates it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three things were wrong with the host-card OS icons.
The Windows mark was Font Awesome 5's, which is still the Windows 8/10 flag
with the perspective skew — dated next to the flat four-pane mark Microsoft
has shipped since Windows 11. No icon set has the current one (Simple Icons
carries no windows/microsoft slug at all), so it is drawn here: four equal
squares at the authentic 11.377 + 1.246 proportion. The Decky plugin was
pulling FaWindows straight from react-icons, so it now inlines the masters
like the web console does, or it would have kept the old flag regardless.
Bazzite, CachyOS and Nobara collapsed onto their family's mark. The host
already advertises the full chain, so this is purely missing art: all three
now ship a leaf mark, because "a Bazzite box" and "a Fedora box" are
different machines to the person reading the card. CachyOS and Nobara come
from Simple Icons; Bazzite has no icon anywhere, so its "b" is lifted out of
the project's own badge (Apache-2.0, attributed).
On Android every non-square mark was stretched. A VectorPainter maps the
viewport onto the ImageVector's default size with independent x and y
scales, so declaring a 448x512 Tux as 24x24 dp squashed it — silently, no
crash, no warning. The longest viewport edge now sets the 24 dp box and the
other follows the ratio, which is what Icon()'s ContentScale.Fit expects.
A unit test pins the invariant; every other client was already correct.
Also: scripts/gen-os-icons.sh replaces the undocumented hand-run pipeline
that turns a master into the GTK symbolic SVG, the Windows PNG and the Apple
template PDF. It reproduces the committed artifacts byte-identically.
Verified: web and Decky typecheck, Decky bundles, Android compiles and its
tests pass, PunktfunkKit builds, osinfo's tests pass. Not verified on glass,
and the GTK/Windows client crates do not build on macOS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Delivery used to be all-or-nothing: the decoder saw byte 0 only after the
last packet of the AU landed, so the whole transmit time sat in front of
decode. With the slice-streamed wire (previous commit) blocks now arrive
addressable, and a client can opt in (connect's new frame_parts) to
receive each AU's newly-contiguous prefix as Frame::part pieces - offset
tiling, first/last marked, the completing push carrying only the suffix.
A PARTIAL_FRAME-capable decoder then chews slices concurrently with the
remaining network transfer.
The reassembler walks a per-frame cursor over successfully-completed
blocks (failed FEC reconstructs don't advance it), coalesces blocks that
finished out of order into one part, keeps probe filler whole, and stops
short of the final block so the zero-padded tail still trims at
completion. Whole-frame consumers see byte-identical behavior - parts
never flow without the opt-in, and never on PyroWave (its newest-wins
draining assumes whole AUs).
Per-AU accounting keeps its units: OWD/ABR feeds, the inter-arrival
series and the clock-based jump-to-live detector only count completing
deliveries, and FrameChannel::depth() counts AUs so a part-rich queue
can't trip jump-to-live at a fraction of the real backlog. The consumer
contract (gap or orphan part = AU lost: abandon, flush, resync on the
next first) is documented on FramePart; the C ABI keeps parts off until
PunktfunkFrame can express them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The streamed-AU path only ever flushed a block when a full FEC block's
worth of bytes accumulated - at real bitrates a frame finished encoding
before that, so every AU degenerated to one tail-heavy send and the
overlap the sentinel wire was built for never happened.
Now a slice boundary flushes every whole shard (floored at
MIN_STREAM_BLOCK_SHARDS so per-block parity stays economical), which
means blocks are variable-size and can no longer be addressed by the
uniform block_index * K_max formula. USER_FLAG_SLICE_STREAM marks such
frames on every packet: sentinel blocks reuse frame_bytes as their
shard-aligned base offset, the final block's base derives from the
totals, and the receiver pins/validates positionally (sentinels must sit
strictly below the final block's base; a mixed-flag or out-of-range
header is dropped before it can place a byte).
The AU's own flag bit gates the wire shape, and the host sets it only
toward clients advertising STREAMED_AU + MULTI_SLICE, so shipped
receivers keep seeing the byte-identical legacy sentinel
(PUNKTFUNK_SLICE_STREAM=0 pins legacy for A/B). Both sides bound the
per-frame block count by the same floored formula, and the flush loop
cuts as many blocks as a chunk completes - a slice bigger than one FEC
block can't leave an oversized final block behind.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report (RX 5700 / SteamOS): 10-bit HEVC "unsupported" — but the GPU was
never the blocker (RADV probed Main10 fine on that box). The session stayed SDR
because stock gamescope offers no 10-bit PQ capture formats and nothing shipped
our pipewire-hdr build outside the Bazzite sysext, while PUNKTFUNK_GAMESCOPE_HDR
additionally defaulted off everywhere. Close all three gaps:
- PUNKTFUNK_GAMESCOPE_HDR now defaults ON (explicit-off grammar, the flip the
config comment planned post-canary). Safe by construction: the +pfhdr banner
probe, managed-spawn term, and client 10-bit cap keep stock-gamescope boxes on
the exact 8-bit path. The nix module's gamescopeHdr (default true now) only
controls whether the patched binary is on PATH; it no longer sets the env.
- SteamOS: new scripts/steamdeck/build-gamescope.sh builds the HDR gamescope in
the same pf2 trixie distrobox (apt dep list validated by a full build in a
debian:trixie container), installs ~/.local/bin/punktfunk-gamescope, and
maintains PUNKTFUNK_GAMESCOPE_BIN in host.env ONLY while the binary passes its
on-glass --version/+pfhdr check — a stale absolute override would break
session spawning, not just HDR. Wired into install.sh (§3b), update.sh
(retrofit), and rebuild-check.sh (ldd-probes it like the host binary).
- Arch: build-sysext.sh gains a --gamescope fold-in (banner-verified by
execution, host-image-only), and punktfunk-host optdepends the companion.
Deliberately NO CI-published host sysext: per the 2026-07-22 packaging
verdict a prebuilt SteamOS host breaks on A/B soname bumps (and /var is
per-partition-set), so arch.yml records that instead — SteamOS hosts use the
distrobox flow above.
Also fixes a latent bug that made build-sysext.sh fail every invocation as
documented: a literal `}` inside `${1:?usage …{host,client}…}` terminates the
expansion early and corrupts $PKG (caught by the new synthetic-package test,
which also covers the fold-in, the unmarked-gamescope refusal, and the
client-image guard).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Installing any plugin from the console failed on a user's Fedora 44 host with
plugin store job failed: @punktfunk/plugin-virtualhere is not present after install
while the same version installed fine on every box we tried. The difference turned
out to be a file in his home directory.
`bun add` does not install into its working directory. It walks UP to the nearest
ancestor `package.json` and installs into that tree. A fresh plugins dir has no
`package.json` — the store and the runner only write `bunfig.toml` — so any stray
one above it captures the install: the packages land in `~/node_modules`, the
dependency is written to `~/package.json`, bun prints "installed …" and exits 0,
and the plugins dir stays empty. The plugins-dir `bunfig.toml` is not read in that
case either, so the outcome splits on whether `@punktfunk` resolves in the hijacked
tree: with no scope mapping there it 404s against npmjs and exits 1 ("the plugin
runner exited with status 1"); with one, it succeeds into the wrong tree and the
job dies on the presence check instead. Only the second shape looks like a broken
store, which is why this took a reproduction to find.
Seed the plugins dir with a `package.json` so it owns bun's install root. Three
call sites rather than one: the store job, the `plugins add` CLI, and the SDK's
`ensurePluginsDir`. The runner ships as its own package and can predate the host
binary, so the host cannot delegate this — the same reason `ensure_bunfig_scope`
writes the registry mapping on this side rather than passing a runner flag.
Seeding only ever touches a tree with no `node_modules`. A dir with packages and
no `package.json` is the hand-assembled/older layout that `installed_packages`
deliberately falls back to the naming convention for, and dropping an empty
`dependencies` on it reports every plugin the operator runs as uninstalled —
already pinned by `an_emptied_dependency_list_means_nothing_is_installed`.
The residual failure stops dead-ending: if the presence check fails anyway, the
error names the capturing ancestor and what to do about it.
Reproduced and fixed on glass (Nobara 44 VM, host + runner 0.22.3): with a stray
~/package.json the store reproduces the field report byte for byte — same 150 ms,
same phase sequence, same message — and with the plugins dir seeded the same
install lands correctly with nothing leaking into the home dir.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Midday on-glass falsified v2's remaining assumptions: a per-frame additive
hold SATURATES the arrival-slaved loop once hold + work >= interval — submits
free-run at their own cadence and the commanded phase dissolves (measured:
+-2 ms hold steps, zero client response) — and the latch statistic hovers at
the target's ANTIPODE, where shortest-way errors sign-flip on noise (the
0<->2<->4 ms offset chatter).
v3, one change per falsified assumption:
- ACTUATOR: submits lock to an absolute grid (epoch + k x period + offset);
the controller walks only the offset. A periodic grid cannot free-run —
occupancy is one frame per period whatever the offset — so phase actuation
is linear BY CONSTRUCTION. Disengaged = no grid sleeps = zero cost; every
failure path DISENGAGES (never parks: v2's e2e-tax lesson).
- SENSOR: the client reports the circular mean + coherence of the ARRIVAL
lead (per-AU reassembly stamps vs the panel latch grid) — the phase the
host actually controls; latch measured downstream of the decoder pipeline,
which absorbed the actuation. pf.phase logs "arrival lead circ= coh=".
- ANTIPODE DAMPING: errors within 1 ms of +-period/2 take half-steps until
they commit to a side.
Harness rewritten around plants glass validated: a GRID plant (linear by
construction), the DECOUPLED plant (v2's saturation — must disengage, not
orbit or park), an antipode start (must converge within one period of
travel), incoherence (never engages), a regime change (re-engages after
backoff), and the actuator's own periodicity/offset-linearity. Also fixes
the harness's SIM_TARGET (a mis-derived max asserted 3.5 ms where the
controller's actual target is 2.5 — the controller was right, the ruler
was wrong; the Python cross-check inherited the same constant, a lesson in
replicating the CODE, not the author's belief about it).
Gates: v3 simulations 6/6; host bin suite 345/1 (the documented
environmental qemu UDP test); clippy --all-targets -D warnings clean;
cargo ndk arm64 check clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The controller iterated twice on the user's gaming PC before this existed;
never again. punktfunk_core::phase::circular_latch is extracted from the
Android presenter (shared: iOS reports next, and the harness generates its
synthetic reports through the IDENTICAL statistic the clients ship), with
unit tests including the wrap-straddling cluster an arithmetic mean gets
maximally wrong.
The stream.rs harness models the plant — latch = (base − hold + noise)
mod P, deterministic LCG noise — and drives PhaseController::adjust through
five regimes:
- tight jitter locks into the deadband within ~5 adjusts and stops moving;
- a wrap-side start takes the SHORTEST way (travel < P/2 asserted);
- an incoherent phase never steps (zero hold, ever);
- a v1 pinned-median report trips the travel budget and DECAYS TO ZERO —
the 2026-07-31 orbit and the parked-hold e2e tax, both as asserts;
- a post-decay regime tightening re-locks.
Gates: host bin suite 344 passed / 1 failed = the documented environmental
qemu UDP-loopback test (clean main fails identically in this container);
core phase tests 4/4; clippy --all-targets -D warnings clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-1 on-glass falsified two v1 assumptions in one morning (NP3 ↔ .173):
- A median lead is immovable under period-spanning jitter (uniform mod P),
so v1 orbited the period at 2 ms/s forever — and the dead-signal freeze
hotfix never armed, because ±1.5 ms of window sampling noise kept
resetting its 'error responded' check. Thresholds can't referee noise.
- A HELD hold is not free: it delays sampling after the capture stamp,
taxing e2e by up to a period (user-measured: ~+4 ms during the orbit).
The correct failure response is DECAY TO ZERO, never freeze-in-place.
v2, all three layers:
- Wire: PhaseReport grows a length-discriminated coherence tail (27-byte
form; the u16::MAX sentinel encodes as the byte-identical 25-byte v1 —
strict-prefix discipline, both forms pinned by tests).
- Client: the reporter sends the CIRCULAR vector-mean latch phase mod the
panel period + its coherence (‰); pf.present logs circ=/coh=.
- Host: steps only while coherent (floor 300‰; a v1 report bypasses the
gate), along the signed SHORTEST way around the period, under a
cumulative 1.25-period travel budget that is noise-immune by construction
(it integrates applied steps, not reported errors); incoherence or budget
exhaustion decays the hold to zero and re-arms once flat. Deadband
convergence resets the budget — tight regimes lock exactly as designed.
report_phase + the C ABI mirror gain the coherence parameter (canary-only
ABI, hours old, sole caller in-tree). Gates: docker amd64 clippy
--all-targets -D warnings (host+core, nvenc) clean; core suite 244/244;
cargo ndk arm64 check clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First on-glass closed-loop run (NP3 ↔ .173, canary 0.23.10498) proved the
transport and both endpoints work — and exposed the controller chasing a
statistic that cannot respond: under period-spanning arrival jitter the
client's latch DISTRIBUTION is ~uniform mod the refresh, so shifting its
mean (the hold — provably reaching the client: the paced-drop bursts locked
to each wrap) cannot move its MEDIAN. The controller stepped +2 ms/s in one
direction forever, wrapping the period every ~4 s, and every wrap coalesced
~30 frames at the client — a periodic hiccup that must not ship.
The controller now remembers its last error: three consecutive steps that
fail to move it ≥1.5 ms freeze the hold (kept, not zeroed) and log; a later
report whose error moved re-arms stepping — a tight-jitter regime still
locks exactly as designed. Controller v2 (a circular phase statistic with a
coherence gate, per the design doc's follow-ups) replaces the median
outright; this freeze stays as its safety net.
Gate: docker amd64 clippy --all-targets -D warnings (host, nvenc) clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The root helper, its unit, the group-scoped polkit rule and the install-kind
marker all shipped with the HOST package. A Steam Deck or a handheld has no host
package, so the client half of one-tap updates had nothing to stand on.
The client packages now ship their own copies — deliberately under their own
paths (`pf-update-client`, `punktfunk-client-update.service`,
`49-punktfunk-client-update.rules`, `/usr/share/punktfunk-client/install-kind`)
rather than sharing the host's. Two packages owning one path is a hard conflict
in dpkg, rpm and pacman alike, and the client marker needs its own DIRECTORY
too because the host RPM claims `%{_datadir}/punktfunk/*` with a glob — a
sibling file there would break `dnf install punktfunk punktfunk-client`.
`pf-update` grows the matching `apply-client` verb. The verb comes from a
root-owned unit's fixed ExecStart, never from the caller, so the zero-parameter
invariant holds; both verbs sweep every installed punktfunk package, and what
the verb selects is which marker is read and which binary the run-the-binary
gate executes afterwards. A client sysext is refused with its reason: the signed
feed carries the host image, and installing that over a client-only box is not
an update.
The opt-in group is created by both packages' scriptlets, idempotently, so
whichever lands first wins.
Also fixes the host's own helper install: it read `$R/target/release/pf-update`,
which is the source checkout, while build() exports CARGO_TARGET_DIR="$srcdir/
target" and every other binary in the file installs from `$T`. Both now read
`$T`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`punktfunk-client --check-update` fetches the same Ed25519-signed per-channel
manifest the host trusts, works out how this client was installed, and reports
what — if anything — can be done about a newer build. `--apply-update` does the
part it owns. `--version` prints the CI-stamped build string, which is both what
the comparison needs and what a packaging gate can run the binary for.
The engine lives here rather than in the Decky plugin for two reasons: verifying
a signature needs crypto that Decky's embedded Python does not have, and the
plugin is not the only surface that wants the answer. Every UI becomes a caller,
exactly as it already is for --pair, --library and --list-hosts.
What is possible depends on the install, and the module refuses to pretend
otherwise. A flatpak is a per-user install its launcher can update. A .deb/.rpm/
pacman client goes through the packaged root helper — a fixed, parameterless
systemd oneshot polkit authorises for members of the punktfunk-update group, so
the request carries nothing an attacker could influence and the payload comes
from the distro's own signed repositories. A sysext, a nix profile or a source
build gets the command and nothing else: the signed sysext feed carries the HOST
image, so a client sysext has no feed to update from, and a button that can only
fail is worse than one honest line.
A failed check reports the failure. "Could not tell" rendering as "up to date"
is the one answer that must never happen.
The box's capabilities are probed once into a `Caps` struct so the routing rules
are a pure function of (install kind, caps) — all seven of them are tested
without a box, including that the kill switch beats every kind and that pacman
needs its own root-owned full-sysupgrade opt-in on top of the group.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The host has known how to answer "is a newer build available for this box's
channel?" since the update-from-web-console work. The Linux client is about to
need the same answer from the same signed document, and a trust rule that lives
in two places is a trust rule that will drift.
So the parts where being wrong is a security bug now exist exactly once, in the
new `pf-update-check`: Ed25519 verification against pinned keys, the manifest
schema and its fail-closed validation, the post-redirect fetch, the version
comparison that has to reconcile four different canary spellings, and the
install-kind ladder — the last parameterised by which product is asking, since
the delivery channels are the same ones but the markers are not.
The pinned key list moves with them. Two lists could have disagreed about who
may announce a release, and the one that drifted is the one nobody would have
noticed; `publish-update-manifest.sh` follows to the new path, and a MISSING
keys file there is now fatal rather than a warning that silently skips the
cross-check the step exists to perform.
Host call sites are unchanged throughout: `store::index` and `update::manifest`
re-export from the shared crate under their old names, and `update::detect`
keeps its cached `detect()` and the host's command hints. Verified with clippy
-D warnings on Linux and on Windows (nvenc,amf-qsv,qsv), 340 host tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6b0bd59b gave the unplug sweep a 300 ms grace so a mask glitch can't flap
PnP devices — and updated pad_slots' own tests, but not the two uhid_manager
consumer tests that encode the old immediate-sweep contract (its CI run was
cancelled by a superseding push, so the breakage surfaced on later shas).
PadSlots gains a #[cfg(test)] expire_grace() that backdates the armed clocks,
so consumer tests drive the debounce without wall-clock sleeps. The removal
test now also PINS the new hold-inside-grace behavior explicitly.
Verified in the amd64 CI-image container: pf-inject 83/83, clippy
--all-targets -D warnings clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The unplug sweep dropped a pad the instant its active_mask bit read
clear — but a pad teardown is a whole PnP device removal (system-wide
device-change broadcasts, and on re-create a full hidclass
re-enumeration), so a client-side mask glitch of a few state frames
cycled real devnodes. The sweep now drops a pad only after its bit has
stayed clear for 300 ms; a bit that returns inside the grace disarms it.
A real unplug tears down one grace later, which nobody observes — the
pad already went quiet. Lands once in PadSlots, so every backend
(uinput/uhid/XUSB/UMDF) inherits it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The new punktfunk_connection_report_phase lacked the #[cfg(feature = "quic")]
its connection-fn siblings carry, so cbindgen emitted it OUTSIDE the guarded
region that declares the opaque PunktfunkConnection — the generated header
stopped compiling as C (CI's harness caught it; my local harness failure was
a pre-existing macOS linker issue that masked this distinct cause — full-suite
comparison attributed the wrong root).
Verified: the harness compiles and passes in the amd64 CI-image container.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The host's capture tick and a client's panel vsync are independent ~120 Hz
oscillators; their drifting phase sweeps every frame's wait-for-latch across
a full refresh period (measured on-glass: latch p50 oscillating 5.4-8.9 ms
with a fixed margin) — the beat is the residual judder and the fat p95, and
no client can fix it alone. Design: punktfunk-planning
design/phase-locked-capture.md.
Protocol (punktfunk-core):
- PhaseReport (control 0x32, next to the clock family): the client's next
display latch ALREADY CONVERTED to host clock (the skew offset lives only
client-side), panel period, uncertainty, and the measured median
arrival-lead — the controller's error signal. ~1 Hz, latest-wins.
CLIENT_CAP_PHASE_LOCK advertises it; CtrlRequest::Phase + report_phase()
+ the C ABI mirror carry it.
- The 0xCF host-timing tail grows a phase ACK (applied_phase_ns, 29-byte
form) under the same strict-prefix append discipline — old readers parse
the shorter forms; degradation pinned by tests.
Host engine (arrival-slaved loop — no backend can move the source vsync,
per the tick-ownership audit in the design doc):
- PhaseCtl bridges control task → encode loop (the fec_target pattern,
multi-field). PhaseController walks a per-frame HOLD before submit toward
the client's reported lead hitting target = max(2.5 ms, uncertainty+1ms):
1 Hz adjust, 2 ms max step, 300 µs deadband, period-wrapping (the
newest-wins capture slot makes a wrapped hold sample fresher content, not
staler). A loop local, so every mid-stream rebuild keeps the lock; a new
session re-acquires. PUNKTFUNK_PHASE_LOCK=0 disarms.
Android reporter: the presenter's 1 Hz pf.present flush returns the
window's measured latch p50; the async loop converts the vsync clock's next
timeline monotonic→realtime→host and reports. Inert toward old hosts.
Gates: docker amd64 clippy --all-targets -D warnings (host+core, nvenc)
clean; core suite incl. the new wire tests; cargo ndk arm64 check/clippy
clean. On-glass A/B vs the .173 host owed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`HidD_GetFeature`/`HidD_SetFeature` reject any buffer shorter than the
collection's `FeatureReportByteLength`, and the proof hardcoded 64. That held
only because every pad enumerated with the DualSense descriptor; a Deck that
enumerates as a Deck reports 65 (its one feature report is unnumbered and 64
bytes wide, plus the report-id slot Windows always reserves), so both calls
would have failed, the proof with them, and — the gate being fail-closed — the
pad would never receive its DATA section at all.
Take the length from the descriptor, and accept the Deck's reply at either
offset 0 or 1: one driver binary serves four identities and Windows places an
unnumbered report's payload behind the report-id slot.
An answer must also VALIDATE, not merely parse. `from_feature_report`
reinterprets any 17 bytes, and a Deck serves its one unnumbered feature report
for *any* requested id — so it answers the PS `0x85` probe with Steam attribute
bytes that parse into a proof and fail on magic. Returning that first answer
stopped the search while the real proof sat one transport away.
A Steam Deck client streaming to a Windows host had a stuck stick and a stuck
d-pad. The virtual pad was enumerating with the DualSense VID/PID **and the
DualSense report descriptor**, so Windows parsed the 64-byte Deck frame as
DualSense report 0x01: LX = report[1] = 0x00 (stick hard left), LY = report[2] =
0x09 (hard up), and a d-pad hat of 0 — which is UP, held forever.
The driver picked its identity from `device_type` in the sealed section, but
hidclass asks for the descriptors and attributes while it STARTS the device, and
the section can only be delivered over the HID device interface — which does not
exist until those queries are answered. The channel was structurally unavailable
at the only moment it was needed, so `device_type()`'s bounded wait always timed
out and every identity fell back to DualSense. Not a race: DualShock 4 and the
Edge enumerated as DualSenses too (verified on .173 — both report 054C:0CE6 with
a 64-byte DualSense input report, while their on-demand strings read correctly).
The devnode's own hardware ids carry the identity and are readable at
EvtDeviceAdd, before anything is asked, so resolve it there. The section stays
authoritative once attached; the old wait survives only for a devnode whose ids
match nothing.
`hwid_devtype_table_matches_the_driver` pins the host's hwid → device_type
mapping against the driver's table, including the ordering trap that
`pf_dualsense` is a prefix of `pf_dualsenseedge`.
An 18-byte DTD with fixed reduced blanking (80 px / 45 lines — a virtual
display's blanking only has to be self-consistent), refusing modes the
encoding cannot carry (pixel clock past the u16 10 kHz field, 4K120-class;
>12-bit actives). Lives here rather than in the driver so it is unit-
tested on every platform; pf-vdisplay splices it over its hard-coded
1080p60 preferred-timing descriptor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-hole stall lines gate on prior ACTIVE flow, so inside a sustained
~2 fps phase only the first hole is reported and the log goes quiet
exactly while the user suffers — the field shape (deep 15 s stretches at
2 fps) was invisible without a stats recording.
StallWatch now tracks the stretch: opened by a reported stall, fed by
every stall-sized hole while the activity gate stays broken, closed when
sustained flow returns (or a ring recreate cuts it — its holes predate
the recreate and still count). Closure surfaces one INFO line: span,
hole count, summed hole time, worst hole. One-hole stretches dissolve
silently (the stall's own line covers them), and a ≥10 s gap closes the
stretch first so a quit-to-desktop pause never folds into the tally.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two standing costs from the interval-stutter investigation, consolidated:
- PUNKTFUNK_STALL_PROBES=0 now opts a box out of the micro-probe engine
(per-GPU 10 Hz fence copies, a parked blocking-DwmFlush waiter, the
5 ms-cadence CPU sentinel). Default stays ON while the field program
runs; off, stall lines keep the driver telemetry and the cheap ETW
present/queue discriminator and only lose the corroborating probe legs
— the verdict matrix already treats an absent probe window honestly.
- The negotiated-depth pin-back stops hammering a display that refuses
the flip: it was 4 CCD writes + 8 display-config queries per second,
forever, all on the session-global display-config lock. After ~2 s of
eager retries it re-attempts every ~4 s — still self-healing, without
the drumbeat.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
driver_store_inventory() ran `pnputil /enum-drivers` synchronously inside
the attach-failure diagnose path — on the pad service thread, with no
timeout. A busy or wedged driver store blocks that enumeration for tens
of seconds, and the thread must keep draining pad slots.
The query now runs on its own thread (spawned once per process); the
caller waits a bounded 2 s so a fast pnputil still lands in the same
report, and a slower one fills the cache for later diagnoses — the WARN
then says the store could not be queried yet instead of blocking.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A box whose only external display is an off/standby TV warned "no
external physical display active after the restore (connected=1)" and
forced the EXTEND preset on EVERY teardown — an inert remedy (with one
connected display the preset can't relight anything) that reads like a
fresh failure in every field log.
The backstop now measures its own effect: after a force-EXTEND it
re-reads the inventory, and a dark set that stayed dark THROUGH the
preset is latched as unlightable — subsequent teardowns log a debug line
and leave the topology be. The poisoned-snapshot chain the backstop
exists for is not latched away: there the panel CAN light, so the first
force succeeds and the latch stays clear. Process-lifetime latch, so a
host restart re-probes once.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The RX-9070 field trace: 14 decode-recovery keyframe requests in 2 s at
~300 Mbps with loss_ppm=0 — and the controller held the rate, because no
loss/OWD/latency signal moved. Repeated keyframe asks ARE the decoder
saying it cannot keep up; now they are an ABR input.
The control task counts outbound CtrlRequest::Keyframe at its send choke
point (the one place every emitter funnels through); the pump drains the
count per 750 ms report window into on_window. Two asks in a window is
ordinary-bad (two-window confirmation, like an OWD rise); four or more is
severe (the emitters throttle at 100 ms, so 4+ means the window was spent
begging) and backs off ×0.7 after one window. RFI asks are deliberately
not counted — they are the routine loss-recovery path and loss_ppm
already prices them in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A compose-silence hole used to be blamed on the frame-generation path by
default — mislabeling benign content pauses (menus, loading, game hitches)
as display-path bugs. The corrected discriminator convicts on witnesses:
- Microsoft-Windows-DXGI Present/PresentMPO (ids 42/55) rides the host's
filtered ETW session — one event per swapchain present, stamped with the
presenting pid (named in the stall line).
- BltQueueAddEntry/CompleteIndirectPresent (ids 1071/1068) witness frames
entering/leaving the virtual display's kernel present queue (the modern
IDD path; anatomy proven on-glass via xperf, 2026-07-30).
- classify() now reads the window counts: presents flowing while the queue
starved = FRAME-GENERATION (the OS dropped composed frames — the real
bug class, never yet observed); no presents anywhere = CONTENT-SILENCE
(benign for the display path); no working witness = UNATTRIBUTED, never
a guess.
- DWM_TIMING_INFO.cFrame is demoted to an advisory dwm_frames_frozen=
print: on Win11 it is refresh-synthesized and advances without real
composes (proven against a kernel trace), so it convicts nothing.
- DxgKrnl legacy Present (id 184) is retired — it never fires on the
redirected BltQueue path.
Stall lines carry etw_presents=/etw_queue_adds= plus named presenters;
compiled and validated on-glass (a quit-transition stall correctly labeled
CONTENT-SILENCE presents=0).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report: since 0.17.0 a stream to a Chromecast with Google TV 4K
freezes on the first frame and ~80% of the time crashes + reboots the
DEVICE — with both the Punktfunk app and Moonlight, while an Xbox
Series S is fine. Root cause: LN1 Phase 3 (67b79810) defaulted Linux
direct-NVENC to 4 slices per frame for EVERY session. Amlogic HEVC
decoders wedge on multi-slice AUs — exactly why moonlight-android
requests slicesPerFrame=1 for every hardware decoder (4 only for
software slice-threading) — and our RTSP parser never read the request.
The Phase-3 commit recorded the untested leg ("a live Moonlight re-test
joins the standing owed Moonlight item"); this report is that re-test.
The slicing ceiling now belongs to the CLIENT, threaded as open_video's
new max_slices from both planes:
- GameStream: parse x-nv-video[0].videoEncoderSlicesPerFrame into
StreamConfig and honor it; absent/out-of-range (pre-auth input) => 1.
- punktfunk/1: new Hello cap VIDEO_CAP_MULTI_SLICE (0x80 — the byte's
LAST free bit; the next cap needs a second byte + ABI bump).
SessionPlan.max_slices = 32 with the bit, 1 without, applied to every
encoder the plan opens so rebuilds can't change the wire shape. The
desktop session client advertises it (FFmpeg/D3D11VA/Vulkan decode
stacks are fine); Android/Apple stay off until they can decide
per-decoder like Moonlight does — the cap is embedder-set decoder
truth, never OR'd in by the shared pump.
- Linux direct-NVENC clamps its Phase-3 default to the ceiling
(resolve_slices(codec, 4.min(max_slices))) and logs slices/max_slices
in the caps-probe line; PUNKTFUNK_NVENC_SLICES stays the explicit
operator override in both directions. Windows keeps its single-slice
default untouched.
Also repairs the nvenc_cuda #[ignore] hardware tests: d2c46eaf added
open()'s cursor_blend param without updating them, invisible because CI
never compiles tests with the nvenc feature.
Verified on .21 (RTX 5070 Ti): pf-encode + host check/clippy clean with
nvenc; host unit suite 263/0; rtsp announce tests 7/7 incl. the new
slicesPerFrame coverage; on-hardware smokes — default e2e still 4
chunks/frame, NEW single-slice client-ceiling test clamps + disarms
chunked poll with no env involved, env escape unchanged. rustfmt clean.
Windows leg (prepare_display param) is CI-only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two ways the tray could misreport a healthy console.
The probe hit `/`, which is auth-gated and 302s to /login, and ureq follows
redirects by default — so TLS, `/`, and a full cold Nitro SSR of /login all had
to fit inside one 2s budget, and a console that was merely warming up read as
down. It now probes /login with redirects(0) on the agent: one round trip, and a
302 already counted as up. Neither user of that agent wants redirects; the
summary is a terminal JSON route.
The menu then opened https://localhost:<port> while the poller probed 127.0.0.1.
web-run.cmd binds HOST=0.0.0.0 — IPv4 only — and Windows resolves localhost to
::1 first, so the tray could call the console healthy and hand the browser a URL
that fails. Both are 127.0.0.1 now, which is what the Linux tray already did, so
the menu can no longer disagree with the status printed above it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every update started from the web console killed the tray permanently. Three
pieces had to line up, and they did: the installer's StopTrays force-kills every
punktfunk-tray.exe (it is one of the files being replaced), its relaunch is a
[Run] entry flagged skipifsilent, and the in-console updater spawns the installer
with /VERYSILENT. So the tray died on every such update and waited for the next
sign-in, because the Run value is a logon trigger. Confirmed on a box whose tray
was absent while its Run key, its exe and a session signed in hours before the
update were all present.
The installer cannot fix this itself. Spawned from the SYSTEM host service, its
`runasoriginaluser` resolves to SYSTEM — so relaunching there would place a
SYSTEM-owned tray in the user's session, where it would hold the per-session
Local\PunktfunkTray mutex and block the real tray at the next sign-in. Strictly
worse than the bug.
The host does it instead, because only the host has the right token. IntentRecord
gains tray_was_running, captured before the installer is spawned (reusing the
conflicting-host scan's process snapshot rather than opening a second one), and
boot reconciliation reads it off the intent before reconcile consumes it and
relaunches through tray::start(). Restored on both terminal outcomes — a
rolled-back install killed the tray just as dead as a successful one — but never
while an apply is still in flight, where the installer may only kill it again.
The field is serde(default), so an intent written by an older host reads as false
and behaves exactly as before; covered by a test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The status tray is a per-user, per-session GUI process with no recovery path of
its own: the HKLM Run value only fires at sign-in, and nothing in the product
ever restarts one. Anything that kills a tray — an upgrade's StopTrays, a crash —
therefore left the operator without an icon until they signed out and back in,
with no way to ask for it back.
windows/tray.rs becomes the one place that knows tray lifecycle (find, start,
stop, is-running), so the CLI and post-update reconciliation share an
implementation rather than growing two.
start() cannot simply spawn the exe, because the launch crosses a privilege
boundary in one direction only:
- from the host service (SYSTEM) the tray must land in the active console
session under the LOGGED-IN USER's token, which is WTSQueryUserToken +
CreateProcessAsUserW and needs SE_TCB — SYSTEM-only;
- from a shell already in the console session that call fails (an administrator
does not hold SE_TCB either) and a plain spawn is both sufficient and right.
Trying the privileged path and falling back discriminates the two without
inspecting a token, and adds no unsafe to the crate. But a plain fallback from
ssh/RDP would put a tray in a session nobody can see and report success, so on
failure it consults console_session_mismatch() and refuses with an explanation
instead. stop() is graceful first (--quit lets the tray remove its own icon via
NIM_DELETE, as the uninstaller does), then forces any instance in another session.
Verified on Windows: launched from a SYSTEM scheduled task the tray comes up in
the console session owned by the interactive user, not SYSTEM; from an ssh
session (0 -> console 2) it refuses; a second start is a no-op; stop is graceful
and idempotent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PunktfunkWeb task registers RestartCount=10/PT1M, but Task Scheduler honours
restart-on-failure only when the action crashes or fails to start — never for a
plain non-zero exit. So any bun exit left the console down until the next boot or
interactive logon, with the host still streaming perfectly next to it. Seen on
glass on a 0.22.3 upgrade: `web setup` started the task one second after the new
service came up, bun exited at once (Last Run Result 0xFFFFFFFF), and the console
sat dead for hours while its box served clients — which reads to an operator as
"the host is gone".
web-run.cmd now supervises bun instead of exiting with it: restart on any exit,
indefinitely for a console that served a while and then died, but give up after
10 CONSECUTIVE fast exits so a genuinely broken install still surfaces as a failed
task. Any run lasting >= 60s resets that counter. It exits 0 rather than
respawning when the payload disappears, so an uninstall does not make it spin.
Uptime is measured with an unambiguous clock rather than parsed out of %TIME%,
which is locale-formatted (12-hour locales append " PM", many others use ',' as
the decimal separator) — the box this was found on prints 18:36:28,39.
stop_web_console() polls until :47992 is really free instead of sleeping a blind
second: both `schtasks /end` and `taskkill /F` are asynchronous, and web_setup
starts the new task immediately afterwards, where bun cannot bind a port the
corpse still holds. It re-ends the TASK halfway through, not just the listener —
with the launcher now supervising, killing bun alone would be undone by its own
restart loop.
The systemd unit had the same shape of hole: Restart=on-failure leaves a console
that exited 0 down. Now Restart=always, which an explicit `systemctl stop` still
overrides.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A field log (2026-07-30) showed a game driving the virtual DualSense's output
endpoint at >2 kHz sustained for tens of seconds. The 8-slot ring — sized on
the assumption that 2 kHz is double any real HID output rate — overflowed
every 4 ms poll, which force-silenced rumble for each storm's whole duration
and flooded the log at ~230 WARN lines/s (96 % of the user's 5000-line
web-console export, evicting the session history it was needed to diagnose).
Three legs, negotiated so every old/new host×driver pairing keeps working:
- pf-driver-proto: the ring grows in place 8 -> 56 slots; PadShm becomes
exactly one page (4096 B), the hard ceiling that keeps cross-generation
section views mappable. A new out_ring_len field carries the driver's side
of the length negotiation. Deliberately NOT a GAMEPAD_PROTO_VERSION bump
(that fails closed - no pad at all).
- pf-gamepad driver: picks its ring length from the host's out_ring_ver
stamp (>= 2 + a full-size map -> 56) and echoes it before every ring_head
bump (now a Release store), so an Acquire-observing drain always reads the
modulo that indexed the slots it copies.
- host drain: follows the echo (0 = old driver = 8); on genuine overflow it
now salvages the legacy latest-report slot - the freshest coalesced state -
instead of total silence, and the per-poll overflow WARN is rate-limited to
1 line/s per pad with a suppressed count.
Verified on the Windows CI runner (drivers workspace build + clippy -D
warnings against the WDK; 64 pf-inject tests incl. the new negotiation/
salvage/limiter tests; pf-inject clippy -D warnings) and on Linux via the CI
docker image (82 pf-inject tests). DriverVer needs no manual bump - the
installer stamps a strictly-increasing build timestamp per release.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
windows-host.yml's clippy sees what the Linux gate structurally cannot (cfg(windows)
code); verified with the same clippy line on the runner this time, not just cargo check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enabling the plugin runner did everything right and then nothing happened.
The task went Ready with LastTaskResult 1 within a second of every start, and
the console kept showing it enabled-but-not-running with nothing to explain
why. Task Scheduler discards the action's output, so the reason never
surfaced anywhere:
error: EPERM reading "C:\Program Files\punktfunk\scripting\runner-cli.js"
This file already knows why. RUNNER_UNIT_DIRS carries the note that bun opens
the files it loads asking for FILE_WRITE_ATTRIBUTES on top of read, so a plain
(RX) grant makes them die with EPERM — found on glass when the plugin and
script directories were fixed. The runner's own entry script was missed. It
lives in {app}\scripting, which carries only Users:(RX), and LocalService
reaches that through Authenticated Users, so bun could never open the one file
it was started to run. The runner has not been able to start since it moved
off SYSTEM, which is a principal that has full control everywhere and so never
met this.
So {app}\scripting now gets the same (OI)(CI)(RX,WA) the unit dirs get, at
enable, revoked at disable like every other grant here. WA moves timestamps
and the read-only bit and cannot touch content, so the three-way split the
module maintains still holds: code read-only, secrets read-only, only
plugin-state writable. A plugin still cannot rewrite the runner.
Measured on glass on .173, same box, same task, only the ACE differing:
without it Ready/lastResult=1 and no runner process; with it Running, a live
runner, and GET /store/runtime answering running:true — which is exactly what
the console reads. Also checked what bun does with the access: nothing. It
opens the entry for write and never writes, and the directory is byte-identical
afterwards.
Windows-only code, so it is rustfmt-clean and type-reviewed but NOT
compile-verified here — the Windows CI job is the gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The U3.1 leg of planning:host-update-from-web-console.md. The Deck's on-device install
gets the Update-now button with no opt-in (user-owned, no root): update.sh --pull runs
under systemd-run --user so the script's own restart of punktfunk-host can't kill it
mid-build (a child in our cgroup would die with us). Outcome without version equality —
which a source rebuild can't promise: a failed build leaves the host alive to report it
(unit watched to failure, log attached); a successful one restarts us, and the new
source_build intent flag makes intent-present-at-boot itself the success signal (the
script only restarts the host after a successful install). The console stops treating
a live long build as a timeout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The U3.2 leg of planning:host-update-from-web-console.md. The service worker loop —
the one piece that survives an upgrade — detects a fresh update-intent whose target
version is the crash-looping child (≥3 rapid restarts, intent <30 min), then exactly
once: picks the newest cached non-target installer, requires a valid Authenticode
signature, writes a rolled-back result record for the console, deletes the intent,
and spawns the installer silently. No cached installer or a failing signature degrade
to today's backoff loop with a loud log — never a brick, never an installer loop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The U2 leg of planning:host-update-from-web-console.md. A new dep-free root helper
(crates/pf-update) runs the distro package manager against the INSTALLED punktfunk
packages — apt (index refresh scoped to our list when present), dnf, rpm-ostree
(single-transaction re-resolve, reported staged), sysext (the proven signed-feed
updater), pacman only behind the explicit PACMAN_FULL_SYSUPGRADE opt-in — then the
run-the-binary gate, then a root-written result record. Zero attacker-influenceable
parameters end to end: fixed ExecStart oneshot (punktfunk-update.service), polkit rule
scoped to that one unit's start verb for the shipped-EMPTY punktfunk-update group
(joining it is the auditable opt-in; every postinst creates it, none populate it).
The host starts the unit, interprets the record (staged / nothing-newer-yet / changed),
and crosses its own restart on the same intent/reconcile machinery as the Windows leg.
Status now reports staged results and the opt-in hint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The U1 leg of planning:host-update-from-web-console.md. The apply request carries no
version/url/channel (the zero-parameter invariant): the host installs exactly what its
Ed25519-verified manifest announced. Pipeline: staged download (resume + disk preflight)
→ manifest sha256 → Authenticode (valid signature, untrusted root tolerated while the
cert is self-signed; leaf sha256 pinned via the signed manifest, extracted from the SAME
WinVerifyTrust state) → intent record → detached CREATE_BREAKAWAY_FROM_JOB spawn of the
winget-blessed silent flags. The installer kills this process by design; boot-time
reconciliation reports the outcome (update.applied event / a durable failure with the
installer log path) across the restart. Session guard (force to override),
PUNKTFUNK_UPDATE_APPLY=0 kill switch, single-flight via job + fresh-intent detection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The U0 leg of planning:host-update-from-web-console.md: a signed update manifest
(Ed25519, keys pinned in the binary via the plugin-store verify path, serial floor
persisted against rollback, channel-bound, 45-day stale hint) fetched lazily behind
GET /update/status + rate-limited POST /update/check, admin lane only (plugin lane
whole-prefix denied, absent from the cert allowlist). Install kind + channel come
from root-owned facts; deb/rpm/pacman builds now stamp /usr/share/punktfunk/install-kind.
Emits update.available once per discovered version.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>