The docs-drift ratchet matches PUNKTFUNK_* by token spelling, not by env
read, so the cbindgen #defines this branch adds — GRANT_POWER,
GRANT_ALL_PRE_POWER and HOST_POWER_CLOSE_CODE — read as new undocumented
knobs. They are ABI constants, not knobs: every sibling grant bit and
close code is already in this file for the same reason.
design/host-actions.md P2. The host has offered sleep/restart/shutdown since
the previous commit; this is where a person can reach them. Every client's host
menu grows the rows right where Wake host appears when the machine is asleep —
finish on the TV, sleep the host from the same menu that woke it.
One shared core: pf_client_core::host_actions holds the discovery read, the
id-only invoke, and a process-wide TTL cache the console, the GTK page and the
Windows tile all read, so three shells cannot end up disagreeing about what a
host offers. Every shell warms it on a refresh tick it already had.
Discovery is PRE-fetched, never fetched when a menu opens: the console screen
holds a cloned row, and rows that appear under a cursor already moving are a
hazard when two of them shut a machine down. For the same reason the console's
armed state became WHICH row is armed rather than a bare flag — a flag let an
arming press on Forget fire Shut down host. Restart and shut down confirm;
sleep is reversible from the same menu, so it goes on one press. An action the
host says it cannot run right now stays listed, disabled, and says why.
Surfaces: console-UI rows + ConsoleCmd::HostAction with session and Android
dispatch; GTK card menu (plus a card-level Toast) with an AlertDialog confirm;
Windows tile menu, its label built and matched through one function so a menu
whose rows outlived their handlers cannot run a different verb; Android touch
home and Skia console; Apple host card and gamepad options. Unknown ids render
from the host's own title on all of them — a later host action needs no client
release to appear.
Typed close: the Swift hostPower case and the Kotlin host-power token. Without
them, sleeping your own host from the couch reads as a crash on those two
clients.
design/host-actions.md P0 (host core) + P1 (web console). A reusable action
registry — GET /api/v1/actions (per-caller permission + honest availability) and
an id-only POST /api/v1/actions/{id} on the mgmt cert lane — with three power
built-ins, so the Wake-on-LAN round trip finally closes from the couch.
Core: GRANT_POWER (1<<6) joins the mask; GRANT_ALL 0x3F -> 0x7F; the legacy-full
read rule (an explicitly stored pre-power 0x3F reads as the current GRANT_ALL,
so old Full records neither render Custom nor silently lack Power); typed close
RejectReason::HostPower (0x6B) so ended sessions say why.
Host: power executors (logind via zbus behind the already-shipped polkit rule,
deliberately without -ignore-inhibit; Windows SeShutdownPrivilege +
InitiateSystemShutdownExW / SetSuspendState; macOS answers 501); ordering
reply-202 -> typed close -> 1 s grace -> act; single-flight; another device's
live session blocks a cert-lane invoke (409); action.invoked on the event bus;
denials logged once per (fingerprint, action) per boot. The plugin token gets
neither route.
Console: Host-power card (password-confirmed, the update-apply recipe) + a BFF
route that keeps the invoke off the ungated catch-all; Host power toggle in the
Access sheet. Mirrors: web/Kotlin/Swift consts + legacy rule, pf-client-core
preset_label now masks (an old client no longer renders a new host's Full as
Custom). Tray's service-restart row renamed Restart Punktfunk before the
clients ship a machine-level Restart host. Docs: host-power page, access-levels
row, WoL cross-link. openapi/header/SDK regenerated.
Two field sessions on 0.32.0 (Windows client over ZeroTier, Android over
Wi-Fi) plus a local repro showed every Automatic session ratcheting
20 -> 2.3 Mbps on the first loss episode and never climbing back.
Phase 4 made the bitrate a wire budget: the encoder is handed
enc_kbps(budget) and the read-back maps through budget_kbps(). That
roundtrip deflates 1-2 kbps by design (the inverse never inflates), and
the applied < requested compare then recorded a phantom
encoder_ceiling_kbps on EVERY successful apply. The control task clamps
each later SetBitrate to it, so every climb was acked short at the
current rate, the client controller learned it as a host cap
(reprobe backoff 16 -> 32 windows), and each descent lowered the
"ceiling" further: a one-way ratchet to the floor. 0.31.4 was immune -
its read-back stayed in encoder units, so an in-range apply compared
equal. EncDerive::applied_budget_kbps now reads back in the request's
own truncated terms: only an apply short of what the request itself
derives to - a genuine driver clamp - records a ceiling. Regression
test proven against the backed-out fix.
The startup link-capacity probe fired on a flat 2 s timer, but host
bring-up can take 6-8 s (IDD display acquisition), so the 940/464 Mbps
burst landed exactly on the first IDR - black video for 5-11 s, the
decoder refusing AUs ("PPS 0 not seen"), 2101 audio underruns - and the
result missed the 6 s timeout anyway (bring-up plus the burst's own
queue drain and QUIC loss-recovery backoff: 8.6 s measured), so the
ceiling stayed negotiated: all disturbance, no measurement. The probe
now arms only once a frame has completed, and the timeout is 15 s so a
slow answer still teaches the ceiling.
VIDEO_CAP_HDR invites a PQ stream, and on a Windows box with no HDR10
swapchain the D3D11VA hand-off shows it through the video processor's
PQ->sRGB tonemap — a driver capability nothing ever validated. The Blt
accepts the colorspaces either way and renders garbage where the
conversion is missing (host-side sibling: NVIDIA RGB->P010 'renders
green'). Field 2026-08-26: an Arc A370M client went green on every HDR
session while AV1 8-bit SDR at the same 2880x1620@120 streamed clean.
video_d3d11 gains pq_tonemap_supported() — one throwaway device +
enumerator asked CheckVideoProcessorFormatConversion for the exact pair
the SDR ring sets (P010 G2084 studio in, BGRA8 sRGB out). Only the
driver's definitive 'no' answers false; API failure keeps today's
behaviour. video::hdr_presentable() wraps it behind the short-circuits
that make it free everywhere it doesn't matter (!d3d11_import,
d3d11_hdr10 pass-through, non-Windows shader tonemap), and the session
binary ANDs the HDR setting with it exactly like the 4:4:4 promise —
loud warn when the switch is on and not honored. ten_bit_sdr stays
unprobed on purpose: 10-bit SDR is no tonemap.
Also: log_layout_once keys on decoder x layout instead of decoder alone,
so a mid-stream Reconfigure or SDR<->PQ flip re-logs the frame-vs-pool
dims — the fact a green-bar/smear report hinges on — for the shape that
failed, not just the one the session started at.
Verified: cargo fmt + dockerized Linux clippy -D warnings green for
pf-client-core + punktfunk-client-session; the cfg(windows) half needs
the Windows CI leg (not compilable from this box).
biome is not in ci.yml's web gate, so formatting and import order had drifted; `biome check
--write` accounts for most of this diff. `biome migrate` moved the deprecated
linter.rules.recommended to rules.preset. public/ and storybook-static/ are excluded — biome
was linting the favicon as if it were inline JSX and asking it for a <title>.
Three findings were real:
- Library lifted the whole entry list into state so the providers card could count owners.
That consumer is gone: `entries` was written on every grid load and read by nothing. The
state, the `onEntries` prop threading it up, and the effect firing it are all removed —
including from LibraryGrid, where the callback had no callers left.
- `flatten` in SourceSettings spread the accumulator per `allOf` branch, rebuilding the object
each time. Object.assign onto an accumulator that already starts as a fresh copy of the node
is the same result without the O(n²).
- LogsCard's `newestVisible` dep already carried a comment begging the next person not to
"fix" it — it is a trigger, not a read, and removing it silently breaks follow-mode. That
warning is now a biome-ignore, so the tool enforces it instead of prose hoping to.
Build, tsc, 22 tests and biome all clean.
@unom/ui has exported `reloadOnStaleChunk` for exactly this the whole time and nothing had
ever imported it — the copy added a few commits ago was a fourth implementation of a solved
problem. Both routers now call the library.
0.10.0 grew the two things using it turned up: an optional `resolveTarget`, so the recovery
lands on the page the user was navigating to rather than reloading in place, and a try/catch
around the sessionStorage guard, because blocked storage throws on `getItem` and that throw
escaping the listener leaves the tab on exactly the dead page the helper exists to prevent.
The helper installs once, so the callback reads a module-level ref rather than closing over a
router: hydration builds a second router and discards the first, which is the same reason the
console keeps its QueryClient in one.
Deps: @unom/ui 0.10.0 and motion 13.1 (0.10.0 widened the peer, which is what had pinned us to
12), plus @unom/app-ui 0.3.0 for the docs site.
docs-site: build + tsc clean, and the deploy simulation still recovers onto /docs with the
library helper in place. web: build + tsc + 22 tests clean, bun.nix regenerated.
Everything in range moves to current — TanStack router/start/query, fumadocs, radix,
recharts, orval, storybook, biome, paraglide, playwright — and the majors that were
holding both apps a generation back go with them: Vite 7 to 8, @vitejs/plugin-react 5 to
6, vite-tsconfig-paths 5 to 6, @types/node 22 to 26, TypeScript 5.9 to 7. The marketing
site already ran the Vite 8 line against TanStack Start, so this closes a split the
monorepo was carrying for no reason.
Two of the console's majors were fixing a mismatch rather than chasing a number:
@unom/ui@0.9.2 asks for lucide-react ^1.17 and depends on tailwind-merge ^3.6, while the
console was pinned to lucide-react 0.469 and tailwind-merge 2.6.
motion is deliberately left on 12.43. 13.1 is available, but @unom/ui declares motion
^12.40 as a peer and inlines framer-motion 12.38, so moving the app alone would put two
motion runtimes in one tree — and a split motion context is exactly the failure that
neither a typecheck nor a build can see. It moves when @unom/ui does.
docs-site: build + tsc clean. web: build + tsc + 22 tests clean, bun.nix regenerated.
Routes are code-split, so every build names its chunks by content hash and a deploy
replaces the whole `.output`. A tab opened before the deploy still holds the previous
build's HTML, so the first navigation asks for `/assets/*-<oldhash>.js` — which the new
server has never heard of. The dynamic import rejects, nothing catches it, and the page
goes blank. `defaultPreload: "intent"` widens the window: a hover is enough to trip it.
Vite raises `vite:preloadError` for exactly this, and its preload helper wraps both the
dependency preloads and the module import itself, so the event fires before the router
ever sees the rejection. A full page load is the entire fix — the fresh HTML names the
new chunks. Navigating to `latestLocation` rather than reloading in place means a click
that tripped this still lands on the page the user asked for.
Deliberately not `preventDefault()`: that suppresses Vite's rethrow and resolves the
import with `undefined`, handing the router a broken module on the way out. A
sessionStorage stamp keeps a genuinely broken deploy from spinning in a reload loop, and
its read is wrapped because storage can be blocked outright.
Verified on the built docs site under Playwright, serving 404s for the chunks an open tab
asks for: without the handler the page collapses to 32 characters and no <main>, with it
the tab reloads onto /docs and renders in full. Re-verified after the Vite 8 bump.
`check-docs-drift.sh` matches on the `PUNKTFUNK_*` shape, which cannot tell an
environment variable from a cbindgen-exported ABI constant — so exporting
`PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO` read as a new undocumented knob and
turned the job red. Every sibling is already listed for exactly this reason:
the four other `CLIENT_CAP_*`, all eight `HOST_CAP_*`, all eight
`VIDEO_CAP_*`. This one goes beside them, in sort order.
It is not documentation debt being waved through. The bit's user-facing half
is the "Keep host audio playing" row, already written up in client-settings.md
and pointed at from the `PUNKTFUNK_AUDIO_OUTPUT_MODE` table; the embedder's
half is the doc comment cbindgen carries into the header. There is no
configuration surface here to document, because there is no variable.
`keep_host_audio` shipped in 0.32.0 wired end to end — the setting, the
`CLIENT_CAP_KEEP_HOST_AUDIO` ask, the host's capture policy — but only three
surfaces ever offered it: the Linux GTK client, the Windows client, and the
desktop console. Apple and Android carried no reference to it at all, so the
one place a phone or an Apple TV would look for "why did my PC go quiet" had
nothing to find. There is no shared settings schema; each client hand-mirrors
`trust::Settings`, and this row simply never got mirrored.
Apple gains the full tier-P treatment: a `keepHostAudio` defaults key, the
`EffectiveSettings` field, the `StreamProfile` overlay (so a per-host profile
can keep the study PC's headphones live while the TV profile mutes them), the
Audio-section row, and the bit itself OR'd into the connect's `clientCaps`
beside the presenter caps. Android gains the same across `Settings`,
`Profiles` — including the `KNOWN` key set, without which the key reads as
unknown carry-through — the settings row, the console JSON both ways, and a
new `keepHostAudio` argument threaded through `nativeConnect` into the JNI
caps expression.
`pf-console-ui` had parked `RowId::KeepHostAudio` in `desktop_only` with a
comment saying "until the Android session advertises the bit". It does now, so
the gate comes off and the order-sensitive split test loses its entry.
The C ABI never exported the constant: `client_caps` is a passthrough byte, so
nothing needed a version bump, but cbindgen had no rename entry and emitted a
bare `CLIENT_CAP_KEEP_HOST_AUDIO` into every embedder's namespace — the exact
pollution R21 exists to prevent. It now carries the `PUNKTFUNK_` prefix its
`AUDIO_HIRES` sibling has, plus the compile-time drift assert.
Captions are the Linux client's wording verbatim, and the row sits between
audio quality and the microphone on all four clients, because a setting that
reads differently per platform is the same bug in a smaller font. The docs
line claiming "Linux, Windows and the desktop console" now says everywhere.
Verified: swift build + 384 Apple tests; `:app:compileDebugKotlin`;
`:kit:cargoNdkClippy` over pf-console-ui and the Android native crate; and all
205 pf-console-ui tests on Linux in a container, the split test among them.
v0.32.0 gave per-app prep do/undo steps PF_STREAM_WIDTH/_HEIGHT/_REFRESH/_HDR
(hooks::prep_mode_env, both serving planes), but the docs' only example was a
one-liner. Add the entry+script pair that shows the point: the prep entry stops
naming a device and reads the negotiated mode instead - an FPS cap at the
client's own refresh, an HDR arm taken only when the session negotiated it, and
the raster for whatever wants pixels.
Written so the traps are visible rather than explained: undo sees the values its
do saw, HDR is 1/0 (the marker file's spelling) not PF_EVENT_*'s true/false, the
app identity is PF_APP_ID or PF_APP_TITLE by plane, and set -u makes an older
host fail the step loudly instead of capping at 'fps_limit='.
Verified: the fenced script extracted from the doc and run under sh - do writes
the cap and takes the HDR arm, undo restores and reverses it, an SDR session
skips HDR, an empty identity falls through to the literal, and an unset
environment exits non-zero.
Two overstatements in the v0.32.0 notes, both flagged on reading the published
release.
**"anyone who could open your web console page"** was simply wrong, and it is the
sentence the Discord embed leads with. Reaching the pairing routes required an
ALREADY-AUTHENTICATED console session — it was privilege escalation inside the
console (those routes alone among the trust-root actions never re-asked for the
console password), not something an unauthenticated peer on the network could
touch. The notes now say so plainly, and say why it still mattered: pairing is
what grants launch, so a console sign-in became the ability to start a process on
the host.
**"58 security issues"** invites the reading that there were 58 exploitable
defects. There were 58 findings, and the long tail is minor hardening and
build-infrastructure tightening. Both documents now lead with that proportion
instead of the raw count.
Same correction applied to CHANGELOG.md in three places: the section intro, the
`Breaking changes` entry, and the security-review heading.
Nothing about the fix itself changed — only the description of its severity. The
tag does not need re-pointing: `announce.yml` checks out the ref it is dispatched
on rather than the tag, and `apply_release_notes` re-syncs the body from
`docs/releases/<tag>.md` there, so this lands in the announcement. The live
release body is PATCHed to match in the same pass.
Gates: the ISO 24495 auditor is back to its 2 standing findings (NVIDIA and AMD,
company names deliberately left unexpanded); check-docs-links.sh and
check-docs-drift.sh both exit 0.
`ci.yml`'s `cargo test --workspace --locked` has been failing at the very last
step for weeks, on a crate the diff never touched:
Doc-tests pf_capture
error[E0463]: can't find crate for `pf_frame`
--> crates/pf-capture/src/lib.rs:11:5
`pf-frame` is an unconditional dependency of `pf-capture`, the rustdoc command
line carries `--extern pf_frame=<path>`, and Format, both Clippy legs and Build
had all compiled `pf-capture` seconds earlier. The path simply no longer existed
by the time rustdoc opened it.
`tests/c_abi.rs::ensure_staticlib` is what removed it. It shells out to a NESTED
`cargo build -p punktfunk-core --features quic` while the OUTER `cargo test` is
mid-run. That resolves features for one package instead of the workspace union
the outer run resolved, so cargo rebuilds punktfunk-core's subgraph under
different metadata into the SHARED target directory — and the outer run's
pending units, which name `target/<profile>/deps/*.rlib` by explicit `--extern`
path, are left pointing at artifacts that have been replaced. Doctests run last,
so they are what falls over.
The old comment asserted the opposite, and that is the bug in one line:
// The outer cargo's build lock is released during test execution, so this is safe.
The released lock is why the nested build RUNS. It was never why it is safe.
Fix: build into `target/c-abi-harness/` and read the staticlib from there. The
nested build can no longer perturb the outer one, and the harness keeps doing
exactly what it did. Unconditional `--features quic` stays — that is `9c164aa6`'s
fix for a featureless `.a` being silently reused, and it is orthogonal.
Reproduced and verified on Ubuntu 26.04 (the CI base — 24.04's PipeWire is too
old to compile pf-capture's lib test), one variable at a time:
cargo test -p punktfunk-core -p pf-capture --locked --no-fail-fast
before -> exit 101, E0463 on pf_frame
before, --skip c_abi_harness_round_trips -> exit 0 (isolates the cause)
after -> exit 0, harness still PASSes
(abi_version=26, 4 frames round-tripped)
Each from a wiped target dir. `cargo test -p punktfunk-core --test c_abi` also
passes on macOS, where the staticlib path moved.
Why it looked intermittent: whether the doctest step is reached at all depends on
what else in the workspace fails first, and `9c164aa6` made the nested build
unconditional — before that it ran only when the `.a` was missing.
`super::super::stall::classify` in `idd_push.rs`'s test module resolves to
`windows::stall`, which does not exist — `mod stall;` is declared at
`idd_push.rs:332`, so `stall` is a child of `idd_push`, and the test module is
its sibling. A nested `fn` does not add a module level for path resolution,
which is what the extra `super::` was reaching for. The `use
super::stall::{ProbeWindow, StallClass, StallVerdict}` two lines above, in the
same test fn, already had the depth right.
Windows-only and test-only, so it reached main behind a green Build: the error
appears solely under `--all-targets`, when the lib TEST target is compiled.
`package`'s "Clippy (host + tray, Windows)" step has failed on every push since
`11217341` landed it (#413), which is the same shape as #408 — a Windows break
the Mac and Linux dev loops cannot see. The commit that introduced it verified
the logic by extracting `stall.rs` into a native scratch crate, so this test was
never compiled for Windows before it merged.
Reproduced and fixed with `scripts/xcheck.sh`, which covers exactly this and
takes seconds on the Mac:
scripts/xcheck.sh windows clippy # broken: exit 101, error[E0433] at :2528
scripts/xcheck.sh windows clippy # fixed: exit 0
scripts/xcheck.sh linux clippy # exit 0
Rides in the 0.32.0 release branch because it blocks the Windows installer: the
`package` job fails before "Pack + sign installer", so a tag cut over this main
would publish a release with no Windows host attached.
90 commits since v0.31.4 (63 non-merge), cut at db0f4f1e.
**The C ABI steps 25 -> 26.** `punktfunk_connect_opts` closes the
`punktfunk_connect_ex*` ladder: eleven generations each added a field by minting
a new exported symbol plus a 20-something-parameter forwarding shim, and the
replacement takes every option in ONE size-prefixed `PunktfunkConnectOpts`.
Every `ex` keeps its symbol and behaviour, so this is an added surface, not a
changed one. Layout is locked padding-free on both pointer widths (96/68 B) by
const asserts AND by the C harness compiling the same sizes.
The minor bump was already right before the ABI moved: ABR Phase 4 redefines
every control-plane bitrate as the TOTAL WIRE BUDGET rather than the encoder
rate, deliberately without a capability handshake. `include/punktfunk_core.h`
documents the 10-bit SDR ask as "0.32" and says "every pre-0.32 client sets the
two bits together", so the tree had pre-decided the number.
Version table, re-derived at HEAD rather than read off the diff:
* wire 2 -- unchanged. Three additive, ignorable additions: USER_FLAG_REPEAT,
CLIENT_CAP_KEEP_HOST_AUDIO, and Welcome::host_caps2 (trailing, absent -> 0)
carrying HOST_CAP2_REPEAT_MARK
* C ABI 25 -> 26 (above)
* crate dirs 27, 39 members, edition 2024, MSRV 1.85 -- unchanged
* driver proto 6, gamepad channel 3, plugin index 1, host event schema 1,
gamescope +pfhdr8, SDK 0.1.6 -- all unchanged; pf-driver-proto and
punktfunk-host/src/events.rs show no diff at all
* api/openapi.json 0.31.4 -> 0.32.0, content-identical (info.version is the
whole diff); both copies re-stamped and byte-identical to each other
* @punktfunk/plugin-kit stays 0.4.4 ON PURPOSE: plugin-kit/src/wire.ts changes
exactly one line and it is a doc comment in the launch.kind table.
`launcher_ui` is a bare string validated host-side, so nothing to republish.
The CHANGELOG's `heroic-console` entry was filed under v0.31.4, but ae13b29a
landed AFTER that tag -- so that frozen section described a change v0.31.4 does
not contain. Moved into the v0.32.0 section verbatim.
Seven behavioural breaking changes, none of them a version step, all listed in
CHANGELOG.md: the wire-budget redefinition; FLOOR_KBPS 5000 -> 2000; DSCP
defaults to AUTO (local peers only); GameStream video AND control encryption
default on (both graduated after their own on-glass pass on .173, and
`PUNKTFUNK_GS_ENCRYPT=video` is the new middle rung); PyroWave forces Automatic
bitrate; the console pairing routes move behind the console password (the
security review's critical finding -- a console session cookie alone reached
code execution); deep links auto-dial by stable record id only.
Release notes rewritten to the ISO 24495-1/-5 plain-language rules: sentences
under 30 words, paragraphs under 6 sentences, bold bullet lead-ins, an explicit
overview, a table of contents, and acronyms defined on first use. The plugin's
own text auditor reports 0 findings on the Play notes and 2 on the release
notes, both of which are the words NVIDIA and AMD -- company names, not
acronyms, deliberately left unexpanded.
`cargo audit` stays GREEN: h2 0.4.18 -> 0.4.19 closed RUSTSEC-2026-0258, left
open deliberately at the v0.31.1 cut. Only two allowed `unmaintained` warnings
remain (audiopus_sys, paste).
The Play whatsnew leads with the Android black screen, which is the one fault a
phone or TV user in this cycle could not work around.
Gates on the cut (the Mac): cargo fmt --all --check; cargo metadata --offline;
lock diff versions-only 36/36; cargo test -p punktfunk-core --lib --features
quic 503 passed / 0 failed; the C ABI harness PASSED (abi_version=26); the
regenerated header is not stale; check-docs-drift.sh; check-docs-links.sh; the
Play notes gate 412/500 chars and unique against 15 files; both openapi copies
cmp identical; repo voice scan clean; cargo audit green.
Why `configure()` passes and `start()` dies with an AImageReader output on the Mi
TV Stick class of device: configure only records the window — start is where ACodec
dequeues (and thereby gralloc-allocates) every codec output buffer from it, with a
usage that is the OR of our reader's consumer bits and the vendor decoder's private
producer bits. Our reader asked for GPU_SAMPLED_IMAGE | COMPOSER_OVERLAY; the
Amlogic OMX component adds its contiguous-vdec-memory flags. On phones (Codec2,
64-bit, gralloc4) that triple is routine. On a 32-bit OMX-era BSP, an app-side
consumer demanding overlay-scanout + GPU-sampled + vdec-writable in a single
allocation is the exotic ask, and a refusal surfaces exactly as
`start failed: ErrorUnknown` after a clean configure.
So the ladder gets a middle rung that keeps ASC instead of abandoning it: retry
with a reader asking for GPU_SAMPLED_IMAGE alone — the SurfaceTexture shape every
TextureView/WebView video path exercises, the most universally allocatable there
is. SurfaceFlinger then GPU-composites the layer (one 1080p quad — noise), and
everything that makes ASC worth having survives: real latch times, real release
fences, the learned panel period. setBuffer has no overlay requirement, so the
only cost is losing the HWC direct-scanout optimization on devices that were never
going to grant it anyway.
Usage is the only reader axis worth a rung: READER_MAX_IMAGES is not a start-time
factor (consumer-side images allocate lazily during streaming), so a start failure
that survives the gpu-only rung genuinely needs the SurfaceView rungs behind it.
The ladder is now: ASC overlay → ASC gpu-only → SurfaceView (aggressive keys) →
SurfaceView (plain keys), deduped as before so a device that works pays nothing
and each log line names the exact configuration that won or was refused. The
"asc: backend up" line now carries the reader profile too.
`configure()` succeeding says nothing about `start()` — start is where the codec
negotiates buffers with its output consumer and allocates them, so a decoder that
accepted the format can still refuse the surface it has to render into.
On a Xiaomi Mi TV Stick (Android 11, armeabi-v7a, OMX.amlogic.hevc.decoder.awesome2)
every session logged `start failed: ErrorUnknown` and the decode thread returned, so
not one access unit was ever fed while the pump kept receiving video. The frame queue
filled, the pump jumped to live once per FLUSH_COOLDOWN, and the host read that
perfect 2 s keyframe cadence as a client too slow to sustain the stream. Audio, input
and the library all kept working, so it presented as a permanent black screen with
sound — nine sessions of it in one log, and nothing on the host able to see why.
Give the async loop a bring-up ladder instead of one attempt. A codec that failed
start is in an error state and cannot be reconfigured, so each rung builds a fresh
one and sheds what a start can choke on, most-suspect first: the AImageReader the ASC
presenter renders into (READER_MAX_IMAGES full-resolution PRIVATE COMPOSER_OVERLAY
buffers, which the SurfaceView path does not allocate at all), then the aggressive
low-latency key set. Every downstream branch already keys off `asc.is_some()`, so a
fallen-back session simply runs the SurfaceView presenter that has always been the
API < 29 / ASC-init-failure fallback. Rung 0 is always exactly what the session asked
for, so a device that works pays nothing; the winning rung is logged, so the next
device that needs one names its own culprit instead of leaving us to guess.
The sync loop gets no ladder: it only runs with low-latency mode off, which is
already the conservative key set, and it renders straight into the SurfaceView — both
axes are already shed, and there is no simpler configuration to fall back to. It gets
the diagnosis in its error line instead, since the session stays up around the
failure there too.
Also lifts the HDR static-info fetch above the ladder so a retry never pays its
250 ms wait again, and extracts the async-notify callback registration, which each
rung's fresh codec now needs.
Field reports of "201" when sending logs to the host: the upload was working
the whole time, and the client was calling it an error.
`POST /api/v1/client-logs` answers **201 Created** — it is a route that STORES
a bundle, and it has said so since the feature landed (`mgmt/client_logs.rs`,
where CREATED is both the returned status and the documented one). The Android
uploader tested `resp.code == 200` and treated everything else as a failure, so
the user got "Couldn't send logs — host answered HTTP 201" while their bundle
was already sitting on the host's web console Logs page. Worse than a cosmetic
lie: it invites a retry, and the store keeps only 5 bundles per device, so a
few retries evict the reporter's own earlier logs.
The check now uses OkHttp's `isSuccessful` (200..299) rather than a
hand-written code, so there is no second list of numbers to fall out of sync
with the host.
One line covers both Android shells: since the send-logs work spread the action
to every UI, the Skia console and the touch home both route through
`SendLogs.toHost`, which is the only place the status is judged.
The other legs were already correct and are untouched: Apple matches
`case 200, 201` (`LibraryClient.swift`), and the desktop ureq path treats any
2xx as `Ok` — only 4xx/5xx become `Error::StatusCode`. A sweep of the remaining
strict-200 comparisons in the clients found only GET art/status fetches, where
200 really is the sole success.
Verified with `./gradlew :app:compileDebugKotlin` (task executed, not cached).
Every OS-level name a virtual pad needs is derived from a pad index and nothing
else: the `Global\pfxusb-boot-<i>` / `Global\pfds-boot-<i>` bootstrap mailboxes, the
`SwDeviceCreate` instance ids (`pf_xusb_<i>`, `pf_pad_<i>`, `pf_ds4_<i>`,
`pf_xbox_<i>`), and on Linux the DualSense pairing MAC, the Deck serial and the
Switch MAC — the last three documented as needing to be unique per pad, because
hid-playstation adopts the MAC as the HID `uniq` and SDL/Steam dedup by that serial.
The host serves up to DEFAULT_MAX_CONCURRENT sessions of the same desktop, each with
its own input thread and its own router, and every client numbers its first
controller wire pad 0. So two paired clients each holding a controller collide on all
of them. On Windows the second session's `Shm::create_named` sees ERROR_ALREADY_EXISTS
on all five retries and never gets a pad for the whole session — and the create-failure
hint tells the operator to restart the service, which would kill both sessions, when no
other process is involved at all. On Linux nothing errors: both mint the same DualSense
MAC and SDL merges the two pads into one controller.
The wire index is a session's own numbering and cannot be an OS identity. New
`pf_inject::pad_pool` makes the OS slot host-wide — claimed on a pad's first present
frame, released when it goes away, freed wholesale when the session drops — and `Pads`
translates once on the way in. Because only the NUMBER changes and not the name format,
the drivers (which read the index back out of `pszDeviceLocation`) need no change.
Slots are claimed lazily rather than as fixed per-session windows, so a single session
still reaches all MAX_PADS pads; two sessions share the range. An exhausted host now
declines with an honest line instead of retrying against a name it can never win.
Feedback reverses the same map: a backend tags rumble and rich HID output with the OS
slot it created the device under, so `Pads::pump` maps it back to the client's wire
index — otherwise the fix would have delivered one client's rumble to another's pad.
`HidOutput::pad`/`with_pad` keep that translation in one place, so a seventh variant
cannot silently forget it.
Verified: pf-inject 40 tests (8 new, covering the collision, single-session reach,
release, drop, exhaustion, mask and reverse-map), punktfunk-core 502 with `--features
quic`, and `cargo check -p punktfunk-host` clean on linux/amd64 in punktfunk-rust-ci —
punktfunk-host does not build on macOS, so that check is the only compile signal for
this file and it was not skipped.
PUNKTFUNK_RENDER_NODE had three independent readers disagreeing on trim
and empty-string handling (pf-gpu: no trim; pyrowave: trim+filter; the
remote worker handshake: raw .ok(), so an empty or padded value crossed
the process boundary verbatim). pf_gpu::render_node_env() is now the one
parse; linux_render_node layers the console's manual GPU preference on
top, and the PyroWave sites keep reading the env-only form deliberately —
their device-selection oracle rules forbid the preference layer.
The two sites had drifted — the class of defect virtual_stream's size
invites. Bring-up keyed gamescope_composite on the compositor alone; the
capture-loss retarget read plan.gamescope_cursor, which also folds in the
gamescope_composites_cursor() capability (a gamescope that paints the
pointer into its own node). On such a node, bring-up planned a host
composite the XFixes reader never feeds. composite_plan() is now THE
derivation for both, settle_portal_cursor's discipline applied to the
pair session_plan.rs already documents as must-agree.
Five mpsc senders and six receivers in a row, two of them both carrying
bare u32s (retarget_rx / gap_rx) — one silent transposition at the spawn
site away from a runtime puzzle. control::run now takes control::Task,
destructured at the top into the same-named locals, so the 500-line body
is byte-for-byte unchanged and the spawn site reads as named fields.
Drops one of the workspace's 127 too_many_arguments allows.
`MaxLumaPixelsHEVC` was the constant `1869449984` in every document, including
the ones whose `ServerCodecModeSupport` had just dropped HEVC — a GPU-less host
encodes H.264 and nothing else, and said so in the mask while still quoting a
4K60 HEVC ceiling two lines above it. The code's own comment flagged the
contradiction and left it, on the grounds that Moonlight gates on the mask.
That reasoning is right, which is exactly why this is worth closing rather than
keeping: the field is harmless only for as long as every client keeps ignoring
it, and nothing on our side guarantees that. It now follows the mask, with `0` —
the field's own way of saying no HEVC capacity — when HEVC is not offered.
Gate: Linux fmt + clippy + 14/14 pacing + 96/96 gamestream; Windows `.133`
clippy on the full matrix + 14/14 + 95/95.
None of these change behaviour. All four were carrying comments that read as
unfinished work, and the work turned out to be establishing what is true.
**TLS accepts any client certificate — and should.** The verifier's comment
called pinning "a hardening follow-up". It is not one. A TLS handshake completes
before the request line is parsed, so "pin the post-pair routes, accept-any on
the pairing routes" cannot be expressed there at all — it would take a second
listener on a second port, and the protocol fixes the ports. Some HTTPS traffic
must come from unpaired peers anyway: `/serverinfo` answers `PairStatus=0`
precisely so a client can discover it needs to pair, and the management API goes
further and admits certless browsers. And deferring costs nothing: the
`CertificateVerify` signature IS checked, so a peer reaching a handler has proved
possession of its certificate's private key, and `peer_is_paired` pins the
SHA-256 of that same certificate before any state-changing work. Every route but
`/serverinfo` goes through it. Rejecting with an HTTP error rather than a TLS
alert is a difference in *when*, not in what is proven. The comment now says
that, including the thing that WOULD be a hole — accepting the certificate
without checking the signature, which is why the legacy-Moonlight fallback
re-verifies rather than waving old certificates through.
**The PIN's 4-digit space is not the exposure.** 10⁴ invites the brute-force
question, and the answer is that nothing can brute-force it: submission is the
bearer-authenticated management API and nowhere else, so a network peer has no
oracle to hammer, and a per-attempt cap would bound the operator's typos rather
than an attacker. A wrong PIN costs a fresh client handshake *and* a fresh
operator submission — not a loop anyone automates from the network. The real
residual is **capture**, not guessing: the PIN slot is bound to no particular
handshake, so a peer parked at the right moment can take the PIN typed for
someone else. That is already narrowed twice (an ambiguous submit is refused, an
unconsumed PIN expires) and its full fix is to key the gate by `uniqueid` —
which also needs the management API to name *which* device is asking, so the
operator answers a named prompt. Written down so the next reader audits the race
rather than re-deriving the brute-force answer.
**Audio's unauthenticated CBC cannot be negotiated away.** The instinct is to
reach for `SS_ENC_AUDIO` (0x04) as the authenticated answer. Per the sanctioned
wire reference, `SS_ENC_AUDIO` *selects exactly the mode we already implement*:
"if SS_ENC_AUDIO: AES-128-CBC encrypt the PKCS7-padded Opus frame", noted there
as "CBC, not GCM. No auth tag appended (unlike video/control GCM)", negotiated
through `x-nv-general.featureFlags` bit 0x20 rather than the `encryptionSupported`
mask. So GameStream has no authenticated audio mode to advertise: offering the
flag would change nothing on the wire, and adding a tag would be a private
extension no client can decode. The malleability is real and it is
protocol-inherent, in the same family as pairing over plain HTTP. A session that
needs authenticated audio needs the native plane, whose audio is AES-GCM. This
moves the item from "blocked on a layout we could not find" to "answered" — the
layout was not missing, the premise was.
**`encoderCscMode` was warning about something it could not act on.** The note
claimed Moonlight renders from the requested value rather than the bitstream VUI,
so any mismatch shifts colours. The wire reference does not say that — it lists
the key among those a host parses, and nothing more — and we emit an explicit,
correct VUI that a VUI-driven renderer would follow. Worse, the common case is
unactionable by construction: this session's own on-glass run shows a stock
client sending `csc=3` (Rec709 **full**) while negotiating HDR, and HDR10 *is*
BT.2020 PQ, so the request and the mode contradict each other and the mode wins.
The old code warned on every HDR session about that. It now says so at debug, and
keeps a warning only for SDR, where the request could in principle be met —
honestly labelled unverified.
Also recorded: honouring it is not "just plumbing". `videoFullRangeFlag` is
hardcoded to 0 in every encoder backend with the capture-side CSC fixed to match,
so an SDR client asking for full range needs a per-session colour request
threaded from RTSP through the capture CSC into each backend's VUI — code the
native plane shares and currently gets right. That is a real piece of work with a
real regression surface, and it should be scoped as one rather than as a logging
follow-up.
Gate: Linux fmt + clippy + 14/14 pacing + 96/96 gamestream; Windows `.133`
clippy on the full matrix + 14/14 + 95/95.
Eleven generations of punktfunk_connect_ex* each added a field by minting a
new exported symbol plus a 20-something-parameter forwarding shim (ex11 over
ex10: two fields). punktfunk_connect_opts takes every option in ONE
size-prefixed PunktfunkConnectOpts — PunktfunkConfig's struct_size guard,
with the growth direction added: an older, shorter caller gets its tail
defaulted to unspecified/auto instead of misread. Layout is locked
padding-free on both pointer widths (96/68 B) by const asserts AND by the C
harness compiling the same sizes, so an appended field can never land in
bytes an older sizeof already covered. Every ex keeps its symbol and
behaviour; the chain is closed in the embedding guide (whose ladder table
had stalled at ex7 — the maintenance tax on display).
Also: the cargo-test C harness now actually links on an Apple Silicon dev
box (-L/opt/homebrew/lib was missing) and always builds the staticlib WITH
quic — a featureless .a left by an earlier plain build was silently reused.
QSV's copies had already diverged twice in ways the file itself confesses
(the dropped trim() that left LTR enabled on Intel, the IR period knob that
silently did nothing) — and PUNKTFUNK_LTR_FORCE_AT=0 still behaved
differently per vendor. enc/policy.rs now parses each knob once, rfi.rs's
sibling; tuned per-vendor defaults (QSV 1/4 s vs AMF 1/2 s LTR cadence) and
API-bound clamps deliberately stay with their backend.
`obu_size` is a leb128 read straight out of the stream — bounded only by `u32::MAX`,
and tied to nothing about how many bytes are actually present. `read_obu` then built
the OBU with an unchecked `&data[start_offset..start_offset + obu_size]`, so any
access unit whose last OBU declared more payload than remained panicked with
`range end index .. out of range for slice of length ..`. That is a bounds check
rather than arithmetic, so it panics in release too, and it aborts whichever thread
is decoding.
It reaches every native AV1 rung: pf-vkdecode, pf-dxvadec and pf-vaadec are all
re-exports of `pf_bitstream::av1::Av1Planner`, whose `plan_au` hands raw access-unit
bytes straight to this function. `PUNKTFUNK_AU_FAULT=truncate` produces the shape,
and so does any AU delivered short over the wire.
This was a hole in an otherwise consistent posture, not a missing idea: `plan_au`
degrades every other malformation to `TruncatedAu`/`Parse`, and pf-vkdecode already
re-validates `obu.end > au.len()` a layer up. Bound it once, where all three rungs
route through. The checked end is reused for `bytes_used`, so the slice and the
advance can no longer disagree.
Both tests fail without the guard — the parser one reproduces the original panic
verbatim (`range end index 10600 out of range for slice of length 5293`).
Recorded as PROVENANCE.md deviation 14. Not filed upstream.
The previous commit shipped `SS_ENC_CONTROL_V2` dark, on the grounds that the
wire had not yet met a real client here. It has now, twice, on `.173` (Win11,
RTX 4090) against Moonlight on macOS at 2560x1440@240 HEVC Main10 HDR.
| Leg | Result |
|---|---|
| `control` (SUPPORTED, not requested) | **the client turns it on by itself** — video and audio flow, hardware decode |
| `require` (both bits REQUESTED) | negotiated and streamed the same way; the lever is not broken |
The line that settles it is the host's own scheme detector, which reports what
it actually locked onto rather than what was negotiated:
```
control: GCM scheme locked in
scheme=Scheme { key_rev: false, nonce: V2 { seq_be: false, marker: [67, 67] },
tag_first: true, aad: None }
```
`[67, 67]` is `b"CC"` — the client→host V2 marker — after which the host sealed
its HDR-mode cue under the flipped `b"HC"` and the client acted on it. Both
directions on distinct nonces, on hardware. That is the defect from the previous
commit's note, observed closed rather than argued closed.
So `Supported` — the default — now carries both bits, exactly as WP7's video
encryption graduated after its own glass pass. What used to be the `control`
opt-in becomes `PUNKTFUNK_GS_ENCRYPT=video`, the way *out*: it keeps video
encryption and drops only the control offer. That granularity is worth its three
lines rather than being YAGNI — this plane serves a spread of client builds and
exactly one has been tested against the V2 offer, so the alternative rollback
(`=0`) would throw away video encryption to fix a control-channel problem.
**The deferral that turned out not to exist.** The worry was that control-v2
would drag encrypted RTSP in with it, forcing a `corever` threshold nobody has
the value for. Both legs kept RTSP plaintext — the client asked for
`rtsp://192.168.1.173:48010` and stayed there — which confirms the framing is
chosen by the launch URL scheme, not by the DESCRIBE flag. The sealed-RTSP
reader is in and unit-tested, but it has not been exercised on the wire; it will
be the day a client opens a connection with the type bit set, and it is written
to answer in kind when that happens.
**The other thing glass settled** is the previous commit's open question about
the ping payload's encoding. Both media planes logged `client endpoint learned
(ping payload verified)` on the first datagram, in both legs — so the payload
crosses in a form `ping_matches` accepts, the two-second fallback never fired,
and that check can be tightened from "prefer" to "require" whenever we want it.
Docs follow: `moonlight.md` no longer says the control channel "uses the older
GameStream scheme", which stopped being true for a stock client with this
commit, and `configuration.md` documents the new `video` value.
Gate: Linux fmt + clippy + 14/14 pacing + 96/96 gamestream; Windows `.133`
clippy on the full matrix + 14/14 + 95/95. One `send_pacing` failure on the
first Linux run was a timing flake under load (0.36 s vs the usual 0.06 s) and
passed 14/14 on re-run — the suite is known for it. `.173` was restored
afterwards: original binary back byte-for-byte by size and timestamp, no test
lines in `host.env`, service running, listeners up, and the build tree this
session created removed.
The legacy GameStream control scheme builds its AES-GCM nonce out of the
sender's own sequence number and nothing else. There is no direction byte, so
the host's messages — rumble, the HDR-mode cue — and the client's input share a
single (key, nonce) space and collide the moment their two independent counters
cross. That is the one catastrophic GCM failure, and `control.rs` has carried a
note saying so, ending "the real fix is V2 control-encryption negotiation".
This is that fix, and most of it turned out to be already written. The scheme
detector has understood `NonceKind::V2` — `seq` in `[0..4]`, `b"CC"` at
`[10..12]` — since it was built, and `encrypt_control` already flips the marker
to `b"HC"` for host-originated messages. What was missing was the one thing that
makes a client choose that scheme: the advertisement. So the control plane needs
no code change at all here; it needs `SS_ENC_CONTROL_V2` in
`encryptionSupported`, and it detects the rest from the first packet that
authenticates.
Enabling V2 also lets the client seal RTSP itself, which is the part that was
deferred, and the reason it was deferred dissolves on inspection. The worry was
needing to know `corever` — the field that decides whether a session speaks
sealed RTSP — whose threshold the sanctioned reference names but does not value,
and guessing it would break every session it guessed wrong about. But the two
framings are **self-distinguishing**: a sealed message opens with
`typeAndLength`, whose MSB is `ENCRYPTED_MESSAGE_TYPE_BIT`, and a plaintext one
opens with an ASCII method name, always below 0x80. So there is nothing to
negotiate and no threshold to guess — the connection is read in whichever
framing it arrived in and answered in the same one, the same "detect it from the
wire" posture the control stream already takes. `corever` never has to be
consulted, which also means it never has to be guessed.
Two details worth their own lines:
**The host's RTSP sequence counter is process-global and never resets.**
GameStream RTSP is one message per TCP connection, so a per-connection counter
would restart at zero for every one of a session's seven messages and reuse
(key, nonce) six times over — reintroducing, in the fix, exactly the defect
being fixed. Same rule WP7 established for the video counter, and same reasoning.
**The frame's length field is bounded before it is believed.** It is
attacker-controlled and arrives before a single byte has authenticated, so it is
checked against the same budget the plaintext path uses rather than trusted
enough to reserve against.
Shipped dark, deliberately: `PUNKTFUNK_GS_ENCRYPT=control` opts in, the default
advertisement is unchanged, and `require` (the on-glass lever) now covers both
bits. This is the posture WP7 shipped video encryption in — dark until glass
confirmed a real client negotiated it — and control-v2 has not yet met a real
client here. Flipping the default is a one-line change once it has.
The two comments claiming we "advertise no encryption" are corrected while I am
here; they have been wrong since WP7 advertised `SS_ENC_VIDEO`, right in their
conclusion and wrong in their reason.
Gate: Linux container fmt + clippy `--all-targets -D warnings` + 14/14 pacing +
96/96 gamestream (93 before); Windows `.133` clippy on the full
`nvenc,amf-qsv,qsv` matrix + 14/14 + 95/95 (92 before). The three new tests cover
the receive path as well as the send path, and were confirmed non-vacuous by
mutating the nonce's direction byte to a constant — both directional assertions
fail, which is the security property doing its job.
Both media planes learned where to send by taking the first UDP datagram that
arrived from the launch owner's address and never looking inside it. The ping
payload the protocol exists to carry — handed to the client in the SETUP
response, echoed back as its first datagram — was the fixed constant
`0011223344556677` for every session on every host, and nothing ever compared
it against what arrived. Source-IP binding was the whole guard, so anything
that could send from (or spoof) that address won the endpoint simply by being
first, and was handed the stream.
So the payload becomes a per-session secret: eight random bytes minted at
`/launch`, re-minted at `/resume`, advertised hex-encoded in SETUP, and checked
before an endpoint is adopted. Both planes now go through one
`learn_client_endpoint`, because they had drifted into two byte-identical loops
and a rule about who owns a stream should exist once.
Minting alone would have proved nothing, though, and this is the part worth
pausing on: SETUP was **unauthenticated** while its siblings ANNOUNCE and PLAY
were not. Any peer that could reach 48010 could simply ask for the payload the
media planes were about to verify, and walk the check. So SETUP is gated on
`authorized_launch` like the other two — a secret handed to whoever asks is not
a secret, and that hole, not the constant, was the reason the old check would
have been theatre.
The check **prefers** a matching datagram rather than **requiring** one, and
that is deliberate rather than timid. The sanctioned wire reference says the
client echoes the payload and that modern clients wrap it in an `SS_PING`
carrying a sequence number, but it gives neither that structure's layout nor
whether the payload crosses as the header's ASCII or as its decoded bytes.
`ping_matches` accepts every shape those unknowns allow — either encoding, with
trailing bytes — yet a hard gate resting on a layout nobody has confirmed would
black-screen every session it guessed wrong about, and compatibility is this
plane's entire reason to exist. An unverified datagram is therefore held as a
fallback, adopted only if two seconds pass with nothing better, and logged with
the bytes that did arrive. One real session settles the encoding; then the
fallback can go and this becomes the hard gate it wants to be.
What this closes: an off-path attacker who cannot read the plaintext RTSP
exchange can no longer guess its way onto a media port. What it does not close:
an on-path observer, who can still read the payload out of SETUP — that one
needs `SS_ENC_CONTROL_V2`, and the two changes are worth more together than
either is alone.
Constant-time comparison throughout (`crypto::ct_eq`), so the expected value
can't be probed a byte at a time.
Gate: Linux container fmt + clippy `--all-targets -D warnings` + 14/14 pacing +
93/93 gamestream (90 before). The three new tests were confirmed non-vacuous by
mutating `ping_matches` to return `true` and watching the negative case fail.
Review of 47d9a7d2 plus the on-glass pass it was waiting for, run 2026-08-27 against
.173 (Win11, RTX 4090) from Moonlight on macOS. Four legs, all pass:
1. encryption off -> streams, encrypt_video: false (no regression)
2. =1 (supported) -> the CLIENT OPTS IN BY ITSELF, decodes, 0 errors
3. =require -> negotiated, decoded in hardware, 0 errors
4. =require + 5% loss -> 27 s, 8 keyframes decoded, ZERO IDR re-requests
Leg 2 is why the default moves: I had argued a client might only enable video
encryption on WAN/relay links — which is why the `require` lever exists at all — and
it opted in on a LAN unprompted. Default-on therefore delivers encryption in
practice rather than being a no-op offer.
Leg 4 is the one that mattered most: at 5 % injected wire loss the client never asked
for a single keyframe, so FEC recovered every dropped SEALED shard. That is the
FEC-then-encrypt ordering proven on hardware, not just in the unit test.
Two independent confirmations of the wire format fell out of the run:
* the client negotiated packet_size 1360 where the plaintext leg used 1392 — it
subtracted sizeof(ENC_VIDEO_HEADER) exactly as the reference says, so the on-wire
datagram is 1408 either way and still fits the MTU it sized for;
* FFmpeg parsed nal_unit_type 19 (IDR_W_RADL) and reported 'Decoded frame with POC
0/0' on a Main 10 VideoToolbox path — a real decoder reading real HEVC out of the
decrypted, FEC-reassembled payload.
Default is now Supported; PUNKTFUNK_GS_ENCRYPT=0 is the escape hatch back to the
plaintext wire, `require` stays as the test lever that forces the negotiation.
Review fixes in the same change (found reading 47d9a7d2 back, before the test):
- A failed seal pushed the CLEARED buffer, putting a 0-byte datagram on the wire.
Unreachable in practice (GCM only refuses absurd lengths) but the wrong failure
mode: the shard is now dropped, which is what FEC is there for. seal_shard returns
bool and — per clippy, correctly — takes &mut [u8] now that it never clears.
- `off` and `key` derive separately from self.enc_key; they cannot desync today, but
if they ever did, sealing would encrypt from offset 32 INTO the shard body and
corrupt every packet with no error anywhere. A debug_assert states the invariant.
- Stale rtsp module header (it still said plaintext-only, P1.5).
Docs follow the behaviour: moonlight.md/clients.md now say video is encrypted, and
the 'legacy control encryption is weaker' caveat is corrected — it was overstating
things now that video and audio are both sealed; what remains true is that pairing is
plain HTTP and the control channel uses the older GameStream scheme.
Gates: Linux container fmt + clippy --all-targets -D warnings (non-vacuous) +
send_pacing 14/14 + gamestream 90/90; Windows .133 clippy nvenc,amf-qsv,qsv clean +
14/14 + 89/89; check-docs-drift.sh + check-docs-links.sh clean. .173 restored to its
original binary and host.env, build tree removed.
GS competitive program WP7 — the largest remaining wire divergence from a Sunshine-class
host, and the last item the docs' "weaker than the native protocol" caveat rested on.
- RTSP DESCRIBE advertises SS_ENC_VIDEO (0x02) as encryptionSupported and NEVER as
encryptionRequested: requiring encryption would refuse every client that doesn't do
it. ANNOUNCE's x-ss-general.encryptionEnabled echo decides the session, and is
honored only for a bit the host actually offered.
- Each finished datagram is AES-128-GCM-sealed under the /launch rikey and shipped as
[iv 12][frameNumber u32 LE][tag 16] || ciphertext(blocksize) — the ENC_VIDEO_HEADER
layout, whose 32 bytes sit OUTSIDE the FEC blocksize (the client already subtracted
them from the packetSize it negotiated, so the datagram still fits its MTU).
- The order is FEC first, THEN encrypt per shard. That is the load-bearing property,
and it has its own test: the client decrypts what it received and runs RS recovery
over those plaintexts, so parity computed over ciphertext would recover nothing.
Sealing is in-place into a buffer that reserved the prefix, so the pooled
no-allocation path (WP1.3) survives; parity, which encode_into sizes exactly, costs
one memcpy on the ~20 % of packets that are parity.
- The GCM nonce counter is PROCESS-global and monotonic, never reset. (key, nonce)
reuse is the one catastrophic GCM failure, and a session-scoped counter would repeat
the moment a KEYLESS /resume — which WP3 defines as keeping the current keys —
started a fresh packetizer on the same rikey.
DEFAULT OFF, opt in with PUNKTFUNK_GS_ENCRYPT=1. This is the compat plane's video hot
path and a wire mistake there is a black screen for any client that opts in; I cannot
run a stock Moonlight client in this environment, so it ships dark and the WP0.3
on-glass pass flips the default. SS_ENC_CONTROL_V2 and SS_ENC_AUDIO stay unoffered:
control-v2 also re-frames RTSP itself, and the audio-GCM layout is not in the
sanctioned wire reference.
Also WP8's docs pass, now that the claims are false in the user's favour:
moonlight.md said the GameStream path "doesn't use the native protocol's
FEC/encryption extensions" — Moonlight-compatible FEC has shipped for months and the
host now adapts it to reported loss. Rewrote that (and the clients.md twin) to say
what Moonlight actually does and doesn't get, documented the bitrate as the wire
budget it became in WP2.1, and documented the three new knobs (PUNKTFUNK_FRAME_DRIVEN,
PUNKTFUNK_GS_ADAPT, PUNKTFUNK_GS_ENCRYPT) — check-docs-drift.sh gates that.
⚠ The drift gate also demanded PUNKTFUNK_IDD_ADAPTIVE be pruned from the undocumented
baseline: it is documented in configuration.md but was never pruned, so that ratchet is
red on main independently of this branch. Pruned here since the gate refuses to pass
otherwise.
Gates: Linux container fmt + clippy --all-targets -D warnings (non-vacuous) +
send_pacing 14/14 + gamestream 89/89 (new: encrypted round-trip incl. tamper + wrong
key + IV uniqueness, and RS recovery THROUGH encryption); Windows .133 clippy
nvenc,amf-qsv,qsv clean + 14/14 + 88/88; check-docs-drift.sh and check-docs-links.sh
both clean.
GS competitive program WP5 (the media items implementable without new wire research —
4:4:4 and encoderCscMode stay follow-ups: the 4:4:4 SCM extension bit values are not in
the sanctioned wire reference, and CscMode honor is an encoder-wide colorspace plumb).
- WP5.1 the capture format comes from the shared SessionPlan (gs_session_plan: 4:2:0,
depth 10 only with HDR, no cursor-forward — this plane has no client cursor channel)
instead of the hand-hardcoded OutputFormat::resolve(hdr, gpu). One resolver for both
planes, and the visible win: a gamescope GameStream session (cursor blend off there)
now resolves nv12_native, so the producer's NV12 feeds Vulkan Video directly and the
per-frame RGB→NV12 CSC the native plane already skips is skipped here too. Also the
structural prerequisite for 4:4:4 when its advertisement bits are researched.
- WP5.4 the encoder gets the source's REAL HDR grade every frame (set_hdr_meta from the
capturer, exactly the native loop's shape): an HDR backend embeds it as in-band
mastering/CLL SEI on keyframes — the channel a stock Moonlight decoder tone-maps
from. This plane never called it, so an HDR GameStream session shipped no grade at
all; the 0x010e control cue only flips the display mode and carries generic fallback
metadata.
- WP5.5 RS(4,2) audio FEC on every layout — stereo included. The parity math is
layout-agnostic (shards are opaque encrypted packets) and a stock client runs the
same fixed RS(4,2) recovery regardless of channel count; the old channels>2 gate was
bring-up caution that left the MOST COMMON configuration with zero audio loss
protection.
Gates: Linux container fmt + clippy --all-targets -D warnings + send_pacing 14/14 +
gamestream 87/87; Windows .133 clippy nvenc,amf-qsv,qsv clean + 14/14 + 86/86.
On-glass owed with the WP0.3 pass: gamescope NV12 path on .41, HDR SEI in a client
bitstream dump, stereo FEC under netem loss.
GS competitive program WP6.1. serverinfo's <mac> was a fake constant
(01:02:03:04:05:06) over HTTPS — and Moonlight PERSISTS that field as the target for
its Wake-on-LAN, so every client-side wake against this host was a silent no-op. It
now carries the real routed-NIC MAC via the same crate::wol::wake_macs the native
plane's mDNS advert uses (routed NIC first, lowercase colon-hex — the wire reference's
documented shape: real MAC on HTTPS, zeros on plain HTTP).
Cached on first SUCCESS only: /serverinfo is polled, so per-poll NIC enumeration is
waste — but a cold-booted host may not have a routable address yet (the #366 boot-race
lesson), so a failed read retries on the next poll instead of latching zeros forever.
Plain HTTP keeps hiding the MAC.
Gates: Linux container fmt + clippy -D warnings + send_pacing 14/14 + gamestream
87/87 (new pin: never the fake MAC, hidden over HTTP); Windows .133 clippy
nvenc,amf-qsv,qsv clean + 14/14 + 86/86.
GS competitive program WP3 (design/gamestream-competitive-program.md). Three fixes
that together make Moonlight's Resume/Quit UX exist against this host for the first
time — serverinfo hard-coded state=FREE/currentgame=0, so no stock client ever showed
Resume or Quit, and /resume read no query params at all, so even a reached resume ran
on stale crypto keys.
- serverinfo now reports the live session's appid + SUNSHINE_SERVER_BUSY — to the
session OWNER's pinned cert ONLY. Owner-scoped on purpose (owner_current_game, pure
+ tested): a non-owner shown the truth would route same-app taps into the owner-only
/resume and /cancel and lose the reject/join/steal admission it gets via /launch
today; and a busy signal over plain HTTP would leak what's running to the LAN.
Advertisement fails CLOSED on unknown fingerprints (unlike the control gate, which
deliberately fails open).
- /resume parses rikey/rikeyid and RE-KEYS the live session (a resuming Moonlight
mints fresh keys; the control GCM and audio CBC derive from them — the old keys made
every post-resume control packet undecryptable). Malformed rikey refuses the resume;
keyless keeps the current keys. The media/RTSP source-IP binds re-anchor to the
address the client resumes from, so a device that moved networks can come back.
- /resume RESTARTS the media planes for the new connection. A PLAY that found
'streaming' still true took its already-running branch: the old threads kept
streaming at the vanished endpoint and the resumed client got no media. The handler
clears the run flags and WAITS (bounded, 2 s) for the old threads' FULL exit — each
media thread now bumps AppState::media_exited as its last act, after its teardown
(capturer re-pool, lease guard, events) has run — so the successor threads never
race the predecessor over the pooled capturer and the old exit path cannot stomp
the new session's flags. The launch surviving that wait is re-keyed; one cleared by
the old threads' client-unreachable teardown falls back to a clean /launch. The
ENet control plane already handles the fast-reconnect half (a new Connect replaces
the tracked peer; the old peer's late timeout is gated off it).
Gates: Linux container fmt + clippy --all-targets -D warnings (non-vacuous) +
send_pacing 14/14 + gamestream 86/86 (new: owner-scoped currentgame matrix, resume
re-key round-trip incl. malformed/keyless arms, busy-state XML pin); Windows .133
clippy nvenc,amf-qsv,qsv clean + 14/14 + 85/85. On-glass resume with a stock
Moonlight client owed (WP0.3 pass).
GS competitive program WP2.2-2.4 (design/gamestream-competitive-program.md). The
Moonlight plane's only in-stream quality signal — the periodic 0x0201 loss-stats
report — used to fall through the control decoder and be silently dropped; the host
was blind to client-observed loss and its only recovery was serving IDR round-trips.
No Sunshine-class host does more than log this signal. Now:
- WP2.2 the control stream decodes 0x0201 (LE i32s: loss count, window ms,
last-good frame — the verified IDX_LOSS_STATS reading, apollo-comparison #94) into
cumulative per-session counters on AppState (GsLossStats), read as window deltas.
- WP2.3 adaptive FEC: the parity percent climbs fast under loss (+max(5, pct/2) per
lossy window, cap 50) and decays slowly on clean windows (-5 per 8, floored at the
configured base). Applied live through the packetizer between frames — safe because
block geometry is derived per frame and the client reads each block's parity count
from the per-packet fecInfo wire percent.
- WP2.4 bitrate de-rating: SUSTAINED loss (2+ consecutive lossy windows) de-rates the
wire budget x0.85 per window, floored at max(1/4 of the negotiated rate, 5 Mbps);
clean windows climb it back at 1/20 of the negotiated rate per 4 — division of
labour as on the native plane: parity answers random/burst loss, fewer bits answer
congestion. The client's negotiated number stays the hard ceiling.
- The WP2.1 invariant is kept live: every step re-derives the ENCODER rate under
(budget, percent) and retargets it in place (Encoder::reconfigure_bitrate); the send
pacer's rate follows through an atomic. An encoder that refuses the in-place
retarget (software paths) turns adaptation OFF for the session — raising parity
over a frozen encoder rate would push the wire back over budget. The stats sample
reports the LIVE budget, so the console shows what the host actually targets.
PUNKTFUNK_GS_ADAPT=0 pins both levers at their configured values (the A/B lever).
Constants are deliberately conservative first values — the WP0.3 netem matrix tunes
them. Also: the stale module/status doc headers the completeness inventory flagged.
Gates: Linux container fmt + clippy --all-targets -D warnings (non-vacuous) +
send_pacing 14/14 + gamestream 83/83 (incl. the two new adaptation state-machine
tests); Windows .133 clippy with nvenc,amf-qsv,qsv clean + send_pacing 14/14 +
gamestream 82/82 (amf-qsv,qsv — the box has no NVENC import lib for test linking).
On-glass netem verification (WP0.3) owed.
GS competitive program WP0/WP1/WP2.1 (design/gamestream-competitive-program.md in the
planning repo). Four latency/overshoot gaps between the Moonlight-compat plane and the
native plane, all closed by wiring GS into machinery the native plane already had:
- WP1.1 arrival-driven capture: the GS encode loop slept out its whole tick and then
SAMPLED, holding a frame that arrived just after the previous sample for up to a full
interval (~half on average, ~8 ms at 60 fps). It now sleeps to a 0.9-interval floor and
wakes on the capture's actual arrival, exactly the native T1.1 shape — with the same
credit bucket pinning the long-run average at the negotiated rate, so a mirrored panel
running faster than the session cannot overdrive the wire. The bucket
(CaptureCredit, ex the native loop's private PaceBudget) and the PUNKTFUNK_FRAME_DRIVEN
gate move to send_pacing, shared by both planes.
- WP1.2 microburst pacing: the GS sender passed burst_bytes: None into the shared pacer,
so EVERY frame — a 3-packet P-frame included — spread across 0.75 of the frame interval
(an ~11 ms tail at 60 fps), and small frames still slept out the full budget. It now
runs the native parameterization: an auto-sized burst leaves immediately (a normal
frame goes out whole, unpaced) and only an oversized frame's overflow spreads, across
the time it needs at ~3x the stream rate, bounded to ~2 intervals. The chunking stays
BOUNDED (<=12 steps) — on that non-RT thread the per-step sleep overshoot must stay
independent of bitrate.
- WP1.3 pooled packetization: the packetizer copied the whole AU into a staging Vec and
allocated one Vec per shard (~3300 for a 4 MB IDR) plus unpooled parity, every frame.
Datagram buffers now cycle sender -> packetizer -> wire through a recycle channel,
parity goes through the pooled encode_into, and each payload byte is copied exactly
once, straight from the encoder's buffer into its datagram. A poisoned-pool equivalence
test pins the pooled path byte-identical to a fresh packetizer.
- WP1.5 wake-on-packet control: the ENet service loop was a 2 ms spin-sleep, so every
client input/IDR-request datagram waited out the rest of the tick. The control socket
now blocks with a 2 ms read timeout (same idle cadence and CPU); an arriving datagram
wakes it immediately. rusty_enet's receive loop returns on the first produced event
(verified in its transpiled enet_protocol_receive_incoming_commands), so the block only
ever sits where the sleep used to.
- WP2.1 budget honesty: the encoder was handed the full negotiated bitrate and then 20%
FEC parity plus per-shard framing went ON TOP — every session carried ~1.23x what the
client configured, and on the constrained links where the setting matters the overshoot
was the failure. The encoder rate is now derived UNDER the client's number
(gs_encoder_bps, the compat twin of the native encoder_kbps_for_budget from ABR
overhaul Phase 4), so the wire fits the budget the Moonlight user actually set.
- WP0 measurement: the wire's frame_processing_latency field (1/10 ms, the Sunshine
extension Moonlight's overlay shows as 'Host processing latency') is now stamped per
frame instead of hardcoded 0; the 1 Hz stats sample grows a send_spread stage measured
on the sender thread; and the video tests gain a GS-wire loopback harness — a
client-shaped reassembler running Cauchy RS recovery over lossy datagram subsets —
locking layout + recovery against every later packetizer change.
Also: the mirror source's capture-format 'gpu' predicate now matches the virtual source's
(resolved_backend_is_gpu, not the zerocopy env knob).
Gates: Linux container (punktfunk-rust-ci) fmt --all --check clean, clippy --all-targets
-D warnings clean and non-vacuous, send_pacing 14/14, gamestream 81/81 (incl. the new
harness + equivalence + schedule pins). Windows .133 clippy/tests with nvenc,amf-qsv,qsv
run in parallel with this commit; macOS spot-check shows only the known cfg(linux) stub
errors. On-glass A/B (WP0.3 baseline matrix) still owed.
window_loss_ppm's frames_dropped bump fired even when lost == 0 && late > 0:
every presumed-lost shard eventually ARRIVED, the frames died of lateness —
a delivery hole (host compose stall's resume edge, client-radio pause), which
neither lever this number drives can touch: FEC repairs loss, not delay, and
a bitrate backoff cannot shorten a hole. In the 2026-08-27 field log that was
loss_ppm=50000 exactly, x0.7 three times to 6.86 Mbps in 4 s, on a wire with
zero measured loss — the false ratchet is why a 160 ms hole reads as a
quality crater on our stream while the same pause is a cursor hiccup on the
competition.
The bump survives everywhere it is right: silent total loss (shards that
never arrived count in neither recovered nor late) reads late == 0 and still
bumps, and any window with real net loss bumps as before. Client-side mint,
so old clients keep the old behavior until updated.
The 2026-08-27 NVIDIA-laptop field case (36 stalls/27 s, blamed on the dark
laptop panel) reattributes to damage starvation: the desktop's only damage
source is the DWM-composited cursor, the cursor moves only while the client's
input arrives, and DwmFlush stayed 8-33 ms through every hole — DWM was
composing all along and correctly presenting nothing, because nothing was
dirty. The detector could not see that, so it fitted a 1.87 s 'display
metronome' to the reporter's input cadence and the WARN put the panel on
trial (connected_inactive lists it on every laptop under the exclusive
isolate — a standing correlation trap).
- try_consume samples GetCursorPos (rate-limited, user32 only) and
accumulates motion per gap; the stall-ending frame's own move is held back
one call so it never counts into the gap it ended. Some(0) = nothing was
dirty anywhere; Some(n>0) through a present-free hole = damage existed and
the display stack composed none of it — the positive conviction
CONTENT-SILENCE could never make.
- window_report attributes the pre-hole flow: flow_dwm_only = every lookback
present came from dwm.exe — a game's holes are never demoted.
- classify: ComposeSilence + working witness + flow_dwm_only + cursor still
=> new class DAMAGE-IDLE. Excluded from the metronome (fed post-
classification via StallWatch::cycle now), both repeated-stall WARNs, and
the connected_inactive blame; per-stall lines carry
cursor_moved_px_during_gap + flow_dwm_only either way.
- The below-OS METRONOMIC warn stops prescribing panel A/Bs for holes the
witness can already explain, and says every pre-0.28 field metronome ran
with the REALTIME lever default-on.
Logic verified by extracting stall.rs verbatim (+ the etw pure slice) into a
native scratch crate: 16/16 tests green, incl. the new damage_idle_split and
damage-idle-does-not-feed-the-metronome cases. Windows-only glue (GetCursorPos
sampler, field inits) is compile-gated on the Windows legs.
Five field requests from one returning user, traced and fixed together
(report: the "Daily-Driver Five" triage).
**10-bit SDR, independent of HDR.** VIDEO_CAP_10BIT and VIDEO_CAP_HDR were
always separate wire bits, but everything above the wire welded them: the
client advertised both from one "HDR" switch, the handshake required an HDR
capture source for any 10-bit depth, SessionPlan derived hdr from bit_depth,
and the encoder stamped BT.2020 PQ on every 10-bit format. Now a client
setting ("10-bit SDR", default off, tier-P) advertises the depth bit alone;
the handshake resolves bit_depth=10 without the HDR gates where the SDR-10
chain exists (Windows IDD-push + direct NVENC + HEVC — everywhere else the
session stays 8-bit, said honestly in the Welcome); the Welcome's colour
label, the virtual display's HDR bring-up and the capturer's want-HDR flag
all follow the new session-hdr verdict instead of the depth. The capture
side grows PixelFormat::Rgb10a2Sdr — the BGRA slot expanded 8→10 by a
trivial full-res pass (HdrRgb10Converter::new_sdr_expand) — which NVENC
ingests as ABGR10 and encodes Main10 under the ordinary BT.709 SDR VUI,
both 4:2:0 and 4:4:4. The win is the ENCODER's precision (compression
stops adding banding to gradients); the desktop stays 8-bit and neither
display's colour state is touched. The client already decodes by the VUI,
so no decode-side change was needed.
**"Capture system shortcuts" now works in Desktop mouse mode.** The setting
was wired end to end and then discarded by one `!desktop` term in the
presenter's grab condition — documented as "a remote desktop is something
you Alt-Tab away from", which made the toggle inert for exactly the
remote-desktop use it matters most for. The keyboard grab now follows the
setting in BOTH mouse models; desktop mode keeps an escape capture mode
never had (the unlocked pointer clicks any other window, focus drops, the
chords come back). Docs updated; the Mac's ⌘-chord behaviour is unchanged.
**Keep host audio playing (Moonlight's "Mute host PC speakers", unchecked,
per session).** New client setting + CLIENT_CAP_KEEP_HOST_AUDIO (0x20,
request-only, no HOST_CAP echo — an older host just re-routes as always).
A session holding the bit gets the host's audio devices left alone for its
lifetime (RAII guard): Windows skips the IPolicyConfig default parking and
loopbacks the operator's own default device; Linux taps the default sink's
monitor instead of claiming the default. Host-global best-effort across
concurrent sessions, as the cap documents. Rows in the GTK, Windows and
console-UI settings (desktop-only until Android advertises the bit).
**Prep commands carry the negotiated mode.** A per-app do/undo step's whole
environment was PF_APP_ID; the width/height/refresh/HDR it needs for a
per-mode frame cap were resolved 80 lines above and written only to the
unix-only marker file. Both serving planes now pass PF_STREAM_WIDTH /
_HEIGHT / _REFRESH / _HDR (the marker file's vocabulary, via one shared
hooks::prep_mode_env) — so an RTSS cap is one step instead of one
hard-coded entry per device, on Windows hosts too.
**The forwarded cursor matches the client's DPI.** The desktop-mode pointer
was resampled by the video fit factor alone, and SDL shows a custom cursor
surface at ~1:1 physical pixels on every backend — so on a 200% client our
pointer was half the size of every native one (and X11 the same; Wayland
escapes via buffer scale). The resample factor now folds in
SDL_GetWindowDisplayScale, the same model the Apple client gets for free
from point-sized NSCursors.
Verified: workspace clippy --locked -D warnings + targeted tests (hooks,
profiles, caps, console-ui rows, capture-policy) green on Linux
(pf-gtkflow); pf-capture/pf-frame windows-msvc green via xcheck; rustfmt
clean. NOT verified: the Windows-only halves of pf-encode/punktfunk-host
(no reachable Windows toolchain — .133 down; windows-host.yml is
dispatch-only, see the preceding commit), and everything on-glass: the
SDR-10 stream end to end, the desktop-mode grab, keep-host-audio wiring,
prep env on a live launch, and the cursor size at 200%.
bbc01cdd (ABR overhaul Phase 4, merged today as #408) threaded the new EncDerive
through prepare_display, but the call site in native/handshake.rs passes enc_of
BEFORE bit_depth while the signature takes bit_depth first — a type error in a
cfg(target_os = "windows") block. Nothing caught it: the ci.yml clippy job runs
on Linux and never compiles this block, and windows-host.yml only runs on
dispatch/release, so main's Windows host is currently unbuildable and no check
is red.
Reorder the arguments to match the signature. (--no-verify: this commit is cut
as a minimal cherry-pickable hotfix from a branch whose full fmt-clean state
lands in the following commits.)
`punktfunk-session` keeps the CONSOLE subsystem on purpose — its stdout is the
shell/session contract. Both couch entry points are GUI processes with no console
to lend it, so a spawn without CREATE_NO_WINDOW makes Windows mint one: a black
terminal that then sits behind the couch UI for the whole session. The shell's
own stream spawns (spawn.rs) have carried the flag since the port; the two
`--browse` hand-offs never did — the "Punktfunk Console" MSIX tile and
`punktfunk-client --console` (Steam shortcut, HTPC startup item) hit it every launch.
The two hand-offs were copy-paste twins, which is how they drifted apart on one
flag, so they become one file (couch.rs) that both roots include — the standalone
`punktfunk-console.exe` bin by `#[path]`, the shell as a module. `session_binary()`
folds in there too, so there is one resolver rather than two.
Second, quieter bug in the same spawn: stdout was inherited. With a console that
was merely invisible output; with CREATE_NO_WINDOW the inherited handle is invalid,
and `match_window` reports the settled window size on stdout REGARDLESS of
`--json-status` — a `println!` onto a dead handle panics the child mid-stream. So
stdout is an explicit `Stdio::null()`, not an inheritance.
Verified: couch.rs type-checks and clippies clean against x86_64-pc-windows-msvc in
an isolated harness (the crate itself cannot be cross-checked from macOS — SDL3 and
aws-lc-rs do not cross-build). On-glass verification on Windows is owed.
The ring layer moves out of the session binary into pf_client_core::logring
(desktop-gated beside the ring it feeds) — it started as punktfunk-session's
private module, and the moment both desktop shells wanted the row, a copy per
bin was exactly the drift the crate exists to prevent. The session keeps the
same layer under its new name; tracing-log and log fall out of its manifest.
Both shells install it beside their visible layer with its own DEBUG filter —
the env filter keeps scoping only what's printed, because the ring exists for
the diagnostics nobody enabled before the bug happened.
The row itself lands on each shell's saved-host menu on the console's own gate
(paired + online), wired to logring::send_to_host on a worker thread, outcome
as a toast (GTK) / the status line (WinUI), in the console's wording verbatim.
And the bundle is worth sending: orchestrate now pipes the session child's
stderr through logring::forward_child_stderr — every line still lands on our
stderr exactly as inherit interleaved it, and lands in the ring too. Without
that, the one surface a GUI-only user can export held everything EXCEPT the
stream it was exported about — the same hole the WinUI logfile module was
built to close for the file; its child forwarder now feeds the ring on the
same pass.
Reported from the field: the guided installer (scripts/install.sh) left a Fedora box with a host
and no web console — and then printed the console's URL anyway. Reproduced with the real script
against a faked Fedora 44 os-release: with `punktfunk-host` on PATH the installer prints "already
installed — skipping the install" and never runs its dnf line, so `punktfunk-web` is never added.
The console is where you pair, approve a device and change every setting, so that box can stream
nothing and offers no way to fix it.
The registry was never the problem: `punktfunk-web` is present in the fedora-44 and bazzite RPM
groups, the debian repo and the arch repo (checked all four). The install step was gated on ONE
binary while installing THREE packages.
* scripts/install.sh asks per package (host / punktfunk-web-server / punktfunk-scripting) instead
of skipping everything the moment the host binary exists. A box that got the host by hand, from
an older docs line, or from a package manager set to drop weak deps now gets its console on a
re-run. Installing a package that is already there is a no-op, so re-running stays safe.
* The install lines NAME the console on every family. apt relied on `Recommends: punktfunk-web`
and pacman on an optdepend, both of which vanish under `APT::Install-Recommends "0"` /
`install_weak_deps=False` / plain pacman. dnf already named it. The packages keep their weak
deps — a hard Requires would break the COPR build, which has no bun and ships no console — but
no documented path leans on them any more.
* The installer stops lying when the console is absent: step 6 warns instead of silently dropping
punktfunk-web from the units it enables, and the closing banner offers the install line rather
than a URL nothing serves.
Verified unchanged on the channels that were already right: the Bazzite sysext bakes in web +
scripting, the NixOS module defaults `web.enable` to `host.enable`, the SteamOS script defaults
`WITH_WEB=1`, and the Windows installer bundles the console unconditionally behind a payload guard
added after 0.22.1/0.22.2 shipped without one. COPR is the documented exception (no bun in mock).
The gates that let this ship:
* check-docs-drift.sh gate 7 asserted the install line only as far as the host package name, so
a line that dropped the console still passed. It now names all three per family.
* installer-smoke.yml asserted `punktfunk-host --version` and nothing about the console. It now
asserts the binary and the user unit per family, and carries the reported failure as a test:
remove punktfunk-web, re-run the installer, require it back.
data/platforms.json is the source for both the docs and the installer, so the per-distro pages,
the install landing page and the web-console page were updated with it — including a "no console on
a box that has the host?" recovery block for anyone already in this state.
Two halves of the same field report: a 0.31.4 user whose gamepad UI never
activates — on Always, with a pad attached — and who then has no way to send
anyone the log that would say why.
"Send logs to host" existed only in the Skia console's host menu. That put the
log-escape hatch behind the very shell that was failing: a phone whose console
does not come up has no route to its own logs at all. The upload moves out of
SkiaConsole into `SendLogs`, one implementation both shells call, and the touch
home's card menu grows the row on the console's own gate (paired + online, host
card only). The wording stays the desktop console's verbatim, so a quoted
message means the same thing on every client.
The activation half is a gap in what the app admits. `App` folds
`SkiaConsole.wanted() && healthy` into the gamepad-UI decision on top of the
user's setting, and those two terms are the ONLY ones that can veto "Always" —
the mode, the pad, the TV check and the dev flag are ORed, so a device whose
console never builds ignores every one of them. Until now that produced a switch
the app silently disobeyed, indistinguishable from the switch being broken.
`SkiaConsole.unavailable()` names the reason and the Interface settings group
prints it as its footer: null on every device where the console works, so it
costs nothing there.
The reason string itself was already in the ring the new row uploads —
`console: render thread ended: …` and `console: window surface: …` go through
`log::error!`, which `RingTee` tees into `pf_client_core::logring`. So the two
halves close the loop: the user stuck in the touch UI can now send the log that
names why they are stuck in it.
Verified: 98 app unit tests green, `:app:assembleDebug` clean, native builds for
all three ABIs.
'20 Mbps' used to mean 22-30+ Mbps on the wire: FEC parity (1-50 %),
packet framing (~4.5 %) and the audio plane all rode on top of the
number the user set — and on the constrained links where the setting
matters most, that overshoot WAS the failure. Per the signed-off RFC
§5.1 (amended 2026-08-27: no capability handshake — the semantics just
change; new-client→old-host is byte-for-byte today's behavior, and
old-client→new-host shifts conservative by the overhead share):
Host: every number on the control plane — Hello resolve, SetBitrate,
acks, live_bitrate, the console — is the TOTAL WIRE BUDGET. Encoder
opens/reconfigures convert through EncDerive (budget − audio, over
framing + FEC) snapshotted at the live FEC percent, and read-backs
convert back so a short apply reports budget truth. A FEC step
re-derives the encoder rate in place, so parity reallocates WITHIN the
budget instead of inflating the wire. The audio reservation comes from
the resolved plane (exact PCM cost, or the shared Opus budget ladder).
PyroWave is the identity (its pin is an encoder operating point);
GameStream keeps its historical semantics untouched.
Client: the controller's actual becomes the WIRE measure — received
bytes with headers, seals and parity (they spend the budget), minus
probe filler, plus the mirrored audio reservation — so utilization and
the proven mark compare like with like against budget targets.
The derivation is pure and unit-tested: roundtrips never inflate the
budget, reallocation is monotone in FEC, and a budget too small for
its own audio floors honestly. punktfunk-core --features quic: all 500
lib tests green natively; punktfunk-host clippy -D warnings green on
the CI image. Docs: the bitrate sections say what the number now means.
Static content used to poison the client's ABR against the first moment
of motion (the 2026-08-26 field chain, step 5), and a frame-driven
source could never climb at all (the documented dead band). Four
changes, per the signed-off RFC §4.1–4.3 + §7 Q4:
- The host marks idle-keepalive re-encodes on the wire
(USER_FLAG_REPEAT; whole-frame and streamed paths) and says so in a
new trailing Welcome byte (host_caps2 / HOST_CAP2_REPEAT_MARK — the
second capability byte the 0x80 wall predicted, same append
discipline, placeholder chain extended one link). Old clients ignore
both; against an old host the client keeps every legacy rule.
- Idle windows (every AU a repeat) are NEUTRAL: they train no OWD/
decode/encode baselines, accrue no climb credit or re-probe
authority, and never authorize a climb — loss/flush/drop keep their
full power. The pinned climb-gate test is extended, not deleted.
- Utilization is measured per frame at the source's own rate: a 35 fps
menu on a 90 Hz config climbs when its frames run full. The
proven-headroom cap prorates with it — it bounds the next target's
projected WIRE rate at ×1.5 over what was delivered — or the two
gates would deadlock exactly where the fix is needed.
- Recovery is bounded and multiplicative: the proven mark is a
two-bucket windowed max (~30–60 s) instead of all-session, and the
first active window after ≥3 s of stillness re-arms slow start with
the cooldown cleared — the ~103 s additive crawl from the floor
becomes seconds, never past ×1.5 over recently-proven delivery.
- FLOOR_KBPS drops 5000 → 2000 (RFC §7 Q4), with a one-shot log
warning on the first descent below the old fence; the client-settings
doc says so.
punktfunk-core --features quic: all 500 lib tests green natively,
including six new field-shaped scenarios (frame-driven climb, idle
neutrality head-to-head vs a legacy host, bounded onset re-arm,
windowed proven decay, floor + warning, idle-never-climbs).
Phases 1+2 of the ABR overhaul implemented review §2.1/§2.2/§2.3/§2.4 while this
branch was doing the same, so every ABR conflict resolves to main's side and my
duplicates are deleted:
- abr.rs: main folds the mode-switch rebind into set_stream_cap itself (a
`mode_switch` flag) instead of a second method — my rebind_stream_cap and its
test are removed; main's a_mode_switch_reteaches_the_stream_cap_both_ways
covers strictly more (up-switch, re-authorize, down-switch, disabled).
- pump.rs / control_task.rs / data.rs: main's ack queue and stream-cap recompute
are the same shape; taken wholesale.
- stream.rs: main's §2.2/§2.3 arms taken (its gap cast saturates, mine did not).
What this branch still carries is what main does not: the capture-stall telemetry
(posture line, repeated-stall WARN, CONTENT-SILENCE prose), the AMF work
(applied_bitrate_bps readback, accepted-props logging, AV1 B-picture pin,
no-reordering assertion), the adl-emul probe fixes, and the wire-MTU-re-key
attribution arm.
H.264 already pins BPicturesPattern=0 ('RDNA3+ defaults > 0'). AV1's three
B-picture properties are VCN5 features (AMF header, verified 2026-08-26) whose
defaults are 0/false today — but a default that flips on newer silicon is
exactly how H.264 grew them, and AV1 is the one AMD codec with no LTR and no
intra-refresh, so a reordering surprise lands on the codec that can least afford
it. Optional sets: pre-VCN5 drivers decline the names, which is the right no-op.
HEVC needs no twin — AMF defines no B-frame property for it at all, and a VCN3
capture measured 3 I + 52 P + 0 B.
The live smoke test now asserts AUs leave in submit order, so a driver that
silently declines a pin fails the test instead of shipping reordered output.
08-22 ABR review §2.4: a full resolve ack plus a corrective short retarget in
the same 750 ms report window collapsed to whichever arrived last — host-cap
learning needs two consecutive short acks, so a lost correction delayed or
prevented the cap and could reintroduce the overdrive sawtooth. Bounded queue
(8), drained fully per window.
08-22 ABR review §2.1: the stream-shape cap was computed once from the Welcome
mode and never again, so a 4K→720p switch kept authorizing 4K-sized climbs for
the whole session (only the reactive loss/decode signals reined them in). The
mode-gen site now recomputes the cap from the accepted mode (depth/chroma are
session-negotiated and ride along) and rebind_stream_cap clamps an already-
learned ceiling down to it. Up-switches lift only the cap — with no untrimmed
measurement stored, a higher ceiling would be evidence-free (§3.3 re-probe owns
that half). Pinned by a_mode_switch_rebind_clamps_the_learned_ceiling_but_never_raises_it.
A tag is a pointer, not a version. `actions/checkout@v4` re-resolves on
every run, so whoever controls that tag controls the first step of nearly
every job in this repo — including the jobs holding signing keys. 52
checkout sites and 31 cache sites now name a commit; announce.yml's lone
existing SHA pin moves up with them so the tree is uniformly pinned rather
than half-pinned, which is the state where a reader cannot tell a
deliberate omission from a miss.
checkout v4 -> v7.0.1, cache v4 -> v6.1.0, both by commit.
The reason this needed proving rather than just doing: v5+ of both declare
`runs.using: node24`, and Gitea's runner only accepts node24 from a certain
version on. Checkout is step 0 of almost everything here, so getting it
wrong fails the whole fleet at once — including the runs that would tell
you it broke.
What was established first:
- every runner serving this repo is Gitea Runner v1.0.8, whose vendored
act accepts node24;
- `runs.using` is only a validation gate — act execs a BARE `node` from
PATH, so the image's node is the real interpreter, and the oldest one
here is 18.19.1 (ubuntu:24.04's nodejs, in rust-ci-noble);
- so checkout v7's node24-targeted ESM bundle was run under that exact
node, in that exact image: it parsed inputs, drove `git` through
@actions/exec, created .git, set up and tore down credential config, and
ran its post step. It reached a genuine git error against the synthetic
file:// remote used for the test — not a module-load or missing-API
failure. It works.
- actions clone straight from github.com here (no Gitea mirror), so these
SHAs are meaningful; both tags are lightweight, pointing directly at the
commits pinned.
No breaking change in v5..v7 applies: this repo passes zero checkout
inputs, and uses neither `pull_request_target` nor `workflow_run` (v7's
fork-PR restriction). cache v5/v6 pick the same legacy service path as v4
because the runner never sets ACTIONS_CACHE_SERVICE_V2.
upload-artifact@v3 is deliberately NOT swept — it is pinned low on purpose,
because Gitea's artifact backend identifies as GHES and v4 refuses it.
The 2026-08-26 lab sessions over an overlay hop (udp_mtu 1336) produced the
'host/display disturbance' warn at period 1.7 s — just outside both client
cooldown bands — while the real cause was the path black-holing full-size video
until the re-key. Period alone cannot make this call; the session's transport
context can. New first-priority arm: wire_rekeys > 0 names the constrained path
and points at PUNKTFUNK_WIRE_MTU.
Last floating tool in the builder images. Unpinned, two rebuilds months
apart bake different cargo-ndk versions with nothing recording it, and this
is the tool that drives the .so files in the published APK/AAB. crates.io
is append-only with a checksummed index, so the version string is the whole
pin — no checksum needed, unlike the bun/sccache/FFmpeg cases.
4.1.2 is current, so this is a zero-change pin like the others.
rust-ci-noble builds FFmpeg and nv-codec-headers from `git clone --branch
<tag>`, and the .so files land in the host .deb every Ubuntu user
installs. Tags are mutable: upstream can repoint one and, unlike a branch,
nothing would look different. The comment above it claimed "immutable-tag
clone, so no separate checksum needed", which is the same
comment-asserts-what-the-code-doesn't shape this whole branch has been
unpicking.
Both are now asserted against the commit the tag points at TODAY, so the
built tree is byte-identical and only the mutability is closed. A moved tag
now fails the build loudly, matching the sha256sum -c pins next door.
Both tags are ANNOTATED, so the bare ref is a tag object, not a commit —
the pin is the peeled `refs/tags/X^{}`, which is what `rev-parse HEAD`
returns after a --depth 1 tag clone. Pinning the tag object would have
failed every build.
rustup's installer and `cargo install cargo-ndk` are left: both float, but
crates.io is append-only with a checksummed index and rustup verifies its
own downloads — neither is a fixed name whose bytes can move.
Two things that cost real time setting DEPLOY_KNOWN_HOSTS for the first
time. ssh looks a host key up by the exact string it dialled, so a plain
entry stops matching the moment DEPLOY_PORT is not 22 — and it fails
looking like a host-key error, not a formatting one. The comma form covers
both. And DEPLOY_HOST is unom-1's public Hetzner IP since the 2026-07-12
cutover, so a stale private address in an SSH config is the wrong thing to
scan.
Also say ed25519 only: pinning every type ssh-keyscan prints means the
weakest pinned key decides, since a host offering just RSA would satisfy
the check on the RSA line.
The marking was opt-in because some consumer ISPs/routers bleach or
reject DSCP-tagged packets — but that risk lives on WAN paths, and the
win (APs map DSCP to WMM airtime priority — real for exactly the Wi-Fi
downlink field case) lives on local ones. The default becomes AUTO:
mark toward RFC1918 / ULA / link-local / loopback peers, stay off
toward anything routable.
PUNKTFUNK_DSCP=1 still forces it on everywhere, =0 stays the kill
switch, and the embedder hook (the Android low-latency tie-in) still
forces on regardless of the peer — for paths like VPNs the address
math can't recognize as local. Decision + classifier are pure and
unit-tested; the connected-loopback test covers the AUTO path end to
end. Windows qWAVE mechanics unchanged.
The four correctness seams from the 08-22 auto-bitrate review §2, chosen
options per the RFC (planning design/abr-stack-overhaul.md §3):
- §2.3: a failed bitrate-change encoder rebuild now snaps the client back
(retarget_tx) — the control task acks BEFORE the apply, so the client's
climb base, utilization and proven math tracked a rate the encoder
never ran until some later event happened to correct them.
- §2.2: the ABR rebuild announces PipelineGap on success, like the
mode-switch and topology rebuilds already do — a ~0.6 s host-local
stall read as congestion killed slow start for the session (the 401 ms
field case: minutes at ~15 Mbps on a clean link).
- §2.1: an accepted mode switch re-teaches the stream-shape cap —
computed once from the Welcome mode, 1080p→4K kept a 1080p-sized climb
ceiling and 4K→720p left an oversized one standing. A re-set
set_stream_cap also rebinds the already-learned ceiling downward
(set_ceiling deliberately never lowers); the FIRST set keeps the
founding semantics, pinned by the existing stream-bound test.
- §2.4: the bitrate_ack slot becomes a queue drained in arrival order —
latest-wins collapsed a full resolve ack + corrective short retarget
landing in the same 750 ms window, and host-cap learning needs two
CONSECUTIVE short acks.
punktfunk-core --features quic: 490 tests green natively, including the
new a_mode_switch_reteaches_the_stream_cap_both_ways.
Heroic 2.21 added a fullscreen gamepad UI — the Big Picture of that launcher,
and the tile you actually want on a stream. Nothing could publish it: a plugin
names a launcher UI and the host builds the line, and the only Heroic value the
host knew was `heroic`, which opens the ordinary window.
`launcher_ui` now also takes `heroic-console` on Linux, resolving to the same
prefix `heroic` does — native binary if on PATH, else the Flatpak — plus
`--console --fullscreen`. Both flags, because Heroic reads them separately:
`--console` only routes the UI to that front end (`isCLIConsoleMode`) and
`--fullscreen` is what fills the screen (`isCLIFullscreen`). No URI can do it —
`heroic://` speaks `ping` and `launch` and nothing else — which is the same
reason Playnite's fullscreen tile spawns its exe directly rather than going
through the registered protocol handler. An older Heroic ignores the unknown
`--console` and still honours `--fullscreen`, so the tile degrades to a
fullscreen desktop UI rather than to nothing.
That makes the value a launcher UI rather than a launcher. It already was one on
Windows, where `playnite` has always meant `Playnite.FullscreenApp.exe`; the doc
comment claiming otherwise is corrected. A `heroic_ui` kind mirroring `steam_ui`
would have been tidier and was rejected on the compatibility asymmetry: an
unknown KIND degrades to an unlaunchable tile, but an unknown VALUE is a hard
400 that refuses the whole reconcile, so either shape has to be gated on
`minHost` in the plugin index and the value is much the smaller change. A plugin
publishing `heroic-console` must set `minHost` to the release carrying this.
Second fix, from the same reading: `resolvable_launcher_ui` special-cased only
Playnite-on-Windows and answered `true` for everything else, so a Heroic tile
survived `sanitize_launcher_entries` even when `heroic_launch_prefix()` resolves
to nothing. Keeping `~/.config/heroic` after uninstalling Heroic is enough to
reach that — the plugin's `detect` only looks for the directory — and the
operator got a launcher tile that does nothing. Both Heroic values now probe the
prefix, exactly as Playnite probes for its exe.
Static content used to strip every defense before motion: the unpaced
microburst allowance was an absolute max(128 KiB, wire/4) — sized for
gigabit LAN, so at Wi-Fi bitrates every frame went out back-to-back —
and adaptive FEC decayed to 1 %, so the first big motion frame died
unprotected (the 2026-08-26 field case; RFC design/abr-stack-overhaul.md
in punktfunk-planning, §2.1–2.4).
- The allowance is now TIME at the pace rate: 10 ms, clamped to
[16 KiB, 256 KiB] (send_pacing::auto_burst_bytes). One constant lines
up both proven ends — ~19 KiB at the field case's 15 Mbps pace (its
discriminator was PACE_BURST_KB=16) and ~112 KiB at 90 Mbps LAN pace,
so LAN latency does not regress. PACE_FACTOR=0 keeps the legacy rule.
- A streamed AU consumes ONE allowance across its block flushes instead
of a fresh one per block (the latent per-block multiplication).
- One frame's paced spread is bounded to ~2 frame intervals so an IDR
can't back the encode|send sync_channel(3) up into cadence_degraded,
which refuses every climb; MAX_PACE_SPREAD stays the absolute bound.
- Adaptive FEC gains a burned floor: any window with real shard loss
raises the decay floor to 5 %; ~2 clean minutes re-earn 1 %, an early
re-burn doubles the requirement (bounded), a durable step-down resets
it — no session-permanent latch, per the encode stand-down's house
rule. PUNKTFUNK_FEC_PCT pin semantics and the GameStream plane are
untouched.
Pure logic (FecFloor, auto_burst_bytes, native_budget) is unit-tested;
the burned-floor band is noted in the FEC_PCT docs.
The chunked poll ships slice bytes the moment the doNotWait lock publishes
them, trusting that they are a byte-exact prefix of the AU the finishing
blocking lock returns. The check for that trust — the ChunkState shadow
comparison — was compiled out of release builds, so a driver branch whose
sub-frame accounting runs ahead of the flushed bytes shipped not-yet-written
buffer content straight to the wire: self-consistent tiling, wrong bytes,
no client counter moving. That is the black-band field report (Windows
client, 4090 host, identical across all three decoders, Android immune —
it never negotiates the slice wire), and most likely the Linux/AMD grey
smears too.
Make the shadow prefix check unconditional (one AU-sized copy + compare
per frame — noise next to the encode), and on divergence latch
subframe_broken before bailing into the existing encode-stall recovery:
the in-place rebuild now resolves sub-frame OFF for every later session
open on this encoder, so the driver that lied is disarmed after exactly
one rebuild instead of looping into MAX_ENCODER_RESETS. The client sees
the abandoned-AU path it already handles (ages the frame out, IDR
re-anchors) — a one-second hiccup, once, instead of endless corruption.
Both direct-NVENC backends (Windows D3D11, Linux CUDA) get the identical
change; the bail path also stops leaking the popped frame's input mapping.
The workflow-level pins closed the bootstrap path only. On the normal path
the bun that gets vendored into the published .deb/.rpm comes from the
builder image, and every image still installed it with the upstream
installer piped into bash — so the script still chose bytes that ship to
users. rust-ci and fedora-rpm now take the same pinned, SHA-256-checked
bun-v1.3.14 asset the workflows do; arch-ci takes bun from pacman, which
verifies package signatures.
Same class, found while sweeping and worse than the cited sites: five
images fetched sccache over a version-pinned URL with no integrity check at
all. sccache is RUSTC_WRAPPER for every binary we ship — it can serve
poisoned object files straight into a signed package, which is the position
the review called the highest-leverage in a build. Download, verify against
upstream's published sum, then extract.
packaging/flatpak/build-flatpak.sh took flatpak-cargo-generator.py from a
mutable master, the same fetch flatpak.yml just pinned; both now name the
same commit and sum, so the local build and CI agree.
Arch note: bun rides the existing -Syu transaction rather than a later
layer. A separate layer resolves against the DB baked into the CACHED -Syu
layer, and Arch mirrors carry only current versions, so a cache-hit rebuild
months on would fail to fetch a package the stale snapshot names.
None of this takes effect until the images are rebuilt: docker.yml's
`builders` job keys on the git tree hash of ci/, so this re-keys the whole
family. Until then the workflow bun pins sit behind `command -v bun ||` and
short-circuit against the image's baked bun.
rustup's own installer is left piped, as apple.yml already does — pinning
rustup-init is a separate decision, and the same argument reaches every
image at once.
First .173 run: 15 logical adapters enumerated, zero connectors walked, exit 0,
no explanation — a probe whose deliverable is an rc must never end silent. One
record per distinct (bus, vendor, present) shape before the filter.
An explicit client rate under PyroWave was ill-defined (all-intra bpp
semantics — the operating point is bits per pixel, not kbps) and bypassed
the PUNKTFUNK_PYROWAVE_MAX_MBPS operator ceiling entirely.
Host: resolve_bitrate_kbps_for ignores the requested rate under PyroWave
(warn when overriding) so every PyroWave session goes through the per-mode
bpp pin + ceiling, and bitrate_auto treats PyroWave sessions as Automatic
so mode switches re-resolve the pin whatever the Hello carried.
Clients: pf-client-core sends bitrate 0 when the preference is an
ADVERTISED PyroWave (a failed decode probe falls back to H.26x, where the
user's rate must survive); the Apple client mirrors the same gate at its
own Hello site. The console UI and the Apple settings dim the bitrate
control with a one-line explanation instead of offering an inert rate.
The stored setting is preserved everywhere — switching codecs back
restores it.
Follow-ups left open by the security-review pass.
The Windows client had the deep-link fix's enforcement but not its UX: a
name- or address-resolved link was REFUSED with a note pointing at the host
list, marked in-code as degraded, because that shell had no prompt surface
wired up. It has one — `ContentDialog`, already used for "Remove saved
host?" and "Delete profile?" — so the link now asks, naming the host and
anything it wants to launch, and on confirm runs the identical dial the
id-referenced path runs. Same wake, trust and error surfaces; confirming is
one click in front of the same code.
The supply-chain pins only covered the jobs the report cited. deb.yml
(a signing-key job) and arch.yml still piped bun's installer into bash;
flatpak.yml still took the deploy host key on first contact while holding
FLATPAK_GPG_PRIVATE_KEY. deb now takes the same pinned, SHA-256-checked bun
asset as rpm and windows-host; arch takes bun from pacman, which verifies
package signatures; flatpak verifies the host key from DEPLOY_KNOWN_HOSTS
through the fail-open-through-setup preflight it already had.
Found while sweeping, worse than the cited sites: flatpak.yml fetched
flatpak-cargo-generator.py from a mutable `master` and ran it in the job
holding the GPG key — third-party Python deciding which crate sources the
signed build vendors. Pinned to a commit and checksummed.
pf-zerocopy's EGL bindings declared glShaderSource/glGetUniformLocation as
`*const i8`, but `CStr::as_ptr()` yields `*const c_char` — i8 on x86_64,
u8 on aarch64. So the crate did not compile for aarch64 at all, and CI is
x86_64-only and could never see it. Verified both directions: with c_char
`cargo check -p pf-zerocopy --target aarch64-unknown-linux-gnu` passes,
and the old spelling still fails E0308 there.
Docs: the note saying Windows has no prompt is gone, and the first-connect
sentence put Windows in the wrong group — it pre-fills its trust prompt
from the link like Linux and Android; only the Apple apps show a notice.
Corrected an Apple comment claiming it seeds the add sheet with the address
and fingerprint when it only sets a notice string.
- Every capture session stamps a 'GPU-priority posture' INFO line (both REALTIME
opt-ins) at open, so a stalling log carries the levers even when no WARN fires.
- Repeated stalls WITHOUT a stable period now WARN with the full triage payload
(tallies, suspects, levers) — the 2026-08-26 7700 XT log had 6 holes in 8 s and
zero guidance because only the metronomic arms spoke.
- CONTENT-SILENCE prose stops overselling benignity: a frozen presenter
(disturbance-immunity Flavor 3) reads identically, and our probes all run at the
host's elevated GPU priority.
- Native AMF implements applied_bitrate_bps via a GetProperty readback (typed the
existing vtable slot), so encoder_ceiling learning / the ABR overdrive guard stop
being inert on AMD; optional-property rejections log at INFO and the encode-active
line carries ltr/intra_refresh — the VCN capability matrix builds itself from
field logs.
- Doc drift: PUNKTFUNK_GPU_PRIORITY_CLASS default is high (not auto) everywhere it
is described; PUNKTFUNK_IDD_ADAPTIVE documented; troubleshooting names the
REALTIME-lever first step and the new repeating-stall warning.
Design: punktfunk-planning design/windows-amd-host-program.md §3.1–§3.3 wave 2.
Follow-up from the same Chromecast field report: the hint legend only ever
spoke gamepad (or desktop-keyboard keycaps), whatever was actually driving.
A TV remote got Enter/Esc/Tab — keys it does not have — and a Switch pad's
badges read the Xbox letters, so 'A Select' sat over the button engraved B.
The shell now notes WHAT drove last at every input seam — the Android host
loop labels its two menu paths (discrete events are the remote, the pad
sampler is the pad), the desktop overlay's menu channel is pad by
construction, and the keyboard path notes itself. Each frame the legend
resolves from that:
* a pad speaks its own family — PlayStation shapes, Nintendo letters (both
pairs swapped, the way the pad is engraved), ABXY for the rest;
* keys on Android are a TV remote: an OK badge for select, the ↩ return
arrow for back, the section hint pointing at the D-pad path (▲) that
reaches the tab strip, and the Y/X hints hidden outright — a remote has
neither, and advertising a button the device cannot press is worse than
silence;
* keys on the desktop stay keyboard keycaps;
* nothing has driven yet: the connected pad's family, else the platform's
key device — so an untouched console reads right on every device.
The controller chip grows a remote mark and says 'TV remote' when that is
what is driving; a pointer press changes nothing, since a tap says nothing
about the buttons in the user's other hand. The screen dump gains the two
remote frames (Home + Settings) for the eyeball pass.
A slice-streamed sentinel's wire base was bounds-checked (in range, below
the final block) but never validated to TILE the AU: a base that lied
within bounds left a zero gap and an overlap, and the reassembler still
stamped the frame `complete`. The decoder then painted the wrong-offset
bytes as garbage rectangles while no loss counter moved — so the recovery
machinery never fired and the corruption marched on indefinitely (the
field report: black bars 'moving like an equalizer', identical across
Vulkan/DXVA/CPU decoders, Android immune because it never negotiates the
slice wire).
At completion, verify the blocks tile [0..total_data] contiguously; a
frame that doesn't is killed exactly like a lying final header — index
remembered against stragglers, counted into frames_dropped, which is the
signal that arms the freeze gate and requests recovery. The
slice-progressive prefix walk gets the same contiguity guard so a lying
base can't extend a 'contiguous' prefix either.
Gate C in check-unsafe-hygiene.sh matches the qualified `env::set_var`
anywhere in a file, comments included — deliberately, so the contract stays
dumb and stable. The new SESSION_BACKEND doc explained what it replaced by
naming it, which reads as a fresh call site to the gate. Reword rather than
raise the baseline: the baseline is there to catch a real write later, and
this change deleted one.
Security review 2026-08-25, 58 confirmed findings across host, console,
clients and supply chain. Nearly every serious one is a documented boundary
whose code stopped enforcing what its comment promised — so where the two
disagreed, the comment won and the code was made to match, and where it
could not be, the comment was corrected instead.
Critical — a console session cookie alone reached code execution: every
pairing route rode the generic catch-all with the operator's admin bearer
attached. Arming, approving and PIN submission now sit behind the console
password like the other trust-root routes, and the armed PIN is returned
once in that gated response instead of riding a 1 s status poll.
High — the plugin lane no longer reads the unredacted log ring (which
carried the webhook credentials the /hooks carve-out exists to withhold);
hook lines log an origin and a short id, never a URL or a command line; a
plugin-reported pid is held to procscan's start-time floor before the
SYSTEM host will signal it; ClipOffer is gated on the live grant mask, so a
revoked guest loses the host clipboard in both directions; ENet refuses
connects with no live launch instead of letting LAN peers squat all four
slots; Windows secrets are born with their DACL applied rather than
world-readable; the sysext feed binds FEED and a monotonic SERIAL inside
the signed bytes; privileged_field allowlists the host-resolved launch
kinds so a new kind is privileged by default; five parser panics reachable
from one malformed NALU are range-checked; release-signing jobs pin bun,
sccache and actions by checksum/SHA; h2 -> 0.4.19 (RUSTSEC-2026-0258).
Deep links only auto-dial by stable record id now — a display name or an
address gets a confirmation on every client. The Apple identity key moves
to ThisDeviceOnly so it stops riding encrypted backups.
pf-vdisplay stops routing session identity through the process environment:
the injector backend threads through a typed slot, so per-batch getenv no
longer races a per-session setenv. The four remaining writes have no
in-repo readers and are documented as such; the SAFETY proof that claimed
ENV_LOCK made them sound is gone.
Verified: cargo clippy --workspace --all-targets --locked -D warnings and
cargo fmt --all --check clean in the CI image; web builds, tsc --noEmit
clean, 22/22 server tests; Swift debug+release + 26/26, Kotlin :kit 7/7.
Not fixed, deliberately: the plugin token can still mint command execution
(the plugin launch kind exists so a plugin names a command the host runs —
per-plugin identity does not change that, and the runner is one process
hosting fibers, so there is nowhere to hang a credential); the shared
plugin-UI origin; the rollback Authenticode publisher pin (Azure mints a
fresh leaf per request, and the signer subject is not in the tree). Each is
now described accurately where it lives instead of being claimed closed.
A Chromecast field report: with only the TV remote — the pad had dropped off,
as Chromecast pads do — every settings tab but the first was unreachable. The
split of the one 30-row scroll into tabs put section switching on L1/R1, Tab
and PgUp/PgDn, and a remote has none of those; the D-pad's left/right already
mean 'adjust the focused row'.
Up from the list's top row now steps onto the tab strip instead of recoiling:
left/right travel the ring (wrapping, like the shoulders), Down or A drop
back into the rows of the tab that's showing, B still leaves the screen, and
the shoulders keep working from either focus. The strip shows the handoff —
brighter pill, ‹ › chevrons — the rows rest their focus ring while it holds
focus, and the hint bar names the D-pad's meaning up there. A pointer press
on the rows takes the focus back.
The gamepad path is untouched: nothing changes until an Up is pressed on the
top row, which used to be a boundary recoil.
A Bazzite triple-monitor field report: monitors cycling off/on around every
session, sessions ending with some or all monitors dark or at the wrong
resolution, and an exclusive apply logging also_disabled=[] on a desk with
three enabled physicals.
KWin persists output state per MONITOR SET (kwinoutputconfig.json). Creating
our virtual output changes the set, and a set that ever ran exclusive has
'physicals disabled' stored — so KWin disables them ITSELF, in the window
between our output's creation and the topology apply's enumeration. The apply
then finds nothing enabled, captures nothing for restore, and teardown
re-enables nothing: the desk stays dark, and KWin's own recovery improvises
modes. Under Extend/Primary the same stored setup switched screens off that
those topologies promise never to touch.
Three moves, all in the KWin backend:
* create() snapshots the enabled physicals BEFORE the virtual output exists —
the only read KWin's reaction cannot have polluted.
* Exclusive: the snapshot joins the restore list (union_restore) — an output
KWin disabled before we could see it is still re-enabled at teardown.
Extend/Primary: reenable_stranded() puts the stored-config casualties back
on, immediately and once more after a short delay (KWin can apply the
stored setup after our first read). One shot each, never a loop.
* The group-teardown restore gains a delayed verify that re-asserts AFTER our
output is reclaimed — under the without-us set, so KWin persists the heal
into the stored setup instead of re-fighting us next session. It stands
down if a new session's managed output already owns the topology.
A box stranded BEFORE this fix stays stranded once: the snapshot cannot see
outputs that were already dark. Re-enabling them once in System Settings
while no stream is live writes the healthy without-us setup back, and the
loop cannot re-form.
`punktfunk` only *Recommends* punktfunk-web (and punktfunk-scripting), so
`sudo dnf install punktfunk` installs the host with no console on any box where
weak deps are off — install_weak_deps=False, --setopt=install_weak_deps=0, or an
rpm-ostree layering that drops them. The user then follows step 4 and gets
"Unit punktfunk-web.service could not be found", with nothing saying why.
Name both subpackages in the install line at its single source
(data/platforms.json + the docs-site snapshot), which the guided installer and
the Fedora page quote verbatim, and drop the "the console comes with it" claim
from the README. Adds the symptom to Troubleshooting, including the other cause:
COPR has no punktfunk-web at all (no bun in its mock chroot).
21 commits since v0.31.3 (14 non-merge). Cut from origin/main c407f6a6 (#393
merged).
THE NUMBER: a patch, and the cleanest one in a while — NOTHING versioned moves.
WIRE_VERSION 2, C ABI 25 with include/punktfunk_core.h showing NO diff against
the v0.31.3 tag (nor against v0.31.2 or v0.31.1 — third release running), driver
protocol 6 / min 3 with pf-driver-proto unchanged, gamepad channel 3, plugin
index schema 1, host event schema 1 (events.rs no diff), gamescope +pfhdr8 with
no new patch files, SDK 0.1.6 and plugin-kit 0.4.4 both untouched. api/openapi.json
is CONTENT-identical — info.version is the entire diff, the first release since
v0.31.0 where the management API grows nothing. No lockfile moves, in any of the
five. Four feat commits, all additive and all client- or Linux-host-side; v0.31.3
and v0.31.1 each carried two feats as a patch on the same reasoning.
THE SHAPE: Linux-host, and the faults share a premise — a policy written against
ONE desktop that silently did nothing on the others. Topology::Exclusive on a
gamescope session was implemented as org_kde_kwin_dpms and reached only a live
KDE desktop, so a Game Mode box (the deployment whose TV the operator most wants
dark) got nothing: #389 is five commits closing that — a DRM floor for a box with
no desktop at all, sway and Hyprland arms, the managed route's own hold, and the
box-session free that was gated on is_steam_launch. The sleep:idle BLOCK
inhibitor refused every Suspend() for a stream's length including the operator's
own from Steam's power menu, and on a DM-stopping takeover polkit's allow_active
fallback found no session to elect and dropped all three power actions to
auth_admin_keep (#391). The web console served the LEGACY identity because every
launcher names that pair and none of them CAN choose, so browsers rejected it and
the tray's mgmt-pinned probe refused the handshake and labelled a healthy console
dead (#393). Plus the client's encode down-driver (#392), an installTap format
snapshot reaching the ObjC terminate handler (#387), the console's bitrate row
(#388), and a GTK front door for the console UI (#390).
TWO ENTRIES WORTH THE READER'S ATTENTION, both recorded as such:
* The sway arm of #389 is NOT verified on glass — the lab has Bazzite, Nobara
and SteamOS, no sway box. Hyprland, KDE and the DRM floor were all measured
on real machines. The notes say so in "Thanks" rather than burying it.
* #389's Hyprland arm shipped WRONG in its first cut and was corrected on glass
(540e282e): the dpms dispatcher is a TOGGLE that ignores the state word, so
the blind "send off, later send on" would have LIT an already-dark head at
stream start and DARKENED a lit one at teardown — the exact failure the
feature exists to prevent. Read -> act only if different -> verify.
DOCS FRESHNESS, per docs/releases/README.md step 1: #390 and #393 carried their
own docs-site updates (clients.md, and #389's two gamescope.md edits). Two facts
were left owed, both in gamescope.md and both the "stale sentence" class that
docs-drift cannot catch:
* the darkening mechanism list still read "KDE, else DRM" and never learned
about the sway and Hyprland arms from e0a82201;
* the "could not deliver a dark screen" example was a box in Game Mode, which
is now precisely the case that IS served by the DRM floor. The real remaining
gap is GNOME/Mutter, so it says that instead.
Also: the punktfunk group grew a third grant this cycle (the three logind power
actions, via 49-punktfunk-power.rules), and the group's own callout block
enumerated only the usbip nodes — one sentence added there.
No new PUNKTFUNK_* variable this cycle (PUNKTFUNK_UI_TLS_CERT/KEY are
pre-existing and already in the baseline), no new host subcommand, and no install
command, repo URL or port change, so data/platforms.json and the website's
vendored copy need nothing.
VERIFIED HERE: scripts/ci/check-docs-drift.sh clean, and PROVEN LIVE rather than
assumed — a deliberate one-byte break of the openapi copy makes it fail, and it
passes again restored; scripts/ci/check-docs-links.sh clean; the android.yml Play
notes gate run verbatim, 481/500 characters and unique against every other
release's file; both openapi copies cmp identical and stamped 0.31.4; cargo fmt
--all --check clean; git diff v0.31.3..HEAD empty on include/punktfunk_core.h, on
crates/pf-driver-proto, on punktfunk-core/src/lib.rs, on punktfunk-host/src/events.rs
and on api/openapi.json, which is the direct evidence for those five version rows;
git diff v0.31.1..HEAD on the C header also empty, which is the "third release
running" claim; Cargo.lock's 36 workspace version strings moved with Cargo.toml
and zero 0.31.3 strings remain; 27 crate dirs / 39 workspace members, both
unchanged; notes voice scan clean (zero backticked terms above ## For developers,
and zero internal names on a scan for gamescope/polkit/logind/DPMS/CRTC/DRM/
systemd/kwin/mutter/wlroots/rustls/ioctl); CHANGELOG link pinned to
src/tag/v0.31.4.
NOT RUN HERE, and why: any punktfunk-host build, clippy or cargo test — the host
does not compile on macOS at all, and CI covers it; the web/ and docs-site/ bun
builds — nothing under web/ is touched by this commit and the docs-site edits are
prose in an existing .md; the Android unit tests — nothing here touches Kotlin.
LEFT AS A DECISION, not made here: the tag itself. Notes, CHANGELOG and Play text
are authored ahead of it exactly as docs/releases/README.md step 1 requires, so
whichever workflow wins the create race seeds a complete release body — but
pushing v0.31.4 fans out every build workflow, and that is step 2.
The web console never followed the identity split: every launcher names the legacy cert.pem/key.pem, so it served a CN-only, SAN-less RSA cert. Browsers reject that outright, and the tray's liveness probe — pinned to the native identity the mgmt API serves — could never complete the handshake, labelling a healthy console "not responding" beside a tooltip reading "idle".
Resolved at the Nitro entry, the one place every launcher routes through: prefer the native pair when both halves are present, usable and in the same directory; fall back to the legacy pair otherwise. The tray's console probe drops its pin rather than gaining a second one, and `serve` now mints the native identity before the legacy one.
Reviewed adversarially (no blockers; the one real finding — a pair that could be assembled from two different directories — fixed in 1e2b956d). All 8 checks green, including the native-only clippy gate. Verified end to end against a built server: both pairs → P-256 with SANs; legacy only → RSA as before; 0-byte native cert → clean fallback rather than a restart loop.
Review of the parent commit. One real defect, the rest accuracy.
The resolver could hand back a MISMATCHED pair, which is the one invariant its
own header promised it never would: `nativeCert` came from `dirname(cert)` and
`nativeKey` from `dirname(key)`, two independent directories, so
("/a/cert.pem", "/b/key.pem") resolved to /a/native-cert.pem + /b/native-key.pem
— two unrelated files presented as a pair. No shipped launcher splits them, but
the guard is one comparison and it is the whole point of the module.
Rewritten off a suffix test instead of `node:path`, which fixes two more things
in passing. `node:path` resolves per-RUNTIME, so a POSIX CI runner reads
`C:\ProgramData\punktfunk\cert.pem` as one long filename and never swaps — and
Windows, where windows/service.rs hands us exactly that, is the platform the CI
job can never exercise. The suffix test gives the same answer everywhere and is
now covered by a win32 case. It also leaves the prefix VERBATIM, where
`join(dirname(p), …)` normalised /a/b/../cert.pem into a different directory the
moment `b` was a symlink.
Existence is no longer enough: `pf_paths::write_secret_file` is
create+truncate+write rather than temp+rename, so a console starting mid-write
could adopt a 0-byte cert and leave `Bun.serve` throwing on every restart. Not
every launcher retries forever — the Steam Deck unit is `Restart=on-failure`
under the default rate limit, i.e. permanently dead. The check is now a
non-empty stat, mirroring the host's own `!c.trim().is_empty()`. Verified: with
native-cert.pem truncated to 0 bytes the console starts and serves the legacy
pair.
Two comments of mine overclaimed and are corrected rather than left to mislead:
* serve() said "cert.pem existing implies the native pair does too". False on
an upgraded host whose native clients pinned the legacy cert — load_or_adopt
returns it and writes no native files at all. The ordering claim that IS
true is narrower: whenever that call writes a native pair, it does so before
cert.pem appears.
* the tray said the console entry "always opens the same URL regardless of the
probe". True of the menu entry, but win.rs gates the tray-icon single-click
on console_up. Also notes that the Windows probe was never pinned to begin
with (punktfunk_config_dir is None off Linux), so that half is a no-op.
Rest is doc drift the parent commit annotated in two launchers but not the other
four: web.env.example, README, web-run.cmd, and the ci.yml comment that still
said the web test step was "Scoped to server/".
18/18 web tests (was 14), biome and rustfmt clean, and the runtime check re-run
against a fresh build: both pairs -> P-256 with SANs; 0-byte native cert ->
legacy RSA, console still serving.
A Linux operator saw "Open web console (not responding)" in the tray next to a
tooltip reading "idle", and the console would not load in a browser either.
The host has kept two identities since the identity split (crate::identity):
native-cert.pem/native-key.pem (P-256, real SANs — what the native QUIC plane,
the mgmt API and every native client pin) and the legacy cert.pem/key.pem (RSA,
CN=punktfunk, NO SAN, kept byte-stable for Moonlight). The web console never
followed the split. Every launcher — the systemd unit, the NixOS module, the
Windows service supervisor, web-run.cmd, the Steam Deck installer — still names
the LEGACY pair, and none of them CAN choose: `Environment=` has no "this file,
else that one". So the console served a certificate with no SAN at all, which
costs twice over:
* browsers reject a CN-only cert outright (ERR_CERT_COMMON_NAME_INVALID), so
the console the operator was told to open does not load;
* the tray's loopback liveness probe reused the agent PINNED to the mgmt
identity — the native cert — so rustls refused the handshake and a perfectly
healthy console was labelled "not responding". The "idle" tooltip beside it
is the proof: the same agent reached mgmt fine on the very same tick.
The entry is the one place every launcher routes through, so the choice is made
there: prefer the native sibling pair when both files exist, as a PAIR or not at
all (a native cert with the legacy key completes no handshake with anyone). A
host that never took the split has no native pair on disk and falls through
unchanged, as does an operator-supplied cert under any other name. This also
hands the bundled bun the smaller secret: on a default build key.pem is the
Moonlight pairing SIGNING key, native-key.pem is only a TLS key.
The tray's console probe loses its pin rather than gaining a second one. It is a
different server and there is no rule that it presents the mgmt certificate — an
operator fronting the console with their own LAN-CA cert would have hit this just
as squarely. The probe sends no credentials, reads no body, and decides only a
menu label, so there is nothing for a pin to protect.
`serve` now resolves the native identity BEFORE minting the legacy one. That
closes a first-run window where the console (which waits on cert.pem) could start
between the two writes and serve the SAN-less cert for the rest of the boot, and
it fixes a second latent fault: with cert.pem missing but native clients paired,
the old order let load_or_create mint a brand-new cert.pem that load_or_adopt
then adopted while logging that it was preserving their pins.
Verified against the built server: configured exactly as the shipped unit does
(PUNKTFUNK_UI_TLS_CERT=.../cert.pem), it now serves the P-256 cert with
DNS:localhost/IP:127.0.0.1; with the native pair removed it serves the RSA cert
as before. 14/14 web tests pass, biome and rustfmt clean.
Nothing this controller learns from evidence is permanent — both learned caps
re-probe on the CAP_REPROBE_WINDOWS_MIN ladder, and the clock-flush detector was
itself changed from "off for the rest of the session" to re-armable for exactly
this reason. The encode stand-down had no business being the exception.
What it answers is transient by nature: contention on the host's GPU ends when
the game exits to a menu, when the shader storm finishes, when the second app on
the card closes. What it silences is the only signal that can descend when the
encoder is genuinely past its compute knee on a link that shows nothing else. So
a permanent stand-down trades a bounded problem for an unbounded one — one
contended stretch would strip knee protection from every calm minute that
follows, including the ones where a climb can reach a rate the ASIC cannot hold.
A clean run now re-arms it, and the interval doubles each time the silence is
immediately re-earned, so a genuinely standing contention settles into a slow
poll rather than thrashing. The asymmetry is the whole argument: a too-eager
re-arm costs one ×0.7 and stands down again; a too-permanent silence costs the
protection outright.
Verified on the NixOS VM (125, Hyprland 0.55.4) — and the arm I shipped for it in
the previous commit was wrong twice over. This is why it went on glass.
**The argv did not work at all.** `hyprctl dispatch dpms off <name>`, the form
this file's own probe notes use, dies on the Lua config manager: `dispatch` is
shorthand for `hl.dispatch(...)`, so the bare words are parsed as a Lua
expression —
error: [string "return hl.dispatch(dpms off HDMI-A-1)"]:1:
')' expected near 'off'
The Lua spelling is `hl.dsp.dpms("off", "<name>")` (found by enumerating
`hl.dsp` through `hyprctl eval`, which only exists on that manager). A hyprlang
box wants the classic form, there is no stable probe for which manager is
loaded, and `hyprctl_dispatch` already catches the exit-0 rejections both
produce — so try classic, then Lua, and report both failures if neither lands.
**And the dispatcher is a TOGGLE that ignores the state word.** Measured:
On ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> Off <- asked ON, got OFF
Off ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> On
Off ==[ hl.dsp.dpms{state="off", ...} ]==> On <- asked OFF, got ON
Both spellings, positional and table. So the blind "send off, later send on" the
previous commit shipped would LIGHT an already-dark head at stream start and
DARKEN a lit one at teardown — the operator's screen left off after the stream,
precisely the failure this policy exists to prevent. (It bit me while probing:
a restore fired at an already-On head turned it off, which for a while looked
like "dpms on cannot restore on Hyprland".)
So `dpms_one` is read → act only if the state differs → verify, via
`hyprctl -j monitors all`'s `dpmsStatus` (measured to track the connector's
sysfs `dpms` exactly, in both states, and a dark monitor stays listed). That
shape is also correct where the call really is a set, so it is not conditional
on detecting the manager. It returns whether it CHANGED anything, and
`dpms_other_heads` records only those — a head already in the wanted state is
left alone, because "fixing" it would break it, and reporting it would have the
re-light toggle a head we never darkened.
The on-glass assertion is relaxed from "every connected head goes dark" to "at
least one did, and all are restored": this VM carries a virtio `Virtual-1`
beside the real `HDMI-A-1` and Hyprland manages only the latter, so the strict
form failed on a difference that is not a defect.
Verified end to end, real Rust through the real dispatcher on a live Hyprland:
before: [("card0-HDMI-A-1", "On"), ("card1-Virtual-1", "On")]
during: [("card0-HDMI-A-1", "Off"), ("card1-Virtual-1", "On")]
after: [("card0-HDMI-A-1", "On"), ("card1-Virtual-1", "On")]
went dark: ["card0-HDMI-A-1"]
test gamescope::tests::live_the_managed_hold_darkens_a_real_panel ... ok
The unmanaged `Virtual-1` is correctly untouched, and the test exercises the
double-acquire (reconnect) path too, so the toggle-safe release is covered.
cargo test -p pf-vdisplay 255 passed / 0 failed; clippy --all-targets clean;
fmt --check and check-docs-drift.sh clean. sway remains the one arm not verified
on glass — there is no sway box in the lab.
A 1440p60 Lutris session ratcheted 57 → 5 Mbps over ten minutes on a link that
never dropped a packet — no keyframe asks, no flushes, a flat decoder, and the
host granting every rate it was asked for. Two things did it, both in the
client's encode down-driver.
The thresholds were absolute durations calibrated at 120 Hz ("~half a frame
budget", "≈1.5 × a frame budget"). At 60 Hz one frame is 16.7 ms, so an ordinary
one-frame encode hiccup cleared the SEVERE tier and took the immediate ×0.7
where the same hiccup at 120 Hz (8.3 ms) never reached it — which is exactly the
asymmetry the field log shows against 1440p120 sessions on the same host and
client. Size them in the session's own frame budget instead, plumbed from the
negotiated refresh and re-read on a mode switch.
And the signal fired for a cause the rate cannot fix. It exists to find the
encoder's compute knee, where cutting the rate cuts the work; when encode time
is held up by a game saturating the GPU that premise is false, the backoff
changes nothing, and `on_ack`'s baseline re-seed erases the evidence that
nothing improved. So remember the level each encode-attributed backoff fires at:
two in a row that fire no lower mean the rate is not the lever, and the signal
disarms for the session. Same shape as NOOP_CLOCK_FLUSHES_TO_DISARM. Loss, OWD,
decode and keyframe signals keep their full power throughout, and the host's own
climb refusal stays the backstop for a genuine knee.
`exclusive` on a gamescope session meant "turn the box's own panels off", and
that only actually happened on two kinds of box: one running KDE (via
`org_kde_kwin_dpms`) and one running no desktop at all (via the DRM floor added
in the previous commit). A gamescope session on a **sway** or **Hyprland**
desktop got nothing — the desktop keeps driving its panels, and the DRM floor
cannot help because that compositor holds DRM master, so `SET_MASTER` is refused.
There is no cross-compositor DPMS protocol, so the module becomes a dispatcher,
each arm self-gating on its own IPC so a box only pays for the one that answers:
| desktop | mechanism |
|----------------|--------------------------------------------------------|
| KDE / KWin | in-process `org_kde_kwin_dpms`, then `kscreen-doctor` |
| sway (wlroots) | `swaymsg output <name> dpms off` |
| Hyprland | `hyprctl dispatch dpms off <name>` |
| none at all | `drm_dpms` — the CRTCs off over DRM |
| GNOME / Mutter | cannot be served (below) |
**`kwin_dpms` is renamed `panel_dpms`.** A module that darkens sway and Hyprland
should not be named after KWin, and this file has already paid for one stale
name: the comment authorising the DPMS skip on the other gamescope routes ("its
takeover already stopped the desktop") outlived the stop it named by three
commits and hid the whole defect. (The rename's sed also caught
`org_kde_kwin_dpms` itself — the vendored protocol's interface names, which are
not ours to rename — and those are restored.)
**DPMS, not the `disable` these backends already have.** Both wlroots and
Hyprland implement their OWN exclusive topology by disabling heads; that is
right when the streamed output lives on that compositor and has to become "the
desk", and wrong here — a gamescope spawn owns no output there, so disabling
would move the operator's workspaces and re-home their windows for a stream that
is not even on their compositor. It matters most on Hyprland, where this
module's own notes record that a disabled head has no clean undo: the only known
restore is re-reading the operator's whole config, which drops every runtime
override they set by hand. DPMS is a separate axis and round-trips cleanly.
Both arms reuse their backend's existing `heads_to_disable` filter with an EMPTY
`ours` — a gamescope session owns nothing to spare, but a concurrent wlroots
session's `HEADLESS-*` still must be spared or darkening blacks out that client's
stream. They address heads by NAME and report which ones actually changed, so the
re-light undoes exactly those.
⚠ **GNOME is a structural gap, not an omission.** Mutter exposes no DPMS to
clients; its own `exclusive` mechanism (`ApplyMonitorsConfig` omitting the
physicals) needs a virtual output of its own to keep enabled, which a gamescope
session does not have; and the DRM floor is refused because Mutter holds master.
The `warn!` now names that instead of saying nothing.
⚠ **The sway and Hyprland arms are NOT verified on glass.** The lab has Bazzite,
Nobara and SteamOS — no sway or Hyprland box. Their argv shapes and the
sibling-sparing filter are unit-tested and everything compiles clean, but the
commands themselves have not been run against a live compositor. Treat them as
unproven until someone streams from one.
Verified: `cargo test -p pf-vdisplay` 254 passed / 0 failed; clippy
--all-targets clean; fmt --check and check-docs-drift.sh clean. Re-ran BOTH
on-glass tests on the Nobara VM after the rename — the managed path falls
through KDE → sway → Hyprland → DRM correctly on a box with none of the first
three, and the panel still goes On → Off → On:
test gamescope::tests::live_the_managed_hold_darkens_a_real_panel ... ok
test drm_dpms::tests::live_the_panels_go_dark_and_come_back ... ok
The previous commit left Managed without a darken hold and wrote down a reason:
its display reports `DisplayOwnership::SessionManaged`, and `registry::acquire`
returns for anything not `Owned` ABOVE its `take_topology_restore()` pickup, so a
hold registered the way the bare spawn does would never be released — and a panel
dark after every stream is worse than one left lit.
That reasoning was right and the conclusion was wrong. Managed is the recommended
route, it gives the best experience, and it is the ONLY way to serve a client its
own virtual output at its own mode — so it is the route where `exclusive` most
needs to mean something. "No release path on the registry" is an argument for
putting the release somewhere else, not for skipping the feature.
Somewhere else is `do_restore_tv_session`: the one teardown every managed path
funnels through, and already the home of the drop-in sweep for exactly this class
of reason ("must not leak past an early return"). The release goes at the very
TOP of it rather than beside that sweep, because the SteamOS branch returns above
it. Safe there: releasing re-lights, and every path below either hands the box
back or deliberately keeps a headless session on a box with no connected display
— nothing lit to darken in that case anyway.
The hold is a bool, not a count, and that is load-bearing. The managed SESSION is
what gets darkened, not each connect: it outlives client disconnects (the point
of `MANAGED_SESSION`) and a same-mode reconnect reuses it warm without a
relaunch. Acquiring per connect would ratchet `kwin_dpms`'s refcount up with no
matching releases and pin the operator's panel dark for the rest of the host's
life. The edges are split into pure `managed_darken_{acquire,release}_edge` — the
same shape as `kwin_dpms::Holds` and for the same reason — so the balance rule is
unit-tested without a live compositor: reconnect takes no second hold, the
unconditional release is idempotent, a full cycle re-arms, and `extend` /
`SharedDesktop` still take no hold at all.
Verified on the Nobara VM (123, Fedora 44) in the takeover state, against a real
connected head, driving the actual managed helpers rather than the pure edges:
before: [("card0-HDMI-A-1", "On")]
during: [("card0-HDMI-A-1", "Off")] <- incl. a second acquire (reconnect)
after: [("card0-HDMI-A-1", "On")]
test gamescope::tests::live_the_managed_hold_darkens_a_real_panel ... ok
The bare-spawn on-glass test still passes alongside it. (`mutter::live_mutter_
create_drop` fails on that box and is unrelated — it needs a GNOME session.)
`gamescope.md`'s per-model table said Managed does not darken a desktop beside
it; it now says what both owning routes actually do, and that neither needs root.
cargo test -p pf-vdisplay 252 passed / 0 failed; clippy --all-targets clean;
fmt --check and check-docs-drift.sh clean.
`kwin_dpms` asks KWin for DPMS-off, which is the right answer whenever there is a
KDE desktop to ask. A box sitting in Game Mode has none — it runs gamescope and
no KWin — and that is *precisely* the deployment whose TV the operator wants
dark. So `exclusive` silently did nothing there, which is the field report.
Measured on the Nobara VM (123, Fedora 44) before writing any of this, because
the obvious cheaper answers all fail on real hardware:
* Getting the box's session out of the way is NOT enough. After the takeover
idles it, `card0-HDMI-A-1` sits at `enabled=enabled dpms=On` indefinitely —
when no client holds DRM master the kernel just keeps the CRTC configured.
That refutes `gamescope.rs:1573` ("left the Deck in a headless session with its
panel dark") as a general claim: that was a Deck's internal panel.
* `setterm --blank force` is unreachable: `/dev/tty1` is `crw------- root:tty`.
* The connector's `dpms` sysfs attribute is read-only even as root.
* `/sys/class/graphics/fb0/blank` DOES work (`dpms=Off`) — but needs root, and
the takeover deliberately stopped needing privilege in 0.31.0. Not worth
re-introducing a group requirement for.
What does work, unprivileged: `/dev/dri/cardN` carries a logind uaccess ACL for
whoever holds the active seat (`crw-rw----+`), and `SET_MASTER` succeeds while
nobody else is master — exactly the state the takeover has just produced. So
walk `GETRESOURCES`, `GETCRTC` each id, and `SETCRTC` with `fb_id=0,
mode_valid=0, count_connectors=0` on the ones actually driving something. The
connector goes `enabled=disabled dpms=Off`, the same end state `kscreen-doctor
--dpms off` reaches through KWin.
Two measured properties shrank the code. The darkness **survives DROP_MASTER**,
so mastership is handed straight back — a host still holding it would starve the
box's own gamescope when the restore relaunches its session, turning a dark panel
into a session that cannot start. And the re-light is **`close(fd)`**: the
kernel's last-close restores the console, so there is no saved mode to replay, no
restore that can half-fail, and crash safety is free (a dead host has its fds
closed by the kernel and the box lights up) — the same property `kwin_dpms` gets
from DPMS being non-persistent. Nothing to journal, nothing to sweep at startup.
`SET_MASTER` failing is also the natural guard for the Attach route: a card
already mastered by a live compositor is not ours, and darkening it would darken
the very picture being streamed.
Four ioctls, three `repr(C)` structs, one `unsafe` block. The ioctl numbers
encode their payload size (`0x40`, `0x68`), so the layouts are pinned by
`const _: () = assert!(...)` at compile time — a struct that drifts would have
the kernel read or write the wrong byte count on someone's TV — plus a test that
restates it greppably and pins the two field offsets the count/data-pass dance
depends on.
The on-glass test earned its place immediately. Everything else was green —
ABI tests, clippy, 251 unit tests, and a Python probe that had proven the ioctl
sequence itself — while the first Rust cut still left the panel lit: `darken_card`
closed its fd before returning and `darken` re-opened a fresh one to hold. That
close is the kernel's LAST close, which restores the console; the new fd held
nothing. It reported `darkened cards: ["card0"]` over a connector at
`enabled=enabled dpms=On`. The fd that does the work is now the fd that is held.
Verified on that VM with this exact binary, against a real connected head:
before: [("card0-HDMI-A-1", "enabled", "On")]
darkened cards: ["card0"]
during: [("card0-HDMI-A-1", "disabled", "Off")]
after: [("card0-HDMI-A-1", "enabled", "On")]
cargo test -p pf-vdisplay 251 passed / 0 failed; clippy --all-targets clean
(incl. the workspace's undocumented_unsafe_blocks deny); fmt --check and
check-docs-drift.sh clean.
The gamepad console has shipped since the arch split, but on Linux the only way
in was `punktfunk-session --browse` (or `punktfunk-client --browse`, which execs
it) on a command line — no way to find a mode. The WinUI shell has had both doors
for a while; this is the GTK half.
Three entry points, one destination:
* A gamepad button in the hosts header (`input-gaming-symbolic`), left of the
hamburger — the same placement WinUI gives it.
* "Console UI" in the main menu, so the mode has a searchable name and not just
a tooltip.
* `io.unom.Punktfunk.Console.desktop` — a second launcher shipped by the deb,
rpm, arch and flatpak packagings. It is what an app-grid search finds and what
gets added to Steam as a non-Steam game. `Exec=punktfunk-client --browse
--fullscreen` deliberately goes through the shell binary: that argv already
execs the session, and it is the command flatpak's Exec rewrite expects.
The in-shell launch is a `gio::Subprocess` — `wait_check_async` lands the child's
exit on the GTK main loop with no thread and no channel, releases `busy`,
refreshes the host list (the console can pair hosts), and banners a non-zero
exit. That last part is also how a session built without its `ui` feature
surfaces: it prints "--browse needs the console UI" and exits non-zero.
Which is why the Nix build does NOT install the new desktop entry — it compiles
the session `--no-default-features`, so that launcher could only ever print an
error. Noted in packaging/nix/README.md next to the existing Skia caveat.
Field report, Nobara, 2026-08-24: launching a game from the library with
`game_session=dedicated` and `topology: exclusive` left the operator's physical
screen on. Two independent causes, neither of which had a line in the log.
The first is ours and recent. `c2f5e91b` replaced the managed takeover's
display-manager STOP with an idle drop-in, deliberately, so the box keeps a DM
able to service the user's own "Switch to Desktop". That stop was also, as a
side effect, the only thing that took the box's own panel down. Nothing replaced
it, and the comment authorising the DPMS skip on this file's other routes — "its
takeover already stopped the desktop" — outlived the stop it named by three
commits, so the gap read as intentional. That comment is now the two reasons that
actually hold, written at the returns they belong to: Managed reports
`SessionManaged` and `registry::acquire` returns above its
`take_topology_restore()` pickup, so a hold taken there would never be released
(a panel left dark after every stream is worse than one left lit — `relight` is
written as "the last line of defence for a dark monitor" for that reason); and
Attach mirrors a gamescope that may be lighting the panel, so darkening it would
darken the picture being streamed.
The second is older and louder, and is what the report actually hit.
`stop_autologin_sessions` was reached only for a Steam launch — `is_steam_launch`
is `cmd.split_whitespace().next() == Some("steam")` — because freeing Steam's
single instance was the only requirement anyone had for it. But on a Game Mode
box that same session is the DRM master of the TV (`gamescope/heads.rs`), so a
NON-Steam library game left it running and the panel showed live Game Mode for
the whole stream. `exclusive` is a second, separate reason to want that session
out of the way, and it is now stated as one: `free_box_session_for_exclusive` is
pure and unit-tested over all four Steam x Exclusive combinations, including that
`extend` / the `SharedDesktop` preset ("never blank the real monitors") must
still leave the box strictly alone. Best-effort, unlike the Steam arm above it —
there it costs a dark screen, not the game, so it warns and continues.
Neither could be diagnosed from a log. `kwin_dpms::darken` is only ever reached
because the operator selected `exclusive`, so every decline is "you asked for
your screens off and they stayed on" — and the definitive not-KDE arm sat at
`debug!`. A box already in Game Mode has no KWin to answer DPMS at all, which is
precisely the box this fires on. It says so at `warn!` now, the same honesty
`relight` has always had for the dark direction.
`gamescope.md` claimed managed "deliberately takes the session over and blanks
the panel". It takes the session over; blanking is Topology's job and differs per
model, so the page now says which model can do what. The polkit/lingering block
above it is NOT stale — it is scoped to the restore path and pre-0.31.0 adoption,
and is left alone.
Verified: `cargo test -p pf-vdisplay` 249 passed / 0 failed, clippy --all-targets
clean, docs-drift gate clean. Not yet exercised against a live Nobara box — the
DPMS half cannot fire there by construction (no KWin in Game Mode), so what wants
measuring on the VM is whether releasing the DRM master leaves the connector dark
or merely blank-but-powered.
The gamepad shell's Bitrate picker has been seven rungs ending at 80 Mbps since the
console shipped, which is the ceiling a user just ran into — the GTK dialog beside it
has always gone to 3000 Mbit/s, so the two surfaces disagreed about what this machine
may ask for, and the console was the smaller of the two.
Three changes, one row:
- The ladder is 30 rungs, 1 Mbps to 2 Gbps. Tight at the bottom (1, 2, 3, 4, 5, 6, 8,
10, 12, 15, 20, 25 …), where one rung decides whether a thin link is watchable, and
coarse at the top, where a rung is noise. Rates at or above a gigabit read as Gbps.
- Y opens a typed rate on that row — digits, four of them, through the tray keyboard
(or SDL text input, and Steam's own keyboard on a Deck) exactly like the add-host and
pair fields. A goes on cycling the ladder everywhere, so the console's grammar is
unchanged; the field is what the ladder cannot be, which is every number in between.
- A rate that is not a rung now steps to its NEIGHBOUR. The generic picker snaps a value
it does not recognise to its first option, which on this row is Automatic: one nudge
threw away a rate typed here or set by the desktop spinner.
The desktop dialog gets the same complaint's other half: its spinner steps 1 Mbit/s
instead of 5, so 3, 4 and 6 are reachable without typing.
`Screen::edit_key` now takes the context, because this is the first field that commits
into the settings store when it closes rather than holding text for a later action row.
`installTap(onBus:bufferSize:format:)` validates a non-nil format against the
bus and raises an Objective-C exception on ANY mismatch. Swift cannot catch
that, so it reached the terminate handler and aborted the process — SIGABRT in
`AVAudioEngineGraph::InstallTapOnNode`, crashing macOS 0.31.0 at session start,
not at launch.
The format handed to the tap is necessarily read a moment earlier
(`input.outputFormat(forBus: 0)`), and on macOS the input can move underneath
it in that window: a device switch, a clock/rate change, or the
`kAudioOutputUnitProperty_CurrentDevice` swap `startCapture` itself performs two
lines before. The existing guard only rejected the 0 Hz / 0-channel case, which
is a different failure — a device that is absent, not one that changed.
Install with `format: nil` instead, which is the documented "use the bus's own
format" and makes the mismatch unrepresentable rather than merely unlikely.
The tap then has to follow the real format, so the rate-dependent pieces (mono
bus, resampler, both scratch buffers) move into a `MicChain` the tap rebuilds
when `buffer.format.sampleRate` differs — a chain pinned to a stale rate would
resample by the wrong ratio and pitch-shift the mic. That rebuild subsumes the
old grow-on-larger-quantum branch; the steady state still allocates nothing.
Tests cover the sizing arithmetic the rebuild rests on — including that
`staging` fits the UPWARD ratio, which silently truncates every packet on any
device below 48 kHz if it is sized for the input rate instead.
Second half of "the power menu does nothing during a stream", and an
independent fault from the sleep veto: on the takeover flavors that STOP
the display manager, the box is left with no active local session, and
that is what logind's power actions are gated on.
logind ships `power-off`/`reboot`/`suspend` as `allow_active: yes`.
polkit decides "active" from the caller's own logind session and, for a
caller that has none — every `systemd --user` unit, which is exactly what
the managed gamescope session is — falls back to the user's elected
DISPLAY session. logind elects that only from `user`/`greeter` class
sessions, never from the user manager's own, so stopping the display
manager removes the last candidate and all three actions drop to
`auth_admin_keep`: an interactive password prompt, put to a
non-interactive caller, on a screen that is switched off.
On SteamOS-like boxes that is the call being refused. Steam does not ask
logind for "Shut Down" at all — it writes $STEAMOS_STEAM_SHUTDOWN_SENTINEL
and exits, and gamescope-session-plus runs a plain `poweroff` once Steam
is gone. During a stream that wrapper is ours, in the session-less
transient unit.
Measured on Bazzite, 2026-08-24: the identical
`pkcheck --action-id org.freedesktop.login1.power-off` from a
`systemd --user` unit answers authorized with sddm up, and
`auth_admin_keep` with sddm stopped — and answers authorized again, in
that same stopped state, with this rule installed.
Scope it to the (shipped-empty) `punktfunk` group, which is the same
group the takeover's own root helper authorizes on: a takeover that stops
a display manager cannot work without that helper, so this grants to
exactly the population the fault reaches. The three actions are the three
entries in Steam's power menu; the `-multiple-sessions` and
`-ignore-inhibit` variants are deliberately left out.
Since b7a00137 the host has held a logind `sleep:idle` inhibitor in BLOCK
mode for the whole length of a stream, so a passive (video-only) viewer
could not have the box suspend out from under them. A block lock on
`sleep` refuses EVERY suspend, though, not just the one an idle timer
asks for: "Sleep" in Steam's Big Picture power menu reaches logind as the
same `Suspend()` call, and logind answers it with
Operation inhibited by "Punktfunk" (PID …), reason is "a client is streaming".
Nothing in that UI surfaces a D-Bus error, so the menu entry simply does
nothing — for as long as anyone is watching the box. Reproduced on a
Bazzite host, 2026-08-24, by taking the same lock by hand.
Hold the veto only while the stream is QUIET. Any client input on either
plane drops it synchronously — releasing is a close(2) on the inhibitor
fd, no round trip, so a Sleep press cannot race it — and it is re-taken
only after 30 s of silence. That is the line the original justification
already drew ("remote INPUT resets the compositor's idle timers, but a
video-only viewer sends none"): a person choosing Sleep is, by
definition, sending input, and a passive viewer never does. The case the
lock was written for is unchanged — 30 s of nothing and the veto is back.
Both planes stamp the clock at their single input entry point: the native
input thread's channel recv (before the grant tests — a denied event
still means a person is there) and the GameStream control plane's three
decode arms, past the keepalive gate, because a keepalive is the one
thing a passive viewer does send.
The v0.31.3 CHANGELOG recorded this cut as a decision left open, on the same
reasoning v0.31.0 used for 0.1.5: a plugin resolves `@punktfunk/host` from the
registry, so types sitting in `sdk/` reach nobody until a version carries them.
#374 added `PATCH /clients/{fingerprint}`, `RenameClient` and
`PairedClient.label` to the management API and regenerated the client for them —
so without this cut the route exists on every 0.31.3 host and no plugin can call
it in a typed way.
ONE FILE is the whole diff since sdk-v0.1.5: `sdk/src/gen/punktfunk.ts`. It is
larger than the feature because regenerating it from the UNCHANGED committed spec
already produced a ~700-line diff — the checked-in copy had drifted from its own
pinned generator, and nothing in CI regenerates or verifies it (unlike
api/openapi.json and include/punktfunk_core.h, which are both gated). #374 landed
the clean regeneration rather than hand-patching generated code, and this cut
publishes it.
`SDK_VERSION` moves with `package.json`. It is a hand-maintained constant — the
build sets `rootDir: "src"` so it cannot import the manifest, and the runner ships
as one bundled `runner-cli.js` with no manifest beside it — and the runner
compares it against the SDK installed in the plugins tree to decide whether to
reinstall. Shipping 0.1.6 with the constant still reading 0.1.5 would publish the
types and then never deliver them. `version.test.ts` gates exactly that, which is
also what sdk-publish.yml's "Tag matches package version" step re-checks against
the tag.
GATES, all four steps sdk-publish.yml runs, in order and locally:
`bun install --frozen-lockfile --ignore-scripts` clean, `bun run typecheck`
clean, `bun test` 83 pass / 0 fail / 191 expect() calls across 12 files (the same
83 the 0.1.5 cut reported), `bun run build` clean. Nothing but the two version
sites and the two release documents is touched — no dist/ or lockfile churn
reached the tree.
`@punktfunk/plugin-kit` is deliberately NOT re-cut: nothing under plugin-kit/ has
moved since 0.4.4, which stays the registry's `latest`.
Tag `sdk-v0.1.6` on the merge commit, alongside `v0.31.3`. The two version
independently by design — sdk-publish.yml triggers on `sdk-v*` and the app's `v*`
tags never republish the SDK — so the shared commit is a convenience, not a
coupling.
41 commits since v0.31.2 (26 non-merge). Cut from origin/main f5931650 (#385
merged, main green).
THE NUMBER: a patch. One versioned surface moves and it moves additively — the
management API gains PATCH /clients/{fingerprint}, the RenameClient schema and
PairedClient.label, none of which existed before, so nothing that consumes the
API today changes shape. Everything else is where v0.31.2 left it: WIRE_VERSION
2, C ABI 25 with include/punktfunk_core.h showing NO diff against the v0.31.2 tag
(second release running), driver protocol 6 / min 3 with pf-driver-proto
unchanged, gamepad channel 3, plugin index schema 1, host event schema 1,
gamescope +pfhdr8 with no new patch files, SDK 0.1.5 and plugin-kit 0.4.4 both
untouched. Two feat commits (#374, #384), both additive; v0.31.1 carried two
feats as a patch on the same reasoning.
THE SHAPE: the faults share a family resemblance — a session degrading or ending
against something ordinary that nothing was checking. Steam's pre-launch trees
latching the game lease and their exit then read as the game's (#372); a
fullscreen game mode-setting the virtual display under both stream loops (#373);
the forced-keyframe coalesce window measured in frames rather than time (#377);
an Android TV negotiating the refresh its own console pin installed rather than
what the panel outputs (#378); a startup capacity probe large enough to
black-hole the link it was measuring (#379); a hand-back that never verified the
panel came back (#375); a half-minted audio devnode nothing afterwards
recognised (#381); and a failed compositor build that unlinked the working one it
never replaced (#382). Plus two Android input/present fixes (#376, #380), the
console's per-frame cost and its new resolution switch (#384, #385), one feature
(#374), and CI (#370, #383).
TWO ENTRIES WORTH THE READER'S ATTENTION, both recorded as such:
* #375 ships WITHOUT a reproduction. Five scenarios across both distro families
on real VMs all recovered cleanly and the first proposed mechanism was
disproved on glass, so it closes the gap that lets any trigger end as a dark
panel rather than guessing at one.
* #380 is re-implemented from #371's diagnosis, and #371 is NOT merged. All
three faults were real and correctly identified; each fix as sent reached
further than the hardware that needed it. The notes credit the diagnosis.
DOCS FRESHNESS, per docs/releases/README.md step 1: #379, #380 and #384 carried
their own docs-site updates (configuration.md, input.md, client-settings.md). The
one fact left owed was naming a Moonlight device, whose canonical home is the
"Managing paired devices" section of docs-site/content/docs/pairing.md — a
paragraph goes there. No new PUNKTFUNK_* variable this cycle
(PUNKTFUNK_RECOVER_SESSION_CMD is pre-existing and already documented in
configuration.md and gamescope.md), no new host subcommand, and no install
command, repo URL or port change, so data/platforms.json and the website's
vendored copy need nothing.
VERIFIED HERE: scripts/ci/check-docs-drift.sh clean; scripts/ci/check-docs-links.sh
clean; the android.yml Play notes gate run verbatim, 444/500 characters and unique
against every other release's file; both openapi copies cmp identical and stamped
0.31.3; cargo fmt --all --check clean; cargo audit clean over all five Rust
lockfiles (h2 fixed in the commit below this one); cargo about --fail clean on the
host workspace; git diff v0.31.2..HEAD on include/punktfunk_core.h and on
crates/pf-driver-proto both empty, which is the direct evidence for those two
version rows; Cargo.lock's 36 workspace version strings moved with Cargo.toml;
notes voice scan clean (zero backticked terms above ## For developers) and the
CHANGELOG link pinned to src/tag/v0.31.3.
NOT RUN HERE, and why: any punktfunk-host build, clippy or cargo test — the host
does not compile on macOS at all, and CI covers it; the web/ and docs-site/ bun
builds — nothing under web/ is touched by this commit and the docs-site edit is
prose in an existing .md; the Android unit tests — nothing here touches Kotlin.
LEFT AS A DECISION, not made here: sdk/src/gen/punktfunk.ts changed in #374 (a
clean regeneration that also absorbed ~700 lines of pre-existing drift) but
@punktfunk/host is not re-cut, so the registry's 0.1.5 has no types for the new
route. Cut sdk-v0.1.6 if anything outside this repo needs them.
`cargo audit` on the root lockfile went red on 2026-08-17, when RUSTSEC-2026-0258
was disclosed against h2 <= 0.4.15 (unbounded empty DATA frames; fixed in
0.4.16). audit.yml's cargo-audit job is BLOCKING and fires on every Cargo.lock
change, so the 0.31.3 version bump in the next commit would have taken it red on
merge regardless of this advisory's own timing.
h2 is transitive — no manifest in the workspace declares it — so this is a
lockfile-only change.
MINIMAL ON PURPOSE. `cargo update -p h2` reports "Locking 1 package" but also
rewrote nine unrelated entries from `windows-sys 0.61.2` to 0.52.0/0.59.0,
gratuitous resolver drift that would have changed what the Windows builds compile
against for no reason. That was discarded; the two h2 lines are applied directly
instead, and `cargo metadata --locked` accepts the result with nothing else
moving — which is the proof the resolver needed none of the rest.
VERIFIED: `cargo audit` over all five Rust lockfiles. The root one is now clean;
the other four already were. The two lines cargo-audit still prints
(`audiopus_sys`, `paste`) are *unmaintained* warnings, already allowed via
.cargo/audit.toml, and do not fail the job.
NOT REGENERATED, deliberately: THIRD-PARTY-NOTICES.txt still records h2 0.4.15.
scripts/gen-third-party-notices.sh walks the dependency closure of the machine it
runs on, and on macOS it DROPS nine crates — the rusqlite / libsqlite3-sys /
fallible-iterator / hashlink cluster, 575 -> 566 — because they are gated to
platforms this Mac is not. Committing that would remove attributions a Linux or
Windows build genuinely links, which the script's own header calls a legal
regression rather than an untidiness. Regenerate on Linux. Nothing in
.gitea/workflows diffs the checked-in copy, and build-deb.sh /
pack-host-installer.ps1 / punktfunk.spec / pack-msix.ps1 each regenerate it on
their own platform, so the shipped packages are accurate and this is cosmetic
drift in the in-repo copy only.
`platform_row_split_hides_only_the_other_platforms_concepts` pins the exact
ordered set of rows the desktop does not show, which is the point of it — a row
that silently changed platform is the regression it exists to catch. The new
switch is Android-only by design, so the expected list grows by one, between the
Controllers action row and the console-UI switch (it sits under Reduce motion,
earlier in the Interface tab than either).
Caught by CI on both the Linux and Windows legs, which run this crate's tests;
the row-COUNT assertion next to it was already updated and passed.
The console draws at whatever resolution the panel hands it, and on a 4K
television or projector that is four times the fragment work of 1080p on a
graphics chip built to decode and composite video rather than to draw a moving
interface. The reporter's two devices — a Fire TV Stick 4K Max and a Valerion
projector — are both premium products and both exactly this shape: the money
is in the light engine and the panel, and the SoC is a TV part. A premium 4K
box is MORE likely to want this than a cheap 1080p stick, which never had the
extra pixels to begin with.
So: an off-by-default switch in the controller-optimized settings, directly
under Reduce motion, because the two are the same kind of bargain — give up
some fidelity, get a smoother console. On, the buffer's long edge is capped at
1920 with `SurfaceHolder.setFixedSize` and the compositor scales it up for
free. Text goes a little softer. Nothing else changes.
Two things this had to get right, neither of which is obvious from the call:
`setFixedSize` shrinks the BUFFER and not the VIEW. Everything that speaks in
surface pixels therefore has to be scaled to match — the safe-area insets, the
design-unit scale, and the pointer coordinates, which a mouse still reports in
view pixels and which would otherwise land the cursor at twice its true
offset. The scale factor is one number applied to both axes, so the aspect
ratio survives exactly and no layout can stretch.
And the buffer is sized from the SurfaceView's own laid-out size, reported
back through `onSizeChanged`, rather than from `displayMetrics`. The two
normally agree, but `displayMetrics` has a long history of disagreeing with a
view's real size by a system bar depending on the version and on who is
hiding what, and a buffer whose aspect ratio does not match the rect it is
scaled into is a stretched interface. "Normally agree" is not something to
hang picture geometry on.
The pointer listeners are installed in `AndroidView`'s `factory`, which runs
once, so the factor reaches them through `rememberUpdatedState` — captured
directly it would freeze at its first-composition value (1, before any layout
has reported a size) and a mouse would be wrong for the rest of the session.
The same reason `platformUp` is already held that way.
⚠ This is the INTERFACE only and shares nothing with the stream. Picture size
is `effectiveMode`, off `Display.mode.physicalWidth` — a physical display
mode, not any surface's buffer — and picture scaling is the separate
`renderScale`. The two `SurfaceView`s are different views and this is the only
`setFixedSize` call in the client. The name keeps "interface" in it, and the
docs entry ends by pointing at Resolution and Bitrate, so that nobody turns
this on expecting a sharper stream.
skia-bindings pulls ~19 MB of prebuilt Skia from inside its build script with a
bare curl and no retry, and swallows a failed download into a from-source Skia
build the CI containers cannot complete — so a dropped transfer surfaced as
"Clippy (Android target) failed" with the real cause 1,800 lines up.
A retrying curl shim first on PATH covers it (skia-bindings already resumes and
caches the part-file, so a retry continues the transfer). The prose rule in
android.yml's env block is now a gate that fails on STARTING A FULL BUILD.
A field report of a sluggish console UI on a Fire TV Stick 4K Max and a
Valerion projector. The Skia shell is faster than the Compose one it replaced
per unit of work; it was doing far more work than anyone had counted, and all
of it on every frame whether or not anything had changed.
Four costs, none of which change a pixel:
`Fonts::paragraph` built a `ParagraphBuilder`, added its text and called
`layout()` on every call — the whole shaper, HarfBuzz and line breaking and
font fallback, for every string on screen, sixty times a second. It is now
built once per distinct (text, shape, weight, size, width, colour) and kept.
Position is deliberately not in the key, so a shelf that scrolls and a screen
that slides both re-use what they already shaped. Cold entries are dropped
once the map passes its ceiling, by the two frames that last drew them, so the
live set is what is on screen and paging a large library cannot grow it
forever. The loose `(TextAlign, Option<usize>)` pair became a `Para` tag on
the way past: those two were never independent, and it is half of a hash key
now.
`LayerEnv::paint` raised an unbounded `save_layer` unconditionally — including
on the settled path, where alpha is 1, the scale is 1 and the slide is 0. That
allocates an offscreen the size of the whole SURFACE and composites it back,
to apply an alpha of one, on every frame the console sat still. Skia does not
elide it: `SkCanvas::saveLayerAlphaf` forwards alpha >= 1 straight to
`saveLayer(bounds, nullptr)`, whose only early-out is an empty clip. On a 4K
panel that is a 33 MB render target per frame, against a Skia budget that is
64 MB on a 2 GB box — so it was evicting real work to do nothing. Dropping it
is pixel-identical rather than close: nothing in this crate draws with a blend
mode other than `SrcOver`, `SrcOver` is associative, and there is no LCD
subpixel text to gain or lose an isolation. `screens::home` had already
learned this one tile-deep; this is the same fix one level up.
The toast's layer was unbounded too, for a 34 dp pill. Everything inside it is
inside the pill, so it takes the pill's rect and some slack for the hairline.
`draw_clipped` measured its ellipsis fit by allocating a `String` per
character, for every over-long title on screen, every frame. It measures out
of a stack buffer now. The controller chip's string stopped being rebuilt
sixty times a second to say the same thing.
On the Android host, the render thread now takes the same priority lift the
decode thread has taken all along (`-8`, a band below the stream's `-10`, so
the two do not compete when the console is up mid-session). At default nice, a
TV box's scheduler is free to park the console's frame loop on a little core
behind background work, which reads as a UI that lags the remote.
And the thing that made this hard to answer in the first place: the console
logged its GLES version and its cache budget and never its render resolution
or its frame cost, so "it feels sluggish" could not be triaged from a log
bundle at all. It now names the surface size when it wraps one, and reports
mean and peak draw time once a minute. The window is timed around the draw and
not the swap — `eglSwapBuffers` blocks on vsync, so wall-clock per iteration
is always the panel period and says nothing.
What is deliberately NOT here is the biggest single lever on a 4K box: capping
the console's render resolution. That is a real quality trade on a panel
someone bought for its resolution, and it is not this commit's to make.
A host that died mid-mint left an orphan devnode and the next start minted a
duplicate; the registry stamp route reached for the Render hive even for capture
endpoints. Both reproduced and verified on the .173 Windows lab box.
The Recording-tab hang that prompted the investigation is NOT fixed — it did not
reproduce on .173, and nine candidate mechanisms were ruled out by direct
measurement. See the PR body for the disproof table.
scripts/ci/retry.sh already wraps every single-shot network call in CI, for the
reason documented there: the runner box runs many jobs in parallel and its
network sheds packets under that load. One of the largest fetches in this
workspace was never wrappable that way - skia-bindings pulls ~19 MB of prebuilt
Skia per target from INSIDE its build script, with a bare 'curl -sS -f -L' and
no retry (build_support/binary_cache/utils.rs).
Measured on main 2026-08-22, android job:
DOWNLOAD AND INSTALL FAILED: curl error code: "18"
curl stderr: "curl: (18) end of response with 17054400 bytes missing"
2 MB of 19,057,024 arrived before git.unom.io closed the connection; the same
asset pulls fine from a dev box. skia-bindings then swallowed it - its
try_prepare_download falls through to STARTING A FULL BUILD, a from-source Skia
build the CI containers carry no deps for - so the job surfaced as
'Clippy (Android target) failed' with a Gradle stack trace and the real cause
1,800 lines above it.
* A retrying curl shim first on PATH is the only lever that reaches inside a
build script, and the cheapest correct one: skia-bindings already passes
'-C -' and caches the part-file under OUT_DIR/.cache, so a retry CONTINUES
the truncated transfer rather than restarting it. --retry-all-errors is
load-bearing: a truncated transfer is not an HTTP status, so plain --retry
would let error 18 through.
* Wired into android.yml and both ci.yml rust jobs - pf-console-ui pulls
skia-safe too, so ci/rust downloads Skia on any target-cache miss.
* The rule android.yml's env block states in prose ('Every ABI's log must show
DOWNLOAD AND INSTALL SUCCEEDED') is now a gate that fails the job on
STARTING A FULL BUILD, so a dropped prebuilt can never masquerade as a lint
failure again.
Caught on the SteamOS lab VM while verifying the previous commit end to end. With
libx11-xcb-dev added the build finally COMPLETED (652/652, banner "3.16.25-21-gb71a56c
+pfhdr8") — and then failed its on-glass check:
punktfunk-gamescope: error while loading shared libraries:
libdisplay-info.so.2: cannot open shared object file
Self-inflicted: the previous commit also took libdisplay-info-dev from the CI image's
list. gamescope vendors libdisplay-info as a submodule, but it is NOT in
force_fallback_for, so meson preferred the system lib the moment the build box had the
-dev package and linked it SHARED. SteamOS ships no libdisplay-info.so.2, so the binary
built, installed and printed its +pfhdr banner inside the distrobox and could not start
on the machine it exists for.
This is verbatim the wlroots trap the same comment block already documents ("starts fine
on the build host and dies with libwlroots-0.19.so ... anywhere else"), so it gets the
same remedy rather than a second one: libdisplay-info joins force_fallback_for. "Just
don't install the -dev package" does not hold — Debian, Fedora and Arch all have it and
anything can pull it in transitively, and the failure is silent right up to the on-glass
check that build-gamescope.sh happens to run.
Also drop libdisplay-info-dev from the Deck list (pointless once the fallback is pinned)
and record why that list must NOT be synced with ci/gamescope-trixie.Dockerfile: the CI
list targets a .deb that runs on Debian, this one cross-builds in trixie for SteamOS
glass. libx11-xcb-dev and libxkbcommon-x11-dev stay — SteamOS ships both sonames.
The on-glass check did its job here: it caught the bad binary, removed it and left the
box SDR rather than letting the host promise HDR it could not deliver.
write_stamps falls back to a raw-registry write when the property store
denies it. That fallback built its path from MMDEV_RENDER_PATH unconditionally,
so stamping the minted microphone's CAPTURE endpoint reached for
...\MMDevices\Audio\Render\{capture-guid}\Properties - a key that cannot
exist. RegOpenKeyExW then failed, write_stamps returned the error, and
stamp_identity degraded to 'keeps the driver's default name'.
Invisible to the pad program, whose endpoints are render-only, and invisible on
any box where the property store route succeeds (both field logs show
registry=[] on every stamp line, so neither reporter ever took this path). It
only bites where the property store is denied - exactly the boxes the ACL
repair exists for.
The hive now follows the direction the endpoint id encodes, with render as the
default for anything unrecognised. Unit-tested.
ROOT CAUSE of "HDR stopped working after updating to 0.31.2" on a Deck source install.
Two defects, one symptom.
1. scripts/steamdeck/build-gamescope.sh has been UNBUILDABLE since 2026-08-13, when
3ac4548c turned `-Denable_gamescope_wsi_layer=true` on. The layer needs x11-xcb, which
Debian splits into its own libx11-xcb-dev; the distrobox apt list — last touched
2026-07-31 — never got it. MEASURED on debian:trixie against that list verbatim,
gamescope at the pinned 5fb8dce4:
Run-time dependency x11-xcb found: NO (tried pkgconfig and cmake)
src/layer/meson.build:3:14: ERROR: Dependency "x11-xcb" not found
`meson setup` exits 1 with the list as it was and 0 with libx11-xcb-dev added, and
build-punktfunk-gamescope.sh treats a missing layer as a hard error, so the whole build
fails. ci/gamescope-trixie.Dockerfile walked into the identical trap one release later
(1b28a7f7, v0.28.1) and asserts x11-xcb at image build; this list never got the same
fix. Debian-family only: Arch's libx11 and Fedora's libX11-devel carry x11-xcb.pc.
xkbcommon-x11 and libdisplay-info measured absent too, and are added with it.
2. The build-failure branch then called `unwire`, deleting PUNKTFUNK_GAMESCOPE_BIN from
host.env. A failed build REPLACED NOTHING — the previously installed binary is still on
disk and still passes `verifies`. So a rebuild that never landed took HDR away from a
box that had been streaming it minutes earlier. The script warns into a log nobody reads
and exits 0, the update reports success, and the host then resolves the distro's stock
/usr/bin/gamescope at patch level 0 and fixes the session at 8-bit SDR in the Welcome —
which the punktfunk/1 handshake cannot take back.
A verifying binary now stays wired (and a box a previous run of this bug unwired gets
re-wired). `unwire` happens only where the binary itself fails its on-glass check, which
is the branch that also removes it.
Also promote the "no +pfhdr marker" line from DEBUG to INFO. The handshake already reports
capture_supports_hdr=false at INFO while the one line saying WHY sat a level below it —
that asymmetry is what made this field report expensive to answer.
Verified: the meson reproduction above (exit 1 -> exit 0); all three added package names
resolve on trixie and satisfy their pkg-config modules; the four states of the changed
branch exercised in isolation (working binary stays wired, stock/missing binary unwired,
previously-unwired box re-wired). Not verified on real SteamOS glass — the lab VM was
unreachable from this machine.
Minting an audio devnode is two PnP steps: SetupDiRegisterDeviceInfo makes it
real and bindable, then the owner marker goes into Device Parameters. A host
that dies between them - the 0.30.0 TLS-destructor abort did exactly this,
five times on one field box - leaves a registered, driver-bound, endpoint-
serving devnode carrying no marker.
Nothing resolved it afterwards. find_role_devnode matches on the marker, so
the next pass minted a SECOND devnode and the orphan stayed: a duplicate
'Punktfunk Speakers'/'Punktfunk Microphone' in the Sound zoo that no uninstall
removed, because devnode_cleanup is marker-matched too. A field box showed
exactly this shape - 'Punktfunk Speakers (3- Punktfunk)' beside an unstamped
'Punktfunk Speakers (4- Steam Streaming Speakers)' - in every wiring plan it
logged. Reproduced on .173 against the shipping 0.31.2 binary by clearing the
marker: ROOT\MEDIA\0005 was minted and 0004 was abandoned, still active and
still serving two live Punktfunk Microphone endpoints.
* minted.rs adopts before it mints. An unmarked ROOT\MEDIA\NNNN devnode
carrying the role's Steam hardware id is re-marked and reused, so the
endpoint GUID survives and no device-change broadcast is paid.
* devnode_cleanup sweeps the same shape, so orphans already on a box go at
uninstall instead of outliving the product.
The instance prefix is what keeps both off Valve's own devices: Steam's
devnodes carry these hardware ids and are ROOT-enumerated too, but live under
ROOT\SteamStreamingSpeakers\* / ROOT\SteamStreamingMicrophone\*. Only
ROOT\MEDIA\* can come from our SetupDiCreateDeviceInfoW(DICD_GENERATE_ID).
is_abandoned_mint carries that rule with unit tests.
Reproduced on both Bazzite 44.20260818 and Nobara f44: a host killed mid-takeover
left the box's Game Mode running `/usr/bin/sleep infinity` with the panel lit and
blank, permanently. Fixed and re-verified on both boxes against canary
0.32.0-0.ci15147.gc63e8cee, with no regression to the ordinary disconnect.
The hand-back also measures its own outcome now instead of trusting a systemd job
status, so any other route to a dark panel is caught and escalated rather than
logged as success.
Three defects reported against a Bluetooth DualSense on a Fire TV Stick 4K Max,
re-implemented from #371's diagnosis. #371 itself should not be merged: all
three problems are real, but each fix lands somewhere that breaks more hardware
than it repairs.
1. Some buttons never reach the stream. Fire OS is reported to tag certain
DualSense buttons SOURCE_KEYBOARD even though the keycodes are standard
BUTTON_*, and MainActivity's `event.isFromSource(SOURCE_GAMEPAD)` gate then
drops them. The event's source class is the platform's per-event guess; the
DEVICE's is the fact. New `MainActivity.fromPad` widens to the device — but
ONLY for `KeyEvent.isGamepadButton` keycodes. That exclusion is the whole
safety of it: DPAD keycodes are a keyboard's arrow keys and BACK is a
remote's way out of the stream, and both share their keycodes with a pad.
`Gamepad.isPad` is untouched (source-class only) and no vendor-id or
device-name matching is added anywhere — the field report records both pads
being IDENTIFIED correctly; only their button positions were wrong.
2. Touchpad click and Mute were dropped. Both have wire bits (BTN_TOUCHPAD,
BTN_MISC1) and no Android keycode, so GENERIC_SONY's `0x13d`/`0x13e` rows now
borrow BUTTON_15/BUTTON_16 to carry them into `buttonBit`. Inside
GENERIC_SONY and nowhere else: `0x13d`/`0x13e` are BTN_THUMBL/BTN_THUMBR —
L3 and R3 — in the standard Linux mapping, and they mean touchpad and mute
only inside the straight-through report order a driverless pad uses. A row in
SONY_MODERN, or an override above `padMap(dev)`, costs every Xbox pad, Switch
Pro, 8BitDo, Steam Deck and hid-playstation DualSense both stick clicks.
`correct()`'s `genericKeyCode` guard stays exactly as it was.
3. Mute toggles the mic — once per press, and only on a pad that has one.
Edge-triggered through the existing `completesChord` as the one-button chord
it is: `onButton` still calls `slotButton(down = true)` on auto-repeat, so an
unguarded check would flap the mic for as long as the button is held. Gated
on a new `Slot.hasMuteButton`, because BTN_MISC1 is the wire's misc/QAM bit
and `Sc2Device` puts a Steam Controller 2's QAM button on it — "any MISC1"
would mute the microphone on every QAM press. Resolved at slot open from what
each path knows: the report order for an InputDevice, the declared kind for a
capture link. Under the "local" system-button policy a real mute button is
exempt from the early return (that policy means the press stays with this
device, which is exactly what the toggle does) and loses only its wire send;
every other system button behaves as before.
Tests: `every other pad keeps L3 and R3 on those scancodes` is the regression
that matters and fails on #371's shape (verified by reproducing it). Plus the
rewritten touchpad/mute assertions, the guard's negative path — untested in
either direction until now, because every existing case fed `correct()` the
keycode `Generic.kl` would have produced — and the mute button's edge rule in
GamepadChordTest. `an Xbox pad at the standard positions keeps X, Y and its
shoulders` is kept.
Not yet verified on hardware: no Fire TV Stick 4K Max or DualSense here, and no
adb device attached. §1's premise (the SOURCE_KEYBOARD tagging) is therefore
unconfirmed — the change is a no-op if it does not hold.
Field reports on 0.31.x, Bazzite and Nobara: after disconnecting, the box's own
physical screen stays black.
I could not reproduce it (PR #375 has the full negative write-up: five scenarios
across both distro families on the real VMs, all recovering cleanly, and the
mechanism I first proposed disproved on glass). So this does not guess at the
trigger. It closes the gap that lets ANY trigger end as a dark panel, and fixes
the one black-screen path I could prove.
## The restore never checked its own work
`do_restore_tv_session` issues a lifecycle verb and logs what systemd said about
the JOB. "The job succeeded" and "the box shows a picture" are different
questions, and nothing in this file has ever asked the second one — the restore
walks away the moment the verb returns, so every way the box can end up dark
looks identical to success in the log.
So measure it. After the hand-back a detached watcher polls
`detect_active_session()`, whose `None` means no compositor of our uid is running
at all — exactly the symptom. If the box is still dark 25 s later it climbs a
ladder of remedies, each measured on both images (Bazzite 44.20260818, Nobara
f44, 2026-08-22):
1. STOP the autologin unit. Its login session's script is parked on
`systemctl --user --wait start <unit>` on both images, so a stop releases that
wait, the session exits, and `Relogin=true` logs back in — starting the unit
inside a session with a seat. `stop`, not `restart`: a restart does NOT
release the parked waiter (measured), which is why it cannot rescue a box the
ordinary restart already failed to bring back.
2. Restart the display manager — what the pre-0.31.0 takeover did on every
disconnect, and proven on the Bazzite VM to return the box to game mode.
3. `PUNKTFUNK_RECOVER_SESSION_CMD`, then an ERROR naming the command a human has
to run.
Detached, and that is load-bearing: the restore holds `RESTORE_FLIGHT`, which a
reconnecting client must take before it can re-take the box, so watching for up
to a minute while holding it would put that wait in front of every reconnect.
The watcher also stands down the instant `takeover_live()` says a new takeover
armed — the box belongs to that stream now, and a remedy fired into it would be
a fresh bug. It runs after `clear_takeover()` so that check means "a client
reconnected" and not "our own takeover has not been filed yet".
Skipped on the shutdown path: `restore_takeover_now` runs inside `native.rs`'s
20 s `SHUTDOWN_RESTORE_GRACE`, and spending that grace watching would cost the
hand-back rather than check it. What covers a shutdown that left the box dark is
the next host start — which this commit also makes true.
## A crashed host left the box's game mode asleep, provably
`restore_takeover_on_startup` sweeps a leftover idle drop-in off the box and logs
that the box's "own Game Mode session would have started and then done nothing".
Removing the FILE does not touch the unit RUNNING under it: its `ExecStart` is
still the sleep, so it sits `active` drawing nothing. Nothing below that sweep
restarts it either — the takeover file may be absent, unparseable, or fail
`takeover_state_is_live`, and all three exits leave the box on a dark panel with
its game mode "running". Any host killed mid-takeover (SIGKILL, OOM, a yanked
update) lands exactly there, and it survives until someone reboots.
`hand_back_idled_units_after_crash` restarts those units, gated on the box
actually being dark so a user already in game mode or on a desktop is never
bounced, and only for ACTIVE instances — under a just-removed idle drop-in,
active means "running the sleep".
## Not changed
The `restart` verb on the ordinary restore path. It works on both distros
(measured), and 0.31.0 chose it deliberately for the idled unit. The `stop` idea
survives only as escalation rung 1, where it runs after the proven path has
already failed.
`listed_autologin_units` is factored out of `stop_autologin_sessions` so both
callers share it, and its column parsing — which decides whether a live gaming
session can be told from a dead leftover — finally has a test against real
`--plain` output from both images.
The startup link-capacity probe burst at a flat 2 Gbps on the reasoning that it
must measure the link and not itself. That reasoning is obsolete: the ABR
already clamps the measured ceiling to `stream_cap_kbps` (what this session's
mode + codec could plausibly use), so every bit measured above `cap / 0.7` is
discarded the moment it lands. The height bought a number nothing reads, and
paid bufferbloat for it — a constrained Wi-Fi link can black-hole under it
(measured on webOS: a 6 s probe timeout delaying first video to 14 s, and a
"successful" probe still reporting send_dropped=20211; the same shape is now
reported on a Fire TV Stick 4K Max).
Derive the target instead: `stream_cap_kbps × 2`, capped at the old 2 Gbps.
×2 is the smallest multiplier that can still prove the cap (the ceiling is
`delivered × 0.7`, so proving it needs `delivered ≥ cap × 1.43`), so this can
never cap anyone — a session whose mode justifies a high ceiling asks for a
high target by itself, and a mode `stream_ceiling_kbps` declines to size still
gets 2 Gbps. Deliberately not a platform `cfg!`: the constraint is the
session's, not Android's, and webOS has the same bug.
Second half of the black screen: if the burst takes the first keyframe down
with it, nothing re-requests one and the client sits on black until an
unrelated recovery path happens to fire. Ask for a keyframe at probe end when
no frame completed across the burst — compared against the count snapshotted
at the burst's leading edge rather than against 0, so it also covers a
mid-session embedder speed test that kills a running stream. One request per
probe, through the control task's coalescer, so it cannot IDR-storm.
`PUNKTFUNK_ABR_PROBE_KBPS` and its `> 0` filter are unchanged.
Field report: on Android TV / Fire Stick, latency explodes whenever the client's
refresh differs from the host's, and setting the refresh by hand is the only
workaround.
The client was manufacturing that mismatch itself, in three steps:
1. `MainActivity.onCreate` pins the panel to its highest-refresh mode for the
console UI (`setConsoleHighRefreshRate(true)`) — unconditionally, TVs
included. That pin exists for phone refresh governors (Nothing OS's LTPO
logic among them) which cap third-party apps at 60 Hz. No TV has one.
2. At connect, `nativeDisplayMode` resolves "Native" refresh from
`display.mode` — which now reports the mode the MENU pinned, not the TV's
real HDMI output. So the session negotiates (say) 120.
3. `StreamScreen` releases the pin again on TV, by design: there the decoder's
own `setFrameRate(CHANGE_FRAME_RATE_ALWAYS)` governs the HDMI mode. The
panel falls back to 60 while the host is already serving 120.
A 120 fps stream on a 60 Hz output, by construction, on exactly the two form
factors in the report. Picking a refresh explicitly is precisely what bypasses
step 2, which is why that is the workaround people found. The mode comparator
sorts refresh before area, so the same pin could also drop a 4K TV to 1080p120
and negotiate the stream at that.
Fixed at the choke point: `resolveHighRefreshMode` returns early on a TV, leaving
`highRefreshModeId` at 0, which `setConsoleHighRefreshRate` already treats as a
no-op — so all three of its callers are covered by the one guard. A TV that
genuinely wants 120 still gets it by choosing it, driven by the native mode
switch, exactly as the TV path documents.
Also in the same chain: `nativeDisplayMode` TRUNCATED the panel rate, so a TV
reporting the fractional NTSC rates over HDMI (59.94, 29.97, 23.976) asked the
host for 59 / 29 / 23 — rates no display mode has, which the host serves by
clamping down to the highest it advertises at or below. Rounded now, which also
makes it agree with `MainActivity.streamPanelFps`; the two describe the same
panel and must not disagree.
The window was `frame_interval * 2`, which is 16.7 ms at 120 fps. A Moonlight
client that has lost decode sync re-asks for an IDR roughly every 30 ms, so the
gate never closed between requests and effectively every request became a full
keyframe.
Field log (AMD RX 7800 XT, Bazzite 44, 1080p120 HEVC over the GameStream plane):
1118 IDR requests in one 91 s session, 1115 honoured, only 3 coalesced — about
one full IDR every tenth frame at a 100 Mbps target. IDRs that size saturate the
send path, which causes the loss that prompts the next request, so the storm
sustains itself. It reads as stutter at a flat latency, because frames are being
lost rather than queued. The same session's H.264 leg (libav VAAPI, same
bitrate) took 2 IDR requests and was clean.
The window is a round-trip bound — how long until the client can receive and
decode the IDR it already asked for — so it needs an absolute floor rather than
a frame count. 100 ms matches the encoder-reset backoff in the same loop.
Simulated against the logged 30 ms request cadence, this cuts honoured IDRs over
a 91 s session from every request to roughly a quarter, while still recovering
promptly from a genuine loss event.
Every pf-decode SIGABRT on the Shield is fdsan catching a double-close of the
acquire fence the ASC presenter passes to ASurfaceTransaction_setBuffer, in
three shapes: inside Fence::Fence(int) under setBuffer when the number had
already been re-owned ("fd N is owned by unique_fd, was expected to be
unowned"), at the end of Transaction::apply when the layer state is torn down,
and in Parcel::freeDataNoInit once the number churns.
The fence is not ours to give. AImageReader::acquireLatestImage drains with a
single int* out-param it overwrites per image, then releases each dropped image
with whatever that out-param currently holds — the successor's fence — and
returns the last value written. So as soon as a burst gives it two images to
collapse, the caller receives an fd the reader has already adopted and closed,
plus one leaked fd per extra drop. This is unfixed as of AOSP main, so the
newest-wins collapse has to happen on our side.
Drain both present intents with acquireNextImageAsync, whose fence is always a
fresh dup we exclusively own, and let latency pick the newest itself — the loop
the smoothing FIFO already ran. Superseded candidates drop as before: image back
to the pool, its own acquire fence closed. Reader drops now show up in `skipped`
instead of vanishing inside the reader lock.
The GameStream twin of the native fix. A fullscreen game can mode-set the
virtual display mid-stream; the IDD-push capturer re-opens its ring at the
new mode, and `try_latest` then hands this loop a frame the encoder cannot
accept. Every submit fails, the submit ladder rebuilds the encoder IN
PLACE at the same configured size — which cannot converge on a size the
source has already left — and after five resets the stream ends, costing
the Moonlight client a full disconnect/reconnect.
Reopen at the delivered size instead, with the same bookkeeping the
capture-loss rebuild in this loop already does (ring depth, RFI caps,
forced IDR, in-flight numbering restart). A failed reopen spends the
shared `encoder_resets` budget at the existing exponential pace rather
than ending the stream on the first try — a mode-set leaves the driver
settling, which is what that backoff exists for.
`gs_bit_depth(frame.format)` is derived per open, so an HDR flip that
recreates the ring at P010 now re-opens at the right depth too.
The client is NOT told: GameStream has no mid-stream mode-change message,
so Moonlight decodes a bitstream that disagrees with the resolution it
configured its decoder from. That is the same bargain the first open in
this function already takes whenever the captured size differs from the
negotiated one (the monitor-mirror case, §7.3) — tolerant decoders re-init
off the SPS and scale; a strict one (Media Foundation on Xbox) may stall
and drop the session. Taking it here too is strictly better than the
alternative, which is ending every stream the moment a game changes mode.
The guard carries that note.
A trailing comment that long makes rustfmt treat the two comment lines
that follow it as a continuation of the same block and reflow them into a
hanging indent past column 60, which fails `cargo fmt --all --check`.
Put it on its own line above the statement instead.
`ci / rust` failed the unsafe-hygiene gate: mgmt/tests.rs went to 6 process-global-API
mentions against a baseline of 3. The new rename test had copy-pasted the existing
`EnvGuard` + CONFIG_DIR_TEST_LOCK + tempdir dance, which is exactly the duplication gate C
exists to catch — its advice is to fix the call site rather than raise the baseline.
So there is now ONE `ConfigDirOverride` both tests use. It also makes the pairing harder to
get wrong than the copies were: the lock is a FIELD rather than a separate `_serial` binding
a test could forget, and since `Drop::drop` runs before any field drops, the environment is
restored while the guard still holds the lock.
Back to 3 mentions, and `sh scripts/ci/check-unsafe-hygiene.sh` reports all three gates clean.
Note the last one is a PROSE mention: the grep counts comments too (deliberately — "keep it
dumb and stable"), so the doc comment had to stop naming the function it warns about.
Not re-run on .173: the box went off-network mid-change. It does not need to be — this is
`mgmt/tests.rs`, which is not Windows-gated, so Linux CI compiles and runs it. The Windows-only
verification (clippy over the `cfg(target_os = "windows")` devtest change) was already done and
that file is untouched here.
The reopen added in the previous commit bailed the session on the FIRST
failed `open_video`. That is worse than what it replaced: the mode-set
that triggers the reopen is exactly the kind of event that leaves the
driver settling, which is the transient the submit path's backoff already
exists for ("NVENC session open failing after a codec switch", 2026-07 —
no 8 ms retry could outlive it).
Spend the shared `encoder_resets` budget on it at the same exponential
pace (100 ms → 1.6 s), re-entering the follow-the-source guard each round.
The old encoder stays installed and mismatched meanwhile, so it simply
keeps failing submit until an open succeeds or the budget runs out — the
same ~3 s ceiling as before, but now every round is a real attempt at the
new mode instead of an in-place re-init that cannot converge.
Also tag the exhausted path accurately: it is an encoder REOPEN failure,
not a submit failure, and the session-end log prints that context.
A fullscreen game can mode-set the virtual display mid-session with no
client Reconfigure. The IDD-push capturer already handles that — it
re-opens its ring at the new mode on a confirmed descriptor change — but
nothing re-opened the ENCODER, which is the one component that cannot
follow a resolution change in place.
Every submit then failed with "captured frame 1920x1080 != encoder
3840x2160", and the submit-error path only rebuilds the encoder IN PLACE
(Terminate + re-Init at the SAME configured size), which cannot fix a
size the source has already left. All five resets burned on it and the
video session ended ~3 s later, with audio still running — the client
sees a frozen picture and has to reconnect.
Field report 2026-08-22 (host 0.31.2, RX 6800 XT, AMF/HEVC 4K60):
IDD push: display descriptor changed — recreating the ring at the new
mode target_id=259 from=3840x2160 hdr=true to=1920x1080 hdr=true
encoder submit failed — encoder rebuilt in place, forcing an IDR
error=captured frame 1920x1080 != encoder 3840x2160 reset=1 max=5
... reset=5 max=5
encoder did not recover after repeated in-place rebuilds — ending the
video session ... resets=6
Track what the encoder was opened against and, when the source delivers
something else, re-open at the delivered size through the same
`open_video` path the client-initiated resize uses — then publish the new
mode to the client exactly as an accepted resize does, so its mode slot,
stats and aspect follow. PyroWave's Automatic rate is re-resolved for the
new mode (it is a per-mode bpp pin); H.26x rates stay with ABR.
Also covers a mid-session frame-format change (an HDR flip re-creates the
ring at a new format), which failed the same way.
The GameStream/Moonlight loop has the identical gap, left alone here: that
protocol has no mid-stream mode-change message, so following the source
there needs its own decision.
A player had to launch Rocket League twice: the first launch streamed the
"Processing Vulkan shaders" dialog and then dropped, ten seconds in. The host
did that to itself.
`reaper SteamLaunch AppId=<appid>` is the *appid's* wrapper, not the game's.
Steam wraps its pre-launch work for a title in one too, so a launch is a chain
of appid-tagged trees and only the last is the game. The lease matched the
first tree two seconds in, and that single sighting latched it out of the start
phase (START_GRACE, five minutes, ending nothing) into the exit watch
(EXIT_CONFIRM, three seconds, ending the session). When the tree exited with
the game still starting, the watch called it the game exiting and closed the
connection with APP_EXITED.
Linux has nothing to catch that: `procscan::running_hint` is Windows-only, and
no provider reports runstate for Steam, so an appid scan with three seconds of
slack is the whole signal. (Steam's registry.vdf is not an option — RunningAppID
is no longer set on modern Steam Linux, and the per-app Running key is
unreliable.)
Two layers, because only one of them can be certain:
* The matcher now rejects a `SteamLaunch AppId=` reaper whose payload is
`fossilize_replay` — Steam's shader replayer, never a game.
* A scan match must be seen continuously for SHIM_WINDOW before it latches.
This is the rule already applied to a spawned child ("a launcher about to
hand off looks exactly like the game for its first few seconds"); the scan
side never had it. It bounds the pre-launch trees nobody has named yet, at
the cost of a few seconds of GameRunning latency. Exit detection is
untouched, and a provider report still latches immediately — that is the
launcher's own statement, not an inference from a lookalike.
The log said `procs=1` and never which process, which is what made this
unclosable from a log alone; `procscan::names` puts that on the line.
Reported from the field: "is there a possibility of renaming the moonlight paired
devices? as they're all named CN=NVidia Gamestream Client". They are, and it is not a
display bug — every moonlight-common-c client self-signs with that same fixed subject,
so the certificate carries no device identity at all. Until now the console listed that
string for every Moonlight row, which means a user with a phone, a TV and a Switch saw
three identical rows and had nothing but a fingerprint prefix to tell them apart — most
sharply when deciding which one to unpair.
The name is an operator-supplied label, stored host-side keyed by fingerprint:
* `client-labels.json`, a SIDECAR to `paired.json` rather than a field inside it.
`paired.json` is a bare `Vec<Vec<u8>>` of DERs, so giving it a shape would be a
migration on the one file that decides who may connect — and a label is not part of
that trust decision, so a corrupt or missing label file must never be able to lock
anyone out. Same atomic temp-file + rename as `save_paired`.
* `PATCH /api/v1/clients/{fingerprint}` sets or clears it; `GET /clients` grows a
`label`. A whitespace-only body clears rather than storing a blank name, and only an
already-paired fingerprint may be named (a label for an unknown one would be
invisible and never cleaned up). Unpairing forgets the label, so the file cannot grow
without bound and a re-pairing of the same certificate starts unnamed.
* Scrubbing reuses `native_pairing::sanitize_device_name` rather than growing a second
one: it already strips C0/C1 controls and Unicode bidi overrides and caps at 64.
That is not cosmetic here — the label is the ONLY thing distinguishing two paired
devices in the console, so an unscrubbed one could dress a stranger's device up as
the operator's TV and be spared an unpair on that basis. For the same reason the new
route takes the plugin/cert lanes of the DELETE beside it (neither may reach it),
not the roster GET's read permission; the lane test now pins that.
* Console: a pencil on Moonlight rows opens the existing `promptText` dialog seeded
with the current label (not the `CN=…` fallback, or every rename would start by
deleting boilerplate). Native rows keep their pairing-supplied name and get no
pencil.
Test: one round trip through the API — name it, see it in the list, watch the bidi
override and the whitespace collapse get scrubbed, clear it two ways, reject a
malformed and an unpaired fingerprint, and assert the unpair forgot it on disk.
VERIFIED on .173 (the Windows box, since punktfunk-host does not build on macOS):
`cargo test -p punktfunk-host mgmt::` → 58 passed, including the new
`client_label_round_trips_scrubs_and_is_forgotten_on_unpair` and both guardrails that
caught this work in progress (`every_route_is_classified_for_the_plugin_and_cert_lanes`
and `openapi_document_is_complete_and_checked_in`). Web `tsc --noEmit` clean.
Two notes on the diff, both PRE-EXISTING and verified as such rather than assumed:
* `sdk/src/gen/punktfunk.ts` is bigger than this feature. Regenerating it from the
UNCHANGED committed spec already produces a ~700-line diff, i.e. the checked-in copy
had drifted from its own pinned generator — nothing in CI regenerates or verifies
it. This lands the clean regeneration rather than hand-patching generated code.
* `api/openapi.json` was regenerated on Windows, not CI's Linux. Checked structurally
before committing: the only differences are `PATCH /clients/{fingerprint}`, the
`RenameClient` schema and `PairedClient.label` — no OS-driven drift.
Unrelated and NOT touched: `mgmt::tests::display_monitors_answers_even_with_no_compositor`
fails on Windows, at HEAD as well. It answers `compositor="windows", monitors=[],
error=null`, and the test's escape hatches only cover gamescope, an absent compositor or
an error. Either the test needs a Windows arm or Windows display enumeration is returning
nothing it should — that is a real question, so it is left for someone to answer rather
than papered over here.
Chasing "gamepad still dead on GameStream clients after dfcffcdd" (Artemis on
Android, Moonlight on a Switch; both report only mouse/touch working). dfcffcdd
moved this plane from the XUSB companion to the UMDF HID Xbox pad and was verified
by `cargo check` + `clippy` only, so nothing about it had ever run.
The suspicion this flag was built to test: `UhidManager` has a `heartbeat` whose
own doc says a UMDF pad "treats a multi-second input silence as an unplugged
controller", the native plane calls it every tick, and `SessionPads::pump_rumble`
does not. That asymmetry looked decisive because the two planes differ in exactly
the way that would expose it: punktfunk's own client re-sends every live pad's
snapshot every 100 ms unconditionally (`input_task.rs` refresh tick), so a native
pad is never silent, while moonlight-common-c sends a controller packet only on
CHANGE — an untouched pad emits nothing at all.
`--idle-after N` stops the state frames while still pumping; `--resume-after M`
starts them again, because enumeration surviving a silence proves nothing on its
own (a pad can stay listed and deliver no input) — what matters is whether a report
written after the silence still lands.
MEASURED on .173 (Win11 26200), and it does NOT reproduce: with `--xboxhid
--idle-after 12 --seconds 75`, the pad sat through 58 s of total input silence with
`SWD\PUNKTFUNK\PF_XBOX_0` at Status=OK and its promoted `HID\PUNKTFUNK&IG_00` child
still present the whole time. So the heartbeat gap is NOT the field bug, and the
one-line "add a heartbeat to the GameStream arm" fix this was going to justify is
not warranted — which is the point of landing the probe rather than the guess.
Also measured with the same binary, and worth recording because it IS real:
* two LIVE processes wanting pad index 0 collide exactly as `PadCreateFault::
IndexOwnedElsewhere` describes (`Global\pfds-boot-0`, ACCESS_DENIED because the
mailbox DACL is SYSTEM+LocalService). dfcffcdd put BOTH input planes on that one
name — before it, GameStream used `Global\pfxusb-boot-0` and the two could never
collide — so the hazard is new, even if it is not what the reporter hit.
* a clean release-then-retake does NOT collide: back-to-back runs at 0 s, 1 s and
3 s gaps all created their pad, so an ordinary client reconnect is not the trigger.
Ruled out on the same box while here: the driver package (`pf_gamepad.inf` 08/18
declares all three Xbox hwids and the `xinputhid` promotion), stale drivers in the
field (the Windows updater is a full Inno Setup run that re-runs `driver install
--gamepad`), and access grants (a Moonlight fingerprint has no grants record, which
`control.rs` reads as GRANT_ALL).
Still open, and it needs a live session: .173 runs `PUNKTFUNK_HOST_CMD=serve`, i.e.
GameStream is switched OFF, so this box has never exercised the plane dfcffcdd
changed. That is how a compile-only fix reached users unexercised, and it is the
first thing to change before the next attempt.
flatpak-deps-present.sh decides whether the job talks to Flathub at all, and
a push-paths filter that ignores it means a change to that decision ships
untested until the next unrelated client commit happens to rebuild. Same
reason .gitea/workflows/flatpak.yml is already listed.
The flatpak job died on every attempt with
Updating runtime/org.freedesktop.Sdk.Extension.rust-stable/x86_64/25.08
Error: Failed to update org.freedesktop.Sdk.Extension.rust-stable: While
pulling ... .filez: Server returned HTTP 404
dl.flathub.org was serving a 404 for one object of the then-current
rust-stable//25.08 commit. retry.sh burned all 10 attempts (~9 min) on the
same object, and flatpak-builder segfaulted on its own error path (rc=139),
so the wrapper could not tell a dead end from a load blip either.
Root cause is ours, not Flathub's: `--install-deps-only` does not install
what is missing, it UPDATES what is present. builder_manifest_install_dep()
branches on `flatpak info --show-commit <ref>` succeeding and runs
`flatpak update` for every already-installed dep, with no fallback to a
plain install when that update fails. ci/flatpak-ci.Dockerfile bakes the
entire runtime set, so that update was a pure no-op on a healthy run while
making every build depend on Flathub's health at that minute. Nothing wanted
the newer commit — the manifest pins a runtime VERSION, not a commit.
So ask first, and reach for Flathub only on a real miss. The check is
scripts/ci/flatpak-deps-present.sh (runtime + SDK at the manifest's exact
runtime-version, sdk-extensions by presence, since their version comes from
the SDK's metadata and any bump that moves them moves runtime-version too).
It fails OPEN: anything it cannot parse takes the full install path. Its
--self-test stubs `flatpak` and covers baked / cold / each dep missing /
wrong version / unreadable manifest.
Also drop --install-deps-from=flathub from the build step. Its comment
called it "a no-op safety net"; builder-main.c calls
builder_manifest_install_deps() whenever that flag is set, and
--install-deps-only only decides whether it exits afterwards, so the step
billed as offline was re-running the same update — and could only ever fire
if the prefetch step had already failed the job.
packaging/flatpak/build-flatpak.sh keeps --install-deps-from: a dev box
genuinely wants deps installed, and it has no baked image.
10 commits since v0.31.1 (6 non-merge). Cut from origin/main 48eeae75 (#368
merged).
THE NUMBER: a patch, and unlike the last cut the version table does not even
have to argue for it. Nothing versioned moved — WIRE_VERSION 2, C ABI 25 with
include/punktfunk_core.h showing NO diff at all against the v0.31.1 tag (not
even a #define, unlike the last two releases), driver protocol 6 / min 3 with
pf-driver-proto unchanged, gamepad channel 3, plugin index schema 1, host event
schema 1, gamescope +pfhdr8 with no new patch files, SDK 0.1.5 and plugin-kit
0.4.4 both untouched. No `!` commit, no feat, no route added or removed, no
breaking change of any kind. Every non-merge commit is fix/refactor/test.
The cycle has a shape: three of the six non-merge commits are the same class of
fault — the host using the wrong local address — reached from three directions.
The data socket bound 0.0.0.0:0 and let routing pick the video source, which the
client's connected socket then dropped in-kernel (#367). Host::detect() froze the
advertised address at process start, so a cold boot that beat the network pinned
127.0.0.1 for the life of the process and broke both mDNS adverts, the Moonlight
session URL, the WoL mac record and HostInfo together (#366). And the firewall
rules guarding the ports those addresses point at admitted any program on the
machine (#368). The fourth is an Android regression from v0.31.1 (#365); the
remaining two are the refactor and test supporting #366.
api/openapi.json changes in DOCUMENTATION ONLY this time — two description
strings on HostInfo, no route, schema, required field or type — plus the stamp.
Re-stamped here, not regenerated: punktfunk-host does not build on macOS, and
#366 regenerated the document itself on a runner where
openapi_document_is_complete_and_checked_in actually executes. "0.31.1" appears
nowhere in either copy afterwards, and the two copies are byte-identical.
That description change is load-bearing rather than cosmetic, so it is called out
as a behaviour change in the CHANGELOG beside the firewall one: HostInfo.local_ip
was a field snapshotted at detect() and is now a method that re-reads per
request, so a consumer that cached it at startup was caching a value that could
be 127.0.0.1 forever.
The other behaviour change is the externally visible one: Windows service install
now scopes all five fixed-port rules to the listening executable while keeping
their localport=, so 5353 is punktfunk's alone and anything else on the machine
that was reachable on mDNS through our any-program rule needs its own. Fallbacks
are asymmetric on purpose — a fixed-port rule that cannot resolve its exe falls
back to the old wide form (a looser rule still streams), while the data-plane
rule skips (it has no port to fall back to, so a program-less version would not
be looser, it would be open).
Also in this commit, because a cut is when docs freshness bites:
docs-site/content/docs/ports.mdx. Its "Video needs nothing opened" bullet has
been wrong for Windows since v0.31.1 added the data-plane rule — it now says so
and names why (no fixed rule can cover a per-session ephemeral port). And the
Windows line gains a Callout for the 5353 change above, since that is the one
thing on this page a reader may have to act on. Callout shape copied from the
proven usage in plugins.mdx (no `title` prop — node_modules is not installed here
and fumadocs' prop surface could not be verified offline).
Play notes are Android-only per whatsnew/TEMPLATE.txt, which this cycle means the
#365 regression alone. The three host-side fixes are deliberately NOT in there:
updating the app does not fix any of them, so listing them on the store page
would promise something the download does not deliver.
Gates: cargo fmt --all --check clean; cargo metadata --offline ok with the
Cargo.lock diff versions-only (36/36); cargo test -p punktfunk-core --lib 273
passed; the C ABI harness PASSED reporting abi_version=25 (needed `brew install
opus` on this Mac to link — the first run failed on the missing library, not on
the code); cbindgen regenerated include/punktfunk_core.h during that build and it
came out byte-identical to the checked-in file AND to the v0.31.1 tag, which is a
stronger check on the ABI row than diffing it; scripts/ci/check-docs-drift.sh
clean; scripts/ci/check-docs-links.sh clean; the android.yml Play notes gate run
verbatim, 357/500 characters and unique; both openapi copies cmp identical and
stamped 0.31.2; notes voice scan clean (one backticked term in the whole file,
the `punktfunk-host service install` command, and the only technical vocabulary
sits inside `## For developers`).
Not run here, and why: clippy and any punktfunk-host build (does not compile on
macOS — CI covers it), and the Android unit tests (:kit: and :app: were run on
#365 itself; nothing in this commit touches Kotlin).
One judgement call left for the tag: SECURITY.md promises to credit a reporter in
the release notes when the fix is public, and the #368 commit records only "a
user on 2026-08-21" with no name. The notes credit them unnamed. If they want
their name on it, that is a one-line edit to docs/releases/v0.31.2.md before the
tag is pushed.
if grep -q "STARTING A FULL BUILD" /tmp/android-clippy.log; then
echo "::error::skia-bindings did not get its prebuilt archive and started building Skia from source — the download was dropped (see DOWNLOAD AND INSTALL FAILED above). This is a fetch failure, not a lint failure."
exit 1
fi
exit $rc
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
# Error: Failed to update org.freedesktop.Sdk.Extension.rust-stable: While pulling …
# .filez: Server returned HTTP 404
# dl.flathub.org served a 404 for one object of the then-current rust-stable//25.08
# commit, deterministically — all 10 retry.sh attempts died on the SAME object over
# ~9 min — and flatpak-builder SEGFAULTED on its own error path (rc=139), so retry.sh
# saw a crash rather than a clean "this will never work" either. The build never wanted
# that newer commit: the manifest pins a runtime VERSION, not a commit, and the baked
# one satisfies it. Updating bought nothing and imported an upstream outage.
#
# So: assert what the image already has, and reach for Flathub only on a real miss —
# the same "guard, don't install on top of a stale image" doctrine as the Tooling step.
# The check lives in scripts/ci/flatpak-deps-present.sh (run its --self-test after
# touching it): a bug in it that reports "satisfied" when it is not would build against
# whatever runtime happened to be lying around, which is worth more than an inline
# if-statement. It deliberately fails OPEN — anything it cannot parse takes the slow
# install path below.
if bash scripts/ci/flatpak-deps-present.sh "$MANIFEST"; then
echo "deps satisfied by the baked image — not touching Flathub"
flatpak list --user --columns=ref
else
echo "::warning::$MANIFEST declares deps punktfunk-flatpak-ci does not have — pulling from Flathub (~1.5 GB). Bump GNOME_VERSION/FREEDESKTOP_VERSION in ci/flatpak-ci.Dockerfile so this stays off the hot path."
if [ -z "${FLATPAK_GPG_PRIVATE_KEY:-}" ] || [ -z "${DEPLOY_HOST:-}" ]; then
echo "::warning::FLATPAK_GPG_PRIVATE_KEY/DEPLOY_* not set — skipping repo deploy (bundle still published)."
if [ -z "${FLATPAK_GPG_PRIVATE_KEY:-}" ] || [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_KNOWN_HOSTS:-}" ]; then
echo "::warning::FLATPAK_GPG_PRIVATE_KEY/DEPLOY_*/DEPLOY_KNOWN_HOSTS not set — skipping repo deploy (bundle still published). See packaging/nix/README.md for the host key."
exit 0
fi
# 1) Import the signing key into a throwaway keyring; sign the repo.
@@ -437,7 +494,13 @@ jobs:
# objects so clients mid-update aren't broken; the fresh signed summary advertises latest.
"description":"The actions this host offers, as seen by the caller: platform availability (with the honest\nreason when something can't run) and whether THIS caller is permitted to invoke each one.\nAdmin lane: everything permitted. Paired-cert lane: permission follows the device's live\naccess mask (the Host power grant). Clients render rows generically — unknown ids still\nwork with the server-supplied title.",
"operationId":"listActions",
"responses":{
"200":{
"description":"The actions, per-caller",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ActionList"
}
}
}
},
"401":{
"description":"Missing or invalid credentials",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/actions/{id}":{
"post":{
"tags":[
"actions"
],
"summary":"Invoke a host action",
"description":"Runs one action by id — empty body, no parameters: the id selects a fixed host-side\nbehavior, and nothing in the request reaches the privileged path. On `202` the host first\nends every streaming session cleanly (clients see a typed \"the host is going to sleep /\nshutting down\" close), waits ~1 s so this response flushes, then acts.\n\nPaired-cert callers need the **Host power** grant, and are refused (`409`) while another\ndevice's session is live — a granted guest cannot yank the host out from under the owner\nmid-stream. The admin console is never blocked (it warns instead). One action runs at a\ntime host-wide.",
"operationId":"invokeAction",
"parameters":[
{
"name":"id",
"in":"path",
"description":"Action id (`power.sleep`, `power.reboot`, `power.shutdown`)",
"required":true,
"schema":{
"type":"string"
}
}
],
"responses":{
"202":{
"description":"Accepted — sessions are being ended and the action follows in about a second"
},
"401":{
"description":"Missing or invalid credentials",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"403":{
"description":"This caller's access does not include this action (no Host power grant)",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"404":{
"description":"Unknown action id",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"409":{
"description":"Refused: an action is already in flight, another device's session is live (cert lane), or the platform said no (a foreign sleep inhibitor, a second local user, …)",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"501":{
"description":"This host platform has no executor for it (macOS host)",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/client-logs":{
"get":{
"tags":[
@@ -364,6 +472,77 @@
}
}
}
},
"patch":{
"tags":[
"clients"
],
"summary":"Rename a paired client",
"description":"Sets or clears the operator-visible display name for one paired Moonlight client. This is\npurely cosmetic — it touches no certificate and no trust decision — but it is the only way to\ntell paired devices apart: every moonlight-common-c client self-signs with the identical\nsubject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable\nonly by fingerprint. The name is stored beside the pairing store and survives host restarts;\nunpairing the device forgets it.",
"operationId":"renameClient",
"parameters":[
{
"name":"fingerprint",
"in":"path",
"description":"Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)",
"required":true,
"schema":{
"type":"string"
}
}
],
"requestBody":{
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/RenameClient"
}
}
},
"required":true
},
"responses":{
"200":{
"description":"The client as it now reads",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/PairedClient"
}
}
}
},
"400":{
"description":"Malformed fingerprint",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"401":{
"description":"Missing or invalid bearer token",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"404":{
"description":"No paired client with that fingerprint",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/compositors":{
@@ -4260,6 +4439,67 @@
},
"components":{
"schemas":{
"ActionInfo":{
"type":"object",
"description":"One action as the caller sees it (`GET /actions`).",
"required":[
"id",
"title",
"group",
"danger",
"available",
"permitted"
],
"properties":{
"available":{
"type":"boolean",
"description":"Whether this host can run it right now (platform probe — a VM that can't S3 lists\nsleep as unavailable rather than offering a dead switch)."
},
"danger":{
"type":"boolean",
"description":"Whether a client UI should double-confirm (the action loses state — reboot/shutdown)."
},
"group":{
"type":"string",
"description":"Action group (`power` for the built-ins)."
},
"id":{
"type":"string",
"description":"Stable action id (`power.sleep`, …) — the invoke path parameter.",
"example":"power.sleep"
},
"permitted":{
"type":"boolean",
"description":"Whether THIS caller may invoke it (admin lane: always; cert lane: the `GRANT_POWER`\nbit of the device's live access mask)."
},
"title":{
"type":"string",
"description":"Display title. Clients localize known ids and fall back to this for unknown ones."
},
"unavailable_reason":{
"type":[
"string",
"null"
],
"description":"Why it is unavailable, when it is."
}
}
},
"ActionList":{
"type":"object",
"description":"`GET /actions` response.",
"required":[
"actions"
],
"properties":{
"actions":{
"type":"array",
"items":{
"$ref":"#/components/schemas/ActionInfo"
}
}
}
},
"ActiveGame":{
"type":"object",
"description":"One launched game, for the console's running-game card.",
@@ -6070,6 +6310,42 @@
}
}
},
{
"type":"object",
"description":"A host action was invoked (`design/host-actions.md` §3.3) — v1: the `power.*` verbs.\nEmitted on ACCEPT (`outcome: \"accepted\"`), and again if the executor later fails\n(`outcome: \"failed: …\"`) — a succeeded power action ends this process, so \"accepted with\nno failure after it\" is the success signal a hook can act on (\"the host is going down\").",
"required":[
"id",
"outcome",
"kind"
],
"properties":{
"device":{
"oneOf":[
{
"type":"null"
},
{
"$ref":"#/components/schemas/DeviceRef",
"description":"The invoking paired device, when the cert lane invoked it; absent for the\noperator's console (admin lane)."
}
]
},
"id":{
"type":"string",
"description":"The invoked action id (`power.sleep`, `power.reboot`, `power.shutdown`)."
},
"kind":{
"type":"string",
"enum":[
"action.invoked"
]
},
"outcome":{
"type":"string",
"description":"`accepted`, or `failed: <the executor's error>`."
}
}
},
{
"type":"object",
"required":[
@@ -7375,6 +7651,14 @@
"description":"Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.",
"description":"Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).\n\nThis is the ONLY thing that can tell two paired Moonlight devices apart in a list, because\ntheir certificates cannot: see [`Self::subject`]. Absent until somebody names the device.",
"example":"Living Room TV"
},
"not_after_unix":{
"type":[
"integer",
@@ -7396,7 +7680,7 @@
"string",
"null"
],
"description":"Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses."
"description":"Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.\n\nDo not display this as a device name. Every moonlight-common-c client self-signs with that\nsame fixed subject, so it identifies the *protocol*, not the device — a list of paired\nphones, TVs and handhelds all read identically. [`Self::label`] is the field to show."
}
}
},
@@ -7949,6 +8233,20 @@
}
}
},
"RenameClient":{
"type":"object",
"description":"Body of `PATCH /clients/{fingerprint}` — the device's display name.",
"properties":{
"label":{
"type":[
"string",
"null"
],
"description":"The name to show for this device. `null` (or an empty/whitespace-only string) clears it and\nthe device goes back to being listed by fingerprint alone.\n\nScrubbed before storage by the same sanitizer the native plane runs on device names:\ncontrol characters and Unicode bidi overrides are stripped (they could make one paired\ndevice impersonate another in this very list), whitespace collapsed, and the result capped\nat 64 characters.",
"example":"Living Room TV"
}
}
},
"RunningTitle":{
"type":"object",
"description":"One running title in a provider's liveness report.",
@@ -9049,6 +9347,10 @@
{
"name":"update",
"description":"Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"
},
{
"name":"actions",
"description":"Host actions: discover what this host offers (per-caller availability + permission) and invoke one by id — v1: sleep, restart, shut down the machine, gated per device by the Host power grant"
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARGSCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz"\
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'\
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
# their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1) — so these bytes end up inside the
# package arch.yml signs and publishes. Arch ships bun in [extra], so take the
# pacman-signed package (pacman verifies package signatures by default) instead of piping
# bun.sh's installer into root's shell, which would be upstream code choosing them. Same
# call as arch.yml's bootstrap guard. It rides THIS transaction rather than a later layer
# on purpose: -Syu refreshes the db in the same step that installs, so a cache-hit rebuild
# can never resolve bun against a stale snapshot the mirrors no longer carry.
bun \
&& pacman -Scc --noconfirm \
&& bun --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARGSCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz"\
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'\
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARGSCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz"\
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'\
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARGSCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz"\
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'\
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARGSCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz"\
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'\
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.