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>
Adds a VirtualHere section to the plugins page — what it is, that both halves of VirtualHere
are yours to install and licence, that the Devices tab writes a name-based rule so it survives
the couch rebooting, and that Diagnostics is where to look when nothing happens.
States the coverage limit up front rather than letting somebody discover it: there is no
VirtualHere server for iOS or tvOS, so those clients cannot pass devices through, and nothing
on our side can change that.
Cuts the automation.md recipe from 75 lines to a pointer. It now leads with "use the plugin"
and keeps only the zero-code two-hook version for people who would rather not install one —
with its trade-offs stated instead of implied: the address is hard-coded so it breaks when the
couch reboots, and an abnormal stream end strands the device on the host. Those two failures
are exactly what the plugin exists to fix, so the reader gets to make an informed choice.
Not build-verified: docs-site does not build standalone in this checkout. Checked by hand that
Callout is in fumadocs' default MDX components with a valid `warn` type, that the JSX balances,
and that the cross-page anchor matches github-slugger of the heading.
Co-Authored-By: Claude Opus 5 (1M context) <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>
On-glass feedback: burying the grant in the Controllers screen made the
user go find it. Now MainActivity asks the moment a Sony pad appears — a
fresh attach while the app is open, or the app foregrounding with one
already plugged in — once per attach (a deny doesn't re-nag; the
Controllers card's button stays as the re-ask). Nothing starts on the
grant: an uncaptured pad is an ordinary InputDevice at menu time, so the
grant is simply recorded and the next stream's capture engages silently.
The grant broadcast is shared with the Controllers card so an open card
refreshes from either dialog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A DualSense on a phone had rumble only where the kernel exposed force
feedback, and adaptive triggers / lightbar / player LEDs nowhere — Android
has no platform API for any of them, and Bluetooth offers no raw path
(L2CAP is LE-only; hidraw is root-sealed — Sony's own Remote Play declares
Android triggers unsupported). Claiming the pad's HID interface over USB is
the one unrooted route, so that is what the client now does.
- HidUsbLink: the device-agnostic half of Sc2UsbLink (claim, multiplexed
UsbRequest loop, newest-wins write queue, signalled-unplug discipline),
parameterized by device match / interface filter / keep-alive. Sc2UsbLink
keeps only its SC2 specifics (Puck interfaces 2..5, lizard refresh).
- GamepadFeedback.PadFeedbackSink: 0xCA rumble + 0xCD Led/PlayerLeds/
Trigger now route to a capture link that owns the pad BEFORE the
InputDevice vibrator/lights paths — Trigger stops being log-and-drop.
- DsDevice: the byte-exact inverse of the host's dualsense_proto /
dualshock4_proto — input report 0x01 parse (buttons/sticks/triggers,
gyro+accel, both touch points; Edge FN/BACK → wire paddles) and output
builders (DS5 0x02 valid-flag-selective incl. the 11-byte trigger blocks
and the lightbar-animation release; DS4 0x05 as composed full-state
writes). Covered by DsDeviceTest (pure JVM).
- DsCapture: stream-mode capture for DualSense / Edge / DS4 — lazy wire
slot on the first parsed report, typed mirror (exit chord included),
touch normalized onto the rich plane + per-report motion, feedback
rendering with a rumble backstop (a USB pad holds its level, so a
stalled poll thread self-terminates via a scheduled zero-write) and a
teardown motor-stop over EP0. The claim releases the pad's InputDevice
slot itself so the wire index hands over deterministically; uncaptured
(toggle off / permission denied / Bluetooth) the pad stays on the
ordinary InputDevice path.
- Rich-input shims: nativeSendPadTouch / nativeSendPadMotion →
RichInput::Touchpad / Motion — the plane the desktop and Apple clients
already feed; Android pads gain gyro + touchpad on the virtual pad.
- Settings: "DualSense / DualShock passthrough (USB)" (ds_capture, opt-out
like the SC2 toggle); Controllers screen card with capture status and a
front-loaded USB grant so streams start without the permission dialog.
The host needs nothing: the DS5/DS4 backends already consume the typed +
rich planes and already emit every feedback event rendered here.
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 branch minted the lock entry at 0.22.2 and the rebase onto the version bump
left it stale — --locked builds fail until it matches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The apply panel serves staged kinds too; notify mode shows the group-join command when
the helper is installed but not yet enabled; outcomes render 'staged — reboot to
finish' and 'your package source had nothing newer yet' distinctly. Docs: the Linux
opt-in section (why it's a group, what the grant bounds, the pacman stance).
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>
apply.post.ts intercepts the one proxied route that restarts the host: the console
password is re-verified per apply (login throttle shared, stripped before forwarding) so
a 7-day cookie alone can't do it. New Sec-Fetch-Site same-origin check on every mutating
request (login CSRF included). The card's apply flow: confirm dialog → live-session
force escalation → download/verify/restart progress rendered from the last snapshot
while polls fail (the host and, on Windows, this very server restart mid-flow) →
durable success/failure from last_result. Docs: the one-click section + kill switch.
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>
printf '%s' hands jq -R no line at all for an empty value; its empty output made
--argjson invalid. Seen on the first live canary-manifest run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit a12c8bc99d41c4f7b8e4c426d560171b2b12dd5e)
Windows-installer patch release. 0.22.1 and 0.22.2 shipped without the web
console in them at all — confirmed from the tag builds' own logs, where the
console build step is `skipped` on a cache hit and the job is still green —
which also removed the only code that stopped bun before the installer
replaced it, hence the "DeleteFile failed; code 5" dialog. Both fixed here,
along with updates no longer disabling an operator's plugin runner.
Lock touched for the 30 workspace members only. `base64` stays at 0.22.1 and
the eight gtk-rs crates stay at 0.22.0 — they share our version space and a
blanket sed would move them to versions that do not exist. Diff is
versions-only, 30 insertions and 30 deletions; `cargo metadata --locked`
resolves; `cargo fmt --all --check` clean in both the main and the
packaging/windows/drivers workspaces.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every payload the job bundles is now asserted before packing: the console, the
bun runtime, the plugin runner, the FFmpeg DLLs and VB-CABLE. Each is optional
to pack-host-installer.ps1 — right for a local debug pack, and the reason
0.22.1 and 0.22.2 shipped with no web console: one unset variable omitted it
behind a single line of log and the build stayed green.
CI knows it bundles all five, so a missing input belongs here as a failure
rather than downstream as a quietly smaller installer. FFmpeg is the one that
would hurt most and was silent too: an amf-qsv host link-imports avcodec, so
an installer missing those DLLs ships a host that cannot start at all, and
FFMPEG_DIR is a fallback to a provisioned path that nothing verified.
The shape is borrowed from the packer's own VB-CABLE check, which already
throws on a supplied-but-empty dir "instead of silently shipping an installer
without the virtual mic - exactly the field regression this bundling fixes".
Same lesson, applied to the rest.
Both paths were exercised on the Windows runner: all five unset fails with
exit 1 naming each one, all five present passes with exit 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WEB_OUTPUT_DIR was exported on the last line of "Build + smoke-boot web
console", a step that is skipped on a cache hit. So the second and every
later build with an unchanged web/ and sdk/ left it unset — and
pack-host-installer.ps1 reads an unset WEB_OUTPUT_DIR as "don't bundle the
console" and says so in one line of log before carrying on happily.
The installers that fell out of that have no {app}\web at all: no
web-run.cmd, so `web setup` bails with "web launcher missing" before it
registers anything, so there is no PunktfunkWeb task, no console, and no
firewall rule for 47992. A user is left with a working host, a tray that says
"Open web console (not responding)" for ever, and nothing to reinstall their
way out of, because every rebuild reproduces it.
It also explains the bun.exe lock reported separately. bun.exe ships under
WithWeb OR WithScripting, but the pre-copy stop was #ifdef WithWeb — so in a
console-less installer bun still shipped while the only code that stopped bun
was compiled out, and replacing a running bun.exe is the "DeleteFile failed;
code 5" modal.
Exporting the variable from its own unconditional step fixes it. The throw
alongside is the actual lesson: a missing web\.output now fails the job
instead of silently redefining what the installer is.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Updating to 0.22.1 could die on a modal: "C:\Program Files\punktfunk\bun\
bun.exe — DeleteFile failed; code 5. Access denied." Windows will not delete a
running executable, so that message means a bun was still alive when the copy
reached it, and the installer had no way to recover.
Two things run that bun, and neither is a child of the host service, so
stopping the service — which the installer does correctly wait for — never
touched either. Both are Task Scheduler tasks: PunktfunkWeb (SYSTEM, the
console) and PunktfunkScripting (LocalService, the plugin runner). The only
pre-copy stop we had looked for exactly one of them, and found processes by
task name or by who was listening on 47992/3000. The plugin runner matched
neither clause: different task, and it listens on no port at all. So anyone
who had run `plugins enable` held bun.exe mapped through the whole install and
failed this way on every single update.
The web console could lose the race too. Stop-ScheduledTask returns once
termination has been *requested* and Stop-Process is TerminateProcess, so the
old code went straight from asking to copying; the Rust twin of that routine
has always ended with a deliberate one-second settle. And neither task was
disabled for the duration, while both carry restart-on-failure (web ten times
a minute apart, scripting 999) and the web task also has a logon trigger — so
a force-kill invited a respawn into the middle of a copy that takes well over
a minute at lzma2/max.
StopBunRuntimes now disables both tasks before stopping them, kills any bun
whose image lives under the install dir — by path, so a developer's own bun
survives — keeps the old port sweep for pre-bun installs that ran node on
3000, and then waits until both are actually gone rather than assuming.
bun.exe also gains restartreplace, so if some bun still escapes all of that,
the file lands on the next restart instead of dead-ending the install.
Disabling a task is not free, though: unlike a stopped one it does not come
back at the next boot, so an install that aborts anywhere after that point
would take the console down for good. Hence two restores — a [Run] entry for
the normal flow, and DeinitializeSetup, which Inno calls even when the user
cancels. Both re-enable only what was enabled before the copy, and enabling an
enabled task does nothing, so the pair is idempotent.
Putting things back also turned up a second bug worth naming: the scripting
entry re-registers its task and then unconditionally disables it, which is
right on a first install and has silently switched an operator's plugin runner
off on every upgrade since. The restore runs after both re-registrations and
honours the prior state, so a fresh install still leaves the opt-in runner off.
The Pascal and the PowerShell were both compiled and run on the Windows CI
runner (ISCC, plus the query halves of each command) — but the install path
itself is not yet exercised on glass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bash+openssl signer (raw-64-byte ed25519 over exact bytes, base64 .sig — the
plugin-index format) with the pinned-key cross-check, manifest-then-sig upload
order, and a live-feed self-verify. announce.yml re-hashes the installer against
its sidecar and fail-closes without UPDATE_MANIFEST_KEY; pre-release tags never
enter the stable feed. windows-host.yml grows a Linux canary-manifest job.
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>
The Automatic startup probe bursts at a fixed 2 Gbps two seconds into every
session, deliberately far above any plausible link so the burst measures the
link rather than itself. On links the burst DISTURBS, that backfires: on an
LG G5 (webOS 10.3), three back-to-back connects to the same Gamescope host
split two ways — the two where the probe finished in ~1-2 s had video within
2-4 s, while the one that hit the 6 s timeout showed no video for 14 s. Even
a "successful" probe on that link reported send_dropped=20211. The webOS
client already caps its own speed test at 320 Mbps because an unbounded
firehose starves a 2-3 core TV; core then bursts the same hardware at 2 Gbps.
PUNKTFUNK_ABR_PROBE_KBPS now sets that target, so an embedder that caps its
own speed test can cap ours to match. Unset, zero, or unparseable keeps the
2 Gbps default, so every existing session behaves exactly as before. The
existing opt-out (PUNKTFUNK_ABR_PROBE=0) is no substitute: it leaves the
climb ceiling pinned at the negotiated ~20 Mbps.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cosmetic only, and kept out of the feature commit it rode in on so neither
has to be read through the other. `biome check --write` reindents both
message catalogues from two spaces to tabs (877 lines each, no key, order,
or value touched — verified by comparing the parsed objects) and rewraps the
Logs story fixtures onto one argument per line with trailing commas.
Worth knowing for next time: the catalogues are also written by inlang, which
formats with two spaces, so an edit made through that tooling will pull them
back. Nothing depends on either shape — `bun run codegen` compiles 440
messages for both locales either way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Logs page could only be read in place. Getting a host log into a bug
report meant selecting a screenful of monospace text and hoping the scroll
container gave up the rest.
Two controls in the toolbar now do it properly. Download writes a .log file
named for the moment it was taken; the second button hands the same text to
the OS share sheet where there is one (phones, iPads), and copies it to the
clipboard everywhere else — which is why it is probed at runtime rather than
guessed, and why the button is absent on the one combination where neither
exists (plain HTTP, no Web Share).
Both export what the filters currently match, not the rendered tail: the
1000-row cap is a DOM budget and has nothing to say about how long a file
may be. Lines carry the full date and UTC offset, since a bare wall-clock
time stops meaning anything the moment the file leaves the browser.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Patch release: 0.22.0 and 0.22.1 gave every default-configured Windows host a
controller no game could see. The pf-dualsense -> pf-gamepad package rename also
renamed a HARDWARE id, so PnP matched none of our models, fell through to the
devnode's synthesized USB ids and let Microsoft's inbox input.inf win — HidUsb
cannot start on a software-enumerated devnode, and without a start there is no
device interface to answer a channel proof. This cut carries that one-line
restore, the [Models]-vs-host guard test, and the Punktfunk display-name rebrand
across the Windows devices and firewall rules.
Windows hosts only; clients and Linux hosts are untouched.
Versions-only lock diff, hand-applied: the 30 workspace-member entries move
0.22.1 -> 0.22.2. Two sets of third-party crates deliberately share our version
space and are untouched — `base64` at 0.22.1, and the eight gtk-rs crates at
0.22.0 (cairo/gdk-pixbuf/gio/graphene/pango); a blanket sed would corrupt both,
so the bump matches on member name only. `cargo metadata --locked` exits 0 on
the pinned toolchain. Release notes in docs/releases/v0.22.2.md seed the release
body per the Model-1 flow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Device Manager, the firewall list and the monitor name all said "punktfunk". The brand
is Punktfunk.
Renamed: the [Strings] blocks of all four driver INFs (device descriptions, install
disks, provider, manufacturer), the `description` on every SwDeviceProfile the host
creates, pf-mouse's HID manufacturer + product strings, pf-vdisplay's IddCx endpoint
friendly + manufacturer names, the EDID 0xFC display-name descriptor — so Windows now
shows `Generic Monitor (Punktfunk)` — and the netsh firewall rule names.
The EDID edit is a single byte (0x70 -> 0x50) and needs no hand-patched checksum:
Edid::generate_with already recomputes both block checksums after patching the serial.
Deliberately left lowercase, because these are IDENTITIES rather than display names and
renaming them would orphan installed state:
* the SwDeviceCreate enumerator `w!("punktfunk")` — it IS the SWD\PUNKTFUNK\... path
every pad instance id is built from
* pf-paths' `join("punktfunk")` — C:\ProgramData\punktfunk
* the CN=punktfunk-driver cert subject, which purge_driver_certs and both driver build
scripts match by string
* install.rs' `lo.contains("punktfunk virtual display")` probes, whose haystack is
to_ascii_lowercase()d, so they already match the capitalised name
Nothing is orphaned by the renames that DID happen either: netsh rule names,
Get-NetFirewallRule -DisplayName and PowerShell's -match are all case-insensitive, so
the firewall delete paths and reset-pf-vdisplay.ps1's -AdapterName / -GhostMatch
defaults still reap what every release up to 0.22.1 created.
Cosmetic, with two consequences worth knowing: it takes a driver rebuild + re-sign to
appear at all, and an existing devnode keeps its cached FriendlyName until it is
recreated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0.22.0 and 0.22.1 hand every default-configured Windows host a controller no game can
see. `GamepadPref::Auto` resolves to DualSense, so this is the pad almost everyone
gets — which is why it reads as "controllers are broken" rather than as one identity
being broken.
The pf-dualsense -> pf-gamepad PACKAGE rename (560e663a) swore the four hardware ids
were untouched, and then renamed one: `WinDsIdentity::dualsense()` started advertising
`pf_gamepad`, a hardware id no INF of ours declares. pf_gamepad.inx still binds
root\pf_dualsense / pf_dualsense / pf_dualshock4 / pf_dualsenseedge / pf_steamdeck,
deliberately — they are the binding contract with every already-installed system.
PnP therefore matched none of our models and fell through to the USB ids the same
devnode synthesizes for the DualSense identity (USB\VID_054C&PID_0CE6, USB\Class_03),
where Microsoft's inbox input.inf wins on signature. HidUsb then bound a
software-enumerated devnode with no USB port behind it and could not start:
CM_PROB_FAILED_START. No start means hidclass never enumerates the collection PDO, so
there is no device interface, so the devnode cannot answer a channel proof, so the v3
delivery gate correctly refuses to hand over the DATA section. Every layer did its
job; the hardware id was wrong.
Measured on .173 against a clean 0.22.1 install — all four identities served by the
one pf_gamepad.inf package:
DualSense pf_gamepad -> input.inf / HidUsb FAILED_START
DualShock4 pf_dualshock4 -> oem74.inf / MsHidUmdf attached
Edge pf_dualsenseedge -> oem74.inf / MsHidUmdf attached
Mouse pf_mouse -> oem75.inf / MsHidUmdf attached
and `devgen /add /hardwareid "root\pf_dualsense"` binds oem74.inf, MsHidUmdf,
CM_PROB_NONE, 'punktfunk Virtual DualSense' — the value restored here.
hwid_matches_inf parses pf_gamepad.inx's [Models] and asserts every hardware id the
host puts on a pad devnode is declared there, with a vacuity assert on the parse so a
shape change fails loudly instead of passing empty. DS4_HWID / DECK_HWID exist so the
test pins the same constants the create paths use. This is the guard the rename needed:
the ids have to outlive any future package rename.
The test is cfg(windows) and has NOT been compiled or run here (no Windows toolchain on
the authoring box) — it needs a Windows leg to go green.
Reported twice on 2026-07-30, a GameSir G8+ and a DualSense, both via Android. The host
log names it exactly: `driver=pf_gamepad ... PnP problem code 10` with
`store=driver package present in the driver store`, i.e. not stale drivers and not a
failed install.
Ship this THROUGH THE INSTALLER. install.rs notes that re-creating a SwDevice with a
known instance id revives the existing devnode with its previously-bound driver and
never re-ranks against the store. Instance ids do not change here, so a box already
holding a PF_PAD_0 phantom bound to input.inf would revive input.inf even with the
right hardware id. `driver install --gamepad` sweeps the phantoms; a bare host-binary
swap does not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Patch release: 0.22.0 shipped the wrong program as punktfunk-session (the
b0ea1e6b clobber), which broke connecting on the Windows and Linux clients.
This cut carries the restore, the CLI's self-documentation, and the CI gates
that run the shipped binaries.
Versions-only lock diff, hand-applied: the 30 workspace-member entries move
0.22.0 -> 0.22.1; the eight third-party gtk-rs crates that also sit at 0.22.0
(cairo/gdk-pixbuf/gio/graphene/pango) are untouched. `cargo metadata --locked`
exits 0 on the pinned toolchain. Release notes in docs/releases/v0.22.1.md
seed the release body per the Model-1 flow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 0.22.0 clobber proved a gap no build gate covers: a wrong program wearing the
right binary name compiles green. `cargo build -p punktfunk-client-session` happily
shipped the GTK shell's three-line Windows stub as punktfunk-session, because nothing
between commit and release ever EXECUTED the result.
Two integration tests close the class, and they run under gates that already exist
(ci.yml's workspace test on Linux, windows.yml's test step on Windows):
- contract_smoke spawns the real punktfunk-session against a refusing port and
asserts the stdout contract answers — whatever fails first on the machine
(presenter init headless, the dial elsewhere), the binary must SAY so in a
contract line. Proven non-vacuous by planting the 0.22.0 stub and watching it
fail, then pass again on the real main.rs.
- cli_smoke runs the real punktfunk over its help surface (stdout, exit 0) and an
unknown verb (stderr, exit 5) — store-free and network-free, safe on any runner.
windows.yml now gates punktfunk-cli in all four steps (build, clippy, fmt, test): the
MSIX has shipped its `punktfunk.exe` alias since bf981027, but only the release
workflow ever compiled it — a PR could break the CLI and find out on tag day.
The session README also stops selling `--pair` as the enrolment route (deprecated by
bf981027, `punktfunk pair` is the door) and says out loud what this binary is: a
deliberately dumb renderer the GTK shell, the WinUI shell and the CLI all call into
through one brain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`punktfunk help` was one usage blob, and `punktfunk pair --help` was worse than
nothing: the flag fell through to the verb, which read "--help" as its subject,
printed a terse usage line to stderr and exited 5. For the command that is about to
be the documented door for scripts and plugins (Playnite shells to `library --json`),
"self-documenting" has to actually hold.
Now `punktfunk help <command>` — and `--help`/`-h` after any verb, caught BEFORE
dispatch so no verb can mistake the flag for its subject — prints that command's own
page: flags, what lands on stdout vs stderr, and which exit code means what, which is
the part a script author actually needs. Help goes to stdout and exits 0; an unknown
topic refuses with 5. A unit test walks USAGE and asserts every advertised verb has a
help page that leads with its own invocation, so the overview and the pages cannot
drift apart, and an integration test runs the REAL binary over both spellings.
`reachable` also stops scolding: probing an unsaved address is that verb's documented
use, but it resolved through the saved-host path first, whose "pair it first" advice
printed before the probe ran. It resolves quietly now — same lookup, no lecture.
Verified on the Windows CI runner (clippy -D warnings, fmt, 8/8 tests, and the built
punktfunk.exe by hand: overview, per-verb pages, quiet `reachable` exit 2) and in the
Linux CI image (same gates, 8/8). One field note from the hand run: the exe needs the
FFmpeg DLLs beside it or on PATH — true of the session binary already, and the MSIX
ships them next to both, so packaging is unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Connecting from the 0.22.0 Windows client bounces straight back to the host list, on
every host. The shell is fine; the binary it spawns is not.
b0ea1e6b was a `clients/linux` change that also dropped a verbatim copy of the GTK
shell into `clients/session/src/` — app.rs, cli.rs, the four ui_*.rs, shortcuts.rs,
spawn.rs — and, fatally, OVERWROTE `clients/session/src/main.rs` with the shell's.
That file is `[[bin]] punktfunk-session`. So the binary every shell execs for a stream
stopped being the Vulkan session and became the GTK shell:
- Windows: the shell's `#[cfg(not(target_os = "linux"))]` arm — print
"punktfunk-client is Linux-only" to stderr, `exit(2)`. It never writes a single
line of the stdout contract, so the shell sees EOF with no `ready`, no `error`,
no `ended`, and returns to the host list with a BLANK banner. Exactly the report.
- Linux: `app::run()` sees `--connect` in argv and calls `exec_session()`, which
execs `punktfunk-session` — itself. A stream is an exec loop.
CI could not catch it. The Windows leg of the clobbered file is a three-line stub that
compiles perfectly; `cargo build -p punktfunk-client-session` stayed green while
building the wrong program. Only running it fails, and nothing runs it.
61bdf11e then read the 327 E0433s on the Linux leg as a missing-manifest bug and
declared gtk4/libadwaita/relm4 on this crate. That fixed the build of the wrong file
and cemented the clobber. Both go: the copy is deleted, `main.rs` is restored from
bf981027 (the last commit that touched the real one), and the manifest loses the GTK
block plus the gresource build-dep and `data/` that arrived with 944c03dd to feed it.
serde_json returns to `optional`/`ui`, which is what it always was — the reason
`--no-default-features` didn't compile was cli.rs, and cli.rs was never ours.
Also closes the hole that made this silent. `SpawnEvent::Exited` now carries the
child's exit code, and a child that exits nonzero having said NOTHING gets a banner
naming that code instead of an empty string. Code 0 (stream window closed) and -1
(our own Disconnect/Cancel kill) stay silent, as before. A wrong or crashing session
binary is now a legible failure rather than a connect that quietly does nothing.
Verified. Windows x86_64 on the CI runner: check, clippy -D warnings, rustfmt, tests
(5 passed, incl. the new one) all green, and the rebuilt punktfunk-session.exe answers
`--connect` with `{"error":"presenter: SDL window: …"}` — the real contract — where
0.22.0's answered with nothing. Linux amd64 in the CI image: check with default AND
--no-default-features, clippy -D warnings, rustfmt, tests green, and the built binary
emits `{"error":"presenter: SDL video: …"}` + exit 4 instead of exec-looping.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I added it yesterday on the theory that cargo's fingerprints would sort out
a restored in-tree target/. They can't: act gives each run a different
absolute workspace (~/.cache/act/<hash>/hostexecutor), and a target dir
restored under a new path carries state pointing at the old one. Measured
today — pf-umdf-util died with 14 x 'unable to create file lock (os error
3)' and failed the job. ~1 min of rebuild is the right price, and it's the
same rotation that made the other Windows jobs use a fixed C:\t.
The web console cache stays: it restored cleanly and skipped its ~2.5 min
build+smoke, taking the job 13.6 -> 8.5 min in the same measurement.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The new windows-host cache steps reported '::warning::Failed to save' and
nothing ever seeded. actions/cache probes for zstd, doesn't find it, falls
back to gzip — and Git's GNU tar then shells out to a gzip that is not on
the runner daemon's PATH: 'Child returned status 127', 'cache.tgz: Cannot
write: Broken pipe', tar exit 2. Since save failures are warnings, the job
stayed green while caching silently did nothing.
zstd (+ a staged gzip.exe as insurance) now installs into its own directory
— never Git's usr\bin on PATH, which would shadow Windows' find/sort/echo.
Applied live to the runner and added to the machine PATH; this step keeps a
rebuilt runner honest.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
256 commits since v0.21.0. Minor, not patch: the headline is settings profiles —
named bundles of overrides bound per host, landing on all five clients at once
(Linux, Windows, Apple, Android) with one settings surface editing either layer,
marked-and-resettable override rows, per-profile colours, host bindings, one-off
"Connect with", and pinned host+profile cards. With them: the punktfunk:// link
grammar (one parser, one 44-case vector file run by the Rust, Swift and Kotlin
suites), double-clickable shortcuts, and `punktfunk` — one headless front-end
over the brain layer, which wakes a sleeping host the way a card click does.
Also: opt-in HDR on the gamescope path plus the cursor-in-the-node patch that
makes those sessions zero-copy; Vulkan Video 10-bit so AMD/Intel HDR keeps the
good path; a zero-copy NVENC HDR leg; host OS detection with marks on every
client and the console; PUNKTFUNK_HOST_NAME, PUNKTFUNK_MAX_FPS and
PUNKTFUNK_VDISPLAY_HZ_MULT; monitor enumeration on Windows; and the release-
integrity work (per-asset SHA256 sidecars, a signed sysext feed, one stable
driver publisher identity, fail-closed signing guards on a v* tag).
Wire protocol stays at 2, the embeddable C ABI at 13 and the Windows virtual-
display driver protocol at 6 — 0.18-0.22 hosts and clients keep mixing freely.
The Windows virtual-GAMEPAD channel protocol goes 2 -> 3 and fails closed both
ways: it carries the fix for a LocalService principal being able to take over a
pad's shared input section and forge HID input into the interactive desktop, so
the host and its drivers must ship together. The installer ships both.
Additive elsewhere: an advisory mDNS `os=` TXT key, HostInfo.os/os_name,
MonitorsResponse.pin_supported, an eighth field on the Android JNI discovery
record, and appended-last StoredHost fields per the frozen app-widget contract.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file had accumulated the gamescope-HDR work as it landed — one "New: HDR on
the gamescope path" section plus nine Under-the-hood bullets (the gamescope
patches, Vulkan 10-bit, the per-codec probe, the NVENC HDR leg, the EFC BT.2020
model, the managed-spawn flag check, the CI wiring). All of it landed AFTER
d0889338 was tagged: `PUNKTFUNK_GAMESCOPE_HDR` does not exist at v0.21.0 and
neither does packaging/gamescope/, verified by content rather than by SHA.
The live release body never carried any of it — it was PATCHed from the trimmed
file at c4e80fd4 and not re-synced since — so this only restores the file to the
7030 bytes users actually see on the release page. That matters because
announce.yml's apply_release_notes re-asserts this file over the live body: a
re-announce or a tag re-point would have rewritten a shipped release to
advertise features it does not contain.
The content itself is not lost; it is where it belongs, in v0.22.0.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flathub fetch has failed 10/10 retries for months, blamed on fleet load
and DNS tuning. It is neither. Measured on home-runner-2, all inside ONE
container: getent resolved dl.flathub.org, curl fetched the same URL with
HTTP 200 (auto and -4), and flatpak still died '[6] Could not resolve
hostname'. Rewriting resolv.conf to a real nameserver didn't help; the
default bridge failed too; --network host works every time. So it is
ostree's own resolver against Docker's embedded 127.0.0.11, and removing
that resolver from the path is the fix. retry.sh stays as the backstop for
genuine upstream blips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`punktfunk-gamescope-3.16.25.pfhdr2-1` off the pacman repo cannot start on SteamOS 3.8.16:
`/usr/lib/libstdc++.so.6: version 'GLIBCXX_3.4.35' not found`. The Arch container CI builds it in
is on gcc 16.1.1; SteamOS ships libstdc++ 3.4.34 and moves when Valve says so. Nothing else about
the binary was wrong — every other soname resolved on the box, and glibc was never close (it asks
for 2.38 at most against SteamOS's 2.41) — so the one dynamic C++ runtime was the whole reason the
gamescope backend's own most important platform got a package that dies at `--version`.
The C++ runtime therefore goes static, for the same reason wlroots already does: this binary is
built on a rolling distro and has to start on a frozen one. It is safe here because gamescope
links no shared C++ library at all — its NEEDED list is all C, and glslang/SPIRV are build-time
only — so no C++ ABI crosses a shared boundary. Cost is ~1 MB (5.9 → 7.1). The flags are appended
to LDFLAGS rather than passed as `-Dcpp_link_args`, which would replace the value meson derives
from the environment and silently drop makepkg's `-z relro`/`-z now`/`--as-needed`.
A static runtime is invisible in a passing build and only surfaces as a binary that will not start
somewhere else, so the build now asserts it: no `libstdc++` in NEEDED, which needs no version
threshold to check and turns the regression back into a build failure.
Verified by building in `archlinux:base-devel` — the environment arch.yml uses, gcc 16.1.1 and
glibc 2.44, both far newer than the target — and running the result on SteamOS 3.8.16 with nothing
supplied: banner `punktfunk-gamescope version 3.16.25-4-g6bbe157+pfhdr2`, no libstdc++ in NEEDED,
max GLIBC_2.38, every soname resolving. The host's whole HDR gate chain then answers on SteamOS
for the first time (780M / RADV PHOENIX): 10-bit PQ capture offered, cursor painted in-node,
native-plane HDR and GameStream HDR capable both true once `PUNKTFUNK_GAMESCOPE_HDR` is on, false
with the distro's gamescope.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Apple parity with the Android card (a94b1d3c's sibling): the OS mark moves
out of the status row and into the tile, replacing the monogram. The
initial says nothing the name beside it doesn't already say — twice over on
a row of home-worker-N boxes — while the mark identifies the machine at a
glance.
Both host surfaces, because on tvOS the console home is the only one there
is: the touch cards (saved and discovered) and GamepadHomeView's badge,
which needed the chain carried on HomeTile to reach it. Sized to the
monogram's own point size, so it lands at ~48% of the tile everywhere, and
tinted through the same foregroundStyle the letter used — the assets are
template imagesets, so they follow it like an SF Symbol.
A host that advertises no OS chain, or one we ship no art for, keeps its
letter: `osIconImage` already returns nil for both, so a mixed row still
reads as one set. The mark carries the accessibility label now, since the
status row it used to ride no longer names the OS.
Untouched: the widget draws no monogram, and AboutView's is the app's own
logo, not a host's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1f59498c added pf-frame to tools/cursor-probe (a workspace member) without
the Cargo.lock entry. The pinned 1.96 toolchain shrugs, but Arch's cargo
1.97 refuses to resolve under --locked — every arch build died with
'cannot update the lock file' right after the windows-rs fetch, which made
it look like a cache problem. One line, written by the pinned toolchain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MediaCodec scales whatever it decodes to fill the Surface it renders into,
and the Surface filled the screen — so a stream whose resolution didn't
match the panel's aspect came out stretched. Nothing downstream of the
Surface can correct that; the Surface itself has to carry the aspect.
Size the video to the negotiated mode's ratio, centred, with the remainder
black. The mode is known from the handshake before the first frame arrives,
via a new `nativeVideoSize` (the same `client.mode()` the HUD already
reports as `w×h@hz`); an older native lib returning nothing falls back to
filling, exactly as before.
Input follows the picture. Direct-pointer touch, multi-touch passthrough
and the pen lane all map positions against the size of the node they sit
on, so the gesture layer moves onto the same rect as the video and all
three stay correct by construction instead of each needing an offset
threaded through it. The physical-mouse path can't work that way — its
events arrive from the activity in WINDOW coordinates — so it now measures
against the SurfaceView's rect on screen, subtracting the letterbox origin
and clamping into the picture: a pointer out on a bar has no host position
of its own, and the edge is the honest answer for it.
One deliberate consequence: trackpad swipes that START inside a letterbox
bar no longer register. Trackpad input is relative and could have kept the
whole panel, but one rule — input lands on the picture — beats a mode-
dependent input surface, and the pen lane rides inside trackpad mode too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The capture-latched client (console.rs latched_mouse) never advertises
CLIENT_CAP_CURSOR, so its session resolved cursor_blend=false and asked
Mutter to EMBED the pointer. On a Mutter virtual stream that is a
fiction: since Mutter 48 (7ff5334a, hw-cursor inhibition removed) the
software cursor overlay is suppressed stage-globally whenever any
physical head realizes a HW cursor — dmabuf-recorded frames blit the
view without a pointer, and cursor-only motion schedules no re-record
either (mutter#4939). Probed on-glass on Mutter 50.3: embedded +
relative motion = frozen frame counter; SPA_META_Cursor positions kept
flowing in the same setup.
So the no-channel session now takes the path that was verified end to
end: cursor-as-metadata + the host composites, permanently — the same
arm a channel session lands in after its capture-model flip. Embedded
remains only the can't-blend fallback (libav VAAPI/NVENC, software).
- session_plan::cursor_blend_for grows the no-channel arm (codec +
depth in, the same CUDA-payload prediction handshake makes);
gamescope excluded so patch-2+ keeps its native-NV12 zero-copy shape
- the encode loop's composite refresh + one-shot breadcrumbs now cover
the no-channel session; the park schedule keeps retrying while its
composite is starved (relative-only clients cannot park themselves)
- the compositor retarget re-applies set_hw_cursor — the rebuilt
display used to come up EMBEDDED even for channel sessions
- the GameStream virtual source takes the same rule (it never has a
channel); its stream_body blend flag mirrors the request
- punktfunk-probe grows --cursor-nochannel (the latched-capture client,
headless); cursor-probe grows --dump (PPM frames + a content-change
counter, the pixel evidence the embedded A/B lacked)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>