The Installed tab could only update one plugin at a time, one dialog and one
watched job each. This adds the bulk action beside the list it acts on — the
same place Sources keeps "Refresh all" — plus a count badge on the Installed
tab trigger, because Browse is the tab the page opens on and a control nobody
passes is a control nobody finds.
The host takes ONE package operation at a time (409 otherwise: bun operations
share a lockfile and a node_modules tree), so this is a queue the console works
through job by job, driven by each job settling rather than by a timer. The run
carries its own copy of what is left: every finished install invalidates the
installed list, and a queue that re-derived itself would change shape underneath
a run the operator already confirmed.
Trust rules are unchanged, only taken once instead of N times. If any entry in
the run comes from an operator-added source the whole dialog wears the external
treatment and names those catalogs — a bulk action must not be a way to wave
through, in one click, a warning each package would have shown on its own. The
dialog lists every version change rather than a count, and names what it will
not attempt: an update with no catalog entry, or one this host would refuse
(400 on incompatible, blocked entries) never enters the queue, so the button's
count still adds up on screen.
A failure ends the run. The failed job's card is the only record of what went
wrong, and starting the next install would replace it with a fresh spinner; the
toast says what was applied and what was not, and the rows are still there to
retry from.
Also fixed, because this change leans on it: disabled buttons were invisible.
AnimatedButton is a motion element and its mount animation settles as an inline
`opacity: 1`, which outranks the `disabled:opacity-50` class the library also
ships — measured `opacity: 1` on a disabled button, console-wide. Only
`pointer-events: none` landed, so every disabled control in the app looked live
and silently ignored the click. Corrected in the components/ui wrapper layer
like the other @unom/ui adaptations.
Verified: tsc, biome, `bun test server/`, production build, i18n check (650
messages, en + de). Storybook stories added for the list header and the confirm
dialog; both rendered headless in light and dark, with the disabled states
asserted on the DOM rather than by eye.
The dependency currency wave took skia-safe/skia-bindings 0.87.0 -> 0.99.0 in
crates/pf-console-ui/Cargo.toml, but packaging/flatpak/io.unom.Punktfunk.yml still
pinned the 0.87.0 prebuilt archive, so every flatpak leg since the merge dies with
error[E0599]: no variant, associated function, or constant named `Default`
found for enum `SkPathFillType` (and `SkPathDirection`)
--> cargo/vendor/skia-bindings-0.99.0/src/defaults.rs:57
Nothing about that message points at the manifest, so it reads like a crate bug. It
isn't. `SKIA_BINARIES_URL: file://…` makes skia-bindings unpack the pinned tarball
verbatim into target/…/build/skia-bindings-*/out/skia/ — *including the bindings.rs
it was generated with*. Those two `Default`s are associated consts emitted INTO
bindings.rs, so they travel with the archive, not with the crate: 0.99.0's
src/defaults.rs was compiling against 0.87.0-era bindings. Verified directly — the
0.99.0 archive carries `impl SkPathFillType { pub const Default = Winding }` and
`impl SkPathDirection { pub const Default = CW }` on both x86_64 and aarch64.
Because the URL is file://, the fetch can never fail, so there is no download error
to notice — the only symptom is a compile error deep in a vendored crate.
The asset name changed across the bump: `jpeg` entered skia-safe's defaults at 0.99,
so the resolved-feature key went `pdf-textlayout-vulkan` -> `jpegd-jpege-pdf-textlayout-vulkan`.
Confirmed against each archive's own key.txt/tag.txt (tag 0.99.0, key
a25a0fdb7d90429aa2d1-<target>-jpegd-jpege-pdf-textlayout-vulkan), and libskparagraph.a
plus the Vulkan backend symbols are present, so the feature set still matches what
pf-console-ui resolves.
Everything else in the offline chain (Cargo.lock, cargo-sources.json) is regenerated
from the lock and self-corrects; this tarball is the single hand-maintained pin, which
is exactly why it was the thing left behind. Both bump sites now carry a pointer to
the other so the next one can't split-brain the same way.
Chaining two clients through a kept (keep-alive) Mutter display segfaults
gnome-shell in meta_monitor_manager_rebuild (libmutter-18) and takes the whole
desktop down; every later session then fails RemoteDesktop.CreateSession:
ServiceUnknown until GDM restarts. A/B'd on .21: byte-identical on released
0.27.0 and the 0.28.0 RC, so it was never a regression — the trigger has been
there all along.
TOPOLOGY_LOCK already serialized every topology-mutating D-Bus call, but two
gaps still let Mutter's REBUILDS overlap:
- Teardown was fire-and-forget: StopGuard::drop set a flag and returned, and
the session thread only noticed on its ≤200 ms park tick. The A2 dead-reuse
path (reused kept display dead on first frame → mark_failed → re-create)
therefore issued its fresh RecordVirtual with the doomed monitor's removal
still pending — the fresh session could even win the lock BEFORE the old
thread had woken to take it, adding a monitor while the dead one still stood.
The drop now waits (bounded, 20 s) for the session thread to finish.
- The lock was released while the shell was still rebuilding: Stop /
RecordVirtual / ApplyMonitorsConfig all return mid-rebuild, and a temporary
(APPLY_TEMPORARY) config auto-reverts asynchronously on top. Every locked
mutation section now ends with settle_topology() — poll GetCurrentState
until a removed connector is actually gone and the config serial holds still
across two consecutive reads — before the guard drops. Bounded at 4 s and
best-effort (a read error means the shell is gone; a hotplug storm must not
park sessions), degrading to exactly the old behavior.
Cost when Mutter is already quiet: one confirming read plus one 150 ms recheck
per setup/teardown. The live_mutter_create_drop harness sheds its grace sleep —
the synchronous drop IS the teardown confirmation now.
Not fixed here, documented on TOPOLOGY_LOCK: the mid-stream mode-switch rebuild
is create-before-drop by design (H2), so its RecordVirtual still lands while
the superseded monitor exists; the settle makes Mutter quiescent at that point
but cannot remove the coexistence itself.
A 4K60 GameStream session captured 1920x1080. `create()` asked KWin for
3840x2160, KWin built something else, and nothing compared the two: only the
>60 Hz arm read anything back, and it gets that for free because it installs a
custom mode. The ≤60 Hz arm installs nothing, which is exactly why it never
noticed.
The line that should have caught it was the one that hid it. `spawn_vout`
returns a node id, never a size, so
tracing::info!(node_id, width, height, "KWin virtual output ready")
was echoing the REQUEST — the field log stated 3840x2160 while the output was
1080p, and the first pass at diagnosing this was done against that number. It
now logs `requested_w`/`requested_h`, and the readback sits under it.
Unverified, the mismatch was silent and total. `final_dims` carried the request
forward, so `apply_topology`, `clear_replication_source` and
`resolve_kscreen_addr` — all of which resolve by dims — quietly missed their own
output, leaving the stream neither primary nor de-mirrored; and the encoder
opened at the captured size, handing the client a bitstream that disagreed with
the resolution it had configured its decoder from.
Suspected trigger is KWin restoring per-output mode/scale from
kwinoutputconfig.json, which is keyed by output NAME — and ours is deliberately
stable across sessions so KDE reapplies that client's scaling (Stage 3). The
feature and the failure are the same mechanism.
- `kwin_output_mgmt::actual_dims()` reads the output's real mode + scale.
Resolution is by name alone, so it declines unless EXACTLY one output carries
our prefix: two means a supersede is in flight, and the dims filter is the
only thing that can tell the replacement from the predecessor whose name it
reuses. Failing closed keeps this a pure addition.
- On a mismatch, re-assert the requested mode through the same
`set_custom_mode` install+select the sacrificial birth already uses (an output
at a size we don't want, moved to one we do) and arm `expect_exact_dims` so
the capturer holds frames until the screencast renegotiates. 60 Hz is
requested, not `mode.refresh_hz`: only the size is wrong here, and asking for
the client's rate would install a 30 Hz mode for a 30 fps client.
- If KWin refuses the correction, report the size that is REALLY there rather
than the request, so the dims-keyed resolves and the encoder key on reality,
and say in the log how to clear the stored entry.
- Scale is logged, never corrected — a non-unity scale here is the Stage 3
feature working, not a fault.
- `mode_satisfies()` extracts the acceptance predicate both arms now share, so
they cannot drift into disagreeing about what "we got what we asked for"
means. Tested: a restored 1080p does not pass for a 4K request, a CVT-aligned
width does, and the slack is bounded, one-sided and width-only.
The stream-side warning is reworded but deliberately still NOT fatal: mirroring
a pinned monitor streams a size the client never negotiated BY DESIGN (§7.3 — a
panel runs at the mode its owner set and the client scales), so refusing the
mismatch would break every mirror session. It now names both causes and states
what the client actually does with the stream.
Does not claim to close the Xbox Moonlight disconnect it was found through: that
client's IDR storm begins ~4.6 s after the first frame, which a decoder simply
unable to handle the size would not do. The 1080p-instead-of-4K is a real defect
on its own terms and is what this fixes.
#192 moved rcgen to aws-lc-rs and removed ring from the tree, but this comment
still explained the P-256 path in terms of "rcgen's ring backend". It also
cross-references gamestream::cert's note, which this branch already corrected —
so leaving it made the two contradict each other.
The substance is unchanged and still load-bearing: rcgen generates EC keys
directly, while RSA has to be generated by the `rsa` crate and handed to rcgen
to self-sign, because no rcgen backend will generate an RSA key.
Covers all five generated files, not just the root one: the four per-client
copies are scoped to the binaries their package installs, so they move
independently of the workspace-wide file.
Root: 571 -> 575 crates, reflecting this wave (skia-safe 0.99, the RustCrypto
digest-0.11 family, jni 0.22, x11rb 0.14, reis 0.7, xkbcommon 0.9, wasapi 0.24,
windows-service 0.8.1, x509-parser 0.18, rand 0.9, base64 0.23, libloading 0.9,
mdns-sd 0.21 + if-addrs 0.15, rcgen 0.14, criterion 0.8, android_logger 0.15).
The per-client diffs are much larger than the wave alone explains, because they
were never regenerated after #192: all four still attributed `ring` and named no
aws-lc-rs at all. Since #192 removed ring from the tree entirely, the shipped
Acknowledgements screens have been crediting a crypto library the clients do not
carry while omitting the one they do. They now catch up on both changes at once.
(`ring` still appears via the generator's deliberate `--all-features`
over-approximation, which sees quinn-proto's wasm-only edge; that is by design —
listing an unlinked crate is untidy, omitting a linked one is the failure the
file exists to prevent.)
Also stops gen-third-party-notices.sh preferring `cargo about` for the root file.
That preference was silently destructive: cargo-about only sees CARGO
dependencies, so it drops every VENDORED_TREES entry -- pyrowave, the Granite
subset, volk, Vulkan-Headers, the Font Awesome brand icons, Simple Icons -- which
are third-party sources shipped inside first-party crates under their own
licences. Measured today: cargo-about emitted 7,274 lines / ~514 crates with zero
mentions of volk, Vulkan-Headers or Font Awesome, against the python generator's
17,324 / 575 with all of them. Merely having cargo-about on PATH was enough to
degrade the file, so anyone regenerating after this commit would have undone it.
cargo-about remains what the CI licence gate runs -- that job asks a different
question (is every licence in the about.toml allowlist) and writes to /dev/null.
Both licence-gate legs pass: `cargo about generate about.hbs --fail` and the
drivers-workspace leg, RC=0.
Dev-dependency of punktfunk-core only — it ships in nothing. `default-features =
false, features = ["cargo_bench_support"]` carries over unchanged; that feature
still exists in 0.8 and still keeps plotters/rayon out of a headless CI run.
One source change, and it is a lint issue rather than an API one.
`criterion::black_box` survives in 0.8 but is `#[deprecated]` — it now just
forwards to `std::hint::black_box` — and benches ARE compiled by
`cargo clippy --workspace --all-targets -- -D warnings`, so keeping the criterion
import would have turned a deprecation warning into a failed lint gate. The
bench imports the std one directly.
What CI actually consumes from criterion is the on-disk result layout, so that
was checked rather than assumed: 0.8 still writes
`target/criterion/<group>/<id>/new/estimates.json`, and the key
scripts/bench/compare.py reads — `median.point_estimate` — is still there:
$ cargo bench -p punktfunk-core --bench pipeline -- --warm-up-time 1 --measurement-time 3
... 12/12 benchmarks reported, e.g. pipeline/gf16/1048576 thrpt: [537 MiB/s 540 MiB/s 542 MiB/s]
$ find target/criterion -name estimates.json | wc -l
24
$ python3 -c 'import json; print(json.load(open(".../crypto/seal/new/estimates.json"))["median"]["point_estimate"])'
817.96
Verified on CachyOS (rustc 1.96.0):
cargo clippy -p punktfunk-core --all-targets --locked -- -D warnings OK (this is what compiles the bench)
cargo clippy --workspace --all-targets --locked -- -D warnings OK
cargo bench -p punktfunk-core --bench pipeline --locked -- --test 12/12 Success
cargo bench -p punktfunk-core --bench pipeline --locked -- --warm-up-time 1 --measurement-time 3 OK (CI's exact line)
cargo test -p punktfunk-core --locked 210 + 8 + 1 passed, 0 failed
cargo fmt --all --check clean
Both declarations keep `default-features = false, features = ["aws_lc_rs",
"pem"]`, which stays load-bearing in 0.14: `ring` is still in rcgen's DEFAULT
feature set, so dropping `default-features = false` would drag the backend this
tree deliberately left back in. Verified after the bump — `cargo tree -i ring`
finds nothing on x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc,
aarch64-apple-darwin or aarch64-linux-android.
One breaking change reached us. `CertifiedKey`'s key field was renamed
`key_pair` -> `signing_key` (the struct is now generic, `CertifiedKey<S:
SigningKey>`), which hits the two `generate_simple_self_signed` callers in
core's quic/endpoint.rs — the dev/test server endpoint and `generate_identity`.
Both are a field rename; `KeyPair` still provides `serialize_der`/`serialize_pem`.
Everything the GAMESTREAM identity depends on survives unchanged, which is what
this bump's precondition was about: `KeyPair::from_pkcs8_pem_and_sign_algo`,
`PKCS_RSA_SHA256`, `CertificateParams::new`, `date_time_ymd`, `self_signed`,
`Certificate::pem`. The split in gamestream/cert.rs is therefore untouched — the
RSA-2048 key is still minted by the `rsa` crate and handed to rcgen only to load
and self-sign, because rcgen cannot generate an RSA key on the backend we use.
That path is asserted, not assumed:
cargo test -p punktfunk-host --bins --locked gamestream::cert 3 passed, 0 failed
generate_mints_a_loadable_rsa2048_identity ok (fresh-install keygen)
moonlight_shaped_peer_completes_a_tls12_mutual_handshake ok
tls13_negotiates_the_post_quantum_group ok (X25519MLKEM768 by name)
Verified on CachyOS (rustc 1.96.0):
cargo clippy --workspace --all-targets --locked -- -D warnings OK
cargo clippy -p punktfunk-host -p punktfunk-core --locked -- -D warnings OK (shipping build)
cargo test --workspace --locked 78 test binaries, all ok, 0 failed
cargo fmt --all --check clean
One commit because splitting them accomplishes nothing: mdns-sd 0.20 ALREADY
depends on if-addrs 0.15, so while our own five crates declared 0.13 the tree
carried both copies no matter which of the two moved first. Moving them together
is what collapses it:
$ cargo tree -d | grep '^if-addrs'
(no output)
Neither needed a source change. mdns-sd 0.21's public API is purely additive
over 0.20.3 — the sole new item is `ServiceDaemon::set_max_packet_size`, and
`ServiceInfo`'s surface is byte-identical — so `ServiceDaemon`/`ServiceInfo`/
`ServiceEvent`/`ResolvedService` behave as before at all six call sites
(host discovery + gamestream mdns, pf-client-core, and the Android, Windows and
probe clients). if-addrs 0.15 keeps 0.13's `Interface`/`IfAddr` shape, and we
only ever read those.
The one real change is a FEATURE, not a version. if-addrs has `link-local`, and
mdns-sd declares if-addrs with it on. Once our crates share that single copy,
unification turns it on for our calls too — meaning `get_if_addrs()` now also
reports fe80:: interfaces (and, on Windows, 169.254.x.x). Rather than inherit
that silently, punktfunk-core and punktfunk-host now NAME the feature. Two
reasons: it is what every real build gets anyway, so a standalone `cargo test -p
punktfunk-core` should not enumerate a different set of NICs than the host does;
and for the consumer here — Wake-on-LAN — it is the behaviour we want, since a
NIC is wake-capable whether or not it currently holds a routable address.
Verified on CachyOS (rustc 1.96.0):
cargo clippy --workspace --all-targets --locked -- -D warnings OK
cargo test --workspace --locked OK, 0 failed
cargo test -p punktfunk-host --bins --locked 501 passed, 0 failed, 2 ignored
cargo test ... gamestream::cert 3 passed
cargo fmt --all --check clean
(One `cargo test --workspace` attempt failed with E0463 "can't find crate for
pf_frame" in a doc-test. That is the target dir having only clippy's .rmeta for
a crate a doc-test wants to LINK, not anything in this change; a plain re-run
after cargo test built the rlibs was green.)
All five declarations move together (pf-encode twice — Linux and Windows —
plus pf-client-core, pf-zerocopy and punktfunk-host), because a split would
have compiled two copies of a crate whose whole job is holding a process-wide
dlopen handle.
No source changes. 0.9 replaces the concrete parameter types with sealed traits
— `Library::new(impl AsFilename)` and `Library::get(impl AsSymbolName)` — and
both cover what our 16 call sites already pass: `&str` literals for the sonames
(`libnvidia-encode.so.1`, `libva.so.2`, `libnvidia-ml.so.1`, `libcuda.so.1`) and
`&[u8; N]` NUL-terminated byte literals for the symbols, which 0.9 implements
explicitly alongside `&[u8]`. MSRV rises to 1.88; the workspace pins 1.96.
libloading 0.8 does not leave the lock, and shouldn't: what remains is
`clang-sys` under `bindgen`, reached only as a BUILD-dependency of
ffmpeg-sys-next / libspa-sys / pyrowave-sys. That copy runs at build time and is
linked into nothing we ship.
Verified on CachyOS (rustc 1.96.0):
cargo clippy --workspace --all-targets --locked -- -D warnings OK
cargo clippy -p pf-encode --all-targets --locked --features nvenc,vulkan-encode,pyrowave -- -D warnings OK
(the only leg that compiles enc/linux/nvenc_cuda.rs, where the `lib.get(b"…\0")` calls live)
cargo clippy -p punktfunk-host -p pf-encode -p pf-zerocopy -p pf-client-core --locked -- -D warnings OK (shipping build)
cargo test -p punktfunk-host --bins --locked 501 passed, 0 failed, 2 ignored
cargo test -p pf-encode --locked 33 passed, 5 ignored
cargo test -p pf-zerocopy --locked 40 passed
cargo fmt --all --check clean
ureq 3 already pulls 0.23, so the tree compiled both minors purely because our
two direct declarations named the older one. The API we use — `Engine`,
`engine::general_purpose::STANDARD`, `encode`/`decode` — is unchanged in 0.23;
no source edits.
Both are declared `default-features = false, features = ["std"]` rather than
taking the defaults. 0.23 added `simd-unsafe` (hand-written AVX2/NEON engines)
as a DEFAULT-ON feature, and ureq declares base64 with default features off, so
today that code is not in the tree. Accepting the defaults here would unify the
feature on and quietly add an unsafe SIMD engine to every artifact as a side
effect of a currency bump. Whether to enable it is a perf question deserving a
measurement and its own commit; this one changes versions, not exposure. `std`
covers every call site (encode to `String`, decode to `Vec`).
base64 0.22 does NOT leave the tree: `rcgen` -> `pem` 3.0.6 is now its sole
remaining consumer, and it stays one after the rcgen 0.14 bump later in this
batch — 0.14 still depends on `pem` "3.0.2", which resolves to the same 3.0.6,
which still uses base64 0.22. Clearing that last copy is upstream's move (a
`pem` release on 0.23), not ours.
Verified on CachyOS (rustc 1.96.0):
cargo clippy -p punktfunk-host -p pf-update-check --locked --all-targets -- -D warnings OK
cargo clippy -p punktfunk-host -p pf-update-check --locked -- -D warnings OK (shipping build)
cargo test -p punktfunk-host --bins --locked 501 passed, 0 failed, 2 ignored
cargo test -p pf-update-check --locked 32 passed, 0 failed
cargo fmt --all --check clean
punktfunk-core and pf-client-core were already on 0.9; the host sat on 0.8 by
drift, not by decision, so every build compiled two rand majors to satisfy six
call sites. Mechanical at five of them (`thread_rng()` -> `rng()`,
`gen_range` -> `random_range`); the sixth is the interesting one.
`RsaPrivateKey::new` is bounded on rand_core **0.6**'s `CryptoRngCore`. rand
0.9's `ThreadRng` implements rand_core 0.9's traits — same names, different
crate — so the RSA-2048 keygen in gamestream/cert.rs stopped satisfying the
bound the moment the version moved. It now draws from `rsa::rand_core::OsRng`:
rsa's own re-export, which is by construction the traits rsa compiled against,
so the two rand_core majors never have to meet in our source. That needs
`features = ["getrandom"]` on rsa (not one of its defaults) — and it must be
declared HERE rather than left to feature unification, because dropping our own
rand 0.8 also dropped the `std` feature that used to switch `rand_core/getrandom`
on as a side effect.
What this does and does not clear. The GAMESTREAM host keeps rand 0.8 +
rand_chacha 0.3 — `rsa` drags them in through `num-bigint-dig`, which is not
ours to move:
$ cargo tree -i rand@0.8.7
rand v0.8.7
└── num-bigint-dig v0.8.6
└── rsa v0.9.10
└── punktfunk-host
The NATIVE-ONLY host (--no-default-features, no rsa) now sheds both entirely —
`cargo tree -p punktfunk-host --no-default-features --features pyrowave -i
rand@0.8.7` and the same for rand_chacha@0.3.1 both report no match. rand_core
0.6 stays in every flavour regardless: `crypto-common` (under aes-gcm) needs it,
which no rand bump can change.
`windows/install.rs`'s `random_password` is the one site a Linux box cannot
type-check; the edit there is the identical `thread_rng()` -> `rng()` the five
compiled sites took.
Verified on CachyOS (rustc 1.96.0):
cargo clippy -p punktfunk-host --locked --all-targets -- -D warnings OK
cargo clippy -p punktfunk-host --locked -- -D warnings OK (shipping build: no --all-targets)
cargo clippy -p punktfunk-host --no-default-features --features pyrowave --all-targets --locked -- -D warnings OK
cargo test -p punktfunk-host --bins --locked 501 passed, 0 failed, 2 ignored
cargo test ... gamestream::cert 3 passed (incl. the fresh-install RSA-2048 keygen)
cargo fmt --all --check clean
x509-parser 0.16 pinned the old asn1-rs 0.6 / der-parser 9 / oid-registry 0.7
chain, and every one of those still depended on thiserror 1.0. It was the ONLY
thing doing so — so the host compiled two thiserror majors (and two
thiserror-impl proc macros) for one crate's error types. 0.18 moves the chain to
asn1-rs 0.7 + thiserror 2, which is the same major the rest of the tree already
uses, and the 1.0 half disappears — for the host, on every target and including
dev-dependencies:
$ cargo tree -p punktfunk-host --target all -e normal,build,dev -i thiserror@1
error: package ID specification `thiserror@1` did not match any packages
Scope that claim honestly: this clears the HOST graph, not the workspace.
thiserror 1.0 is still built, reached by `jni` 0.21 AND by the vendored
`ndk` 0.9.0, both under punktfunk-client-android. That is a different graph
and a different bump.
No source change was needed. The one API shift that could have reached us —
asn1-rs 0.7 making `BitString::data` a `Cow<[u8]>` instead of a `&[u8]` — lands
on `x509.signature_value.data.to_vec()` in gamestream/cert.rs and
gamestream/pairing.rs, and `to_vec()` goes through Deref either way. Both are
Moonlight pairing-hash inputs, so they are covered by the gamestream::cert
tests rather than taken on faith. nom 7 and nom 8 were already both in the lock;
this adds no new duplicate.
Verified on CachyOS (rustc 1.96.0):
cargo clippy -p punktfunk-host --locked --all-targets -- -D warnings OK
cargo clippy -p punktfunk-host --locked -- -D warnings OK (shipping build: no --all-targets)
cargo clippy -p punktfunk-host --no-default-features --features pyrowave --all-targets --locked -- -D warnings OK
cargo test -p punktfunk-host --bins --locked 501 passed, 0 failed, 2 ignored
cargo test ... gamestream::cert 3 passed
cargo tree -i ring nothing to print
Version currency for the virtual-keyboard keymap in pf-inject's wlroots path
(`inject/linux/wlr.rs`), the crate's only consumer.
Additive on the Rust side: one new wrapper, `Keymap::key_get_mods_for_level`,
and the `extern` declaration behind it. Nothing we call changed shape -- we use
`Context::new`, `Keymap::new_from_names`, `get_as_string`, `State::new`,
`Keycode::new`, `KeyDirection` and the `serialize_mods`/`serialize_layout` pair,
all untouched. The feature set is unchanged too: `default = ["wayland"]` in both
releases, so `x11` -- the feature that would pull `as-raw-xcb-connection` -- stays
off and this links only `libxkbcommon`, exactly as before.
The one thing worth writing down is the new symbol's floor. On .25's
libxkbcommon 1.13.1 it reads
`xkb_keymap_key_get_mods_for_level@@V_1.0.0`, so the declaration wants
libxkbcommon >= 1.0.0 (2020) if it is ever referenced. Nothing in the workspace
calls the wrapper, so no relocation for it reaches our binaries -- and every ship
target clears 1.0 by years regardless (Ubuntu 22.04 is 1.4, Debian 12 is 1.5,
Debian 13 is 1.7). pf-inject's test binary, which unlike the rlib actually links,
builds and runs clean.
Two internal fixes come along, both in constructors we do not use:
`new_from_string` passes the Rust string's pointer + length to
`xkb_keymap_new_from_buffer` instead of round-tripping through a `CString`
(whose `unwrap()` panicked on an interior NUL), and `new_from_fd` passes the
full mapped `size` rather than `size - 1`.
Verified on .25 (Ubuntu 26.04, `CARGO_BUILD_JOBS=2`), all rc=0:
cargo build -p pf-inject --locked
cargo clippy -p pf-inject --locked -- -D warnings
cargo clippy -p pf-inject --all-targets --locked -- -D warnings
cargo test -p pf-inject --locked 127 passed, 0 failed, 8 ignored
+ motion_contract: 7 passed, 0 failed
cargo check -p punktfunk-host --locked
cargo clippy -p punktfunk-host --locked -- -D warnings
cargo fmt --all --check
Clippy is run BOTH ways because host CI lints without `--all-targets`.
`cargo tree -i ring` stays empty.
Version currency for the libei path. pf-inject is the only consumer -- reis is a
pure-Rust implementation of the EI wire protocol, so this links nothing new and
still needs no libei on the host. The release tracks libei protocol 1.5.0 ->
1.6.0.
**Nothing in our API surface moved.** `ei::Context::new`, `handshake_tokio`,
`reis::tokio::EiConvertEventStream` and `reis::event::{Device, DeviceCapability,
EiEvent, Region}` all keep their shape; `Region`'s six fields are byte-identical.
The two enums grew variants for libei 1.6's `ei_text` (`EiEvent::TextKeysym` /
`TextUtf8`, `DeviceCapability::Text`), which compiles because `handle_ei`'s match
ends in `_ => {}` and the capability set we bind is written out explicitly. The
handshake now ADVERTISES `ei_text` as a supported interface, so a 1.6 EIS may
offer one; we never bind it and never emit on it.
**Behaviour we inherit, all of it upstream bug fixes:**
- Frames now commit per device. 0.6 held one global pending queue, so an
`ei_device.frame` from device A committed device B's timestamped events under
A's timestamp. Inert for us -- we are an EI *sender*, and the events we
receive are device lifecycle plus `KeyboardModifiers`, none of which are the
timestamped kind that queue -- but strictly more correct.
- `Device::interface()` now forgets an interface when the server sends its
`destroyed` event, and `DeviceRemoved` un-registers the device's interfaces
from the converter's reverse map. Our `slot.interface::<ei::Button>()` and
friends therefore stop handing back a proxy for a torn-down interface instead
of emitting into a dead object.
- `Device`, `Seat` and `Object` now hash consistently with their `PartialEq`.
Both were already `Arc::ptr_eq`; `Hash` used the protocol object id, which is
a broken pair. We keep devices in a `Vec` and compare with `==`/`!=`, so this
changes nothing here.
- The wire backend rejects a header length above libei's 1 MiB `max_msglen`
BEFORE waiting for that many bytes, so a malformed length no longer parks the
reader until the connection dies.
**The host graph loses the `futures` facade crate.** reis's `tokio` feature
depended on `futures`; 0.7 depends on `futures-util` directly, which pf-inject
already declares. `cargo tree --target all -i futures` now names only relm4 (the
GTK Linux client), and `futures` + `futures-executor` are gone from
punktfunk-host's Linux tree (`futures-task` stays, under futures-util). Lockfile
delta is one package: `reis` 0.6.1 -> 0.7.1 with `futures` -> `futures-util` in
its dep list; the workspace package SET is unchanged, since relm4 and glib still
need those crates for the GTK client.
Verified on .25 (Ubuntu 26.04, `CARGO_BUILD_JOBS=2`), all rc=0:
cargo build -p pf-inject --locked
cargo clippy -p pf-inject --locked -- -D warnings
cargo clippy -p pf-inject --all-targets --locked -- -D warnings
cargo test -p pf-inject --locked 127 passed, 0 failed, 8 ignored
+ motion_contract: 7 passed, 0 failed
cargo check -p punktfunk-host --locked
cargo clippy -p punktfunk-host --locked -- -D warnings
cargo fmt --all --check
Clippy is run BOTH ways because host CI lints without `--all-targets`.
`cargo tree -i ring` stays empty.
Version currency, but a real API migration rather than a version-number edit — and the
payoff is bigger than "one crate is newer", because jni 0.22 was ALREADY in this .so:
`rustls-platform-verifier` (via quinn-proto, for Android cert verification) depends on it,
so pinning 0.21 here compiled two complete jni copies into one library. Matching the
version collapses them.
Eliminated, measured as the delta in THIRD-PARTY-NOTICES.txt (582 → 571 crates, nothing
added): jni 0.21.1, its `cesu8`, and — because jni 0.21 was the SOLE consumer of
windows-sys 0.45.0, the oldest crate in the tree — that whole windows-rs 0.42 generation:
windows-sys 0.45.0, windows-targets 0.42.2 and its seven per-arch import libraries. Eleven
crates, carried for a `cfg(windows)` dependency of an Android-only bridge.
NOT eliminated, contrary to what the sweep expected — recorded in the manifest so the next
person does not re-derive it. thiserror 1.0 and the jni-sys 0.3/0.4 split both survive,
because jni was never their only source:
thiserror 1.0.69 ← vendor/ndk 0.9.0 (+ asn1-rs/x509-parser, host side)
jni-sys 0.3.1 ← vendor/ndk 0.9.0 AND crates.io ndk-sys 0.6
jni's share of both is gone; the remainder is the ndk stack. jni-sys 0.3.1 is itself a
facade crate over 0.4.1, so the split cannot close until ndk and ndk-sys move, and `ndk` is
vendored for a one-line visibility patch — changing its dependency versions would mean
rewriting the vendored snapshot instead of reading it as a diff against upstream. Left
alone deliberately.
The migration itself, across 66 native methods in 10 files:
* `JNIEnv` split into `EnvUnowned` (the FFI-safe native-method argument) and `Env` (where
the JNI calls live). The 41 methods that never touched the environment are a type
rename; the 22 that do now acquire an `Env` inside `EnvUnowned::with_env` and map the
outcome with an `ErrorPolicy`.
* `LogErrorAndDefault` everywhere, chosen to PRESERVE behaviour: the old code swallowed
JNI errors and returned a default, and this logs and returns the same default. The
throwing policy would have been a behaviour change (new Java exceptions out of methods
that previously failed quietly), which is not what a currency bump should do.
* `with_env` also catches panics, which is exactly what the crate's own `jni_guard` did,
so the guard is folded into it where the two would have nested. It stays on the methods
that take no `Env`. ONE exception, marked at the call site: `nativeNextHidout` returns
-1 as its failure sentinel, and every error policy resolves to `T::default()` — which
for `jint` is 0, a *valid* byte count. That method keeps `jni_guard(-1, …)` outside and
uses `with_env_no_catch` inside so the sentinel survives the panic path unchanged.
* `jboolean` is `bool` in jni-sys 0.4, not `u8` — a type change, not a rename, and the
reason for most of the mechanical diff (`down != 0` → `down`, `return 0` → `false`).
* `Env::get_string` is deprecated in favour of `JString::try_to_string`, and CI runs
clippy with `-D warnings`, so the call sites moved rather than being left to warn.
Likewise `set_/get_*_array_region` → `JPrimitiveArray::set_region`/`get_region`.
* `Env::get_native_interface()` is now `Env::get_raw()` — the raw pointer handed to
`ndk::NativeWindow::from_surface`. The `as *mut _` cast next to it was already commented
as bridging jni-sys skew between `jni` and `ndk`; that skew is now real (0.4 vs 0.3)
rather than hypothetical, so the comment says so.
* Return types moved from raw `jni::sys::jstring`/`jdoubleArray`/`jintArray` to
`JString`/`JDoubleArray`/`JIntArray`, because `resolve()` requires `T: Default` and raw
pointers have no `Default`. All three are `#[repr(transparent)]` over the same
`jobject`, so the exported ABI is unchanged and `Default` IS the null reference the old
code returned explicitly.
Kotlin and Gradle needed NO changes, checked rather than assumed: every affected
`external fun` in NativeBridge.kt already declares `Boolean` / `String?` / `IntArray?` /
`DoubleArray?`, which is what these signatures still present to the JVM, and the Gradle
side only shells out to cargo-ndk without naming a jni version.
Verified on NDK 30.0.14904198, both shipping ABIs, using the environment
clients/android/kit/build.gradle.kts hands cargo-ndk:
cargo clippy -p punktfunk-client-android --all-targets -- -D warnings → ok (host)
cargo test -p punktfunk-client-android → 20 passed
cargo ndk -t arm64-v8a --platform 28 clippy … -- -D warnings → ok
cargo ndk -t armeabi-v7a --platform 28 clippy … -- -D warnings → ok
cargo ndk -t arm64-v8a -t armeabi-v7a --platform 28 build → both .so LINKED
scripts/ci/check-android-jni-imports.sh … 28 → 2 ABI(s) clean at the API-28 floor
That last one matters for this change specifically: a cdylib links with dangling undefined
symbols, so the floor check is the only thing that would catch jni 0.22 hard-importing an
NDK entry point above minSdk 28 — the shape of the 0.9.0 `System.loadLibrary` regression.
It is clean.
`cargo tree -i ring` stays empty on host and on aarch64-linux-android.
Version currency for the three crates that speak core X11: pf-capture's XFixes
cursor source, pf-vdisplay's gamescope splash client, and pf-client-core's
gamescope overlay watcher. Nothing outside the workspace pulls x11rb, so all
three move together and no two versions coexist in the lock.
**The no-libxcb property survives, which is the whole reason those manifests
say `default-features = false`.** 0.14 declares no `default` feature either, so
that flag is still the belt to the braces; `libc` and `as-raw-xcb-connection`
are still optional and still reachable only through `allow-unsafe-code`, and
`dl-libxcb` still requires it too. `cargo tree -e features -i x11rb -p
pf-capture` resolves to exactly `xfixes` -> `render` + `shape` and nothing else,
and neither `as-raw-xcb-connection` nor any other libxcb-linking crate appears
in Cargo.lock. `RustConnection` remains the only connection type, so no host or
client package gains a C dependency.
**One upstream behaviour change does land**, and it is why the `SessionBind` doc
in gamescope.rs moved. 0.14 removed the abstract-unix-socket attempt from
`rust_connection::stream` -- `ConnectAddress::Socket` is now documented as
"Connect to this Unix socket by path" -- so `@/tmp/.X11-unix/X<n>` is no longer
tried ahead of the filesystem path. That doc asserted the ATTACH route's XFixes
cursor reader reached the display over the abstract socket; it now records what
is actually load-bearing. On ATTACH the session belongs to
`gamescope-session-plus`, we arm no bind, its `/tmp` is the real one, and
`punktfunk-host.service` sets no `PrivateTmp` (nor does the NixOS host unit), so
`/tmp/.X11-unix/X<n>` is exactly where `DISPLAY` says it is. The two conditions
that would have needed the abstract fallback still cannot coincide: the bind
only arms for a resolved `punktfunk-gamescope`, whose patch level 2+ makes
`SessionPlan::gamescope_cursor` false and the reader is never spawned. The
splash client is gamescope's own nested child, inside the namespace, and reads
the bound directory directly. If those ever do have to coexist the reader logs
and retries forever and the stream runs without a composited pointer -- the doc
now says so instead of promising a fallback that no longer exists.
The rest of the 0.13.2 -> 0.14.0 delta is inert here: `AtomEnum::CUT_BUFFE_Rn`
was respelled `CUT_BUFFERn` (unused), the optional `raw-window-handle` went
0.5 -> 0.6 and `libloading`'s range widened (both features off), and the MSRV
moved 1.64 -> 1.68 against a 1.96 toolchain.
Verified on .25 (Ubuntu 26.04, `CARGO_BUILD_JOBS=2`), all rc=0:
cargo build -p pf-capture -p pf-client-core -p pf-vdisplay --locked
cargo clippy -p pf-capture -p pf-client-core -p pf-vdisplay --locked -- -D warnings
cargo clippy -p pf-capture -p pf-client-core -p pf-vdisplay --all-targets --locked -- -D warnings
cargo test -p pf-capture --locked 68 passed, 0 failed
cargo test -p pf-vdisplay --locked 210 passed, 0 failed, 3 ignored
cargo check -p punktfunk-host --locked
cargo fmt --all --check
Clippy is run BOTH ways on purpose: host CI lints without `--all-targets`, so a
`#[cfg(test)]`-only import would pass the local run and fail the shipping build.
`cargo tree -i ring` stays empty.
aes 0.9 runtime-detects the ARMv8-Crypto backend on aarch64 via `cpufeatures` and polyval 0.7
picks its armv8 PMULL intrinsics by target_arch, so neither cfg exists any more — passing them
is inert. That retires a real footgun rather than tidying a file: a RUSTFLAGS env var overrides
config rustflags ENTIRELY, so every aarch64 lane that set its own (cargo-ndk does so internally
for every Android arm64-v8a build) silently dropped both and ran SOFTWARE AES on the per-packet
decrypt path.
Measured before deleting, `crypto/open_in_place` (1408-byte MTU shard, AES-128-GCM, single core,
Mac15,14 M3 Ultra, four runs back to back under identical background load):
aes 0.8 + both cfgs 2.19 GiB/s
aes 0.8, cfgs stripped 225 MiB/s ~10x cliff — reproduces the recorded ~240 MiB/s
aes 0.9 + both cfgs 5.28 GiB/s
aes 0.9, cfgs stripped 5.28 GiB/s identical to 4 s.f.
The ChaCha20-Poly1305 series of the same bench was the control and moved 0.07% across the cfg
toggle at both versions, so the toggle demonstrably reached only the AES path. A final run with
the flags actually deleted (not merely RUSTFLAGS-overridden) reproduced 5.29 GiB/s.
.cargo/config.toml is kept as a tombstone carrying that table so the flags are not reintroduced.
The two CI comments that warned about losing these cfgs to a RUSTFLAGS override are updated —
mold in ci/cargo-config-mold.toml is now the only thing such an override can cost.
Twelve skia-safe releases (0.88 … 0.99), carrying Skia milestones 140 through
150, every one of them breaking under 0.x semver. Only three of those changes
actually reach this crate — the Vulkan surface/backend-texture path, the
textlayout/paragraph typography and RuntimeEffect all came through untouched:
* m143 (skia-safe 0.91) DELETED SkPath's mutating API. `Path::new()` followed by
`move_to`/`line_to`/`arc_to`/`close` no longer compiles at all — geometry is
built through `PathBuilder` now and frozen with `snapshot()`/`detach()`. That
is the entire error list: 34 E0599s over eight call sites (the hint-bar
triangles and the PlayStation triangle in `glyphs.rs`, the chevron / space /
backspace / check icons in `widgets.rs`, the padlock shackle in
`screens/home.rs`). Each becomes a `PathBuilder` detached at the draw call, so
the path is still built and thrown away once per draw exactly as before.
* 0.93 deprecated the `gradient_shader` module in favour of `gradient`. Only a
warning, but the Format/clippy gate runs `-D warnings`, so it is a hard break
for us. The three gradients — the panel stroke in `theme.rs`, the
connect-overlay vignette in `shell/overlays.rs`, the host monogram in
`screens/home.rs` — now build a `gradient::Gradient` from
`gradient::Colors::new_evenly_spaced` plus `Interpolation::default()`. That
default (unpremul interpolation, destination colour space, shorter hue) is
what the old `flags: None` argument mapped to, so the pixels do not move. The
new API takes `Color4f` directly, which drops the `.to_color()` 8-bit
round-trip the old signature forced.
* 0.98 deprecated `vk::BackendContext::new` in favour of a builder (upstream
#1292). `skia_overlay.rs` now calls
`BackendContext::new_builder(...)` + `build()`, passing `max_api_version:
None` so Skia keeps deriving its cap from `vkEnumerateInstanceVersion()` —
bit-for-bit what `new()` passed. `build()` is the unsafe half, so the SAFETY
proof moved down onto it.
`ash` is untouched and stays on the workspace's exact `=0.38.0+1.3.281` pin:
skia-safe lists ash only as a DEV dependency, so the bump cannot reach it.
The prebuilt-binary assumption still holds — verified from the build log, not
from the release page: skia-bindings printed `DOWNLOAD AND INSTALL SUCCEEDED`
for
`skia-binaries-a25a0fdb7d90429aa2d1-x86_64-unknown-linux-gnu-jpegd-jpege-pdf-textlayout-vulkan`,
so no CI leg compiles Skia from source. The asset name DID change: `jpeg` joined
skia-safe's default feature set between 0.87 and 0.99, so `jpegd-jpege` is now
in the name. We take defaults, so the JPEG codecs came along — which is a fix in
disguise, since `screens/library.rs` hands host poster art straight to
`Image::from_encoded`, and JPEG posters used to fall out as "undecodable". The
Cargo.toml comment now records the verified asset names and the silent-source-
build trap for the next bump.
Verified on 192.168.1.21, x86_64-unknown-linux-gnu, toolchain 1.96.0:
cargo build -p pf-console-ui exit 0
cargo clippy -p pf-console-ui --all-targets -- -D warnings exit 0
cargo test -p pf-console-ui 82 passed, 1 ignored
cargo fmt --all --check exit 0
These six share the `crypto-common` and `digest` traits, so they move as ONE change — a
partial bump leaves crates on incompatible trait generations that cannot interoperate.
The point is to delete a footgun, not for version hygiene. `aes` 0.8 only enabled ARMv8
hardware AES on aarch64 behind `--cfg aes_armv8`, and `polyval` 0.6 gated its PMULL GHASH
path behind `--cfg polyval_armv8`. A RUSTFLAGS env var OVERRIDES config rustflags
ENTIRELY, so any aarch64 lane that sets its own (cargo-ndk does this internally for every
Android build) silently dropped both and fell back to software AES on the per-packet
decrypt path. `aes` 0.9 runtime-detects via `cpufeatures` on aarch64 and `polyval` 0.7
selects its armv8 intrinsics backend by target_arch, so neither cfg exists any more.
API changes this generation forces:
- `AeadInPlace` -> `AeadInOut`; `{encrypt,decrypt}_in_place_detached` ->
`{encrypt,decrypt}_inout_detached` taking an `InOutBuf`.
- `generic-array` -> `hybrid-array`: `Array::from_slice` is deprecated in favour of the
infallible `&[u8; N] -> &Array<u8, UN>` reference cast, or `TryFrom` for runtime slices.
- `Mac::new_from_slice` moved to `KeyInit::new_from_slice`.
- `BlockEncrypt`/`BlockDecrypt` -> `BlockCipherEncrypt`/`BlockCipherDecrypt`;
`BlockEncryptMut` -> `BlockModeEncrypt`; `encrypt_padded_vec_mut` -> `encrypt_padded_vec`.
`rsa` 0.9 is the one crate that cannot come along: it is built on `digest` 0.10 and its
0.10 line is still release-candidate only, which is not something the Moonlight pairing
ceremony should ride. Its `sha2` feature re-exports the digest its own traits speak, so the
three sites where a digest appears as an `rsa` TYPE PARAMETER (cert.rs, pairing.rs, tls.rs)
now take `rsa::sha2::Sha256` explicitly; everything else in the crate is on sha2 0.11.
The GameStream wire formats are untouched — AES-128-ECB no-padding, the CBC audio path, and
the GCM control-stream seal all keep their exact byte behaviour; only the type plumbing moved.
Version currency for the SCM plumbing behind `punktfunk-host service` (the
dispatcher, control handler and ServiceManager install) and the tray's
unprivileged QUERY_STATUS probe. No code changed in either crate.
The payoff is dependency unification, not the API. `windows-service 0.7` was the
ONLY crate in the workspace still pulling `windows-sys 0.52`, so it alone kept a
fourth windows-sys major compiling. It resolves to 0.8.1, which moves to
`windows-sys 0.61` — a version the tree already builds — and the duplicate
disappears:
cargo tree -d --target x86_64-pc-windows-msvc | grep '^windows-sys v'
before: 0.45.0, 0.52.0, 0.59.0, 0.61.2
after: 0.45.0, 0.59.0, 0.61.2
Note 0.8.0 would NOT have been enough — it lands on windows-sys 0.59. 0.8.1 is
the release that reaches 0.61, hence the `"0.8"` caret plus the comment pinning
the reasoning to the manifest.
The 0.7 -> 0.8 delta is tiny and touches nothing this tree calls: `ServiceAccess`
gains READ_CONTROL / WRITE_DAC / WRITE_OWNER (additive), and `Service::raw_handle`
changes return type from `Security::SC_HANDLE` to `Services::SC_HANDLE` as a
consequence of the windows-sys bump — we never call it. `ScHandle` is crate-private
upstream. No enum gained variants, and the service control handler's match already
ends in a `_ =>` arm, so the `#[non_exhaustive]` types stay safe.
What remains duplicated (deliberately out of scope here): windows-sys 0.45 via
`jni`, and 0.59 via `punktfunk-core` + `if-addrs`.
Version currency for the Android client's only `log` backend. No code change: 0.15 is
almost entirely an internal refactor (the single `lib.rs` split into `config`/`arrays`/
`id`/`platform_log_writer`/`tests`), and the surface this crate uses — `init_once`,
`Config::default`, `with_max_level`, `with_tag` — is untouched. The lockfile delta is the
version and checksum alone: no dependency was added, removed or re-resolved, and the
third-party crate count stays at 582.
The one thing 0.15 adds that we must NOT take is recorded next to the dependency: the new
opt-in `android-api-30` feature filters levels through `__android_log_is_loggable_len` so
logcat's `setprop log.tag.*` overrides are honoured, but it HARD-LINKS that API-30 symbol.
Against our minSdk-28 floor that is a `System.loadLibrary` failure on Android 9/10 — the
identical shape of the ndk 0.9.0 `AMediaCodec_setOnFrameRenderedCallback` break the manifest
already warns about a few lines further down. Default features keep it off; the comment
explains why so nobody "completes" the upgrade by enabling it.
Verified with cargo-ndk (NDK 30.0.14904198), which is the only way to exercise this crate at
all — `android_logger` sits behind `cfg(target_os = "android")`, so the host workspace build
never compiles it:
cargo ndk -t arm64-v8a check -p punktfunk-client-android → ok
cargo ndk -t armeabi-v7a check -p punktfunk-client-android → ok
Checked, not built: these are `cargo check` runs, not a linked `.so` and not an APK.
Version currency for the crate behind the host's WASAPI loopback capture and
virtual mic, and the Windows client's render/capture path. No behavior change.
The 0.23 -> 0.24 API delta is almost entirely additive (device-change
notification callbacks, `AudioMeterInformation`, `HardwareSupport`, `DeviceState:
Clone + Copy`). The single removal is `AudioClient::get_bufferframecount`,
deprecated since 0.17 in favour of `get_buffer_size` — this tree never called it,
so no call site moved.
0.24 also fixes upstream the dangling-`PCWSTR` bug this tree routes around in
five places: `DeviceEnumerator::get_device` used to build its argument as
`PCWSTR::from_raw(HSTRING::from(id).as_ptr())`, dropping the `HSTRING` at the end
of that statement so `GetDevice` read freed memory. Those five comments asserted
the bug in the PRESENT tense and are now wrong, so they are corrected here rather
than left to mislead. The workarounds themselves STAY: `open_wasapi_device` is
still the one resolution path whose errors name the endpoint id and whose
`IMMDevice` `probe_activation` needs, and `device_by_id` additionally filters to
ACTIVE endpoints (`EnumAudioEndpoints(dir, DEVICE_STATE_ACTIVE)`), which the
crate's `get_device` does not. Removing them is a behavior change, not currency.
⚠ This does NOT collapse the duplicate windows-rs. wasapi 0.24 still depends on
`windows ^0.62` / `windows-core ^0.62` exactly as 0.23 did, so the crates.io
`windows 0.62.2` still sits alongside the pinned git copy that `clients/windows`
uses. That duplicate costs build time and binary size, not correctness, and the
blanket `[patch.crates-io] windows` that would collapse it stays ruled out — the
pinned rev uses header-named features while a dozen manifests still use the old
`Win32_*` namespace features.
PR #192 moved the rustls backend to aws-lc-rs and merged before CI reported.
Two of the things it changed here shipped with no assertion behind them.
`generate()` mints the RSA-2048 host identity and runs ONLY when no cert
exists, so no upgraded box ever re-executes it — a fresh install is the
only thing that would have found a regression. It was reached by other
tests via `ServerIdentity::ephemeral()`, but purely as a fixture: nothing
checked that what came back was still RSA-2048, which is the one property
Moonlight requires. The handshake behaviour had no coverage at all, and
the GameStream TLS path is the single place a legacy peer meets the new
backend.
Three tests:
- generate_mints_a_loadable_rsa2048_identity — the fresh-install path,
asserting the cert signature is 256 bytes (RSA-2048) rather than
depending on an `rsa` accessor that could change shape.
- moonlight_shaped_peer_completes_a_tls12_mutual_handshake — a peer that
pins out of band, as Moonlight does, presenting an RSA-2048 client cert
against the real `tls::server_config`.
- tls13_negotiates_the_post_quantum_group — pins X25519MLKEM768 by name,
so a provider or feature regression that silently drops ML-KEM back to
a classical curve fails here instead of in the field.
Also corrects the comment on `generate()`. It opened by asserting the
workspace is ring-only because aws-lc-sys breaks Windows CI, and explained
that rcgen's *ring* backend is what loads the RSA key. Both are now false:
rcgen is on aws_lc_rs and loads and self-signs the key fine — verified, not
assumed. rcgen still cannot GENERATE an RSA key on either backend, which is
the part of the comment that remains true and load-bearing.
Verified on Linux (Ubuntu 26.04, x86_64): 3/3 pass, clippy clean both with
and without --all-targets (host CI lints without it), and the native-only
`--no-default-features --features pyrowave` build still checks clean — the
whole module is gamestream-gated, so it compiles out there.
THIRD-PARTY-NOTICES regenerated after the dependency changes (582 crates).
audit.yml's header claimed to cover "EVERY dependency tree the project ships"; it now
actually does, so the note spells out that each Rust lockfile needs its own `--file` —
a bare `cargo audit` reads only the root one, which is how the drivers lock stayed
unscanned while already sitting in this job's `paths:` filter. Also corrected "BOTH
Rust workspaces" for the licence gate, which covers the host + driver workspaces.
Both cargo-about legs re-run after the dependency removals: RC=0.
Acting on the 2026-08-13 dependency sweep. Every claim below was re-verified against
the tree before acting on it (greps carry a positive control; the advisories were
re-checked with cargo audit 0.22.2).
SECURITY
- event-listener 5.4.1 -> 5.4.2 (RUSTSEC-2026-0221, unsound Send/Sync on StackSlot;
reaches the tray via zbus and the host via ashpd). This sat unnoticed because
`cargo audit` reports unsoundness as a WARNING and the job fails only on
vulnerabilities — audit.toml now says so out loud.
- spin 0.9.8 -> 0.9.9. 0.9.8 is YANKED and was genuinely compiled (flume via mdns-sd
and relm4, plus lazy_static).
- wayland-scanner 0.31.10 -> 0.31.11, which moves quick-xml 0.39 -> 0.41. That is the
exact trigger audit.toml documented for RUSTSEC-2026-0194/0195, so both ignores are
deleted rather than left as permanent exceptions. Only RUSTSEC-2023-0071 (rsa
Marvin, still unfixed upstream) remains.
- Corrected audit.toml's claim that `paste` arrives "via utoipa-axum": rav1d pulls it
too, so every client has it through the decode path and dropping utoipa-axum would
not have cleared it.
TWO CI GATES THAT SCANNED NOTHING
- `cargo audit` only ever reads the ROOT Cargo.lock. The drivers lock was already in
this job's `paths:` filter, so edits to it triggered a run that then ignored them.
All four secondary workspaces now get an explicit `--file` (verified: clean, bar the
known `paste` warning in drivers).
- packaging/windows/pf-vkhdr-layer had NO lockfile at all while shipping as a DLL in
the host installer, so every build resolved fresh and neither cargo-audit nor
cargo-about ever saw it. Lockfile generated and committed, and added to `paths:`.
UNUSED / DUPLICATE DECLARATIONS
- punktfunk-host: removed 13 dependencies it never references — the Wayland stack
(client, protocols{,-wlr,-misc}, scanner, backend), xkbcommon, reis, khronos-egl,
ash, usbip-sim, parking_lot, bytemuck. The code moved to pf-inject and pf-zerocopy
in the subsystem extraction and those crates declare them; only the manifest entries
and their now-false comments stayed. Also dropped four redundant re-declarations
(tokio/serde_json/futures-util in the Linux block, tower in dev-deps).
- Removed genuinely unused: bytes (punktfunk-core), anyhow (pf-win-display),
tracing (clients/cli), anyhow (clients/session), serde (clients/windows).
- Removed the high-level `wdk` crate from all five driver crates and the drivers
workspace: none of them ever referenced `wdk::` (62 `wdk_sys::` uses; pf-umdf-util
is a full WDF crate that never declared it). `tracing`/`tracing-subscriber` remain
in that lock afterwards but ONLY as wdk-sys build-dependencies, not in the DLLs.
- pf-win-display took punktfunk-core with `quic` for one type (`Mode`) that lives in
the ungated `config` module; now `default-features = false`, which keeps
quinn/tokio/rcgen/opus out of a leaf crate's declared closure.
- pf-encode declared the windows-rs feature `Wdk_Graphics_Direct3D` for a call that
lives in pf-frame and is resolved via GetProcAddress on gdi32.
LATENT BREAKAGE (compiled only by feature unification)
- pf-inject uses `tokio::select!` without declaring `macros` (borrowed from
punktfunk-core's quic feature); pf-capture uses `tokio::sync::oneshot` without
declaring `sync` (borrowed from ashpd->zbus); pf-client-core uses the `minwindef`
and `winnt` windows-rs headers without declaring them (borrowed from
clients/windows). Each now declares what it uses, so an unrelated crate changing its
features cannot break them.
- pf-console-ui took pf-client-core WITHOUT `default-features = false`, unlike every
other consumer. That default is `pyrowave`, which compiles the vendored PyroWave C++
— "fatal on Windows ARM64". Only safe today because the ARM64 leg passes
--no-default-features (which also drops `ui`).
CORRECTED A FALSE INVARIANT
- clients/windows claimed "the workspace builds ONE windows-rs". It does not: wasapi
pulls the crates.io windows 0.62.2 beside the git-rev copy. The invariant that DOES
hold is narrower (reactor and that crate share one rev, which is what makes the
IDXGISwapChain1 hand-off type-check). Comment rewritten, with a warning against
"fixing" it via a blanket [patch.crates-io] — this rev uses header-named features
while a dozen other manifests use the old Win32_* namespace ones.
Plus the safe in-compat `cargo update` sweep (no manifest edits).
Verified on macOS: punktfunk-core 385, pf-update-check 32, c_abi 1 (with
LIBRARY_PATH=/opt/homebrew/opt/opus/lib), cargo audit clean bar the two known
unmaintained warnings. Linux and Windows legs follow.
Both failures found running the store + plugin-launch tests on the Windows runner
after the ureq 3 port. Neither is a production defect — the request/response round
trip and the 304 semantics both hold — but both tests were resting on assumptions
that ureq 2 happened to tolerate.
catalog::ureq_returns_304_as_ok: the stub answered without ever reading the request.
Closing a socket that still holds unread received data makes Windows send an RST
rather than a FIN, which discards the response already written, so the client saw a
transport error (os error 10053) instead of the 304 the test exists to pin. The stub
now drains the request first. The pinned behaviour is unchanged and still true:
ureq 3 turns only `is_client_error() || is_server_error()` into Err, so 304 arrives
as Ok exactly as before.
plugin_launch::asks_the_registered_plugin_and_takes_its_answer: hardcoded a cwd of
`/opt/emu`, which has no drive letter and is therefore NOT `Path::is_absolute` on
Windows, so `validate_reply` refused the recipe. This test could never have passed
on Windows, with either ureq version — its sibling
`a_working_directory_must_be_absolute` already had the `cfg!(windows)` split and this
one was simply missed. Confirmed by diagnostic before touching it: the body came back
over ureq 3 byte-perfect, so everything up to validation was working.
`about.toml` carried `OpenSSL` in the global accepted list and a `[ring]` per-crate
acceptance, both there solely because ring's licence is an AND that includes the
OpenSSL terms. The ureq 2 -> 3 upgrade removed ring from every target we build, and
aws-lc-sys 0.44's SPDX (ISC AND (Apache-2.0 OR ISC) AND Apache-2.0 AND MIT AND
BSD-3-Clause AND ... MIT-0) carries no OpenSSL clause, so neither entry has anything
left to permit.
Note about.toml sets no `targets`, so cargo-about still walks quinn-proto's wasm-only
ring edge — removing the exception is safe not because ring is invisible but because
ring 0.17.14 declares `Apache-2.0 AND ISC`, and both are globally accepted already.
Verified by running the gate itself, both legs of what audit.yml runs:
cargo about generate about.hbs --fail -> 0
cargo about generate -m packaging/windows/drivers/Cargo.toml -c ... -> 0
and proved non-vacuous with a negative control: dropping "ISC" from the accepted
list makes the first leg exit 1.
THIRD-PARTY-NOTICES regenerated: 601 -> 580 crates (ureq 3 pulls a smaller tree than
ureq 2 + ring), now listing ureq 3.4.0.
The aws-lc-rs move left `ring` compiled in for one reason: ureq 2 names
`features = ["ring", ...]` inside its own `[dependencies.rustls]` block, and cargo
features are additive, so no dependent could switch it off. ureq 3 declares rustls
with `default-features = false` and selects no backend, which finally makes the
choice ours.
`cargo tree -i ring` is now empty for macOS, Windows and Linux. The one remaining
hit under `--target all` is quinn-proto's wasm-only dependency
(`cfg(all(target_family = "wasm", target_os = "unknown"))`), a target we never build.
⚠ The dependency must be spelled `features = ["rustls-no-provider",
"rustls-webpki-roots"]`. ureq 3's convenience `rustls` feature expands to include
`_ring` — the same shape of trap as rustls's own `features = ["ring"]`, and the
reason punktfunk-webos still carries both backends today.
Ported 9 files. The two pinning call sites (the desktop client's library fetch and
the tray's status poll) needed a custom ureq Connector, because ureq 3's `TlsConfig`
exposes roots/client-cert/off-switch but no hook for a custom ServerCertVerifier.
That glue lives once in punktfunk-core behind a new off-by-default `ureq-tls`
feature rather than being hand-rolled twice on a trust boundary; the Apple/Android
cdylib embedders do not enable it and pull no HTTP stack. The connector is modelled
on ureq's own crate-private RustlsConnector and is transport glue only.
Two behaviour changes worth noting, both improvements:
- Body caps are enforced by the reader, so an over-cap response is now an Err rather
than ureq 2's silent truncation — which used to surface as a signature failure
pointing at the wrong thing.
- A pin mismatch matches ureq 3's typed `Error::Rustls(..)` instead of sniffing a
substring out of a transport message, which could also fire on unrelated cert
errors.
Conditional requests are unchanged: 304 still arrives as Ok (only 4xx/5xx are Err),
which the existing `ureq_returns_304_as_ok` socket test still pins.
Also removed four now-dead `std::io::Read` imports. One of them (plugin_launch) is
still needed by its test module, so it moved there rather than being deleted: host
CI lints without `--all-targets`, so a top-level import used only under cfg(test)
fails the shipping build.
Verified on macOS: punktfunk-core (quic + ureq-tls) checks clean, pf-update-check
32/32, cargo fmt clean.
The workspace pinned `ring` everywhere because aws-lc-sys 0.41.0 failed to C-compile
on the Windows CI runner. Re-tested on that runner (.133) with aws-lc-sys 0.44.0: the
`params.c` cl.exe failure does not reproduce under MSVC 14.44, and rustls's `aws_lc_rs`
feature turns on `aws-lc-rs/prebuilt-nasm`, so no NASM is needed on the box either.
That unblocks post-quantum TLS: `prefer-post-quantum` offers X25519MLKEM768 first on
every TLS 1.3 handshake (mgmt API, native control plane, QUIC), which ring cannot do —
it has no ML-KEM. Classical curves stay in the list, so older clients still connect.
rustls, quinn, rcgen and tokio-rustls each select a backend independently, so all four
had to move together; a single dissenter pulls a second crypto stack in via feature
unification. The direct `ring` users (ed25519 in pf-update-check, SHA-256 in the Windows
updater) moved to aws-lc-rs, whose API is ring-compatible.
`ring` does NOT leave the tree: ureq 2 names `features = ["ring"]` in its own rustls
dependency line and cargo features are additive, so no dependent can switch it off. Two
backends compiled in means rustls refuses to infer one, and anything built via
`ClientConfig::builder()` panics instead of picking — which is what ureq's default agent
does on its first HTTPS request. `tls::install_default_provider()` makes the choice
explicit; it runs at each binary's entry point and defensively in pf-client-core, which
several binaries link. Dropping ring entirely needs the ureq 2 -> 3 upgrade (36 call
sites), deliberately left out of this change.
Verified on macOS: pf-update-check 32, punktfunk-core 385, c_abi 1 (the last with
LIBRARY_PATH=/opt/homebrew/opt/opus/lib) — aws-lc-sys links into the C ABI harness, so
the Swift/Kotlin embedders keep working. cargo fmt --all --check clean.
The floor that matters is the DESKTOP, not the package: 24.04 installs
punktfunk-host and then has no compositor over the version floors and no
gamescope, which reads as a bug rather than an unsupported base. The
requirements page now leads with a per-release table separating 'package
installs' from 'can actually host', and install.md carries the same
caveat next to the apt row.
Debian was already a supported target after the previous commits but was
still invisible at the entry points — README's install table, the docs
index cards, and the 'what you need' list all said Ubuntu only. All three
now name Debian and carry the version floor.
Measured on a real linuxmintd/mint22-amd64 image and on Ubuntu 24.04. The
package installs on both, which is exactly what makes this easy to miss —
nothing on the box can then produce a stream:
* Cinnamon cannot host a virtual display (Muffin has no RecordVirtual).
* gamescope is absent from 24.04 and cannot be built for it: the tree needs
wayland >= 1.23.1 (has 1.22.0), libinput >= 1.26 (1.25), libavif >= 1.2.1
(1.0.4), pixman >= 0.44 (0.42), plus libdisplay-info2 and libxcb-errors0,
neither of which 24.04 packages at all.
* Switching desktop does not rescue it — 24.04 has KWin 5.27 (floor 6.5.6)
and GNOME Shell 46 (floor 48). Only sway 1.9 is even a candidate.
So the gamescope route documented for Cinnamon holds for LMDE 7 (Debian 13,
verified end to end) but NOT for Linux Mint 22.x — which is every mainstream
Mint until Mint 23 lands on a 26.04 base in December 2026. Both the Debian and
Ubuntu pages now say so, and the Debian page carries a per-edition table.
Also states what Debian 13 itself can drive: GNOME 48.7 and sway 1.10 are above
the floors; its KWin 6.3.6 is below.
Two merges, both of which exist to express an ordering Gitea cannot express across
files, and both of which delete a duplicated build.
release.yml -> apple.yml (as the `distribute` job)
The name described neither what it did (Apple only — every other platform's release
is its own packaging workflow attaching to the same Gitea release on a v* tag, with
announce.yml as the manual "go") nor anything a reader would guess. The name was the
smaller problem. Gitea has no cross-workflow `needs`, so nothing sequenced it against
apple.yml's tests: a canary main push uploaded iOS, macOS and tvOS builds to
TestFlight even when `swift test` had just failed on that same commit. It is now
`needs: swift`, which is only expressible in one file.
The two files' paths: filters had also drifted — apple.yml watched crates/**,
release.yml watched crates/punktfunk-core/**. The merged filter takes the NARROW one,
because that is the correct one: everything on this runner is built from
punktfunk-core via build-xcframework.sh, and punktfunk-core's only path dependency is
its own vendored fec-rs. That is checkable in one command, and the header says so, and
says to widen it if that ever stops being true. Net effect on the shared mac mini:
pushes that touch host-side crates no longer build or upload anything Apple.
windows.yml + windows-msix.yml -> windows-client.yml
The pair built the same three crates FOUR times per client push on ONE runner: debug
x64 + arm64 for lint/test, release x64 + arm64 for packaging. windows-host.yml already
records why a second (debug) dep tree on this machine is a liability rather than a
cost — it re-runs openh264-sys2's vendored C++ through cc-rs's cl.exe fan-out and tips
the runner into C1069, which is disk exhaustion wearing a compiler error's clothes. So
there is one release build per arch now and clippy/fmt/test run against it, exactly as
windows-host.yml does. The paths list went from three copies to one; PRs get the
build/lint/test signal and stop before packaging.
The rename is safe, and this is worth recording because the GitHub instinct is wrong
here: `github.run_number` is REPO-WIDE in Gitea, not per-workflow — consecutive runs of
DIFFERENT workflows get consecutive numbers (verified against the API: android 13226,
apple 13227, arch 13228, ci 13229, deb 13230). The canary MSIX version <minor>.<run>.0
and Apple's CURRENT_PROJECT_VERSION therefore keep climbing across a rename. On GitHub
the same rename would reset both to 1, sorting every new canary below the published ones
and getting the TestFlight uploads rejected outright.
25 workflows, down from 27, and every `name:` now matches its filename. Cross-references
in windows-host.yml, windows-drivers.yml, android.yml, flatpak.yml, sbom.yml, the
provisioning scripts, gitea-release.sh and clients/windows/packaging/README.md updated.
The Nitro console bundle is a pure function of web/ and sdk/, and it was being built
six times on every push: ci.yml, deb, both RPM legs (f43 + f44), arch, and the docker
app image, at roughly 2.5 min each. windows-host.yml has cached it on exactly this
shape for a while — this extends the same arrangement to the Linux packaging legs,
sharing one key family so whichever job builds it first warms the others.
The bun version is part of the key. Each builder image runs the bun.sh installer at
image-build time, so rust-ci, fedora-rpm and arch-ci can drift apart; keying on it
means they share while they agree and simply stop sharing when they do not, rather
than one image's bun silently producing the bundle another image ships.
Each packaging path needed a different hand-off:
* deb — build-web-deb.sh already builds only if web/.output is missing, so the
restore alone is enough; the workflow's build+smoke step is now gated on
the miss.
* arch — makepkg builds with PF_SRCDIR pointing at the workspace, so a restored
bundle is already where it needs to be. PKGBUILD gains the same
build-if-missing guard the deb script has.
* rpm — neither direction works by default. build-rpm.sh packages a `git archive`
tarball and web/.output is gitignored, so a bundle in the workspace is
invisible to rpmbuild; and the spec's own build lands in rpmbuild's
%{_topdir}, which build-rpm.sh mktemps and removes on EXIT, so a console
built there is gone before the cache's post step and the cache would never
populate — every run a miss that quietly rebuilt. So the workflow builds it,
and hands it over by absolute path through a new optional `pf_prebuilt_web`
macro. Undefined (plain rpmbuild, COPR) takes the original build path.
Every path asserts the bundle exists and carries the Bun.serve marker, on cache hits
too. A cache is one more place a wrong artifact can come from, and the packaging
scripts' build-if-missing behaviour — correct for a local build — would otherwise turn
a broken restore into either a silent rebuild or, with the build step skipped, a
package with no console in it. That is not hypothetical: windows-host.yml shipped
0.22.1 and 0.22.2 with no console because an unset path variable was handled by a
single Write-Host, which is why its equivalent step throws.
Three independent reasons Rust CI stayed slow despite sccache, fixed together because
they share the same measurement.
1. sccache only ever covered RUSTC. Every C/C++ dependency in the tree — aws-lc-sys,
openh264-sys2's vendored C++, the CMake-built libopus behind audiopus_sys — was
compiled from scratch on every job of every workflow. CMAKE_{C,CXX}_COMPILER_LAUNCHER
plus CC_/CXX_x86_64_unknown_linux_gnu route both build-script styles (cc-rs and
cmake-rs) through the same shared cache.
The CC_* vars are JOB-scoped in ci.yml and deb.yml, never workflow-scoped: the
arm64 cross image sets its own CC_x86_64_unknown_linux_gnu=pf-host-cc, the wrapper
that keeps ffmpeg-sys-next's host probe off the arm64 include dirs. Overwriting it
would surface as a header mismatch rather than as a CI config error.
2. Linking is cacheable by nothing, and these jobs relink the host, client, session,
cli, worker and tray on every run — twice per push for rpm (f43 + f44). The four
Linux builder images now install mold and carry a $CARGO_HOME/config.toml that uses
it for x86_64. aarch64 is deliberately left alone (cross driver, already-fast legs).
Each image asserts `mold --version` in its build, so an image can never ship the
flag without the linker: docker.yml goes red and :latest stays on the last good one.
3. THE EXPENSIVE ONE. ci.yml (debug) and deb.yml (release) named a byte-identical
target-cache key, under a comment claiming the release build reused ci.yml's
artifacts. It never could. actions/cache is first-saver-wins on an exact key and
ci.yml is the faster job, so the shared key always held a debug-only target/ — and,
worse, deb.yml could then never save its own, because the key was taken. Every
canary .deb has been a from-scratch release build for as long as both keys existed.
Same collision on the arm64 pair, and a third participant in
linux-client-screenshots.yml. Split into -debug-/-release- key families; that job
reads deb's tree via restore-keys but keeps its own exact key so it can never win
the save race and replace a full tree with its single-crate one.
Also: one scripts/ci/ensure-sccache.sh replaces ten copy-pasted bootstrap blocks that
had already drifted into two dialects (GNU tar --wildcards vs bsdtar), every Rust job
now ends with --show-stats so a cache regression is visible instead of just "CI got
slower", and deb.yml's web install joins every other CI install on --ignore-scripts.
No behaviour change to any artifact: same compilers, same flags, same outputs.
PUNKTFUNK_COMPOSITOR=cinnamon is the first thing a Mint or LMDE user
reaches for, and the bare list of accepted values invites the
next-closest guess — 'mutter', since Muffin is a Mutter fork — which
starts a session that fails deep inside an org.gnome.Mutter.ScreenCast
call Muffin does not serve. No value of the variable can work, so say
that and name headless gamescope, which needs no desktop compositor.
software-properties-common is not available in Debian 13, so the
apt-add-repository line could not have worked. Debian 13 keeps its
sources in deb822 format; edit Components there instead (verified in a
trixie container — the NVIDIA driver it then offers is 550, above our
535 floor).
`punktfunk-gamescope` had never been published to the apt registry — not in any
release. It was built inside the host job's Ubuntu 24.04 image, where it cannot
build: our pin vendors wlroots 0.19.3, which floors `wayland-server` at 1.23.1,
and noble ships 1.22.0 (it also lacks libxcb-errors-dev and has only
libdisplay-info 0.1.1). Every rung of that path was a `::warning::` returning 0
and the one hard gate ran last by design, so v0.26.0 and v0.27.0 both released
with the package missing while docs-site told apt users to install it. The same
tags shipped it fine for Arch, Fedora 44 and Bazzite.
It now builds in its own job on Debian 13 (ci/gamescope-trixie.Dockerfile), the
oldest apt base the tree configures on. One package serves Debian 13 AND Ubuntu
26.04 — measured by installing and running it on both — because the build also
vendors libdisplay-info via the new `--extra-fallback` option: linked against
the distro copy it demands `libdisplay-info2` on trixie, which Ubuntu 26.04 does
not have (it carries libdisplay-info3). The option is opt-in, so the
Arch/Fedora/nix outputs are byte-for-byte unchanged. Ubuntu 24.04 gets no
gamescope package and cannot — its wayland is too old to run one however built.
Debian 13 is now a documented host target. That needed no packaging change at
all: the host .deb's glibc-2.39 floor and bundled FFmpeg already made it
installable, and it had been working for a long time while docs-site said Debian
was unsupported and unverified. Verified by installing: host, web console and
plugin runner install, resolve every soname and run. The desktop client stays
Ubuntu-26.04-only (built there, floors at `libc6 >= 2.43`; Debian 13 has 2.41).
Compositor detection now answers Cinnamon (Mint, LMDE) with the route that works
instead of advice that cannot help. Muffin forked from Mutter 3.36:
`org.cinnamon.Muffin.ScreenCast` has only RecordMonitor/RecordWindow, never
RecordVirtual, and xdg-desktop-portal-xapp implements no ScreenCast — so no
value of PUNKTFUNK_COMPOSITOR makes a Cinnamon desktop host a virtual display.
The error names headless gamescope, which needs no desktop compositor. The XDG
sniff moved into a pure function so those branches are testable; Cinnamon is
matched before GNOME, since it is a GNOME derivative and the generic arm would
otherwise hand it the Mutter backend (caught by the new test).
New `smoke-install` job installs every published package from the registry in
pristine ubuntu:24.04, ubuntu:26.04 and debian:trixie images, asserts each
binary resolves its libraries and runs, and insists the version served is the
one this run built. Nothing in deb.yml had ever installed a package it produced,
which is how both of the above survived unnoticed.
⚠ Bootstrap: seed `punktfunk-gamescope-trixie:latest` into the LAN registry once
(docker.yml builds it thereafter) or the new job cannot start.
Field report: "on Bazzite when using gaming mode it is mirroring the main display
instead of giving the client its own." It is our own template that does it.
`packaging/bazzite/host.env` set `PUNKTFUNK_GAMESCOPE_ATTACH=1`, and every install
path — rpm, deb, Arch, nix — ships that file as `/usr/share/punktfunk/host.env.bazzite`
with the docs telling people to copy it verbatim. So the recommended Bazzite setup
turned the attach override ON for everyone.
That override is rung 2 of `pick_gamescope_mode`, ABOVE `dedicated_launch` at rung 3.
The rung comment calls the operator overrides a debug/CI escape hatch, which is right —
but we were shipping one as a distro default, so on a Bazzite box the managed takeover
and the dedicated game session were both unreachable. A game launched from a client's
library could not get a session of its own either, which is the case the dedicated
route exists for. With a physical display connected, attach then takes the
`physical_display_connected()` arm and streams the box's own head at the box's own
mode: the mirror the reporter saw.
The template now forces nothing and lets the per-connect detection answer, which on a
box with `gamescope-session-plus` is MANAGED. Attach stays available, documented as the
opt-in it is, with the mirror and the dedicated-session cost stated. Because managed
depends on the `punktfunk` group to stop the display manager, the template now says so
where someone choosing a model will read it, rather than only in the distro guide.
Also fixes the off-switch. Both overrides were read with `var_os(..).is_some()`, so
`PUNKTFUNK_GAMESCOPE_ATTACH=0` meant ATTACH ON — the opposite of what the line says,
and of every other knob on this host. They now use the shared `env_on` grammar, so
`0|false|off|no` disable and a bare `=1` keeps working. Anyone who "turned attach off"
in an older host.env had it on the whole time.
Note an upgrade never rewrites an existing `~/.config/punktfunk/host.env`, so boxes set
up from an older template keep the pin until the line is deleted by hand; the Bazzite
and HDR pages now say that.
Verified: `scripts/xcheck.sh linux` check + clippy `-D warnings` clean, pf-vdisplay
206/0 under rust:1.96, `cargo fmt --all --check` clean. Gate proved non-vacuous against
a planted `compile_error!` in routing.rs.
The raw-dmabuf passthrough handed the SPA buffer back to gamescope at
.process return while the encode thread had not yet imported - let alone
read - its dmabuf, and nothing ordered the producer's writes against the
consumer's read (no explicit sync; the implicit-fence wait measures
NoFence on every compositor x vendor pairing we have). On the direct-VCN
arms (native NV12, RGB-direct EFC) the captured buffer IS the encode
source for the whole ring-2-deep encode plus the phase-lock hold, so at
120 fps gamescope cycles back into the buffer mid-encode and the stream
ships torn frames: luma/chroma desync (magenta tint) plus block
corruption propagating through the P-chain until the next intra. Field
report: Nobara, gamescope mode at 120 fps - KDE sessions were clean
because cursor_blend routes them to the compute-CSC copy arm whose read
window is microseconds.
The fix defers the requeue: a published passthrough frame carries a
FrameHold (new on DmabufFrame), and the buffer rejoins the producer's
pool only when the last clone drops. The Vulkan encoder clones the hold
into the ring slot at submit and releases it when the slot's fence
retires (poll/backpressure/reset), extending "the producer must not
rewrite this" across exactly the GPU read. The host loop's repeat path
is fixed by the same mechanism: a re-submitted frame now aliases a
buffer the producer never got back, instead of whatever gamescope last
composited into it.
Bookkeeping lives in a per-stream HoldBook (loop-thread mutations only):
holds release through a pw channel onto the loop thread, a generation
tag keeps a stale release from requeueing a renegotiated pool's reused
address, and at most pool_depth - HOLD_POOL_RESERVE buffers are ever
withheld - a pool at the old floor of 2 cannot spare any and falls back
(with one warn) to the previous racy contract. PUNKTFUNK_ZEROCOPY_HOLD=0
restores the old behavior outright for field bisects.
Gates (.25): cargo check + clippy --all-targets -D warnings on
pf-frame/pf-capture/pf-encode/punktfunk-host; pf-capture 68/68 (4 new
HoldBook tests), pf-encode 75/75 (+15 ignored, host-feature set);
workspace cargo fmt --check clean. punktfunk-host's
hooks::prep_runs_do_in_order_and_undo_in_reverse fails on that box on
pristine main too (pre-existing; crate untouched here).
The host's arming check asked `ethtool` about every NIC, which is the wrong
question for Wi-Fi: the magic-packet trigger lives in nl80211's WoWLAN state,
and most wireless drivers print `Wake-on: d` whether or not it is armed. An
armed Wi-Fi host was therefore told it was NOT armed, and handed an
`ethtool -s wlan0 wol g` its driver rejects. A NIC with an nl80211 phy
(`/sys/class/net/<i>/phy80211`) is now asked `iw phy <phy> wowlan show`
instead, and the warning carries WoWLAN-correct guidance — `iw ... wowlan
enable magic-packet`, plus the NetworkManager
`802-11-wireless.wake-on-wlan magic` that survives a reconnect. Two fallbacks
for when `iw` can't answer (missing binary, driver without the command, or
privilege the user-level host service lacks): a POSITIVE ethtool reading
counts (brcmfmac & co do report there), a negative one never does, and sysfs
`device/power/wakeup` reading `disabled` is conclusive in the negative.
The client sender now emits from a socket bound to EACH non-loopback
interface's own address rather than leaving the path to the routing table. A
station in WoWLAN sleep stays associated and its AP buffers broadcast frames
for it until the next DTIM beacon — but only if the datagram reaches the
wireless segment at all, and with a VPN or mesh interface holding the default
route `255.255.255.255` never did. A failed bind falls back to the routed
socket, so no segment is lost.
Tests: `iw`/`ethtool` output parsing split from the commands so both are unit-
tested on any platform, and a new end-to-end test asserts a real listener
receives the 102 magic-packet bytes.
Verified on Linux (Ubuntu 26.04, 12 interfaces): `cargo fmt --all --check`,
`cargo clippy -p punktfunk-core -p punktfunk-host --all-targets --locked
-- -D warnings`, and both wol test sets green. NOT yet exercised against real
Wi-Fi hardware — no Wi-Fi Linux box was reachable.
`ProviderEntry.icon` landed in f62a48d4 along with the token's whole
supporting cast: the host-side shape guard, the seven masters, six client
renderers, the SDK and the OpenAPI. What it did not get was a version
bump, and the kit had cut 0.4.0 the day before.
So the registry's 0.4.0 is the tarball WITHOUT the field, and it is the
newest thing any plugin can resolve. A plugin that emits `icon` on a
launcher entry therefore fails `tsc --noEmit` — "Object literal may only
specify known properties, and 'icon' does not exist" — which is a CI gate
in every plugin repo. That is why the three plugins that were supposed to
carry the token never shipped it: the edits could not be committed
against a kit that had no field to fill.
Nothing but the version moves here. The only plugin-kit change since
0.4.0 was published is f62a48d4 itself, so 0.4.1 is exactly that commit's
kit surface — one optional string on an existing struct, additive, and
inert for a plugin that never sets it.
Field report (2026-08-12): Punktfunk's audio devices tank Helldivers 2 to
1% lows of 2-5 FPS; uninstalling restores performance. Two host-side
mechanisms can plausibly do that, both fixed here.
The mint retry storm: minted::ensure_blocking() ran a FULL provisioning
pass on every mic-pump open with no cooldown, no in-flight guard, and no
give-up - and ensure_role() reached UpdateDriverForPlugAndPlayDevicesW
even when the devnode already existed. On a box where minting never
latches, the pump's reopen backoff (capped 60 s) turned that into a PnP
driver rebind + system-wide device-change broadcast roughly once a
minute, forever - and games rebuild their audio graph on each broadcast.
Now:
* ensure_role() gets a steady-state fast path: a marker devnode whose
endpoints are all live resolves without touching PnP or the
default-device policy.
* ensure_blocking() waits on an in-flight pass instead of racing a
second SetupAPI sweep against it (the dead-mic-air deploy race),
honours RETRY_COOLDOWN after a failed pass (first-ever resolve still
blocks, per the cold-boot mint contract), and
* five unlatched passes stop minting for the host lifetime (a service
restart re-arms) - counted across the worker and the blocking path.
The never-reverted session tuning: pf-frame's tune_process_once() put
the whole host at HIGH_PRIORITY_CLASS with timeBeginPeriod(1) and DWM
MMCSS on the first hot stream thread and documented 'reverts at process
exit' - but the host is a 24/7 service, so after one stream it competed
at HIGH class with a 1 ms global timer against whatever the user played
locally, forever. The process-wide tuning is now refcounted across the
hot threads via a TLS guard: the first hot thread applies it, the last
one's exit reverts it (timeEndPeriod, DwmEnableMMCSS(0), NORMAL class) -
the same thread-exit lifetime the MMCSS and execution-state effects
already ride. Every on_hot_thread() call site is a session-scoped
thread (capture/encode, packetizer, send, NVENC retrieve), so the
revert lands at session teardown.
Field complaint: the plugin toasts too much. Inventory of all 14 toast
sites says almost all are rare, explicit-tap feedback (pairing, update
buttons, recovery actions) — but two were routine-volume offenders:
* startStream toasted "Starting stream — <host>" on EVERY successful
launch, i.e. the overwhelming majority of all toasts the plugin ever
shows. It repeats the button the user just pressed, and lands ON TOP
of the starting stream after the QAM closes. Gone; launch FAILURES
still toast (the QAM may already be closed, so inline state would go
unseen).
* useHosts.refresh() toasted "Couldn't list hosts" from its catch —
and the panel remounts (and refreshes) on every QAM open, so a broken
backend nagged on each open. It's now a third inline `problem` row
("Couldn't scan for hosts"), sitting next to the Refresh button that
retries it, like the client-unavailable/client-outdated states
already did.
The update-flow, pairing, trust and recovery toasts stay: each is a rare,
single, information-carrying response to an explicit tap (or, for the
request-access hint, the only warning that the connect is about to park).
Verified: tsc --noEmit and the rollup bundle pass.
Field report: each Steam start added another visible "Punktfunk" entry
(spotted in the desktop client, where the pile is plain to see).
Mechanism: db063792 made shortcutStillExists() actually answer for the
first time — and its callers treat a null overview as "the user deleted
the shortcut" and AddShortcut a replacement. But the plugin mounts while
Steam is still starting up, BEFORE appStore has registered its overviews,
so the remembered (perfectly live) appId looks up as null on every boot:
mint a duplicate, remember the new id, orphan yesterday's. One new entry
per load, forever.
The deleted verdict now has to be earned, and creation is a last resort:
* shortcutStillExists() only believes "absent" once the store is
demonstrably hydrated: wait out App.WaitForServicesInitialized (raced
against the poll budget so a wedged signal can't hang the guard),
poll until allApps is non-empty, then one grace recheck — overview
registration can trail the bulk hydration. Unverifiable within budget
answers true: a false "alive" merely no-ops until the next ask, a
false "dead" duplicates forever.
* On a genuinely lost id, both ensure paths first ADOPT an existing
same-named shortcut (excluding the other role's) instead of minting
an N+1th — which also heals installs the old builds already littered.
* Both ensures are single-flight: mount's fire-and-forget can now be
mid-wait when a QAM press arrives, and two ensures racing past the
liveness check would each AddShortcut.
* "Recreate library shortcut" additionally sweeps surplus "Punktfunk"
shortcuts (RemoveShortcut) and toasts the count — cleanup for piles
already minted. Deliberately button-only, never mount: automatic
library deletion at boot is a bigger hazard than the mess.
Verified: tsc --noEmit and the rollup bundle both pass; the launch paths
(launchStream / launchGamepadUi) hit the fast path unchanged — a live
overview answers the first query and nothing waits.
55a3d8b9 (#181) added the edition-2024 lint-level rationale to the session
bin's header naming std::env::set_var/remove_var — gate C's grep counts
comments by contract, so main went red at 5 mentions against the 4-call-site
baseline. Reword the comment instead of raising the baseline: a baseline of 5
with one comment inside would hide the next real call site.
Verified: scripts/ci/check-unsafe-hygiene.sh clean, cargo fmt clean.
A field report (GE-Proton 11-5, real DualSense on-host) surfaced the missing
constraint: haptics only work when the pad's card runs the Pro Audio profile —
because GE's route opens the node through its bundled pipewire-alsa plugin
with aux_channels=1, and its pulse fallback forces a PA AUX0..3 map with
stream.dont-remix (proton-ds5-haptic patches 0013/0115/0116: "the hidden
PipeWire parent for a DualSense output exposes AUX0 through AUX3"). A
positioned FL FR RL RR sink puts those writers through position channelmix
instead of index passthrough.
The sink now advertises AUX0..AUX3. Proven on the box: an AUX-mapped
rear-pair-only tone captures index-exact (speaker pair 0.0000, coil pair
0.3662); a positioned stray stream folds into the speaker pair and never
excites the coils. The devtest reports per-pair peaks so exactly this class
of remix bug is visible.
Also confirmed from the GE patch set while here: device matching is
device.bus/vendor.id/product.id + the Sony/Wireless_Controller name
substrings (both of which the sink carries), and the MMDevice container is
now synthesized from the wine-side HID USB parent (patch 0112) — the old
pure-PW-node GUID_NULL concern no longer applies on GE >= 11-4.
The 0xD1 plane was Windows-host-only: host_cap() answered false and spawn()
was a stub everywhere else, so an Android tier-A client against a Linux host
negotiated the cap off and stayed on wire rumble. The whole downstream
machinery (framer, silence gate, lanes, 0xD1 send) was already capture-
agnostic — only the capturer was WASAPI.
- audio/linux/pad_sink.rs: one Audio/Sink stream node per DualSense-family
pad, minted with the identity the matchers read (ALSA-style node.name with
the pad's pairing MAC, description "Wireless Controller", bus/vendor/
product/form-factor proplist, per-pad serial), 4-ch F32 48 kHz FL FR RL RR,
no default-sink claim, priority.session 50. The process() callback IS the
capture. PUNKTFUNK_PAD_SINK_NAME/_DESC override the strings for field
debugging ({pad}/{mac} expand).
- native/pad_audio.rs: the shared logic and lanes compile on Linux;
pad_audio_thread is generic over the capturer (open-with-backoff kept);
host_cap() Linux arm = client asked + PUNKTFUNK_PAD_AUDIO + a reachable
PipeWire socket; spawn() Linux arm mints the sink lazily in the streamer
thread. spawn() gains an edge flag (Edge identity; ignored on Windows).
- devtest pad-sink-test: mint one sink and capture from it, no client — the
WP3 on-glass gate. Verified on a Bazzite 44 host: identity served through
pipewire-pulse, rear-pair (voice-coil) tone captured bit-exact over both
the native and pulse legs.
- docs: PUNKTFUNK_PAD_AUDIO{,_SLOTS} are no longer (Windows); the roadmap
non-goal narrows to Bluetooth client pads.
Gates (fedora:44 container, natively on the .41 box): cargo build --release
--locked (nvenc+vulkan-encode), clippy --all-targets -D warnings, cargo test
pad_audio+pad_sink 11/11, cargo fmt.
The half of the #177 fallout #180's follow-up could not reach: WP20 wrapped
the session bin's single-threaded-startup env writes in the `unsafe {}`
blocks edition 2024 requires — under `#![forbid(unsafe_code)]`, which no
inner attribute can override, so `punktfunk-client-session` fails with two
hard errors on every Windows leg (main push runs 17615/17616 red at Build;
verified on .173). Same resolution as #180 gave the GTK shell: `forbid`
becomes `deny`, and the three documented SAFETY sites carry the localized
`#[allow(unsafe_code)]` pf-update models.
A 2026-08-12 field report (RTX 5060 client): every HEVC session demoted to
D3D11VA with 81 "outside device caps: stream level (Std code point 12) above
the device's maxLevelIdc (H.265 Std level 11)" refusals — the host's AMF
encoder stamps general_level_idc 6.2 (the codec maximum) on a 4K120 stream
that needs 5.2, and NVIDIA's driver caps H.265 decode at 6.1. The hardware
decodes the actual stream trivially; only the declaration was oversized.
AV1 passed the same gate, which is why "native-vulkan runs only with AV1".
The declared level is a claim, and the stream's real demands are enforced
where they are physical facts — coded extent and DPB depth, both checked at
session build. So the up-front level gate (H.264 + H.265) now warns once and
proceeds, and every SPS/VPS handed to the Vulkan parameters object has its
level clamped to the device ceiling (a set above maxLevelIdc is invalid
usage). AV1's gate is untouched: its code space is the bitstream's own and
no over-declaration has been seen in the field.
Verified on .173 (RTX 4090, driver 610.88): HEVC and AV1 both decode on the
native Vulkan rung at 60 fps against an NVENC host; unit tests pin the clamp
(lowers, only lowers, mutates the driver-visible block in place).
- adl_emul.rs adl_malloc: panic-free (a reachable expect in an extern fn is an
abort — gate B; the Err arm is unreachable, ADL treats null as failure)
- punktfunk-host main.rs: reword the carve-out comments so gate C's textual
count stays at its baseline (comments count)
- clients/linux: forbid(unsafe_code) -> deny with two named allows — the SDL
device-filter clear and the spawn test's HOME scoping are unsafe calls in
edition 2024 (caught by the aarch64 leg, the only one with glib)
The mic pump's WASAPI backend rendered silence into the virtual mic's
render endpoint (typically the Steam Streaming Microphone) for the whole
host lifetime. A RUNNING stream makes the Windows audio stack hold a
kernel power request ("An audio stream is currently in use", visible in
powercfg /requests), so every idle Punktfunk host blocked system sleep
forever — field-reported 2026-08-12 ("doesn't go to sleep anymore since
installing punktfunk; powercfg shows the Steam Streaming Microphone").
Stop the render stream (IAudioClient::Stop — client stays initialized,
the mic endpoint keeps existing) after 10 s of silence-only output at an
unchanged queue length, and park the render thread on a condvar the
producer notifies on the empty->non-empty edge, so the next mic frame
resumes the stream within one device period — well under the jitter
buffer's prime depth, so nothing audible changes. Keying the idle window
on the queue LENGTH (not emptiness) also covers a sub-prime tail a
vanished client left behind, while any fresh burst moves the length and
resets the window instead of being dropped.
During a session the box stays awake through the session's own
DisplayWakeRequest (pf-frame), never through this silence.
PUNKTFUNK_MIC_ALWAYS_ON=1 restores the old always-running stream in case
a virtual audio driver misbehaves while its render side is paused.
Field report: "no matter what I select the stats overlay is stuck showing as
detailed" on the Deck, cured by restarting the client app.
The console (Gaming Mode, and therefore Decky) builds its window and its run loop
ONCE and streams every session through them, and the loop took its stats tier from
the settings snapshot read at process start. Its own settings screen writes the
chosen tier to the file and redraws its row, so the choice looked taken while every
stream kept the tier the process happened to start on — Detailed for anyone who had
been on Detailed. Only a restart re-read it. The desktop shells were never affected:
they spawn a session process per stream, which resolves settings for itself.
The tier now rides `SessionParams` per launch, so browse mode adopts what THIS launch
resolved and the start-of-process value only seeds the loop until the first stream.
Two things fall out of resolving per launch rather than per process: a profile bound
to a host can finally move the tier in console mode (part of the documented P4 gap),
and the adoption sits in the `Start` arm rather than `StreamState::new`, so the
codec-fallback retry can't snap the overlay back and undo an in-stream cycle.
The `--stats` rule (a floor that lifts Off to Normal and demotes nothing) was written
out three times and is now one tested helper. The rest of the console's latched
presentation tier — touch and mouse model, shortcut inhibit, match-window, render
scale — is unchanged and still needs the models rebuilt per launch.
Gate: clippy --all-targets -D warnings, plain build, and tests for pf-client-core,
pf-presenter and punktfunk-client-session, all green in pf-lxcheck2 (linux/amd64);
clippy proven non-vacuous by touching the four edited files. cargo fmt --all --check
clean.
The safety half of the rust-safety programme's §8.4: `std::env::set_var`/`remove_var` are
`unsafe fn` in edition 2024, converting the class of bug the programme found the hard way
(the 972af299 environ data race lived in a file with ZERO occurrences of the word
`unsafe`) from invisible to counted and compiler-enforced.
Manifests: [workspace.package] edition 2021→2024, rust-version 1.82→1.85 (the pinned
toolchain is 1.96.0, so no toolchain bump — only the declared floor rises); the 13 crates
pinning `edition = "2021"` literally now inherit it (Trap 1: the root bump alone reaches
only `edition.workspace = true` crates and would have left pf-encode/pf-capture/pf-inject
et al. on 2021 while reading as complete); pf-driver-proto's stale rust-version 1.82 pin
now inherits; pf-vkhdr-layer (a separate workspace, inherits nothing) bumped to 2024. The
four vendored crates (fec-rs, cros-codecs, usbip-sim, the patched ndk) stay on 2021
deliberately — upstream code stays pristine. The excluded usbip-poc standalone PoC is
untouched.
Mechanical, done textually across ALL cfg branches so no platform's half is left behind
(Trap 3 — 44% of the host's unsafe is Windows-only and a one-platform `cargo fix` misses
it): 148 `#[no_mangle]` → `#[unsafe(no_mangle)]` (83 in abi.rs); 12 bare extern blocks →
`unsafe extern`; `gen` is a reserved keyword, so pf-vdisplay's generation stamps
(registry.rs, windows/manager.rs) and the WinUI shell's animation counters rename
gen → generation (internal identifiers only, no serde/wire surface); two
match-ergonomics patterns take the compiler's suggested reference form.
env mutation: every `set_var`/`remove_var` site (20 files) now sits in an `unsafe` block
whose SAFETY comment states the real serialization argument (pf-vdisplay's ENV_LOCK,
CONFIG_DIR_TEST_LOCK, ART_ROOTS_LOCK, vkdecode's gpu_lock, the `--test-threads=1`
contracts of the hardware spikes, or single-threaded startup). Two genuine hazards
surfaced en route — exactly the WP3b-class finds this migration exists to make visible —
and are fixed here:
- windows/service.rs spawned the network-profile warner thread BEFORE `load_host_env()`,
so a child-spawning thread (child spawn snapshots the env block) was live while
`set_var` ran in a loop; the load now precedes the spawn.
- pf-console-ui's `fake_home()` re-set HOME outside its OnceLock on EVERY call, so two
parallel tests could race the write; the set now happens exactly once inside
`get_or_init`.
cbindgen (Trap 2): 0.29.4 parses `#[unsafe(no_mangle)]` — verified empirically; the
header regenerates byte-identical. The ci.yml drift check could never catch "failed to
regenerate" (build.rs demotes a cbindgen failure to a warning and writes nothing, leaving
the checked-in header untouched and the diff clean), so the step now first asserts the
"punktfunk-core: wrote" line and the absence of "cbindgen failed" (sh -e safe: no `!`
pipeline, no tee-masked exit).
rustfmt: style_edition pinned to 2021 at the root — edition 2024 would otherwise flip the
style edition and reformat ~370 untouched files inside this same commit, burying the
migration diff. The drivers workspace pins its already-current 2024 style. Adopting the
2024 style tree-wide is its own future one-line-plus-reformat commit.
Census: the primary metric moves UP BY DESIGN — 2435 → 2453 operations, unsafe blocks
1534 → 1577, and env_set_var is now a counted category (45 ops). The newly counted env
sites are a truer number, not a regression; baseline snapshot saved as punktfunk-planning
design/rust-safety-census-baseline-2026-08-12-edition-2024.txt. Gate C's env ratchet is
now compiler-enforced (the hygiene-script header says so); the two shrunk file counts
(nvenc_cuda 49→2 via the test helpers, shell/tests 2→1) are lowered in the same commit
per the gate's own rule.
Drop order (the semantic change most likely to bite this codebase): the migration lint
`-W tail-expr-drop-order` reports zero findings on the macOS-visible halves of
pf-encode / pf-zerocopy / pf-capture / pf-frame; the Linux and Windows halves run the
same lint on the gate boxes. The four #[ignore]d alloc/drop-cycle tests on the hardware
boxes remain owed, as before this change.
The RX 9070 XT field A/B (2026-08-11/12 logs) convicted BOTH of our REALTIME
GPU-scheduling levers of generating the metronomic capture-stall class the
stall program has chased for weeks — compose-silence holes of 150-800 ms in
which ETW shows NO process presenting while the GPU stays responsive:
- the vdisplay driver's IddCxSetRealtimeGPUPriority raise beat at ~1.75-1.78 s
(PFVD_NO_RT_GPU=1 alone removed that metronome: ~0.35 stalls/s metronomic ->
10 sparse aperiodic over 3.9 min);
- the host auto-gate's HIGH->REALTIME upgrade (pf-frame dxgi.rs, T2.3) beat at
~3.58 s in the AV1 sessions where it promoted (vram_pct=1, 12:59:26); pinning
PUNKTFUNK_GPU_PRIORITY_CLASS=high removed that residual too (13:45 session:
zero metronomic, stall rate at the clean-run baseline).
Neither period matches any punktfunk clock: the full periodic-actor census
(driver: event-paced drain + 16 ms E_PENDING wait, 33 ms cursor poll, 3 s
watchdog reap; host: 250 ms descriptor poll, 5/50/100 ms probes + ~2 s scanline
retarget, 2 s VRAM gate, 2 s exclusive re-assert, 3.33 s pinger, 1 s stats,
~1 Hz phase-lock, fps/2 LTR marks) has nothing in the 1.69-2.29 s band, and
every host-side actor ran unchanged in the A/B that killed the fast metronome.
The periodicity is emergent from holding an unreachable-priority queue against
the WDDM scheduler on this AMD family (the period even differs by which of our
processes holds REALTIME); it is not a punktfunk cadence being amplified, so
there is nothing punktfunk-periodic to fix - the fix is to stop holding
REALTIME by default, which is also canonical parity (no shipping IDD raises
it, and HIGH was the class that delivered the original Sunshine-parity encode
win).
- Driver: PFVD_NO_RT_GPU (default-ON, opt-OUT) becomes the PFVD_RT_GPU ladder,
default OFF on every vendor: unset = no raise (canonical IDD behavior);
=thread = SetGPUThreadPriority(+7), a graduated in-band middle rung for field
A/B (not default: unmeasured here, and the host measured the same call as "no
help" for its own starvation case); anything else = the old REALTIME DDI.
PFVD_NO_RT_GPU stays recognized and WINS over the opt-in, so the field boxes
that carry it through the default-ON era keep meaning OFF. Both directions
remain A/B-able without a rebuild (machine env + device restart). The CPU
half of the original branch-2 hardening (MMCSS / TIME_CRITICAL) is untouched
- it addressed the delivery holes that were actually observed.
- Host: PUNKTFUNK_GPU_PRIORITY_CLASS default auto -> high. `auto` (the gated
REALTIME upgrade) stays available as an explicit opt-in, `realtime` still
pins; unrecognized values now land on the HIGH default instead of silently
opting into the gate - a typo must not buy the hazard. The VRAM/HAGS gate
machinery is unchanged for `auto`; it guards the NVENC-hang hazard but cannot
see this one.
- stall.rs: the no-OS-event METRONOMIC warning now carries rt_gpu_driver /
rt_gpu_host fields (the machine-env state of both levers) and names clearing
them as the FIRST cure, ahead of the display-hardware suspects - a field log
self-answers the triage question this program just spent a week on.
No console policy axis for the driver knob: the lever is default-safe now, the
driver reads config at WUDFHost scope where machine env already matches the
device-restart lifecycle, and a policy axis would need pf-driver-proto churn
(or a device-key registry write) for an experimental lever that only exists to
be A/B-ed. If the `thread` rung ever proves out as a default-worthy raise,
that is the moment to revisit.
enable_instances cleared every requested id from the pnp-disabled-monitors
journal unconditionally — a mid-life re-enable failure erased its own
crash-recovery entry, so neither the running host nor the next start would
ever retry, leaving the operator's monitor disabled (invisible to Windows and
every display listing) until a manual Device Manager fix. Field 2026-08-12
(Android-client host running the pnp_disable_monitors axis): displays gone
from the client after 2-3 sessions, only a full host restart bringing them
back — the restart path only works while the journal still has the entry.
Only successfully re-enabled ids clear now; failures stay journaled (and are
logged with the consequence) so startup_recover retries them.
Both pumps (Stage2Pipeline + the stage-1 StreamPump) dropped every AU in a
silent guard while 'format' was nil — the opening IDR's parameter sets never
arrived or never parsed, and under the host's infinite GOP nothing re-delivers
them unless the client asks. Field 2026-08-12 (iOS, H265): sessions decoding
nothing, requesting nothing, host streaming perfectly — a black stream with
zero recovery requests, randomly self-healing only when some later event
produced a fresh IDR.
While format is nil after the first received AU, the pumps now set awaitingIDR
(routing through the existing 100 ms-throttled recovery.request()) and log the
state once, so a lost/unparsed opening IDR converts to a normal recovery
round-trip instead of a permanent silent black.
A monitor unplugged mid-session left the teardown replaying a snapshot whose
paths reference an absent target — SetDisplayConfig rejects the WHOLE array
with 0x57 ERROR_INVALID_PARAMETER, nothing restores, the desk stays dark, and
the next session snapshots the wreckage (the poisoned-snapshot chain; field
2026-08-12: rc=0x57 across a mid-session unplug, then sessions flipping
between black and working at random, incl. one that genuinely encoded black).
The restore now prunes the snapshot against a live QDC_ALL_PATHS availability
sweep before replaying: stale paths drop, the mode table is rebuilt with only
the entries the survivors reference (an orphaned mode entry is itself an
0x57), and an empty survivor set skips the apply so the dark-desk backstop
decides. Verbatim behavior is unchanged when everything is still attached or
when the availability query itself fails.
Productizes the adl-emul probe (the prior commit) as the display-policy axis its
PR promised: the ADL FFI moves to pf_win_display::adl_emul (one surface shared by
the probe tool and the host, so a reporter's probe and the console's toggle
exercise byte-identical driver calls), and an EXPERIMENTAL edid_lock axis joins
ddc_power_off/pnp_disable_monitors — orthogonal to presets, off by default.
At the first Exclusive isolate the host pins each occupied AMD connector's live
EDID + ADL_EMUL_MODE_ALWAYS (the software HPD dummy) BEFORE the physicals
deactivate; last-member teardown unlocks. Pinned emulation outlives the process,
so a crash journal (edid-lock-active.json) unlocks on the next host start,
mirroring the pnp_disable_monitors recovery. Inert without an AMD driver.
The console shows the toggle ONLY when the GPU inventory lists an AMD adapter —
the lever exists nowhere else, and a toggle that can never act is the 'saved and
then did nothing' trap the enforced-axes list exists to prevent.
The standby-sink stall program's §3 dead-end list marked ADL EmulationMode
'likely Pro-gated' on field hearsay, with 'probe once, log rc' as the owed
falsification — never run. Three RX 9070 XT field cases later (ASUS
VG32VQ1B/DP, Odyssey G60SD/DP, LG UltraGear 32GS95UE/HDMI), this is that
probe, shippable to reporters: read-only caps/board-layout/connection-state
walk by default, --lock pins the live EDID + ADL_EMUL_MODE_ALWAYS on
occupied connectors (the software HPD-holding dummy), --unlock restores.
Every call prints the bench's epoch_ms correlation line with the decoded
ADL rc — ADL_ERR_NOT_SUPPORTED(-8) vs ADL_OK on consumer Adrenalin is the
Pro-gating answer, and a --lock run during a stream with the sink asleep
is the direct A/B for the metronomic stall class.
atiadlxx.dll is bound dynamically (absent = clean exit 2), structs mirror
adl_structures.h verbatim, and the probe touches only connectors the
board-layout walk enumerated. Gates: check/clippy -D warnings (msvc
cross-target) + fmt clean; native stub unaffected.
Every unrecoverable loss armed the client's freeze gate twice: instantly at
frame-index-gap detection (which fires the RFI), and ~120 ms later when the
reassembler ages the lost frame into frames_dropped and poll() re-armed
unconditionally. An LTR-RFI recovery anchor lands in ~60 ms — between the two
signals — so the stale climb re-froze a bit-exact-healed stream, the host
swallowed the re-ask as an RFI echo, and the picture stayed frozen until the
overdue backstop extracted a full IDR: the field 'H265 freezes on every loss,
AV1 fine' signature on AMD hosts (AMF is the only LTR-RFI backend; the slower
IDR path usually lands after the climb and dodged the race).
The gap-arm now pre-credits the expected climb (ReanchorGate::arm_expecting_drops;
credit expires after DROP_CREDIT_WINDOW so a straggler-filled gap can't mask a
later real loss), and poll() consumes credited climbs instead of re-arming.
Plumbed through every embedder: pf-client-core's session pump, Android's
sync/async loops (note_frame_index now returns the gap width), and the Swift
client via new ABI exports punktfunk_connection_note_frame_index_ex +
punktfunk_reanchor_gate_arm_expecting_drops (additive; the bool ABI stays).
Two newtypes beside AvBuffer/AvFilterGraph (same house shape: alloc/from_raw
rejects the allocator's null once, as_ptr lends, Drop frees, no Clone; NonNull
inside so the Options get a niche). 8 av_frame_alloc + 3 sws_getContext sites
converted; all 22 hand-placed av_frame_free and 5 sws_freeContext calls are
gone, and the three hand-written Drop impls (CpuInner, SystemInner,
NvencEncoder) with them.
The live defect this closes: ZeroCopyInner::submit (ffmpeg_win) leaked the
frame AND one pooled hwframe surface on each of three ? exits between the
pool pull and the send — under a SAFETY comment asserting no leak — and with
POOL=8, eight such failures starved the pool and wedged the encoder with no
error naming the cause. Every exit now returns the surface.
Drop-order care (the hidden cost the survey flagged): NvencEncoder's sws_csc
moved to field #1 (its hand-Drop freed it before all fields; this path runs
on every stall-watchdog recovery via *self = fresh); CpuInner's nv12/sws
declaration order flipped to match its hand-Drop; SystemInner's already
agreed. Pinned by FIELD ORDER comments, not offset_of asserts — the survey's
assert suggestion is the wrong tool: offset_of measures repr(Rust) memory
layout, which the compiler may reorder independently of the declaration
order that drop order actually follows.
The dmabuf path keeps its early descriptor release via an explicit drop()
at the exact point the hand-written free sat.
Gates: .25 clippy -D warnings + tests green (nvenc,vulkan-encode,pyrowave);
.133 check --all-targets + clippy --release -D warnings + 80 tests green
(nvenc,amf-qsv,qsv; test step needs ffmpeg\bin on Path — 0xC0000135
otherwise). Owed on hardware: the #[ignore]d alloc/drop cycles on
.136/.116/.173/.47 and the pool-exhaustion assertion (9th submit succeeds
after 8 forced failures).
abi.rs's two send-input entry points built &InputEvent straight out of
caller memory with ev.as_ref(); InputKind is repr(u8) with 16 valid
discriminants, so a C embedder writing ev->kind = 42 was immediate UB the
moment the reference formed — in a file whose stated principle is that
failures become status codes. New read_input_event() checks null, reads
the tag as a raw byte, validates through the same InputKind::from_u8 the
wire path uses, and only then forms the reference; bad tags return
InvalidArg. Every other field is a plain integer, valid for any pattern.
Test stages the event in MaybeUninit storage so the test itself never
holds a reference to the invalid value. 380 lib tests + the C harness
round-trip + clippy -D warnings green on .25; header regenerated.
3c: forming &TOKEN_USER (align 8) out of a bare [u8; 256] (align 1) was UB
by the validity rule whenever the stack slot landed misaligned — shipped
codegen happened to 8-align it, which is luck, not a contract. Fixed with
a repr(align(8)) wrapper that keeps the buffer at 256 BYTES; the comment
records why [u64; 32] is the wrong shape (len() would silently become 32
and misclassify every hand-run host as SYSTEM via ERROR_INSUFFICIENT_BUFFER,
invisibly to a SYSTEM-side test). Length arg now size_of_val.
3d: EqualSid().is_ok() read BOTH 'SIDs differ' and 'EqualSid failed' as
Err, so a genuine failure yielded 'not SYSTEM' — the fail-OPEN direction,
contradicting the documented fail-closed contract. Now split three ways on
the last-error code, with SetLastError(0) cleared first so a stale value
cannot misclassify.
Gate: cargo check -p punktfunk-host + cargo clippy --release -D warnings
both green on .133 (real MSVC, fresh extraction, sentinel-verified).
Same shape as the miri job (dated nightly, own san- cache prefixes,
non-blocking day one via a step-level ::warning::, a proved-it-ran grep).
run.sh gains PF_SAN_TOOLCHAIN so CI can pin its dated nightly — bare
+nightly would ask for the rolling channel the job never installs. Both
the pinned and vanilla paths re-verified green on .25.
PF_SAN=address builds the punktfunk-core staticlib on nightly with
-Zsanitizer/-Zbuild-std and the C harness with clang -fsanitize, so ASAN
instruments both sides of the boundary at once and LSAN (detect_leaks=1)
becomes the first automated check on abi.rs's Box::into_raw/from_raw leak
contract. Verified on the .25 box: green run passes byte-exact; deleting
one punktfunk_session_free() in the harness makes LSAN report the 308
Rust-side allocations behind the handle and the script exit 1.
The harness binary moves from mktemp to target/ — a debug+ASAN static
binary can exceed a tmpfs /tmp (it did, on .25's 3.6G tmpfs).
The tray leg builds punktfunk-core with default-features off, where
lock_recover's only callers (the quic-gated punktfunk_connection_* entry
points) do not exist — dead code under -D warnings. The helper takes the
same feature gate.
In pf-vkhdr-layer, rustfmt had reflowed destroy_surface's lookup into a
multiline closure, leaving the SAFETY comment outside the closure that
contains its unsafe block — the box's clippy rightly stopped accepting the
adjacency. The comment moves inside, directly above the block.
The .25 gate corrected the carve-out: rustc flags `unsafe { u.arm.field = x }`
as unused_unsafe — plain assignment through a union projection is safe
(writing an arm cannot itself be UB; the hazard is the mismatched READ).
The 11 plain writes go back to bare statements under their codec matches.
What stays in per-op unsafe blocks with arm-guard proofs is the real unsafe
surface: union reads, borrows, and the bindgen bitfield-setter calls — which
is exactly the surface the shipped 4:4:4 bug lived on (set_chromaFormatIDC
stamped under a wrong codec).
Linux fallout from the hoist the mac could not see: bindgen emits unsafe
blocks (layout tests/accessors) into OUT_DIR, where nobody hand-writes
SAFETY proofs — pyrowave-sys failed clippy on .25 with 17 of them, and
libvpl-sys would do the same on the Windows leg. Both crates are
bindings-only by charter (the safe wrapper lives with the consumer), so the
allow is crate-wide with the rationale at the crate root; the hand-written
link-sanity tests keep their proofs by convention.
scripts/ci/check-unsafe-hygiene.sh — textual gates for three classes no lint
covers:
A. unsafe fn markers carrying no contract. unsafe_op_in_unsafe_fn forces real
ops into blocks, so an unsafe fn with no `unsafe` in its body is a marker
with no contract (db659809 found two by hand). Contract-deferring fns
(Vec::set_len shape) waive with `// unsafe-fn-no-op-ok: <reason>`; fenced
files and `unsafe extern "ABI" fn` (signature-mandated markers) are
skipped structurally.
B. unwrap/expect/panic! inside extern "C"/"system" bodies — an abort since
Rust 1.81, not linted, not fuzzable (8b98d0b3). catch_unwind bodies are
exempt; `// panic-in-extern-ok: <reason>` waives a deliberate abort.
C. Safe-but-process-global APIs (env::set_var/remove_var, sigaction,
setlocale, set_current_dir) — the 972af299 environ race lived in a file
with zero occurrences of the word `unsafe`. Per-file count ratchet with
the baseline in the script; any increase or new file fails.
Making gate B clean on main surfaced 14 real instances of exactly its class —
`.lock().unwrap()` in unguarded extern fns, where a poisoned mutex aborts the
embedding process: six punktfunk-core abi.rs entry points (poll_frame,
next_au, next_audio, next_audio_pcm, next_cursor_shape, next_clipboard),
seven Android JNI entry points, and the Windows client's deeplink wnd_proc.
All fixed with poison-recovering locks (the slots are last-value caches,
valid whatever a poisoned writer left) and Option::insert for the
set-then-unwrap shape; punktfunk-core's 203 lib tests pass. Gate A's
findings were six genuine contract-deferring fns — waived with reasons, not
fixed, because the markers are correct.
Gate-of-the-gate: all three shown to FAIL on deliberately planted instances
(marker fn, panicking extern callback, env::set_var in an unlisted file) and
to run clean on the tree, before the ci.yml step made them blocking.
undocumented_unsafe_blocks joins unsafe_op_in_unsafe_fn in
[workspace.lints], and the ~100 scattered per-file #![deny(...)] attributes
(85 files) are deleted — a new crate, or a new module in an old one, is now
covered on creation rather than on remembering. The per-file form is how
pf-vkhdr-layer, wdk-probe and half of pf-clipboard stayed uncovered.
There are THREE workspaces, so the claim is made three times: the main
Cargo.toml, packaging/windows/drivers (workspace table + [lints]
workspace = true in all seven members), and packaging/windows/pf-vkhdr-layer
(its [lints] table, previous commit). pf-update now opts into workspace
lints; the two vendored member snapshots (cros-codecs, usbip-sim) stay out
deliberately and now both say so.
Newly-covered fallout was two link-sanity tests (pyrowave-sys, libvpl-sys)
— proofs written. Stale prose that claimed the workspace held
unsafe_op_in_unsafe_fn at "warn" (it has been deny) or pointed at the
deleted attributes is corrected.
nvenc_core.rs is carved OUT of the unsafe_op_in_unsafe_fn fence: its
exemption rationale ("raw entry-table calls almost line for line") was
false — the file makes zero FFI calls. Its unsafe surface is C-union writes
whose soundness hangs on which codec arm is active, and its own 4:4:4 note
records the shipped bug (hevcConfig bytes stamped onto an AV1 config) that
per-operation blocks make visible. It now runs the strictest discipline in
the crate: clippy::multiple_unsafe_ops_per_block at deny, one union access
per block, each naming its codec guard.
Verified here: cargo fmt clean in all three workspaces; native clippy
-D warnings clean for everything that compiles on macOS (the three
pre-existing mac-native failures — pf-client-core wol.rs, pf-encode
dead-code/closure-call, probe mic_burst — reproduce on the clean tree).
Linux/Windows legs ride the .25/.133 gate.
pf-vkhdr-layer — the sharpest gap: an implicit layer injected into every
Vulkan game process, 32 unsafe usages, zero SAFETY comments, own workspace so
no lint table reached it, and an explicit missing_safety_doc allow. Now: a
[lints] table (unsafe_op_in_unsafe_fn + clippy::undocumented_unsafe_blocks,
both deny), the allow removed, every unsafe operation in an explicit block
with a real proof (loader layer protocol / Vulkan valid-usage), # Safety docs
on the contract-carrying fns, const layout asserts for the SurfaceFormat2Raw
mirror, the five helpers with no caller-facing contract demoted to safe fns,
and the two redundant `unsafe impl Send` deleted (fn pointers and vk handles
are Send intrinsically — the type-check proves it).
wdk-probe — 21 unsafe blocks, 12 proofs: the 9 missing SAFETY comments are
written (the iddcx_rt.rs DDI slot-dispatch ones are about table population
and PFN/index pairing, not pattern fill), the sibling denies added at the
crate root, missing_safety_doc allow dropped, # Safety on DriverEntry, and
the crate joins windows-drivers.yml's clippy list — it was the only driver
crate not in it.
pf-clipboard — the undocumented_unsafe_blocks deny moves from host/windows.rs
to the crate root so host/wayland.rs (4 blocks), host/mutter.rs (2) and any
future backend under host/ are covered on creation. All existing blocks
already carry proofs; free today, structural tomorrow.
Verified here: pf-vkhdr-layer cargo fmt --check + clippy --release
-D warnings at x86_64-pc-windows-msvc. wdk-probe and pf-clipboard compile
checks need the WDK/Linux boxes and ride the .133/.25 gate.
Adds a non-blocking `miri` job to audit.yml, per rust-safety-programme.md §7.
What it buys is one narrow, real thing: pf-driver-proto interpreted CROSS-COMPILED to
x86_64-pc-windows-msvc, on a Linux runner, with no Windows box in the loop. That crate is
`#![forbid(unsafe_code)]` and path-dep'd by BOTH the main workspace and the driver
workspace, so it is the layout oracle for every frame and IOCTL crossing that boundary,
and nothing else in CI checks it at MSVC layout. It is NOT unsafe coverage — Miri can
execute on the order of 2% of the host's unsafe and cannot run ash, windows-rs, ffmpeg,
CUDA or the WDK — so no "Miri coverage" number is reported anywhere.
Three steps, every one of them measured on 192.168.1.25 with a cold target dir and cold
sysroot cache, on the dated toolchain the job installs, BEFORE being committed:
step A pf-driver-proto + pf-host-config + pf-gpu 21 + 12 + 4 pass 43 s
step B pf-driver-proto @ x86_64-pc-windows-msvc 21 pass 26 s
step C punktfunk-core fec::gf8 with +avx2,+ssse3 2 pass 63 s
Four corrections to the §7.3 job spec, found while doing this and folded into comments:
* `-p punktfunk-core fec packet crypto` does not parse — cargo rejects the extra
positionals. Corrected (filters after `--`) it selects 63 tests and was killed at a
25-minute cap with not one test complete, so the bulk step is dropped entirely and only
the narrow `fec::gf8` selection is kept, timed at 63 s.
* `nightly-2026-08-10` resolves to rustc 1.99.0-nightly (969b803cb 2026-08-09), NOT the
12c36e253 2026-08-10 the doc cites: `nightly-<date>` names the day rustup PUBLISHED the
build, which is compiled from the previous day's commit. The doc's hash came from the
ROLLING `nightly` channel and was mislabelled. All three steps were re-run and are green
on the dated pin actually installed here.
* fec-rs dispatches its GF(2^8) multiply through RUNTIME `is_x86_feature_detected!`, so
step C's RUSTFLAGS are load-bearing in both directions. Verified by probe: bare,
avx2=false and the step would silently interpret the scalar fallback; with the flags,
avx2=true and `_mm256_shuffle_epi8` genuinely executes under the interpreter. GFNI stays
false either way, so that branch is simply not covered.
* `RUSTC_WRAPPER: ""` is a guard, not a fix, and the comment says so — audit.yml sets no
sccache today, and cargo-miri warns "Ignoring `RUSTC_WRAPPER`, Miri does not support
wrapping" and carries on regardless.
Non-blocking via a step-level `||`, not job-level continue-on-error, following the
precedent audit.yml already documents for docs-site-audit. Each step additionally asserts
a non-zero pass count, so a crate rename or a filter that stops matching surfaces as a
warning rather than as a green zero-test run. Both paths were exercised directly: a
failing run emits the annotation and still exits 0, and a zero-selection run trips the
guard, while a green run with empty bin/doctest targets does not false-positive.
Leak checking stays ON (no -Zmiri-ignore-leaks); the two deliberate leaks in the tree are
named in a comment so whoever expands coverage annotates those sites instead of blanket-
disabling the check. pf-bitstream and the FFI crates are excluded with the reasons inline
so they are not helpfully re-added. `paths:` is deliberately not widened to
crates/pf-driver-proto/** — that filter is workflow-level and would fire all six audit
jobs on every driver-proto edit.
Two conflicts: the test-module import list in gamescope.rs (union — the branch's takeover-state
tests and main's WSI opt-out tests both stay), and next_frame_timed_out in pf-capture, where the
branch still carried the pre-#168 else-if chain — resolved to main's match-based refactor, which
already embeds the same arm semantics plus the provisional-budget latch gate.
An unpair removed the certificate but left the revoked client's running
session streaming until the client chose to leave. Now it is a complete
revocation:
- GameStream: when the removed certificate owns the active launch, the
session is quit_session'd — the ENet control thread's ended-session arm
gives the client the standard TERMINATION+disconnect. (An owner-less
launch cannot be attributed and is left to the WP0 port teardown when the
last pairing goes.) The endpoint docstring's long-standing caveat
('removes the client from the listing without severing its ability to
reconnect') is retired: TLS handshakes complete by design, authorization
is per-request, and a live session no longer survives its own revocation.
- Native: session_status::stop_by_fingerprint signals the unpaired
client's live session(s) to tear down deliberately (quit+stop), matched
by the registry's client label — the fingerprint's 12-hex-char prefix for
every pairable client; anonymous/TOFU sessions carry IP labels and are
never touched (they have no pairing to revoke).
(The unpair-didn't-PERSIST half of 'unpairing was broken' was already fixed
in 13d57210 — save_paired was never called; this closes the other half.)
Gates: Linux amd64 both flavors clippy --all-targets -D warnings clean;
session_status 2/2 (new revocation test), the extended paired-clients test
green in both flavors, native_pairing test green.
The console fix before this one closed the leak on one client. The same question
has a different wrong answer on each of the others, so this closes it everywhere:
a pinned host+profile card can be browsed, and every title launched off a pinned
card's shelf streams with that card's profile.
Two shapes of bug, one per client:
**The library was not offered on a pinned card at all** — Apple (both UIs:
`hasLibrary: profile == nil` on the console tile, no menu item on the touch card),
Android (`hasLibrary` required `pinnedProfileId == null`, and the console host
options gated Library on `pin == null`), GTK (the pinned card's menu had Connect,
Copy link, Create shortcut, Unpin — no library) and Windows (pinned tiles had no
menu whatsoever). Each was justified in a comment as "a pin is a shortcut, not a
second host, so the host-level actions stay on the host's tile" — right about
wake, pair, edit and forget, wrong about the library. Browsing is not a property
of the machine: it is this card's connect with a title picked first, which is
exactly what a shortcut is for. So the library joins Connect on every pinned
surface, and the host-level actions stay where they were.
**The launch dropped the profile.** GTK already carried it (its library clones
the card's whole ConnectRequest) and Windows already carried it (its library page
launches through the shared target, which the tile parks). Apple did not: the
library was keyed on a bare `StoredHost`, so `launchTitle` connected with
`.inherit` and the host's binding won even from a pinned card. Android was worse
still, and not only for pins — `LibraryScreen` dialled `connectToHost` with the
RAW settings, so a library launch applied NO profile at all, not even the host's
binding, on every host. Its host list resolves
`settings.effectiveFor(profileStore.resolveFor(kh, oneOff))`; its library resolved
nothing.
So the shelf, not the host, is now the unit both clients navigate by. Apple gets
a `LibraryTarget` (host + `ProfileSelection`) threaded through `libraryTarget`,
the shell's screen enum, both presentations and `SessionModel`; Android passes
the pinned id into `LibraryScreen` and resolves it there through the same
`ProfileStore.resolveFor` rule the connect path uses. Falling out of that:
* a game that exits returns to the shelf it was launched from, pin and all,
rather than to the host's default one — `SessionModel.launchedShelf` on Apple,
`ActiveSession.libraryProfileId` → `LibraryReturn` on Android. Android also
drops a pin that was unpinned while the game ran, rather than reopening a card
that no longer exists.
* Android's stats overlay finally names the profile a library launch used
(`profileName` was never set on that path).
* Apple's `punktfunk://browse/<host>` honours `profile=`, which it parsed and
ignored — and refuses an unknown or ambiguous reference exactly as the connect
route does (§10.6) rather than degrading to the binding.
* every shelf says which one it is, in the card's own `host · profile` shape:
the console's title, GTK's page title, Android's ConsoleHeader, Apple's
navigation title and its console heading.
Verified per platform, none of it on trust:
* console + GTK: fmt, build, clippy `-D warnings` and 85 tests green in the
pf-lxcheck2 container (a Mac `cargo test -p pf-console-ui` compiles nothing).
* Apple: `swift build` green for macOS, iOS and tvOS. Worth all three — the tvOS
pass alone caught `navigationDestination(item:)` needing Hashable, and an
iOS-only screen was confirmed genuinely compiled by planting a type error.
* Android: `compileDebugKotlin` + unit tests, with HomeTilesTest's pin
expectation flipped to match.
* Windows: `cargo check --all-targets` + clippy `-D warnings` on the CI runner,
cold (3m10s) — that client cannot compile on a Mac.
Patch 0008: honor GAMESCOPE_NO_FOCUS in steamcompmgr's focus selection. hhd (Handheld Daemon)
sets the atom once at init on its hhd-ui overlay window and never clears it; MangoHud sets it
too; show/hide for these clients runs over the STEAM_OVERLAY protocol. NOTHING consumed the atom
— not upstream gamescope, not Bazzite's fork (checked ba148 by strings) — so a
mapped-but-unpainted hhd-ui window (it crash-loops under a headless punktfunk takeover and remaps
on every respawn, stamping Steam's appid 769) was an ordinary focus candidate, and steamcompmgr
picked it over Big Picture. The composite, and the stream fed from it, went black while every
health signal stayed green: on .41 the client sat decoding 60 fps at 0.1 Mb/s of black,
GAMESCOPE_FOCUSED_WINDOW named the hhd-ui window with GAMESCOPE_NO_FOCUS(CARDINAL)=1 on it, and
killing hhd-ui brought the picture back the same second.
The patch wires the atom exactly like GAMESCOPE_EXTERNAL_OVERLAY — read at map,
PropertyNotify-tracked with MakeFocusDirty, skipped by both focus-candidate collectors (X11 and
XDG) — and touches neither compositing nor appID, so a NO_FOCUS window still paints if the
baselayer protocol brings it into view; it is only barred from being CHOSEN. Applies cleanly on
the full 0001..0008 series from the bare 5fb8dce4 pin (verified with git am).
Banner +pfhdr5 → +pfhdr6, PKGBUILD 3.16.25.pfhdr6-1; README gains the 0008 row, the missing
+pfhdr5 ledger row, and the reconciled bump rule (a bugfix bumps the level only when field triage
must read the difference off a box's banner — 0007's crash-loop, 0008's lost composite).
The pipeline retry loop deliberately shortens its first attempt's first-frame wait to 2.5s so a
stream bound during a gamescope re-init fails over quickly. But the portal capturer's timeout
diagnosis treated EVERY expiry as a verdict: it latched whichever offer it implicated — HDR
capture off for the source, the raw-dmabuf offer off, the EGL→CUDA offer off — process-wide and
permanently, when the attempt was truncated by design and a gamescope cold start routinely
delivers nothing inside that window while accepting every offer a few seconds later (observed on
.41: pid 1962 hit the expiry at connect and every later session in that process ran silently
degraded). This is bug #6 from the pf-capture sweep, verified then and unfixed until now.
The truncated attempt is now declared PROVISIONAL end to end: a new
`Capturer::next_frame_within_provisional` (default: delegates) lets the retry loop say "this
budget is the schedule, not a verdict", and the portal capturer's timeout classification — split
out as the pure `classify_first_frame_timeout` + `timeout_convicts`, with tests — names the same
suspect in the error text but latches nothing unless the expired budget was full-length.
A new 'gamestream' feature (default ON — every stock package is behaviorally
identical, and GameStream stays runtime-opt-in via --gamestream /
PUNKTFUNK_GAMESTREAM) gates the whole Moonlight-protocol surface: control
(the ENet plane), rtsp, nvhttp, pairing, serverinfo, the _nvstream mDNS
advert, the compat media path (stream/video/audio), pen/gamepad/input
decode, apps, crypto, cert (the RSA identity), and tls's
Moonlight-client-cert leniency. AppState keeps the shared vocabulary
unconditional and cfg-gates the Moonlight-only fields; the mgmt API's PIN
endpoints (routes, handlers, OpenAPI entries, lane classifications, tests)
exist only under the feature.
Building --no-default-features --features pyrowave yields the hardened
NATIVE-ONLY host: no rusty_enet (the c2rust-transpiled C ENet stack, 158
unsafe sites) and no rsa (the identity split's legacy fallback became a
pem-only read — rustls/ring serves an existing RSA cert without the crate —
so the accepted Marvin advisory no longer applies to native-only builds).
Both claims are ASSERTED, not assumed: a new CI leg keeps the native-only
flavor clippy-clean and fails if cargo tree finds either crate in its graph.
serve --gamestream (or the env knob) against such a binary refuses to start
with a clear error rather than serving less than the operator configured.
En route: the logs-paging test assumed a quiet process-global log ring
between its cursors and raced other tests' legitimate log lines (the
identity tests added new emitters) — it now asserts on its own markers
within the page.
Gates: Linux amd64 — BOTH flavors clippy --all-targets -D warnings clean;
default tests identity 3/3, mgmt 37/37, gamestream 59/59; native-only tests
identity 3/3, mgmt 35/35, residue 4/4; rusty_enet+rsa absent native-only,
present default. .133 Windows — both flavors clippy clean (clean-first,
sentinel-checked), tree claims hold, and the WP0 port-lifecycle functional
gate PASSES on the default build.
Pinning a profile onto a host gives it its own card on the console home, and
pressing A on that card has always connected with the pinned profile as the
one-off the resolver prefers over the host's binding. Y on the same card opens
a library — it is paired and saved, which is the only thing the hint bar asks —
and every title launched off that shelf went out with no profile at all, so the
host's default binding won. Connecting straight from the card honoured the
profile; going through its library did not, which is the shape a user reads as
"the pin works until I pick a game".
The screen was the leak: LibraryScreen copied the row's address, port,
fingerprint and mgmt port, and dropped `pin` — so its launch had nothing to
send and hardcoded `profile: None`, under a comment asserting that game
launches follow the binding. They should follow the card: a launch off a pinned
card's shelf is that card's connect with a title attached.
The screen now carries the row's pin and sends its id as the one-off. It also
says so, in the card's own `host · profile` shape: the shelf's title names the
profile, and so does the connecting card, so which of a host's cards you came
in on is legible from the screen rather than inferred from the tile you pressed
two screens ago. Off the host's primary tile there is no pin and nothing
changes — `None`, and the binding decides.
Console-only. The Apple and Android consoles keep Y off pinned cards outright
(`hasLibrary: profile == nil`), the GTK client clones the card's whole
ConnectRequest — profile included — into its library, and the Windows client
offers "Browse library" from the primary tile only. The console's own
copy-link and wake-then-connect paths already carried the pin.
Both directions are tested, and the pinned one was checked against a reverted
fix: it fails with exactly the reported symptom (left: None, right: "hdr").
The nitro server build re-bundles the whole dep tree (`noExternals: true`), so
every React package shipping a `"use client"` banner earns a MODULE_LEVEL_DIRECTIVE
warning — ~150 locally, ~800 in CI — which buries the warnings worth reading.
Ignoring the banner is correct rather than papered over: this bundle is the
Bun/Nitro server, not an RSC module graph, and TanStack Start splits client from
server with its own transform, so nothing downstream consults it.
Supplying `onwarn` replaces nitro's own handler, so its three filters
(CIRCULAR_DEPENDENCY, EVAL, "Unsupported source map comment") are restated.
Verified: `bun run build` drops from 148 such lines to 0 with no other log
delta; `tsc --noEmit` and `biome check` clean.
One RSA-2048 identity served every plane, because Moonlight mandates RSA and
the planes grew out of the GameStream host. The native punktfunk/1 QUIC plane
and the management API now share a separate ECDSA P-256 identity
(native-cert.pem/native-key.pem, src/identity.rs): ring-generated via rcgen
(no rsa crate on the native path — the accepted Marvin advisory stops
applying once WP19 gates the compat planes), real SANs (localhost, loopback,
machine hostname — the legacy cert had none), and browser-compatible on
purpose: Ed25519 was rejected because no mainstream browser accepts an
Ed25519 server cert and /api/docs is opened in one. GameStream keeps the RSA
identity untouched (Moonlight pins it; its pairing hashes bind its X.509
signature bytes).
Migration is pin-preserving by construction. Clients TOFU-pin ONE leaf-DER
SHA-256 for both QUIC and the mgmt/library API, so the identity is resolved
ONCE in serve (the planes cannot race the first-run mint) under the rule:
identity files exist → use them; else the native trust store is EMPTY →
mint P-256 (fresh installs); else keep presenting the legacy RSA cert the
paired clients pinned, and log the migration path (unpair all, restart,
re-pair). Fingerprint pinning is algorithm-agnostic — existing shipped
clients pair against P-256 hosts unchanged.
Followers updated: the tray's loopback pin and the plugin SDK's mgmt CA
prefer native-cert.pem → cert.pem; the Windows runner ACL grant lists both
(the grant loop tolerates absent files). The in-process native tests now run
on an EPHEMERAL identity — they previously read, and would newly have
MINTED, identity files in the real config dir, which on a dev box that is
also a live host would have switched its identity and stranded every pinned
client.
Gates: Linux amd64 clippy --all-targets -D warnings clean (host+tray);
identity 2/2, mgmt 37/37, control 6/6, native 68/68 (C-ABI roundtrips over
the ephemeral identity). .133 Windows clippy clean; the port-lifecycle gate
re-run PASSES with the split live — the fresh host minted P-256 and served
mgmt over it (curl 200/204), ports tracked the paired list as before.
The user direction after WP0: ENet exists only for Moonlight, so the native
plane must be provably safe and the compat planes a deliberate choice.
Opt-in, everywhere. Windows already was (unchecked installer task). The three
opt-out surfaces are flipped: the shipped systemd user unit (deb/RPM/Arch/
sysext) no longer bakes --gamestream into ExecStart — a new
PUNKTFUNK_GAMESTREAM=1 host.env knob (pf-host-config, OR-ed with the CLI
flag) is the packaged opt-in; the NixOS module default goes true→false, with
a module-check assertion that unset = native-only; the Deck installer takes
--gamestream to opt in (--no-gamestream kept as explicit-off). Docs
(quickstart, running-as-a-service, moonlight, ubuntu/fedora/arch firewall
sections, gnome/sway, how-it-works) rewritten to the opt-in shape; the
CHANGELOG carries the upgrade note.
Enforced-safe. punktfunk-core is #![deny(unsafe_code)] crate-wide — every
module that parses network bytes is safe Rust as a compile error, not a
census result. Carve-outs are exactly two documented classes, neither of
which interprets attacker bytes: the client surface (abi, client) and the
transport syscall-batching shims (udp/{apple,linux,windows}, qos_windows).
In punktfunk-host, the modules a secure-default host exposes — native
(cfg-not-test: its tests exercise the client C ABI on purpose),
native_pairing, mgmt, mgmt_token, discovery, wol — are #[forbid(unsafe_code)].
Gates: Linux amd64 container clippy --all-targets -D warnings clean over
core+host-config+host; core 204 tests green under the deny; mgmt 46/46,
control 6/6. .133 Windows clippy (shipped features, clean-first,
sentinel-checked) clean — covers the qos_windows/udp-windows carve-outs.
macOS + iOS cargo check green (the apple.rs carve-out compiles for real).
A Nobara 44 field box on 0.27.0 got a working Game Mode takeover — right mode,
right refresh, perf overlay in the stream — and then every game it launched
played sound and took input over a BLACK PICTURE, with no error anywhere.
#144 disabled the distro's `VkLayer_FROG_gamescope_wsi` with
`--setenv=ENABLE_GAMESCOPE_WSI=0` on the transient unit, because that layer
speaks the distro gamescope's `gamescope_swapchain` protocol and ours rejects
its `swapchain_feedback`, killing the client. `gamescope-session-plus` then
runs an unconditional `export ENABLE_GAMESCOPE_WSI=1` near the top of the
script, before it launches anything — so the opt-out lived exactly as long as
it took the script to start, and gamescope, Steam and every game got the layer
back. The host logged that it had disabled it, which is what made this cost a
field round-trip: the claim was true of the unit and false of everything in it.
Nothing else looked wrong because the casualty is Vulkan clients specifically.
Steam's Big Picture UI is not one, so the session came up looking perfectly
healthy right up until a game started.
Send `DISABLE_GAMESCOPE_WSI=1` as well. The Vulkan loader resolves an implicit
layer's two manifest knobs in a fixed order (`loader_implicit_layer_is_enabled`):
`enable_environment` must equal `"1"` to switch the layer on, and
`disable_environment` is consulted last — "has priority over everything else" —
where the mere PRESENCE of the variable, at any value, forces it off. The
session script never mentions that one, so it is the only one that survives it.
Both spellings now go out through one place, so the transient unit
(`launch_session`) and the box's own session drop-in (`write_session_plus_dropin`)
cannot drift apart — the same shape `SessionBind` already uses for the bind.
`ENABLE_GAMESCOPE_WSI=0` stays alongside for a layer built without a
`disable_environment`, and because it is what an operator reads the unit for.
rusty_enet — a c2rust-style transpile of C ENet, 158 unsafe sites — parsed
unauthenticated UDP on 47999 from GameStream startup, before any client had
ever paired: the host's entire pre-auth-reachable unsafe surface. Pairing
itself is HTTPS on nvhttp and never touches the port, so it now binds only
while the paired-client list is non-empty: a Gate in control.rs reconciles
the port to the list (armed only under --gamestream), pairing phase 4 brings
it up before the new client can /launch, and removing the last pairing tears
it down — a live client gets the same termination+disconnect farewell as a
host-side session end. A never-paired host on a hostile LAN exposes no ENet.
En route: the management API's unpair never called save_paired, so a restart
resurrected the client — and would now have silently re-opened the port; it
persists (the test now runs against a throwaway PUNKTFUNK_CONFIG_DIR so it
can't clobber a real paired.json). rusty_enet is pinned =0.4.0 per the WP,
left to the cargo-audit job to flag advisories against it.
Gate (amd64 container): clippy --all-targets -D warnings clean;
gamestream::control 6/6; mgmt::tests 37/37 incl. the regenerated
api/openapi.json. On-box .133 verification (ports/pair/stream) still owed.
`ext_advertised` did `CStr::from_ptr(e.extension_name.as_ptr())` over a
driver-filled `[c_char; VK_MAX_EXTENSION_NAME_SIZE]`, and `vk_build.rs` open-coded
the identical call a second time. Neither had an in-Rust bound: a driver that
fills all 256 bytes without a NUL runs the walk into the NEXT
`ExtensionProperties`, and on the LAST element past the allocation.
The SAFETY comment asserted the spec guarantee ("a spec-guaranteed NUL-terminated
byte array") instead of enforcing it. That is the defect class this programme
keeps finding: a proof that restates what the other side promised rather than
checking it. Vulkan drivers are exactly the other side.
The bounded answer already shipped in the same crate — `pyrowave.rs:210` uses
ash's `extension_name_as_c_str()` for the identical job. It stops at
VK_MAX_EXTENSION_NAME_SIZE and returns Err when there is no terminator, so a
malformed entry is a non-match instead of an overrun. Both sites now route
through the one helper, which is no longer unsafe at all.
Deletes 2 unsafe operations and one duplicated walk.
⚠ The pre-existing test could not have caught this: it only ever built
well-formed, NUL-terminated entries. Added a case whose LAST element is 256
non-NUL bytes — the exact shape that used to leave the array — and a
prefix-match case, so the bound is now asserted rather than assumed.
Verified on 192.168.1.25 (Ubuntu, cargo 1.96.0 — the pinned toolchain):
cargo check -p pf-encode --features vulkan-encode,pyrowave --locked ok
cargo test -p pf-encode --features vulkan-encode,pyrowave ext_advertised
2 passed / 0 failed
cargo clippy -p pf-encode --all-targets --locked
--features vulkan-encode,pyrowave -- -D warnings clean
Linux-only code (`enc/linux/`), so the Windows leg is unaffected.
`27f08340` wrote every vtable offset assertion as `offset_of!(T, f) == N * SLOT`
so the slot INDEX stays visible in the assertion. For N=0 and N=1 that is
`0 * SLOT` and `1 * SLOT`, which clippy rejects as `erasing_op` and
`identity_op` — six errors, and windows-host.yml runs clippy with `-D warnings`,
so the branch as pushed would have turned the Windows leg red.
This is the blind spot the programme document names in §1.5, demonstrated on the
programme's own first code commit: 44% of the host's unsafe is `#[cfg(windows)]`,
no Linux or macOS check compiles it, and `cargo fmt`/`cargo check` on a Mac are
all clean. Only the .133 gate sees it.
Fixed with a `const fn slot(i: usize) -> usize` rather than by writing the two
offending cases as bare `0` and `SLOT`: that would have made those two the only
assertions where the slot index is invisible, and the index is the entire point.
Also records the cheap local gate that would have caught this without a Windows
round-trip: `amf_sys.rs` depends on nothing but `c_void`, so copying it into a
throwaway one-file crate and running `cargo clippy -- -D warnings` reproduces the
exact error on any host. Verified by reintroducing `0 * SLOT` and watching the
harness fail with the same message the runner gave.
Verified on 192.168.1.133 (Windows CI runner, the box with the WDK), after a
`cargo clean -p pf-encode` that reported `Removed 47 files, 135.5MiB` so the
recompile is real and not a cached green:
cargo check -p pf-encode ok
cargo check -p pf-encode --all-targets --features nvenc,amf-qsv,qsv ok
cargo clippy -p pf-encode --all-targets --features nvenc,amf-qsv,qsv
-- -D warnings exit 0 (was 101)
cargo clippy -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings
exit 0 (was 101)
The gate also greps the extracted tree for the assertions before building, so a
stale upload cannot produce a passing run.
`control_structs_roundtrip_through_bytes` built the legacy-size wire form in a
stack `let mut legacy = [0u8; 40]` (align 1) and then called
`bytemuck::from_bytes::<control::AddRequest>`. `AddRequest` opens with
`session_id: u64`, so it is align 8, and `from_bytes` hands back a REFERENCE
into the buffer — it panics unless the buffer happens to be 8-aligned.
A stack `[u8; 40]` usually is, which is why this passed on every machine and
every CI leg since it was written. Under Miri it fails outright: Miri does not
let an accidentally-favourable stack slot stand in for a guarantee.
Switched to `pod_read_unaligned`, which reads by value and has no alignment
precondition. That is not a new idea here — `ChannelProof::parse` at lib.rs:1013
already carries a comment saying "`pod_read_unaligned`, NOT `from_bytes`" for
exactly this reason. This site is the only other one in the crate that reads a
POD out of a stack byte array; every other `from_bytes` call in the tests reads
from `bytes_of(&x)`, which is aligned by construction.
Test-only, so no shipped defect — but the crate is `#![forbid(unsafe_code)]` and
is path-dep'd by BOTH the main workspace and the driver workspace, so it is the
layout oracle for every frame and IOCTL that crosses that boundary. A test that
cannot be trusted to fail is worth fixing there more than anywhere else.
Found by the first Miri run ever performed against this repo.
Verified on 192.168.1.25 (Ubuntu, cargo 1.96.0):
cargo +nightly miri test -p pf-driver-proto 21/21
cargo +nightly miri test -p pf-driver-proto --target x86_64-pc-windows-msvc
21/21
cargo test -p pf-driver-proto --locked ok
cargo clippy -p pf-driver-proto --all-targets --locked -- -D warnings clean
The cross-target run is the interesting one: it interprets the crate at MSVC
layout on a Linux box with no Windows anywhere. Nothing else in CI does that.
`connect_via_env_swap` did set_var("XAUTHORITY", …) / connect / restore, guarded
by a mutex that serialised this source against itself and against nothing else.
`getenv` takes no lock. setenv/unsetenv rewrite the process-global `environ`, and
glibc REALLOCATES that array when a variable is added — while, at that exact
moment, the PipeWire thread is inside pw_init()'s dlopen making bare getenv()
calls and EGL/CUDA init is running alongside. The file's own doc already called
the pattern "unsound from a live multithreaded host"; it stayed as a fallback.
Three things made it worse than the comment suggested:
- The damaging branch is the one where XAUTHORITY is ABSENT and therefore gets
ADDED (the realloc case). scripts/punktfunk-host.service deliberately does not
import the login shell's environment, so absent is the DOCUMENTED NORMAL
configuration for the shipped unit, not an edge case.
- `rediscover` re-runs this every 2 s for the whole session. A display whose
connect fails is never pushed into `displays`, so the dead-display skip never
covers it — the race is not once at startup, it repeats forever.
- It is unfixable in place. Sharing pf_vdisplay's ENV_LOCK is the wrong layer: it
cannot make C `getenv` take a lock.
The fix is to stop writing `environ` at all. Connecting with an explicitly empty
auth token is what the swap actually achieved: we only reach the fallback when
our own lookup found no usable MIT-MAGIC-COOKIE-1 entry, and x11rb's internal
lookup reads the same file with a STRICTER matcher (it matches family/address
too, which we deliberately do not), so where we find nothing it finds nothing
either and connects unauthenticated. That is exactly why the swap "worked"
against a nested Xwayland started without -auth.
Gives up one case: an .Xauthority using an auth family we decline to guess at but
x11rb would have handled. A gamescope Xwayland writes a single-entry
MIT-MAGIC-COOKIE-1 file, so it is not reachable here, and declining to attach a
cursor overlay beats tearing `environ` out from under a live session.
Also removes XAUTH_LOCK, whose only user this was.
Verified on 192.168.1.25 (Ubuntu, cargo 1.96.0 — the pinned toolchain, pipewire
dev headers present): `cargo check -p pf-capture --locked` and
`cargo clippy -p pf-capture --all-targets --locked -- -D warnings` both clean.
Not verified on glass: the fallback is only reached when the cookie parse fails,
so a normal gamescope session does not enter it. Forcing it needs a nested
Xwayland started without -auth, or a mangled cookie file, on .181/.136.
Five permanent ratchets, all free today — the point is that they cannot regress
tomorrow. Each crate was re-measured at the commit, not taken from a survey.
`forbid(unsafe_code)`:
punktfunk-encode-worker the binary that carries cap_sys_nice. Its header
claims "no Wayland, no D-Bus, no network, no
plugins"; this makes the memory-safety half of that
claim mechanical. `forbid`, not `deny`, so it cannot
be re-opened by an #[allow] further down.
pf-update-check parses a signed, network-fetched manifest and its own
header says it "owns the part where being wrong is a
security bug". Signature checking is worthless if the
parser around it can be walked out of bounds.
pf-vaadec its header states the design constraint outright — it
links no libva and compiles on macOS, "which is the
point". The crate is full of hand-declared libva
repr(C) mirrors; one raw deref and it stops being the
CPU-testable half.
tools/cursor-probe free, and a probe is where "just deref it to see" is
most tempting.
`deny(unsafe_code)` + one localized allow:
pf-update root runs this. Its single unsafe operation, a bare
geteuid, moves into a named `effective_uid()` helper
carrying the crate's one #[allow(unsafe_code)].
Deliberately NOT rewritten to rustix, contrary to the programme document's first
draft: pf-update's Cargo.toml states that its zero-dependency posture IS a
security invariant of a root helper ("no HTTP client, no TLS, no argument
parsing"), and the extern block says the same. Pulling a general-purpose syscall
crate into a root helper to delete one `unsafe` would trade a real property for
a cosmetic one. The localized allow keeps the ratchet: any NEW unsafe anywhere
in the crate is a build error.
Verified: `cargo check -p pf-vaadec -p pf-update-check` and
`cargo check -p pf-update -p cursor-probe` clean on macOS, plus
`cargo check -p pf-update --target x86_64-unknown-linux-gnu` — pf-update's whole
body is behind `cfg(target_os = "linux")`, so the macOS check does not reach the
line that changed. punktfunk-encode-worker is not built here (pf-encode's C
dependencies do not cross-compile from macOS) and needs the Linux CI leg.
amf_sys.rs mirrors five AMF COM vtables by hand and amf.rs dispatches through
them BY SLOT POSITION — 18 distinct slots across the five tables. The mirrors
carried 118 `Slot` placeholders whose only job is to hold the following slots at
their C offsets, and not one layout assertion of any kind. A slot inserted,
removed or reordered in an AMF header bump calls an arbitrary function pointer
through a mismatched signature: no compile error, no runtime signal.
`AMF_MIN_VERSION` does not defend against this. It checks a version NUMBER, not
a layout, and it is a floor with no ceiling.
The three POD checks that did exist (`AmfVariant`, `AmfGuid`, `AmfHdrMetadata`)
lived in amf.rs's `#[cfg(test)]` module, so they were verified only when someone
ran pf-encode's tests, on Windows, with AMF enabled — and NEVER in a release
build, which is exactly where a mis-mirrored `AMFVariantStruct` does its damage:
it crosses the FFI BY VALUE on every SetProperty. This is the same hole
`a8dd348b` closed for the cuda.h mirrors and missed here.
Adds ~40 `const _: () = assert!(...)` guards next to the mirrors: size of each
of the five vtables, the byte offset of every slot amf.rs actually calls, the
three POD layouts promoted out of the test module, and the AMFData/AMFBuffer
shared-prefix agreement that `create_surface_from_dx11_native`'s
AMFSurface-through-AMFData reinterpretation silently depends on.
Verified by compiling amf_sys.rs standalone (it needs only `c_void`, and a
repr(C) struct of code pointers has the same layout on any 64-bit target, so a
macOS const-eval proves the Windows arithmetic), and by deliberately breaking one
offset to confirm the guard actually fires rather than silently passing.
That check earned its keep immediately: `alloc_buffer` sits at slot 43, not 42.
Counting AMFInterface(3) + AMFPropertyStorage(10) + the AMFContext block by hand
is exactly the error these assertions exist to catch.
Zero runtime behaviour change. The `AMF_MIN_VERSION` ceiling is deliberately NOT
part of this commit: a ceiling would make the next AMF driver release refuse
encode on every AMD box, so it needs a warn-and-continue policy plus an env
override and a real AMF session to gate it.
Founding commit for a host-focused Rust safety programme. Adds the census tool
that measures the programme, the 2026-08-11 baseline it produces, and the
programme document itself.
The metric is SHIPPED NON-FFI UNSAFE OPERATIONS: 713. Raw `unsafe {}` block
count is the wrong target and the workspace manifest already says why — 63.3%
of unsafe operations in host scope (1542 of 2435) are a single third-party FFI
call that ash/windows-rs/ffmpeg mark unsafe on our behalf. A block count also
rewards merging blocks, ignores SAFETY comments, and IMPROVES when code moves
from Linux to Windows, because no local check can see the Windows half.
The tool shipped here had two defects, both fixed:
- `in_test_mod` cached parsed `#[cfg(test)]` spans in a dict keyed on `id(src)`,
the memory ADDRESS of the source string. CPython recycles addresses, so once
one file's source was collected the next file's string could be allocated at
the same address and silently inherit the previous file's test spans. Ten
consecutive runs over an unchanged tree produced 694, 695, 696, 701, 703,
709, 710, 713, 714 and 721. Fixed by holding a strong reference to the string
beside its spans, which makes the address un-recyclable while the entry is
live. Five consecutive runs now agree exactly.
- The layout-assertion regex matched `const _: () = assert!(...)` but not the
`const _: () = { ... };` block form, which 18 files use — including abi.rs,
pf-inject/linux/gamepad.rs and pf-capture/.../idd_push/probes.rs. It reported
102 unguarded repr(C) declarations across 25 files where the true figure is
60 across 22, defaming three well-guarded files.
A metric that is not reproducible is not a ratchet. The acceptance gate for
this commit is therefore five consecutive identical runs, not one.
Baseline: 713 shipped non-FFI unsafe operations; 60 unguarded repr(C)
declarations across 22 files; unsafe reachable pre-authentication by an
unpaired peer = 0 first-party.
`main` gained the launcher brand tokens (`f62a48d4`) while this branch was open, and both sides
touch the generated document — so it was regenerated from the MERGED source rather than
text-merged. Verified to carry both: the 18 launcher-token entries from main, and this branch's
corrected schema descriptions. No `required` array changed, so no client regeneration is needed.
The sweep rewrote doc comments on `ToSchema` types (`KeepAlive`, `Topology`, `ModeConflict`,
`Identity`, `LayoutMode`, `Layout`, `DisplayPolicy`, `EffectivePolicy`), and utoipa emits those
verbatim as schema descriptions — so the checked-in snapshot went stale and
`mgmt::tests::openapi_document_is_complete_and_checked_in` would have failed.
Several of the corrected descriptions were shipping outright falsehoods to API consumers. The worst:
`KeepAlive::Forever` documented itself as "**Not honored until the display-lifecycle stage**" while
the mgmt handler honors it end-to-end and the `gaming-rig` preset selects it (sweep item 11.7).
Diff is descriptions only — the `required` arrays are unchanged, so no SDK or client regeneration is
needed. Generated with `cargo run -p punktfunk-host -- openapi` in `ci/rust-ci.Dockerfile` under
`--platform linux/amd64`, and confirmed by running the host's own drift test there (37 mgmt tests).
`docs-site/public/openapi.json` is deliberately untouched: it is already ~34 KB behind `api/` from
earlier work, and refreshing it here would sweep in unrelated changes.
An adversarial review of the sweep's own diff raised 39 claims; 23 survived independent
verification. This commit fixes them. Several are cases where the sweep traded one bug for another.
**The display budget was enforced in the wrong place.** The new Linux `max_displays` ceiling sat in
`registry::acquire` — which runs again on every mid-stream rebuild. All three create-before-drop
paths hold the old lease while acquiring the new display, and only the mode-switch path passes
`supersedes`, so a session at the ceiling counted itself against the budget and could never recover
from capture loss or a Game↔Desktop switch. At `max_displays = 1` that is a single streaming client.
Moved to `admission::admit`, which is where Windows has always applied it and which is reached once
per connect — so a rebuild cannot hit it.
**"Cannot tell" was collapsed into "wrong mode".** `unanimous_output_size` returning `None` for two
disagreeing gamescopes was compared with `== Some(target)`, so ambiguity took the destructive branch:
a nested per-title gamescope — the normal Game Mode shape — made every connect restart the box's
session and kill the running game. Now a three-state `BoxOutputSize`, where `Ambiguous` mirrors the
live node instead of re-moding, and the post-restart wait asks "did what we asked for come up"
rather than demanding unanimity.
**Decide-then-act lost its mutual exclusion.** Re-scoping the `MANAGED_SESSION` guard fixed the
shutdown restore but let two concurrent creates at the same mode both relaunch, the second stopping
the unit the first was polling. A separate `MANAGED_LAUNCH` mutex restores the exclusion without
putting launch progress back into the lock the restore samples.
**Per-axis policy salvage was applied to a selector.** `preset` chooses the other axes, so salvaging
it to the default silently re-pointed the whole document; it now refuses the document instead. A file
whose every axis is unreadable also reported `configured() == Some(default)` — flipping Linux
identity from Shared to PerClient — and now correctly reports unconfigured.
**The six `#[serde(default)]` on `EffectivePolicy` are reverted**: they loosened `POST/PUT
/display/presets` (an omitted axis defaulted where it used to 400), which nobody asked for. The
catalog salvage they were added for now lives in a private Deserialize-only mirror type, so the read
path stays lenient and the wire contract stays strict.
Also: the Windows create path stored the OS-committed refresh in the field `acquire` uses as its
resize discriminator, so a same-mode re-acquire looked like a hotplug — the requested and committed
modes are now separate fields; `output_within`'s timeout arm detached both reader threads (now
bounded by a drain grace, capped at 16 MiB, and logged honestly — a `systemd-run --pipe` unit escapes
the process group and cannot be reached); `reenable_outputs_kscreen` abandoned the mode restore
whenever kscreen-doctor hit its budget even though the enable may have landed (now tri-state);
`write_atomic` replaced a symlinked portal config with a regular file, severing dotfiles management;
several new budgets were too short for the helper they bound (`steam -shutdown` was being killed
before it could deliver the request; `linger_enabled` read a 300 ms timeout as "not lingering" and
hard-failed a correctly configured box); and a restore logged an operator-facing error for a
`systemctl` call that had merely outlived its budget while systemd still owned the queued job.
Verified: 107 tests on macOS, 202 on Linux (executed in a container, not merely type-checked),
Linux and Windows clippy clean at `-D warnings`, fmt clean.
Reviewed the whole crate (15.6 kloc) for bugs, safety, structure and comment truth.
Both compile gates are green: `scripts/xcheck.sh windows clippy` and
`cargo clippy -p pf-capture --all-targets --locked -- -D warnings` in the amd64 CI
image (the Linux half needs libpipewire, so it cannot ride xcheck).
Code defects, each one contradicted by a comment sitting next to it:
* `pipeline_depth` clamped to `OUT_RING` (3) while both `repeat_last` and `OUT_RING`
state the safe maximum is 2. `d` frames in flight need `d + 1` textures, so
`PUNKTFUNK_IDD_DEPTH=3` rotated onto the slot NVENC was still reading and the convert
overwrote it in place — torn frames, silently. Now `OUT_RING - 1`.
* The GDI cursor poller published `visible: true` for a NULL `hCursor` carrying
`CURSOR_SHOWING` — how an app hides the pointer for its own window. The last
rasterised arrow was then blended into a game that had hidden its cursor. Every
rasterise gate already tested `handle != 0`; the published verdict now agrees.
* The ETW event callback did `RING.lock().unwrap()`. That is an `extern "system"` fn, so
a poisoned lock panicked across an FFI boundary and ABORTED the host — a diagnostic
taking down capture. Poison-tolerant now, which also makes the poison unreachable.
* `ChannelBroker::send` bounded the ring with `debug_assert`, so a release build instead
panicked mid-`duplicate_and_deliver`, unwinding past the reap and leaking every handle
already planted in the driver's WUDFHost. Refuses before the first duplication.
* `set_active(false)` did not clear `stall_since`, so a pooled capturer carried a stale
stall clock into its next stream and reported capture loss microseconds in.
* `attach_gamescope_cursor` evaluated `spawn` before dropping the old source: two readers
published into one slot, and a failed spawn destroyed a working reader. Idempotent now.
* `PUNKTFUNK_FORCE_SHM` used a bare `== "1"` compare, silently ignoring `=true`/`=on`.
* `spa_meta_bitmap.offset == 0` is SPA's "no image data" signal, distinct from the
`bitmap_offset == 0` position-only case. Unhandled, it decoded the header's own words
as cursor pixels and cached them.
* A `VideoInfoRaw::parse` failure was swallowed, so a malformed Format pod surfaced as
the generic "no acceptable format" timeout. It is logged, and parsed once, not twice.
Comment corrections, all verified against the code they describe: four claims that a
failed open falls back to DDA (removed — the caller drops the keepalive under
"no fallback"); three comparisons to the removed WGC path; "we do NOT gate HDR on the
client's VIDEO_CAP_10BIT" (it does, in three places); the P010 sampler's "4 explicit
taps / 2x2 box" (two taps, left-cosited — the box was the bug it replaced); the cursor
meta cap quoted as 256x256 (1024, and 256 is the value that cost the whole Linux cursor
channel on-glass); the poller's "~60 Hz" (4 ms, ~250 Hz); "several minutes of coverage"
(~26 s); "8 frames in 400 ms >= 20 fps" (7 intervals, so 17.5); three "process-wide" HDR
latch claims (per-source, which is why HdrSource exists); a SAFETY proof claiming a view
is "unmapped never" (Drop unmaps it); the Linux module header describing a bounded
channel and BGRx-only frames (one-deep overwriting slot, several formats); and a doc
line stranded on `DisplayDescriptor` by an earlier split, restored to `IddPushCapturer`,
which had none.
`portal_config::ensure_key` folded EVERY read failure into an empty string
(`read_to_string(path).unwrap_or_default()`). `upsert("", …)` then produced a file containing only
our block, the one-time backup was skipped because `!existing.is_empty()` was false, and the write
replaced the user's config — returning `Ok(true)`.
So a single Latin-1 character in a comment in `~/.config/hypr/xdph.conf` or
`~/.config/xdg-desktop-portal-wlr/config` destroyed the operator's entire portal configuration, with
no backup and no warning. The module doc says flat-writing these files "destroyed [everything else]
on first connect, silently and permanently" and that this module exists so it cannot happen; that
one line re-opened the door. The same shape hit a transient EIO on an NFS or overlay config dir.
Now: bytes are read with an explicit match, only `NotFound` may mean "empty", a non-UTF-8 config is
refused by name rather than replaced, the backup is taken by BYTES, and the write is atomic
(temp + `sync_all` + rename in the same directory, permissions carried over). Five new tests, all
running on macOS — `a_non_utf8_config_is_refused_not_replaced` fails against the old code.
Also in the wlr/Mutter family:
* **Mutter's `Primary` rebuilt kept physicals from scratch** — scale forced to 1.0, transform to 0,
disabled heads re-enabled — so a rotated, 2x-scaled or deliberately-disabled monitor came back
wrong, while the code went to real trouble to preserve refresh. Each head now carries its
pre-connect scale and transform, and x advances by the LOGICAL width.
* Three availability probes read session env (`SWAYSOCK`, `XDG_CURRENT_DESKTOP`,
`HYPRLAND_INSTANCE_SIGNATURE`) with no `ENV_LOCK` while `apply_session_env` `set_var`s the same
keys from another thread — the glibc setenv/getenv race this crate's own lib.rs documents as UB.
* `wlroots::create_output` ran a statement before its `OutputGuard` existed, so a raced
`wait_new_output` orphaned the output permanently — hyprland takes the guard first. The
before/after name diff also ran outside any lock, so two concurrent creates could adopt each
other's output. Both now run under a create lock, with a stray sweep on the failure path.
* `select_and_cast`'s timeout arm dropped the portal thread's `stop` flag un-set — the same leak
Mutter was already fixed for. The guard is now built before the wait, in both copies.
* The xdpw chooser file was written per session and never removed, permanently shadowing the
config's fallback with the name of an already-unplugged output. Its lifetime is now the handshake,
not the session — scoped deliberately, because tying removal to the keepalive would let one
session delete another's selection hours later.
* Hyprland's headless outputs are now named `PF-<pid>-<n>` and reconciled at startup, so a crashed
host's leftovers are reclaimed while a live sibling host's outputs cannot be pulled out from under
it. `set_monitor_rule` no longer discards hyprctl's rejection text and then hard-codes a
GBM/dmabuf diagnosis it never verified.
* Both wlr backends silently dropped the `topology` policy axis: `Primary`/`Exclusive` was accepted,
echoed by the mgmt API, applied on three backends and a no-op on two. They now say so.
Item 8.1: `swaymsg`, `hyprctl` and the portal `systemctl --user try-restart` calls are bounded
through `proc` with named budgets.
* **`reenable_outputs` returned `true` when it matched NONE of the requested outputs.** Unresolvable
outputs were `continue`d and the return was the apply verdict alone — but an empty
`kde_output_configuration_v2` still gets an `applied` event. So a total no-op suppressed the
`reenable_outputs_kscreen` backstop and the operator's physical monitor stayed dark. Now counts
staged outputs and returns `ok && matched == outputs.len()`, and refuses to apply an empty
configuration at all.
* **The kscreen restore logged "restored the physical/bootstrap outputs" unconditionally**, with
both call results discarded — including when `kscreen_ok` returned false on its 5 s budget, which
is exactly the wedged state that fallback exists for.
* **`Session::open` swallowed every failure reason** — connect error, barrier timeout, missing global
— and three of four callers degraded to kscreen-doctor with zero log. This is the class that hid
the KWin >= 6.7 registry regression: a shipped fallback firing silently on every machine. It now
logs at warn with the reason and the caller's operation name.
* `last_name` was seeded with a name kscreen-doctor can never resolve (KWin's address is
`Virtual-punktfunk…`), so the intended default was guarded by an `is_none()` that could never hold
and `apply_position` ran against no output. `our_uuid` was never reset per `create` and only
assigned under `outcome.handled`, so a supersede positioned the *previous* output and never fell
back.
* `probe()`'s `roundtrip` was the only unbudgeted compositor wait in the crate — every sibling path
is budgeted — and it is reached from an async mgmt handler. Now bounded at 3 s. The pre-`created`
dispatch loops gained deadlines and now set `stop` on the timeout arm.
* Every `wl_output` global was bound for the session's life with no `GlobalRemove` arm and no
`release()`, on the virtual-output path too, which never reads them: unbounded growth on a
hotplugging session.
* `monitors::list` was the one KWin call site with no kscreen fallback at all, despite `list_monitors`
failing on exactly the condition the other four fall back for. It has one now.
* `CVT_H_GRANULARITY` and `MANAGED_PREFIX` existed as two literals under prose asserting they match;
the second copy now imports the first.
The wider facade extraction (item 9.1) is deliberately not in this commit, but its two prerequisites
are — a comment at the restore seam records why they had to come first: a fallback arm that returns a
value the helper never checked re-introduces the silent success, behind a seam whose selling point is
one honest log per decline.
Also corrects the `PhysicalMonitor` type doc, which claimed "logical geometry throughout" while
`width`/`height` are the mode's PIXELS and `x`/`y` are logical, and adds the `logical_size()` helper
that is the only correct way to compare an extent against a position.
The gamescope subsystem — the crate's largest and fastest-churning area, and the one the 2026-07-28
sweep predates most of.
* **`MANAGED_SESSION` was held across the ~90 s managed launch**, and the shutdown/idle restore
blocks on that same lock — *after* it has already stopped our unit. So the display-manager restore
never ran and the box was left with no session at all. `create_managed_session` now decides under
the guard and acts outside it, re-acquiring only to store the result; `do_restore_tv_session`
consumes the record in a short scope at the top. Same shape the SteamOS twin already used.
* **The physical-display guard was bypassed whenever no gamescope node happened to be published.**
`if physical_display_connected() { if let Some(node) = find_gamescope_node() { … } }` fell through
to `set-environment SCREEN_WIDTH/HEIGHT/CUSTOM_REFRESH_RATES` + `restart` when the node was
momentarily absent — gamescope restarting between titles, or built without PipeWire — flipping the
operator's own screen to the client's resolution and bouncing a DM-driven login session. The guard
now refuses instead of falling through, and the forced `SCREEN_*` values (which were never unset,
so every later session on the box inherited them) are tracked and `unset-environment`ed on restore.
* **`current_gamescope_output_size()` reported an arbitrary gamescope's `-W`/`-H`** — whichever
`/proc` enumerated first — and four consumers treated it as this session's output size. It now
answers only when every gamescope on the box agrees, and `None` ("cannot tell") when they differ.
`heads.rs` no longer takes it at all: it reads the size off the DRM-backed argv it already
selected. Its test previously passed `None`, which is why the hazard was invisible.
Resource and honesty fixes: the ATTACH path armed the box's own session-unit bind drop-in and no
in-process path ever removed it (now tracked and disarmed on both restore arms); `wait_for_node`
never called `try_wait`, so a gamescope that died at `vkCreateDevice` was polled for the full 15 s
and the error then blamed headless capture support; `do_restore_tv_session` deleted its crash-recovery
state *before* the unbounded work that state records, so a grace-period expiry in that window left
the DM down with nothing on disk to heal it; the SteamOS takeover's two failure arms never armed the
TV restore though the session-plus twin does; the TV-session restore logged success with the
`systemctl` status discarded; the `steam -shutdown` child was dropped un-reaped; and a managed
session that took nothing over was never persisted, so a host crash orphaned the transient unit.
Item 8.1: the unbounded `pw-dump`, `systemctl`, `loginctl` and `pkexec` calls in this subsystem now
go through `proc::{status_within, output_within}` with per-call budgets. `pw-dump` is polled from
three separate 45 s loops against the very daemon this file documents gamescope as head-blocking,
and until now a hang there pinned the session's stream thread forever.
Windows half of the sweep — the reap bug, a panic that poisons two locks, and a round of unsafe
reduction.
* **The ghost reap selected the wrong devices.** It filtered `Status -ne 'OK'`, a HEALTH field: that
matches devices that are PRESENT but in Error/Degraded/Unknown, not the ABSENT ones the reap is
for — and it handed them to `pnputil /remove-device`, contradicting its own documented contract.
It runs from `add_monitor`'s mid-session slot-exhaustion recovery, so the blast radius is a live
session. Now filters on `-not $_.Present`.
* **`ensure_pinger` still used the panicking `thread::spawn` while holding two locks**, poisoning
both — the un-fixed twin of a fix that already landed for `ensure_exclusive_watch`. Same shape
applied.
Unsafe reduction, continuing the program that made pf-win-display's CCD helpers safe fns:
* `resolve_target_gdi` and `reisolate_after_swap` were `unsafe fn`s containing zero unsafe
operations, and the three call-site SAFETY proofs described FFI they no longer perform. Both are
now safe fns and those blocks are gone.
* `VdisplayDriver::open`'s `# Safety` section named no caller obligation — the same empty shape an
earlier phase already removed from `open_device`.
* `(*detail).DevicePath.as_ptr()` derived a pointer from a `[u16; 1]` field and handed it to
`CreateFileW`, which reads the whole flexible-array path beyond it. Now taken with `&raw const`
from the full struct, so the pointer carries the provenance of the bytes actually read — the same
correction already made for `MONITORINFOEXW` in ddc.rs.
Comment fixes, all verified against the code: three intra-doc links to a type this crate does not
have; a doc-comment run merged so that `shrink_action` — the gate that keeps a `Primary` group's
physical panels lit — read as undocumented while its rationale sat on an unrelated polling helper;
and the backend module header, which documented itself against a `sudovda` module that does not
exist and a fallback the crate says was removed.
Adds the first tests for `knobs.rs`, `instance.rs` and `driver.rs` — including `is_privileged_sid`,
the security-relevant predicate that decides whether an existing single-instance name is another
host or a squat, which had no coverage on any platform.
`gamescope_ours_and` answered "did WE spawn this gamescope?" by reading `PUNKTFUNK_GAMESCOPE_NODE`.
Phase 2.3 deleted the code that published that key — routing.rs's own doc says "Nothing is written
back to the two knobs" — but this consumer was never migrated, so the read now returns "not
attaching" for every attach.
Both consumers then answer for a session this host has no flags on. On a plain box with a foreign
gamescope already running, `pick_gamescope_mode` resolves Attach at its fifth rung while the env key
stays unset, and the probe half only inspects the resolved BINARY, which is our patched build:
* `gamescope_composites_cursor()` returns true, so the host attaches no XFixes reader and blends
nothing — while the stock gamescope actually running was never given
`--pipewire-composite-cursor`, so the stream carries no pointer at all.
* `gamescope_hdr_available()` returns true, so the Welcome fixes `bit_depth` at 10 and the session
negotiates BT.2020/PQ over an 8-bit SDR composite. The Welcome cannot take that back.
The same two failures hit the `capture_monitor` mirror route on any Bazzite or SteamOS box, where
the running Game Mode gamescope is by definition not one this host spawned.
The question is now asked of the resolved route rather than the environment, via a pure
`session_is_a_foreign_gamescope` that runs — and is tested — on every platform. The residual gap is
named in the doc rather than papered over: `create_managed_session`'s create-time degrade to a
foreign attach is still invisible to a ladder re-run.
Also in this commit:
* Two unguarded session-env reads now take `ENV_LOCK` (`detect()`'s `XDG_CURRENT_DESKTOP` fallback
and `effective_topology()`'s legacy pins). `apply_session_env` `set_var`s those same keys from
another thread, which is the glibc setenv/getenv race this crate's own lib.rs documents as UB.
* `mirror.rs`'s `names_ours_conclusively` was a `matches!` whose omitted default was the UNSAFE
direction — a new backend would silently get its own virtual displays mirrored. Now exhaustive, so
adding a `Compositor` is a compile error at the one site where the answer is a safety decision.
* `MirrorDisplay` overrides `poolable_now() -> false`; its `create` always reports `External`, so
the trait's `true` default was a pre-create claim contradicting the post-create fact. The trait
doc now says plainly that the default is a default and not a fact.
* The crate front-door doc listed 3 of 7 backends and quoted line counts half the size of the
current crate; `routing.rs`'s summary was attached to the wrong item and described a published env
channel that no longer exists; `available()` is no longer documented as cheap when it forks
`gamescope --version` and does an unbudgeted Wayland roundtrip per call.
The policy layer folded every failure into "unconfigured", then wrote that emptiness back.
* **Any parse error reverted the WHOLE policy.** An unknown enum variant, a mistyped scalar, an
EACCES or EIO — all became `Err(_) => None`, i.e. the host silently ran on built-in defaults with
the operator's `display-settings.json` still sitting on disk. Parsing is now layered: strict
first, then per-axis salvage so one unreadable axis costs only that axis, and only `NotFound` is
quiet — EACCES/EIO warn loudly that the host is on defaults. `version` is read instead of being
blindly rewritten to 1.
* **One malformed entry dropped the entire custom-preset catalog**, and the next CRUD atomically
renamed the empty vector over the file. Entries are parsed one at a time now; a lossy load is
flagged and refuses to overwrite.
* `sanitized()` clamped `max_displays` but never `KeepAlive::Duration.seconds`, so a PUT could pin a
display for ~136 years — a deadline the reaper never reaches and a nonsense `expires_in_ms` in
`/display/state`. Clamped to a day, in both `sanitized()` and `sanitize_preset_fields`, and
sanitization now runs on LOAD as well as on write.
* The two stores' temp files had fixed names and no write lock, so concurrent saves could interleave
serialize -> rename -> in-memory update. Unique suffixes, a lock, and the in-memory update ordered
after the rename.
* `new_preset_id` never consulted the loaded entries for collisions.
* **Manual layout could place an unpinned display exactly on top of a pinned one**: the fallback was
the unconditional auto-row prefix sum, blind to where prior members were pinned. Unpinned members
now pack clear of the pins. Layout keys are canonicalized and unusable ones dropped at write time
rather than persisted-and-ignored.
Adds 20 tests, all running on macOS: a 20k-round randomized property test asserting no unpinned
member ever overlaps a sibling (verified to fail against the pre-fix `arrange_manual`), the salvage
and quarantine paths, the clamps, and a field-count guard that fails the moment a 13th policy axis
appears without being wired into the merge path.
Note: `partial_json_fills_defaults` was renamed to `serde_defaults_fill_a_partial_document` with no
assertion weakened — it pins the FILE contract (an old settings file must still load), which is not
the mgmt PUT contract that sweep item 11.1 is about.
Five defects in the registry/identity half, plus the restructure that finally makes them testable.
* **A new client could be assigned a LIVE client's identity slot.** `DisplayIdentityMap::resolve`
LRU-evicted purely on its `seen` stamp, with no knowledge of which ids are streaming. On Windows
that id keys the manager's slot map, so the newcomer took the plain-JOIN branch and inherited the
other client's monitor, capture target and stop flag. `resolve` now takes the live set, never
evicts a live id, and REFUSES rather than hand one over — degrading to the shared/auto identity.
* **A transient `ActiveKind::None` invalidated every backend entry, including live streaming ones.**
A `read_dir("/proc")` that happened to fail satisfied the change test and bumped the session
epoch. A `None` observation is no longer evidence a desktop went away, and no longer overwrites
the baseline (which would have bumped the epoch on the next poll anyway).
* **The Linux pool had no display ceiling at all** — `max_displays` was enforced only on Windows,
while the pool keys on the CLIENT-SUPPLIED mode, so each distinct requested resolution minted a
new display. Now capped in `linux::acquire`, gated on `poolable_now` so a gamescope attach or
managed session (which consumes no pool slot) is not refused.
* **Two different definitions of "display group"** — `group_key` and a bare backend-name compare —
and only one separated gamescope spawns. Unified as `pool::in_group`. The `position_for_new`
collection also lacked the supersede exclusion the topology check 70 lines earlier had, so a
mid-stream resize auto-rowed the replacement past its own dying predecessor, walking the display
one width to the right on every mode switch.
* **Lifecycle events were wrong in both directions**: `Created` fired on keep-alive reuse, and
`Released` fired only from the mgmt endpoint — never from a lease drop, the linger reaper,
`mark_failed`, `retire` or `invalidate_backend`. All six now emit.
Also: `Release::Noop` no longer runs a full teardown (the one outcome the state machine defines as
"do nothing"); a failed linger-reaper spawn logs and retries instead of consuming its `Once` and
never tearing a kept display down again; group ids are a monotonic per-key counter instead of an
index into the currently-live sorted set, so an unrelated group appearing no longer renumbers a
display; and a corrupt `display-identity.json` is renamed to `.bad` with a warning rather than
silently overwritten, which used to reset every client's stable id and its saved DPI.
The pure half of the pool (`Entry`, `group_key`, `epoch_matches`, `take_expired`, `at_display_budget`,
`position_for_new`, `assign_group_ids`, `assemble_displays`) is now a non-cfg'd `mod pool`, so the
registry's decisions are exercised on every platform's CI instead of only on a Linux box. Crate test
count 53 -> 94.
`output_within` read stdout/stderr only after the child exited, and its doc justified that with
"these helpers emit at most a few hundred KiB, well under any real pipe pressure". A pipe holds
64 KiB. Anything past that blocks the helper in `write()`, so it never exits, the budget kills it,
and a successful query is reported to the caller as `TimedOut` with its answer discarded.
The busiest caller is the one that trips it: `pw-dump` on a populated PipeWire graph clears 64 KiB
routinely and is polled from the 45 s gamescope loops. Confirmed empirically — a child writing
1 MiB into an undrained pipe never exits.
Both pipes are now drained on their own threads, concurrently with the wait.
That makes the joins load-bearing, which exposed the second half: the Unix `tree::Guard` was an
empty stub whose doc claimed `Child::kill` "already ends the only process there is". It never did
for this crate's Linux helpers — `pkexec`, `systemd-run`, `systemctl --user` and the `sh -c`
wrappers all fork — and a surviving grandchild holds the pipes' write ends, so a reader would wait
for an EOF that never arrives. The child is now the leader of its own process group and the guard
`killpg`s it, which is the Unix shape of the Job object the Windows half already used.
Also gates `pf-frame`, `pf-gpu` and `pf-encode` to Windows: every use site of all three is
`cfg(windows)`, and between them they dragged FFmpeg, ash and openh264 into the Linux build for
nothing (sweep item 13.19).
Every item from the on-glass review on the Nothing Phone 3, which found the refresh technically
correct and visually wrong.
**The glass was structurally broken, twice.** The focus bloom was drawn OUTSIDE the clip on
purpose ("a glow that stops at the edge is just a brighter border") — but unclipped drawing does
not stop at the row's neighbours either: in a list it painted over the rows above and below, and in
the carousel it escaped the card entirely. And the focus drop shadow is drawn UNDER the surface,
which is translucent — so the shadow showed straight through the fill as a dark rectangle floating
inside every card and field. Both are gone, and the comment forbids their return: the Apple glass
(`GlassStyle.swift`) is material + an animatable tint, full stop. Focus is now the fill and border
brightening (tint up to 0.28, near Apple's 0.30) — which is also why it finally animates like the
Apple client: one interpolating fill instead of four stacked effects arriving on separate curves.
**The select field is now the Apple drum.** `ConsoleOptionBand` ports `GamepadOptionBand.swift`
whole: options ride a turning cylinder segment, position driven by one spring whose retargeting
preserves velocity — rapid steps accumulate into one accelerating travel instead of five restarted
fades. Linear, not a ring; neighbours exist only mid-flight; the soft edge is per-option opacity,
never a mask (a mask rasterises the projection away — the Apple file's own field verdict). The
band's width is fixed by the row, so a step can never reflow the chevrons; portrait narrows it
(132 dp) because at 156 the LABELS truncated, and a clipped label loses meaning where a drum value
only loses its tail into the edge fade. Chevrons are icons now, not '‹' text glyphs.
**Landscape got room.** Rows cap at the Apple client's 620 dp and sit left; the focused row's
description moved into the width a wide phone was wasting — a side pane on the right, with the
row's label anchoring it — instead of a band floating over the list's tail. Portrait keeps the
band. Add-host fields take the same cap.
**The list is no longer guillotined.** The safe area now applies to the CHROME (strip, sides, top)
only; the list runs to the physical bottom edge with the bottom inset folded into its
contentPadding, so scrolled rows glide off the screen instead of being cut at an invisible line
above it.
**"Default settings" stopped shouting.** The headline that repeated itself on every tab is a quiet
"Defaults" chip at the strip's end — same honesty (this screen edits the base layer only), no
second heading repeating the tab pill's own word.
**Tab switching is finally directional.** The old cut slid a single list's contents 24 dp under an
85 % fade — the same crossfade whichever shoulder was pressed. It is now an AnimatedContent whose
incoming section slides from the side the press pointed at while the outgoing leaves the other way.
Each pane owns its own LazyListState (one state cannot attach to two lists — the constraint that
motivated the single-list hack), seeded at the section's restored cursor.
**The carousel ignores the safe area again, on purpose.** Insetting the pager clipped the fanned
neighbours at the cutout edge; cards visibly cut off is worse than cards behind a camera. Only the
centred card matters and it sits mid-screen — the title and legend keep their insets, they are
content.
**The legend stopped jumping.** The connect takeover parked its pill at bottom-CENTRE, so pressing
Connect made the one piece of chrome that should read as fixed leap halfway across the screen. It
now sits at the same bottom-start inset as every console screen.
A launcher tile (role: "launcher", design D4) shipped no art on purpose:
a launcher's own icon is square, every client cover-crops a 2:3 poster,
and the crop turns a mark into a strip. So the tiles were the launcher's
name on a flat accent face — legible, and the blandest thing in the grid.
Entries now carry an optional `icon`: the NAME of a brand mark, never
image bytes and never a URL. `[a-z][a-z0-9-]{0,31}`, shape-validated by
the host on every lane (a client interpolates the value into a resource
name or an asset lookup, so the guard belongs upstream of all of them,
and each client re-checks rather than trusting the peer).
A token rather than art because the alternative is closed by
construction, and deliberately: the art proxy serves what the bytes ARE
(sniff_image_type) and SVG is not on that list — it is script-capable
XML and the console renders library art in a browser. Widening that
sniff would trade a rendering nicety for a stored-XSS surface. Naming
the mark keeps the refusal intact, keeps the glyph vector at whatever
size a tile happens to be, lets it take the tile's ink, and adds nothing
to a reconcile payload that is already body-limited. The cost is that a
third-party plugin cannot ship a mark no client bundles; its tile falls
back to the launcher's name, exactly as before, and the fix is a PR
adding the master.
assets/launcher-icons/ holds seven monochrome masters with per-mark
provenance and licensing (Simple Icons CC0: lutris, heroic, epic, gog;
Font Awesome CC BY: steam, xbox; Playnite's own logo, MIT). steam is
generated FROM assets/os-icons/steam.svg so the SteamOS host badge and
the Steam launcher tile can never drift.
scripts/gen-launcher-icons.sh bakes the three derivatives that cannot
consume a master (GTK symbolic SVG, Windows PNG, Apple template PDF)
and — unlike gen-os-icons.sh, which prints path data for a human to
paste — GENERATES the three inline registries (web console, Android
ImageVector, pf-console-ui Skia). Three clients x seven paths of up to
3 kB is a transcription error waiting to happen, and a mangled character
is a silently wrong logo rather than a build failure. The generated Rust
goes through rustfmt, since `cargo fmt --all --check` is a CI gate and a
generated file that fails it would fail every regeneration.
All six renderers draw the mark CONTAINED, never cover-cropped: the
masters' viewports are not square (steam 496x512, playnite 1024x1024)
and filling a 2:3 frame would reproduce the strip this exists to avoid.
Every one keeps its old fallback for a token it has no art for.
Epic, GOG and Xbox marks ship dormant. Those plugins' launcher switches
are off by default and emit nothing, because the host has no verified
launcher_ui activation for them yet — shipping the art now keeps turning
one on the one-line plugin change those plugins promise, instead of also
needing a release of all six clients.
api/openapi.json and the SDK are regenerated (the spec's version field
was stale at 0.25.0 and now reads 0.26.0, which is the crate's actual
version — an unrelated line that regeneration necessarily corrects).
Verified: host cargo check, clippy -D warnings across pf-client-core /
pf-console-ui / punktfunk-client-session / punktfunk-client-linux, plain
build, pf-console-ui tests (77, including a new one asserting all seven
masters parse under Skia and one asserting the letterbox stays inside
its box), pf-client-core tests (188), cargo fmt --all --check, Apple
swift build, Android compileDebugKotlin, web tsc + vite build,
plugin-kit tsc, biome. The Windows client is NOT compile-verified — it
cannot be built from a Mac (scripts/xcheck.sh covers only the capture
stack by design) and CI does not build it either; its tile change needs
a real box before it ships.
v0.27.0 resolved the nixpkgs wrapper decoration (`.kwin_wayland-w`) through `/proc/<pid>/exe`.
On the box that fix was written for, the kernel refuses that link.
Reading `/proc/<pid>/exe` is not gated on owning the process: `cap_ptrace_access_check` requires
the reader's effective set to be a superset of the target's PERMITTED set. NixOS's own Plasma
module ships `security.wrappers.kwin_wayland = { capabilities = "cap_sys_nice+ep"; }`, and our
host must stay uncapped — a capability is precisely what makes it unidentifiable to KWin (#136).
So the two traps compose: the name NEEDS `exe` because nixpkgs wrapped the binary, and `exe` is
DENIED because NixOS capped it. `detect_active_session` went back to `ActiveKind::None`, and every
connect died `no usable compositor` with `wayland="-"` — the identical signature to the bug #158
fixed, which is why a box worked around with a decoy process broke again when the decoy went away.
`match_name` now falls through to `argv[0]` (`/proc/<pid>/cmdline`) when `exe` is unreadable. It
reads correctly for the same reason `ps` does: make-wrapper's wrapper `exec -a "$0"`s the hidden
binary, so `argv[0]` survives the decoration `comm` does not. It is consulted LAST and never
overrides a readable `exe` — it is the process's own claim about itself, and a same-uid process can
set it to anything; the worst a spoof achieves is aiming detection at a backend that then fails its
own availability probe.
MEASURED (Linux 6.x, same-uid reader, target holding cap_sys_nice), for a file capability and for
the ambient form `security.wrappers` actually uses — identically:
/proc/<pid> owner ✅ real uid (so the uid filter upstream was never the problem)
comm ✅ readable (decorated + truncated, unusable alone)
exe ❌ EACCES
cmdline (argv[0]) ✅ readable
End-to-end against a LIVE capped, `.kwin_wayland-wrapped` process, old resolver vs new, both
extracted verbatim from their own sources: v0.27.0 answers `.kwin_wayland-w` (no match) in both
capped scenarios and `kwin_wayland` uncapped; the fixed one answers `kwin_wayland` in all three.
Also reached by the same rung: gamescope carries `cap_sys_nice` on a number of distros, so a
wrapped-and-capped gamescope was equally invisible to the foreign-gamescope probe.
Tests are fixture-driven (a temp dir with `comm`/`exe`/`cmdline`), for the reason #158 documented:
a renamed `/bin/sleep` stand-in dies instantly on multi-call coreutils and reads exactly like the
resolver being broken. 11 tests extracted verbatim and executed on Linux as a non-root user —
0 failures. `cargo fmt --check` and `scripts/xcheck.sh linux clippy` (`--all-targets`) clean.
Refs #158, #136.
The audit workflow's blocking `bun audit` leg went red on web with five
findings; sdk and plugin-kit stayed clean. Three had published fixes and are
bumped through the existing overrides block:
* dompurify 3.4.12 → 3.4.13 GHSA-55q2-fjhq-7xh7 (IN_PLACE hook removal
leaves a detached subtree executable). The override was already pinned at
^3.4.12, which the new advisory range swallowed.
* js-yaml 4.3.0 → 4.3.1 GHSA-5p4m-2wfm-xmqj (quadratic CPU in !!omap).
* nanoid 3.3.16 → 3.3.18 GHSA-2v37-7h3g-55p8 (custom generators loop
forever at size zero). New override — it arrives under postcss, which was
already pinned, but the pin does not reach postcss's own dependency.
The remaining two are the image-size infinite-loop DoS pair
(GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq), and they are ignored in the
workflow rather than fixed, because they are both unfixable and unreachable:
the vulnerable range is `<= 2.0.2` and 2.0.2 is the latest published version,
so no override can clear them; and image-size rides in under
@unom/ui > @payloadcms/richtext-lexical > ... > payload, where
@payloadcms/richtext-lexical is a peer that only @unom/ui's ./richtext export
needs. The console imports section, toast, button, card, dialog, form/*,
material and tabs — never ./richtext — so payload is auto-installed peer weight
that no bundle and no request path touches. The ignores are scoped to the web
matrix leg so sdk and plugin-kit stay strictly fail-on-any, and the reasoning
is recorded at the step so they can be dropped once image-size ships a fix or
@unom/ui marks that peer optional.
bun.nix is regenerated by the bun2nix postinstall; scripts/ci/check-bun-nix.sh
reports both bun packages in sync. web build, typecheck and server tests pass.
chore(release): bump workspace version to 0.27.0
87 commits since v0.26.0. C ABI 17 -> 18 (additive), so a minor rather than a 0.26.x patch.
The one red check is nix.yml's `flake` job: `bun run build` for punktfunk-web was OOM-killed
(exit 137) inside the nix sandbox, twice, on two different runner hosts. It is not this diff --
main built the identical web sources in 32s, and this commit touches no file under web/ or
packaging/nix/. nix.yml does not run on tags, so it cannot affect the release. Tracked as a
memory-headroom problem in the gate #158 added.
87 commits since v0.26.0. The number is forced rather than chosen: the C ABI moves
17 -> 18, and `scripts/ci/pf-version.sh` already reports PF_BASE=0.27.0.
Fills in what `docs/releases/v0.27.0.md` and the `## v0.27.0 — in development`
CHANGELOG section deferred to the bump -- the lead-in, the TL;DR, "Before you update",
the version table and the commit count -- and merges in the work that landed without
notes: the Windows Xbox HID pad, the Apple console-UI batch, and the v0.26.0 field
repairs. Everything already accumulated on main is kept as written.
Version table, all re-derived at HEAD rather than read off the diff:
* wire 2 (unchanged), C ABI 17 -> 18 (ADDED symbol, not widened)
* crate dirs 26 -> 27 (`punktfunk-encode-worker`), 39 members
* gamescope `+pfhdr4` -> `+pfhdr5` (6 patches -> 7, the PipeWire UAF), pkgrel resets to 1
* driver proto 6, gamepad channel 3, plugin index 1, SDK 0.1.4, plugin-kit 0.4.0 -- all unchanged
* `api/openapi.json` stays 0.25.0: it moves only on an API EDIT, and the last one
(`6cffe29b`) predates the v0.26.0 tag. It is not a mechanical per-release bump.
⚠ `crates/pf-driver-proto` is NO LONGER byte-identical to the previous release -- it was
through both v0.25.0 and v0.26.0, and the last two CHANGELOGs told packagers so. Three
additive `device_type`s. Called out explicitly so a packager who has been skipping it stops.
The Play whatsnew names the one real Android change (the OLED palette is Eclipse now;
display name only, the stored `oled` id is unchanged so saved choices carry over) and is
otherwise explicit that the release is host-side.
`docs-site/.../running-as-a-service.md` claimed "0.26.0-2 revoked it everywhere". No such
version exists -- the Arch registry goes 0.26.0-1 straight to 0.27.0 canaries -- and the
corrected 0.26.0 packages shipped under the SAME version string, which is exactly why a
machine that installed an early one was never offered the fix. Reworded to say that, since
it is the reason this release matters to those users.
Gates, all green on this commit:
* `cargo fmt --all --check` clean
* `cargo metadata --locked` resolves
* `Cargo.lock` diff versions-only -- 72/72 changed lines, 36 crates
* Play gate's own python3 arithmetic: 400/500 CHARACTERS, not byte-identical to any other
release (`wc -c` misleads -- `•` is 3 bytes)
* notes voice check: 1 hit, reviewed and kept -- `getcap /usr/bin/punktfunk-host` is a
command the reader runs, the same class as the `usermod` line v0.26.0's notes carried
* `docs-site` builds
NOT DONE: no tag yet.
The shared-vectors tests added in 0b550dad read console-vectors.json with serde_json, but the
lockfile never caught up: every gate that has run since was either the Android JVM suite (no cargo
at all) or the Linux container, which builds an rsynced COPY of the tree. The first cargo run
inside the worktree itself — building the phone's native library — wrote it.
There are three places Nix is documented — the public docs-site, packaging/nix/
README.md, and packaging/README.md — plus the changelog. All had drifted.
STALE CLAIM, and not only for Nix. install.md said the plugin runner's "user unit
ships **disabled** — enable it once you have" something to run. That is true only
of Arch and source installs: the deb postinst and RPM %post both
`systemctl --global enable punktfunk-scripting.service`, and the Bazzite sysext
bakes in a default.target.wants symlink (build-sysext.sh:113). bazzite.md carried
the same claim about its own image. Both corrected, per channel, with the reason
the default flipped — the library scanners are plugins, so a host without the
runner can come up with an empty library — and the `mask`-not-`disable` opt-out
the sysext's own comment documents.
docs-site:
* install.md NixOS — `desktopSession` in the example and explained, the runner
no longer needs enabling, and the host/console line says what autoStart does.
* running-as-a-service.md — "Restart the host with your desktop" documented the
drop-in for packaged installs only; NixOS gets its one-liner beside it.
* bazzite.md — the runner is started for you, not "isn't started".
packaging/nix/README.md:
* option tables gain `desktopSession`, `gamescopeHdr`, `gamescopePackage`, and
the `punktfunk` group next to `input` (both are required — the udev rule
chgrp's the vhci nodes and fails outright if the group was never created).
* "what the module configures" gains the security.wrappers entry, and a note on
why the capability sits on the encode worker and never on the host: a wrapper
raises it into the ambient set, which lands it in the permitted set and fails
KWin's /proc/<pid>/exe readlink identically to a file capability.
* the appliance snippet no longer tells you to put pkgs.gamescope on PATH —
gamescopeHdr does that with the patched build, and desktopSession is called
out as the thing to leave off there.
* a caveat recording that `nix flake check` does not check the module, and the
two rules for editing module-check.nix (assertions stay pure Nix; assert
list-valued unit fields on the lists, not the rendered text).
packaging/README.md: the flake ships five packages, not "host + client".
CHANGELOG.md v0.27.0: a NixOS section covering the comm/session-detection fix, the
module changes including the scripting default flip as an explicit behaviour
change, and the flake-check gap — plus the documentation bullets above.
WP9.2 and WP8.3 of the console visual-refresh plan.
**WP9.2 — the 1361-line ConnectScreen becomes 979 plus three files.** `HomeTiles.kt` holds a pure
`buildHomeTiles` (non-composable, so it is unit-testable — `HomeTilesTest` pins six behaviours the
console carousel had no cover for at all, including that a saved host also advertising on a NEW
DHCP address is listed once, which exercises the fingerprint match rather than just "the builder
lists what it is given"). `ConnectGrid.kt` holds the touch home. `ConnectPrompts.kt` holds
everything modal. `ConnectScreen`'s signature is untouched, so `App.kt` compiles unchanged.
What stayed, deliberately: the state and the engine — discovery, the permission dance, identity,
the trust decision, the dial and its wake fallback, the deep-link router. Those close over ~20
locals that a dozen callbacks read AND write, so lifting them means inventing a state holder, which
is a second refactor and a second thing to get wrong. A comment above `ConnectScreen` now says so.
🛑 **A real bug the split surfaced, fixed here:** the console carousel was live under a FINISHED
speed test. It looked covered by `!connecting`, and was — until the measurement ended, because
`startSpeedTest` clears `connecting` before the Done card is dismissed. From that moment the card
and the carousel underneath both consumed the pad, so one A dismissed the card *and* started a
connect. `speedTest` now sits in the `navActive` gate with every other modal.
**WP8.3 — two screens the console could not reach.** On a TV box there is no touch interface to
fall back to, so the notices and the controllers view were simply unreachable. Both are now console
screens at nav depth 2 (reached FROM settings, which is what makes the trip a push and the way back
a pop), opened by a Controller-tab row and an About row. `GpSettingsPlace` carries the cursor across
the trip, keyed by row ID rather than index, so Back lands where you left rather than on the first
row of the first tab — and because a tab's length follows the hardware, an index would have been
the stale-pointer bug the tab-switch clamp already exists for.
Four blockers, all real:
* 🛑 `ControllersScreen` installed the shared input probes unconditionally and NULLED them
unconditionally on dispose — no identity check, unlike `GamepadNavEffect2D`. During the shell's
push/pop both screens are briefly composed, so its teardown would have killed the incoming
screen's pad navigation. Now it releases only the slot it still owns.
* 🛑 `LicensesScreen` had exactly ONE focusable node, and Compose only scrolls to keep a FOCUSED
child visible — so a D-pad could not read past the first screenful of a many-screen file. Both
screens now drive their scroll state directly: up/down steps 0.28 of the viewport, shoulders page
0.88 — under a screenful on purpose, so the line you were reading survives the press.
* ⚠ Both were inked from the TOUCH theme (28 `colorScheme` sites plus implicit pulls from
`OutlinedCard`, `Switch`, `OutlinedButton`, `LinearProgressIndicator`), which is always dark —
invisible over the six PALE palettes. They are now shown through one `ColorScheme` derived from
`LocalGamepadInk` rather than 27 call-site branches, because call-site edits cannot reach the
implicit pulls at all. Screenshot scenes shoot both on a dark and a pale palette; the pale pair is
the point.
* ⚠ B was already taken — the input test's exit is a 1.2 s hold. The rule is now stated on screen:
while the test runs the pad is the test's, a short B answers with the boundary thud instead of
doing nothing, and the legend collapses to one "Hold to finish" cell.
🛑 **Second bug fixed in passing:** that hold ended the test AT the 1.2 s mark, so the B *release*
then fell through to MainActivity's B→BACK remap and closed the whole screen. It bit the touch
screen too. The test now ends on the release, which is therefore consumed.
Residual TV gap, flagged not fixed: the Controllers screen's inner buttons ("Grant USB access",
"Test rumble", "Test haptics") have no console focus list, so they stay touch-only — a denied Sony
USB grant still has no console recovery path.
THE CI GAP. `nix flake check` does not check `nixosModules`. It forces the value
and asserts it is a lambda taking an open attribute set — nothing more; nix's own
source carries `// FIXME: if we have a 'nixpkgs' input, use it to check the
module.` Measured: a flake whose module sets a nonexistent OPTION, references a
nonexistent `pkgs` attribute AND calls a nonexistent `lib` function passes clean,
printing `checking NixOS module 'nixosModules.default'... all checks passed!`.
nix.yml's header claimed that leg covered the module; it never did, for the
module's whole life — on a flake whose history is Nix regressions reaching main
invisibly.
Closed with `checks.<system>.nixos-module` (packaging/nix/module-check.nix): it
evaluates the module against real nixpkgs in four scenarios (desktop, appliance,
native-only, client-only) and asserts on the rendered systemd units. The
assertions are PURE NIX so instantiating the check runs them — which means the
eval-only `--no-build` leg CI already runs is sufficient, and no Rust is built.
Stub fake-derivation packages keep it independent of punktfunk-host/-client and
the from-source gamescope; crane and bun2nix are provably not needed (they are
`throw`s in the wiring test and it still instantiates).
17 checks, including regression guards for every divergence the sweep found and
for the KWin identification trap (host ExecStart must stay on the plain store
path, never a capability wrapper, while the encode worker points AT the wrapper).
Mutation-tested: 8 mutants, each re-introducing one real defect, all 8 rejected,
baseline green. The suite already earned it once — its first run failed a correct
module because systemd renders `After=` as one space-separated line, so those
assertions now read the evaluated lists instead of the text.
Also closed from the sweep:
* services.punktfunk.host.desktopSession (new, default false) — binds the host
to graphical-session.target, the declarative form of the
punktfunk-host-desktop-session.conf drop-in. Without it a Plasma/GNOME
restart leaves the host holding a Wayland socket and portal D-Bus connection
that died with the old compositor: it still listens, still answers, and every
session it then serves fails at capture. Off by default because an appliance
may never reach that target and would be left permanently stopped.
* scripting.autoStart now defaults ON, matching the deb postinst and RPM %post,
which both `systemctl --global enable` the runner. It was opt-in here on the
reasoning that the runner is inert until you add automation — which stopped
being true when the game-library scanners became plugins. A NixOS host came up
with an empty library and no obvious reason why. The module and README carried
the superseded rationale verbatim; both updated.
* A warning when the host is enabled and xdg.portal is not. A warning rather
than `xdg.portal.enable = mkDefault true`, because enabling the portal service
with no `extraPortals` backend is its own broken state and only the operator
knows which backend their compositor needs.
* punktfunk-gamescope gets a `build-gamescope` dispatch input. It is on the
critical path of every host build (`gamescopeHdr` defaults true) yet nothing
compiled it; it tracks nixpkgs' gamescope, so a flake.lock bump — not a change
of ours — is what breaks it, and the first to find out would be an operator
whose system rebuild fails.
All .nix files reformatted with the flake's own declared formatter
(nixfmt-rfc-style from the PINNED nixpkgs, not a channel's).
Hiding a library title shipped in 0.26.0 and was, in practice, unusable: the operator
reported there was simply no button, then found it by CLICKING the empty top-right corner
of a poster.
Both halves of that are the same defect. The card's control row was `opacity-0` until
`group-hover`, and `opacity-0` paints nothing while still HIT-TESTING. So the corner of
every tile in the grid was a live hide button with no visual presence at all: a stray
click there removed that title from every play surface — the client grid, the native
clients, the GameStream app list — with nothing on screen having suggested a control was
under the cursor. What read as "the button finally appeared" was the hide taking effect,
since `hidden` is the one state that drops the `opacity-0`. The feature announced itself
by firing.
And the reveal rested on hover ALONE. `:hover` never fires on a touch screen, so on a
tablet the hide control was unreachable by construction and discoverable only by the blind
click above. The original commit spotted this hazard for UN-hide — it kept those controls
always-visible so nobody could be stranded in the hidden state — but left the hide side
hover-gated, which is the same trap one step earlier.
So opacity and `pointer-events` now move together, always: whatever cannot be seen cannot
be clicked. `pointer-coarse:` shows the row outright wherever the device has no hover to
give, rather than making touch a second-class path. Keyboard reach is unchanged —
`pointer-events: none` does not block focus, so tabbing in still trips `focus-within`,
which now restores interactivity along with opacity.
The eye icon also gains a `title`. On a scanned entry it is the ONLY control on the card,
with no edit/delete beside it to read as a toolbar, and an unlabelled eye-with-slash is
not a promise that a game is about to leave the library.
Verified in the built CSS rather than by eye, because a variant that does not compile
fails exactly like the bug being fixed: `@media(pointer:coarse)` emits both
`pointer-coarse:opacity-100` and `pointer-coarse:pointer-events-auto`, and it lands at the
END of the sheet — media queries add no specificity, so this tie against the base
`.pointer-events-none` / `.opacity-0` is won on source order, not by accident. The
`group-hover:` and `focus-within:` forms compile to `:is(:where(.group):hover *)` and
`:focus-within`, carrying a pseudo-class each, so they win on specificity outright.
Console: tsc clean, production build clean, biome clean on the touched file, i18n 633
messages across en+de.
WP9.1 of the console visual-refresh plan — dialog unification.
Six prompts existed twice: a Material `AlertDialog` in `ConnectDialogs.kt` and a console glass card
in `GamepadDialogs.kt`, maintained by hand. They had drifted, and always in the same direction —
the console losing something:
* "Pair with PIN…" and "Use a PIN…" lost their ellipses, so the console said the buttons finished
something the touch UI said would open another step;
* "if no prompt appears when you tap Allow" became "after Allow";
* the speed test dropped `speedTestTargetNote` entirely, leaving a console user — often on a TV box,
which is exactly the machine whose link is worth measuring — no statement of which layer "Apply"
was about to write to. That is a write in an unknown direction.
What is shared now is the DESCRIPTION of a prompt (a title, a list of `DialogAction`s, a body) and
what stays per-interface is only how it is drawn. `PunktfunkDialog` takes that description and
renders it as an AlertDialog or as the existing console modal. Actions are ordered primary-first:
the console stacks them that way with the cursor on the first, and the touch renderer lifts the
same first action into `confirmButton`. One order, two idioms.
The two renderers cannot be one tree — an AlertDialog composes into its own platform window while
the console modal is a Box in the calling tree, which is why one needs a `BackHandler` and the
caller's `navActive` gate and the other needs neither.
Deliberately NOT unified, and they belong apart: the PIN ceremony (a keyboard field and an editable
device name against four D-pad digit slots is a different input model, not a different skin),
Add/Edit Host (a bottom sheet against a full screen with its own on-screen keyboard), and the host
action list (an anchored dropdown against a modal stack that also grows a row per profile).
Twelve composables become six. `ConnectScreen`'s dialog block loses ten `if (gamepadUi)` branches.
A sweep of the Nix packaging against the units the deb/rpm actually install
found three decisions that were made, documented and deliberate everywhere
else, and simply not carried into packaging/nix/nixos-module.nix.
punktfunk-web — StartLimitIntervalSec=0.
The unit's EnvironmentFile for the mgmt token is mandatory ON PURPOSE, so the
console genuinely fails until the host's first `serve` writes it. systemd's
default rate limit (5 starts / 10 s) against RestartSec=2 then gives up
permanently after ~10 s — which on an appliance is exactly the window before
the host is ready, so a console enabled before the host's first run stayed
dead until someone restarted it by hand. scripts/punktfunk-web.service has
carried the override since that defect was found; the Nix module omitted it
while its own comment went on promising "Restart retries until the host has
created it".
punktfunk-web — Restart=always, not on-failure.
A console that exits 0 has still stopped serving, and on-failure leaves it
down. Matches the shipped unit and web-run.cmd on Windows, both of which
relaunch bun on ANY exit. An explicit `systemctl --user stop` is unaffected.
punktfunk-scripting — the sandbox was missing entirely.
The shipped unit confines the runner with NoNewPrivileges, ProtectSystem=
strict, ReadWritePaths=%h /tmp and an AF_UNIX/AF_INET/AF_INET6 address-family
restriction, plus PrivateTmp=no (a field report: a private /tmp hides
/tmp/vhclient and /tmp/.X11-unix, so a plugin launches its vendor binary and
then cannot reach the daemon behind it). The NixOS unit had none of it — so
the one unit here that executes arbitrary operator TypeScript by design ran
strictly LESS confined on NixOS than on every other channel.
Verified by evaluating the module against the pinned nixpkgs and rendering the
units: assertions clean, cap_sys_nice=ep on the encode-worker wrapper, firewall
47984/47989/47990/47992/47993/48010, and each unit carrying exactly the
directives above. That evaluation is NOT something CI does — measured: `nix
flake check` passes a nixosModule containing a nonexistent option, a nonexistent
pkgs attribute and a nonexistent lib function, printing "checking NixOS module
... all checks passed!" while never evaluating it against nixpkgs. nix.yml's
header claims that leg covers the module. It does not; tracked separately.
WP8.2 and the rest of WP8.7 from the console visual-refresh plan.
**The console's Edit Host was missing two decisions entirely.** The touch edit sheet has always
offered a shared-clipboard switch and a profile binding; the console form built only name, address,
port and MAC. Nothing was ever LOST — `KnownHost.copy` preserved both — but a couch-only user could
never CHANGE either, and a TV box has no touch interface to fall back to. Both are now rows in the
console form, driven like settings rows: left/right steps them, A flips or cycles. The binding is
filtered through the live catalog, so a host bound to a since-deleted profile reads as unset rather
than as a name nothing resolves — the same guard the touch sheet applies. "Default settings" leads
the ring as the ABSENCE of a binding, not as a fake catalog entry.
**Accessibility, finished.** The library's coverflow now says which poster a press acts on — from
the art alone a centred cover and a neighbour are indistinguishable, and only the centred one
launches. The group heading is a live region: it is the sole signal that the cursor has crossed
from the launchers into the games, and a coverflow gives a reader no other way to notice, being one
strip rather than two lists. The store badge says why it is there ("Opens Steam" / "From Steam")
instead of reading out a bare vendor name after the title.
WP8 and WP9 of `punktfunk-planning/design/android-console-ui-visual-refresh.md`, in part.
**WP9.3 — shared parity vectors.** The console's background palettes, its settings section names
and its screen-transition motion each existed in three hand-written copies (`pf-console-ui`, this
client, the Apple client) held together by a comment asking the next person to keep them in step.
`clients/shared/console-vectors.json` now holds them, read the way `deeplink-vectors.json` already
is: `include_str!` in Rust, a relative path in Kotlin, `#filePath` in Swift — never a copy, because
a copy is a fourth contract free to go stale. It carries the DERIVED tables too, the 16-cell mesh
and the 4 blob colours per palette, which is the half that reaches the screen and the half Android
never checked: `GamepadPaletteTest` only ever measured the `stops` they are computed from.
Two drifts it immediately caught, both now closed:
* **The easing was the wrong curve.** `ConsoleMotion.EaseOutCubic` shipped as
`cubic-bezier(0.215, 0.61, 0.355, 1)` while claiming to be the desktop's `ease_out_cubic`. It is
not: that is the Penner/Ceaser table's curve, ~0.80 at the midpoint where `1 − (1−t)³` is 0.875 —
visibly slacker over a 260 ms transition. Compose's `Easing` is a plain function, so it now
evaluates the real thing analytically rather than approximating it at all. (Apple approximates
with a different bezier only because SwiftUI's `timingCurve` cannot take a closure; the vectors
sample the curve with a tolerance so all three can meet it.)
* **The desktop has a seventh tab.** Input — touch mode, mouse, invert-scroll, shortcuts — with
nothing to set on a phone or a TV. `settings.rs` claims in prose that a setting is found under
the same word on every client; that was true modulo an omission nobody could see. The vectors
model it with `desktop_only` rather than picking a side, so neither client has to be wrong.
Rust reads it from three tests placed in the files that own the constants, so nothing had to be
made `pub` to be checkable. Verified green under Linux (the crate is `cfg(linux|windows)` throughout
— `cargo test` on a Mac compiles nothing and passes vacuously): 77 passed, 0 failed. Android's side
gates in CI as a FILTERED task; a plain `:app:testDebugUnitTest` would drag the ~20 Roborazzi
screenshot scenes into every push, and those are a release-artifact job.
**WP8.1 — a pad route to the stats overlay.** The tier could only be cycled by a three-finger tap,
which does not exist on a TV, on a gamepad-only session, or under touch passthrough — while the
settings row promised a live cycle. `Select + X` now cycles it, byte-identical to the Apple
client's `GamepadWire.back | GamepadWire.x`, implemented as the mic chord's twin in `GamepadRouter`
and edge-triggered on the button that completes the mask. The buttons still reach the game, as both
existing chords do. `GamepadChordTest` pins eight cases the kit had no cover for at all, including
that the three chords intersect only on Select and that none is reachable through another.
**WP8.5 — a start-of-stream banner.** The desktop's `skia_overlay` banner, ported with its timing
(opaque 5.4 s, then a 0.6 s fade) and its rule of naming only shortcuts that exist: pad chords when
a pad is present, the touch gesture when there is a touchscreen and the mode can use it. Nothing
`Ctrl+Alt+Shift` is advertised, because Android has none of it. It yields to the motion-unreachable
notice rather than stacking with it — that one reports something broken about *this* session.
**WP8.6 — the home card says which profile it connects with.** `HomeTile` carried a
`pinnedProfileId` the card never drew, so a pinned host+profile card was distinguishable from the
host's own only by a subtitle that had been quietly repurposed to hold the profile name. Both now
show the address like every other card and wear a tinted profile chip — the touch grid's own
convention and the Apple client's, inked from the console palette. Unsaved tiles (discovered, Add
Host) take a dashed edge, which is what the other two surfaces already use to say "not yours yet".
⚠ Not a detail panel: the Apple client REMOVED its own and moved the status onto the card, which is
where the lock and the online pip already were here.
**WP8.7 — accessibility, in part.** The console screens carried three `contentDescription`s and no
`semantics`, `Role` or `stateDescription` at all. A settings row now announces once, merged —
label, value, and the description that lives in the floating band far from it — with `Role.Switch`
and a real toggle state, because a toggle row's on/off string was drawn by nothing at all: the
switch replaces the value text, and the switch was two undescribed `Box`es. Decoration is silenced
rather than labelled (the chevrons were read aloud as punctuation on every focused row). The hint
bar's glyphs, the tab strip and the home tiles are done; `GamepadAddHostScreen` and `LibraryScreen`
are not yet.
The session probe decided "is a desktop live?" by reading /proc/<pid>/comm for
every process of our uid and exact-matching it against "kwin_wayland" /
"gamescope" / "gnome-shell" / "Hyprland". comm is the kernel's name for the
executed FILE, truncated to 15 bytes — not argv[0].
nixpkgs wraps essentially every graphical binary: wrapProgram moves the real
ELF aside to `.<name>-wrapped` and installs a wrapper under the original name,
which then `exec -a "$0"`s the hidden file. So on NixOS the kernel reports
`.kwin_wayland-w` (15 bytes of `.kwin_wayland-wrapped`) while ps/pgrep -a show
a perfectly ordinary `kwin_wayland`, because they read argv. Measured against a
live kernel: `.kwin_wayland-w`, `.kwin_wayland_w` (KWin's own
kwin_wayland_wrapper), `.gamescope-wrap`, all 15 bytes.
Nothing downstream could recover from that one string comparison:
- detect_active_session returned ActiveKind::None on a *running* KDE desktop;
- wayland_display is only resolved for a detected kind, so the connect log
reported wayland="-" even though WAYLAND_DISPLAY was correct;
- pick_compositor's Auto arm returns the DETECTED backend, so a live, fully
working KWin sitting in available() was never chosen — every connect died
"no usable compositor";
- and PUNKTFUNK_COMPOSITOR could not rescue it: pinned_at_a_dead_session
consults the same probe, turning the miss into a hard error instead.
No environment variable reached the comparison — the XDG_CURRENT_DESKTOP
fallback in detect() is only on the pinned path. Capture itself was never at
fault: a decoy process merely NAMED kwin_wayland satisfied the probe and the
stream came up against the real KWin.
Resolve the name through /proc/<pid>/exe (the full, untruncated file name) and
strip the nixpkgs decoration. Both the leading `.` and the trailing `-wrapped`
are required before anything is stripped, so KWin's own real
`kwin_wayland_wrapper` binary keeps its name rather than collapsing into
`kwin_wayland` and handing the probe the parent's PID. The comm fast path is
kept for every ordinary distro — one read, no readlink, and no name that
matched before can stop matching.
Also applied to foreign_gamescope_running, which had the same defect: nixpkgs
wraps gamescope too, so the attach-vs-spawn ladder saw no foreign session.
Tests are fixture-driven rather than spawn-driven on purpose: a stand-in has to
be a real ELF that tolerates being renamed, and /bin/sleep is not one — modern
coreutils is a multi-call binary that dispatches on the executable's own name,
so a copy called `.kwin_wayland-wrapped` exits instantly and /proc/<pid>/exe is
gone before it can be read. That failure looks exactly like this resolver being
broken; it cost one debugging round here and the same trap is already recorded
in punktfunk-host's /proc matcher.
Implements P1–P3 (WP1–WP7) of `punktfunk-planning/design/android-console-ui-visual-refresh.md`.
**The two named bugs.**
Settings rows GREW ON FOCUS. The focused row unfolded its description in place
(`AnimatedVisibility` + `expandVertically`), so every D-pad step shrank one row and grew another
and shifted every row below the cursor — on a list simultaneously being scrolled to keep the
focused row visible, whose target therefore moved out from under it mid-animation. Sideways, the
value's `AnimatedContent` animated its own WIDTH on every step, walking the ‹ chevron back and
forth. Rows are now fixed: the description renders in a floating `ConsoleDetailBand` above the
legend pill — the desktop console's reserved detail band (`screens/settings.rs`) achieved by FLOAT
rather than by subtraction, so an overlay can never displace the list. Apple's per-row
`reservesSpace` was rejected for density. The value slot gets a fixed minimum, end alignment, a
snapped `SizeTransform` and tabular figures, so `1920 × 1080 → 2560 × 1440` changes nothing but
the glyphs. `GamepadSettingsLayoutTest` is the regression net — it drives the real screen and
asserts the neighbouring rows' bounds and the focused label's bounds do not move.
The NP3's HOLE PUNCH sat over settings and add-host content. All twelve console call sites padded
with `systemBarsPadding()`, which EXCLUDES `WindowInsets.displayCutout`; in landscape a punch is a
left/right edge inset with no bar behind it. New `Modifier.consoleSafeArea()` is
`systemBars ∪ displayCutout` (the rule Material3's own components lay out against) and the
floating legends' landscape branch keeps ignoring the bars while still taking the cutout —
`SENSOR_LANDSCAPE` parks the punch on exactly that corner. The backdrop stays full-bleed; it is
ambience, and running under the camera is what ambience should do.
**The premium-feel levers.**
The screen transition is now the desktop console's contract instead of `Crossfade(240)`: push
slides the incoming screen up 36 dp out of a fade at 0.985→1 while the outgoing recedes to 0.96;
pop runs it backwards. Direction comes from a nav depth on `GamepadScreen`. `ConsoleMotion` is the
single motion vocabulary (`shell.rs:29`'s `TRANSITION_S`, `render.rs:120-151`'s geometry, plus the
focus/value/tab tweens that were scattered literals) and `ConsoleShape` the corner radii that were
four literals in as many files. Reduce-motion collapses push/pop to a 90 ms cross-fade.
The backdrop is the desktop's actual field: `mesh_sksl` (`library.rs:433-525`) ported to AGSL —
4×4 bicubic colour mesh, four drifting interior warp points, ±8° hue sway, vignette, scrim, `calm`
— compiled per palette and cached, gated on API 33+ (`RuntimeShader`). `GamepadPalette` already
carried `CELL_RAMP`; it grows `MESH_COLORS`, `MESH_INTERIOR` and `meshColors`, so the 16-cell
table is now literally shared with the Rust. API 28–32 keeps the four-blob field, and a shader
that fails to compile falls back to it rather than taking the console down. The `cross()` builtin
is written out by hand — AGSL's function set is a subset of SkSL's and not worth betting on.
Constants are formatted through `Locale.ROOT`: on a German-locale device `String.format` emits
`0,075`, which is a syntax error in shader source.
`Modifier.consoleGlass` gives every row, field, card and tile a vertical luminance gradient, a
1 px top-edge highlight fading into the border, and — with focus — a drop shadow plus an accent
bloom drawn outside the clip, so focus reads as a lens rather than a recolour. Modals get
`consoleCard()`, which fixes a real bug: the cards were a hardcoded near-black indigo while their
text came from the palette, so on any of the six PALE palettes a dialog rendered dark ink on a
dark card and was unreadable. `GamepadInk` grows `card`, `modalScrim`, `highlight` and `danger`.
Micro-interactions: menu haptics (tick on move, thud on a refused press, pulse on confirm) on the
driving pad's own motors, else the phone body, else silence — a TV has neither; a refused value
step nudges 4 dp toward the press and springs back; the pressed-side chevron ticks 2 dp; the
switch knob squashes at mid-travel; one indicator pill GLIDES between the section tabs instead of
six fills cross-fading; the home carousel dips 0.97 under A and its neighbours turn away on the
Apple client's projected `cos` fan (never `rotation3DEffect` — see its note on the settle flash).
Palette leaks closed: the library's group heading was hardcoded white (invisible on a pale field),
the poster ground a fixed indigo, and the launcher badge read `MaterialTheme.colorScheme.primary`
— the TOUCH theme's wallpaper-seeded colour, nothing to do with the field it sat on. The add-host
caret and the keyboard's fill went the same way. The home header now outranks the controller chip,
which was eating "Select a Host" down to "Selec…" on a 360 dp phone.
Screenshot scenes gain the console home on the mesh (API 36), the same home on the blob fallback
(API 31), a landscape home where the carousel's fan is visible, and a pale-palette pass of each.
Not in this change: WP8 (functional parity gaps) and WP9 (structural) — the plan's own P4/P5, and
the dialog cards' backdrop frost, which would need a `HazeState` plumbed through `ConnectScreen`
to be worth anything behind a 94 %-opaque surface.
A managed takeover runtime-masks the box's `gamescope-session-plus@*` unit so its
session supervisor cannot restart it underneath our Steam. The only unmask ran in
`do_restore_tv_session`, on client disconnect — so for the whole stream the mask
stayed on, and it silently barred the door the user was most likely to walk
through next.
`mask_unit`'s own doc said the mask "blocks nothing" on images whose sddm helper
execs the session script directly. That is half right, and the half it gets wrong
is this bug: on f43 bazzite-deck the script's last act is
systemctl --user --wait start gamescope-session-plus@${CLIENT}.service
(verified on the .41 VM). What the mask fails to stop is the RELOGIN LOOP — sddm
keeps trying regardless, which is why stopping the DM is the real defense. What it
very much does stop is the unit, and with it every entry into game mode, including
the user's own deliberate "Return to Gaming Mode" after a mid-stream switch to the
desktop. Steam then sits on its "Switch to Desktop…" modal forever. `--runtime`
lives in tmpfs, so a reboot cleared it — hence "it works right after a reboot" —
and a plain `unmask` does not (measured: still `masked-runtime`).
So the mask's sound lifetime is shorter than the takeover's: it ends the moment the
box stops being ours. The mid-stream session watcher already detects exactly that,
so it now lifts the mask on a confirmed switch to a desktop session — ahead of the
`compositor_for_kind` arm, because a switch we cannot follow still has to unbar the
return. `Gaming` and `None` deliberately do not lift: a takeover's own managed
session reads as `Gaming` and one momentarily down between relaunches reads as
`None`, and lifting on either would void the mask for the whole stream, in exactly
the SDDM-storm window it exists for.
Fixes a second, worse leak on the way: `honor_session_select_switch` consumed
`STOPPED_AUTOLOGIN` — the only record of what carries a mask — without unmasking,
so under a DM-stop takeover the disconnect restore found an empty list and lifted
nothing. That mask outlived not just the stream but the boot. It is also what let
that path's own step 1 work at all, since the DM's autologin heads back into game
mode through precisely this unit.
The lift is idempotent, keeps the restart list intact (the disconnect restore still
owes those units a `start`), and every hand-back path now routes through it.
Verified on Linux: `switch_ends_mask_window` decision table, plus an ignored
end-to-end test driving real `systemctl --user` (masked → survives Gaming/None →
lifted by a desktop switch → restart list intact → idempotent). Proven non-vacuous
by planting "Gaming also lifts", which fails it on the during-stream assert.
The reserved punktfunk://browse/<host-ref> route now routes on Apple: it
drives the same libraryTarget every internal surface writes, so the link
lands in whichever presentation the current mode owns — the gamepad
console's in-place library screen, the touch cover, the macOS sheet, or
tvOS's cover. Connect's posture minus the connect: a pin conflict
refuses, a live session is never preempted, an unsaved host gets a
notice (the library rides the paired mTLS identity, so there is nothing
to browse before the host is saved). browse ignores launch=/profile= —
nothing streams until a title is picked, and that connect resolves its
own profile.
On top of the route, the two new front doors:
- OpenLibraryIntent ("Open Game Library") beside Connect/Wake/End in
Shortcuts/Siri/Spotlight, host-parameterized like the others and
round-tripping through the URL — one router, no second path.
- A configurable library widget (kind "PunktfunkLibrary",
AppIntentConfiguration over HostEntity — the configuration the
HostEntity doc comment anticipated): pick a host, tap into its
library. Unconfigured it follows the most recent host; a configured
host that was removed shows the empty state rather than silently
following a different host. Same .never timeline + HostStore push as
the hosts widget, now reloading both kinds.
DeepLink.browse(host:) is the one emitter both doors share, covered by
a round-trip test beside connect's; the parse side was already in the
grammar and the vector file. Docs updated (clients, game-library,
profiles-and-links).
Field report, with a competitor cited doing it right: connecting to a host
PAUSED the user's Apple Music, and resuming Music mid-stream silenced the
stream's audio for good. Both are one defect — the audio session was
EXCLUSIVE. Activating it evicted whoever was playing (the pause at
connect), and Music's resume evicted us right back; iOS stops the engines
on that interruption and restarts nothing, so the stream stayed silent
until reconnect.
`.mixWithOthers` on every category path (playAndRecord, playback, tvOS) is
the heart of it: a mixable session interrupts nobody at activation and is
not interrupted by another app's mixable playback — game audio rides over
the playlist, the way a console does it. The trade is that a mixable
session is nobody's Now Playing app, so the lock screen keeps showing the
music. For a game stream that is the correct reading.
The other half is `installInterruptionObserver`: interruptions still exist
for a mixable session — a phone call, Siri, an app claiming a NON-mixable
session of its own — and ending one restarts nothing by itself. On
`.ended` the observer re-runs the full activation (an interruption can drop
the category, and the earpiece steer is per-route) and revives the engines
through the same conservative `reviveStoppedEngines` the route-change and
media-reset observers already use — unconditionally, not only on the
`.shouldResume` hint: a live stream is the one case where the user's intent
to keep hearing it is not in doubt, and the revive already declines when
playback never went down.
The teardown's `.notifyOthersOnDeactivation` stays as a courtesy for the
edge where an old exclusive install interrupted something; a mixable
session has nobody to notify.
macOS build (session paths are #if'd out there), tvOS typecheck, 284 tests.
The form screens' pinned title and legend lost their gradient blur when
GamepadTrayScrim went (2590238b): the scrim was `.ultraThinMaterial`, and a
material by definition lifts and tints whatever it blurs — it read grey over
the aurora, and washed with the palette's ground it read coloured. There is
no public way to blur a backdrop without that tint, so the layer was deleted
rather than re-tuned, and the rows have scrolled to a raw edge since.
Glur's GlurView blurs the backdrop through a gradient with NO material stage
on top — the rows soften as they slide under the pinned trays and nothing
carries a colour. Three things about the adoption are deliberate:
- It is the library's GlurBackdrop product, which uses a private API
(a variable-blur CAFilter on UIKit/AppKit). The public `.glur()`
modifier is a Metal shader on a view's OWN content and silently no-ops
over platform-backed views like ScrollView, so it cannot reach a
backdrop at all. GlurBackdrop ships as a separate module precisely so
adopters can weigh the App Store exposure; it disables hit testing
itself, so the band never eats a touch.
- The dependency is pinned by REVISION: GlurBackdrop exists only on main —
no release carries it (the newest tag, `1.1`, predates it and is not
three-component semver anyway, so `from:` resolution stops at 1.0.4,
which doesn't have the product). Wired into BOTH manifests — the
package for `swift build`, and the xcodeproj (remote package reference
+ per-target product deps) for the app targets, which compile these
sources themselves and know nothing of Package.swift's dependencies.
- The band takes NO content-side overhang, unlike the scrim it replaces —
and dropping the scrim's hard-won -44/-72 runway is the point, not an
oversight. That runway existed because a material carries body at every
alpha and had to dissolve OUTSIDE the tray; carried over here it blurred
fully-visible rows at rest (first on-glass verdict). Glur's ramp reaches
literal zero, so the band ends exactly at the tray's layout edge: open
content untouched, rows soften only where they actually slide under.
The 80 pt outer bleed and the pinned shape
(`transaction { $0.animation = nil }`) are kept — full-bleed by LAYOUT
rather than `.ignoresSafeArea()`, so a push never shows the band
growing into place.
Mounted exactly where the scrim was: settings, add-host and pair trays
(top + bottom) and the library title (top). Glur is attributed on the
Acknowledgements screen alongside the bundled font — it is a Swift package,
so the Rust-closure notices generator rightly knows nothing about it.
macOS build, tvOS typecheck; band placement and the blur itself verified on
the iPad on glass.
The bit_depth field said '8 on Linux until Phase 5.1 lands a P010 capture path'.
The code outran it: the gamescope HDR capture patches offer 10-bit BT.2020/PQ,
nvenc_fmt maps X2Rgb10/X2Bgr10 to ARGB10/ABGR10, and is_ten_bit_input flips
bit_depth and hdr from the negotiated input. Verified on home-nobara-1:
'resolved session plan ... bit_depth: 10, hdr: true' on the direct backend.
A 10-bit frame deliberately takes neither the NV12 nor the YUV444 convert (both
compute CSCs write 8-bit planes) and rides packed RGB to the encoder, which does
its own BT.2020 CSC — pf-capture/src/linux/pipewire.rs owns that gate. So Main10
needed no P010 path to arrive, and P010 is now a perf follow-up (skip NVENC's
internal CSC, as NV12 does for SDR), not the thing that makes 10-bit work.
`can_encode_10bit`'s Linux NVIDIA arm answered "can this GPU encode 10-bit?" by
opening an ffmpeg `hevc_nvenc` encoder. On a host that then streams over the
direct SDK, that is the LOG-3 field bug: one ffmpeg NVENC open in a direct-SDK
process wedges every later open process-wide with `NV_ENC_ERR_INVALID_VERSION`
until the host restarts.
`can_encode_444` was moved off the ffmpeg probe for exactly this reason on
2026-07-27. The 10-bit one was deliberately left behind, on the reading that
"Linux HDR genuinely rides the libav P010 path". `open_video` contradicts that:
if cuda && nvenc_direct_enabled() { // no 10-bit exclusion
… NvencCudaEncoder::open(…, bit_depth, …)
A CUDA capture goes to the direct backend at whatever depth was resolved, and
`is_ten_bit_input` already accepts the packed 10-bit RGB (`X2Bgr10`) that a
gamescope HDR capture negotiates. So on a default NVIDIA host the probe was
loading ffmpeg's NVENC client for a session that never uses it.
Observed on home-nobara-1 2026-08-10, gamescope + RTX 5070 Ti, client HDR on:
resolved session plan … bit_depth: 10, hdr: true
pipewire format negotiated … xBGR_210LE mapped=Some(X2Bgr10) modifier=0 hdr=true
encoder submit failed — encoder rebuilt in place … NV_ENC_ERR_INVALID_VERSION
encoder did not recover after repeated in-place rebuilds — ending the video session
and with `PUNKTFUNK_NVENC_DIRECT=0` (nothing mixes, libav serves everything) the
same HDR session streams clean: 0 errors, bit_depth=10, hdr: true.
The 10-bit cap now rides `nvenc_cuda::probe_support()`'s existing throwaway
session — the same place the 4:4:4 cap already rides, queried per listed GUID
with `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE`, which is what the Windows NVENC arm has
always done (`enc/windows/nvenc.rs`). Unanswered fails CLOSED: an 8-bit session
beats a wedged one. A host that will really serve over libav
(`PUNKTFUNK_NVENC_DIRECT=0`, or a build without `--features nvenc`) keeps the
ffmpeg probe, where it validates the actual path and ffmpeg's client is loaded
anyway.
⚠ NOT YET VALIDATED ON GLASS. Gates are green — clippy `-D warnings` with
`--features nvenc,vulkan-encode,pyrowave` on linux/amd64, 67 pf-encode tests,
fmt — but the end-to-end HDR run is still owed. This branch is 42 commits behind
main and its build cannot complete a punktfunk/1 handshake on home-nobara-1 at
all (it stalls between "audio channels resolved" and "encode bit depth" and
times out at 10 s, on EVERY attempt). That stall is NOT this change: a control
build with only the routing reverted stalls identically, and the released
0.27.0 RPM on the same box handshakes fine and reaches `bit_depth=10`. Rebase
onto main before re-testing.
Asked for by a field user: "make the iPadOS client compatible with keyboard to
select games with keyboard arrows, enter to launch". An iPad on a Magic
Keyboard and a couch Mac are the same situation the console layout was built
for — a screen driven from a distance with a fixed set of directional inputs —
and the cursor/confirm/back model already exists here for the pad. A keyboard
is a third input onto it, not a new navigation scheme: arrows move, Return and
Space activate, Esc backs out, everywhere the controller already worked
(carousel, menu lists, prompts) plus the plain poster grid.
`active` mirrors each caller's existing controller gate rather than being a
second, parallel notion of "who has input". Without that, a launcher sitting
under an open screen would keep eating key presses and navigate behind it —
the same defect the pad gate exists to prevent. Esc returns `.ignored` when a
screen has no back action, so it still reaches the `.cancelAction` shortcut
that closes a macOS sheet.
The plain grid needed real arithmetic rather than a flat index. It renders up
to TWO `LazyVGrid` sections (launchers above titles), so a flat index steps by
the wrong amount at the boundary whenever the first section's last row is
partial — up from the titles' first row lands mid-launcher-row instead of
above. `LibraryGridNav` moves within a section and hands off at its edges
preserving the column, clamping into partial rows. It lives in PunktfunkKit
because it is edge-case arithmetic and that is the target tests can reach; 12
cases cover the partial row, the hand-off, a stale cursor, an empty grid and a
zero column count.
The column count comes from the grid's MEASURED width run through
`.adaptive`'s own fitting rule, so up/down move exactly one visual row instead
of a guess that drifts with window size. Measured via a background
GeometryReader — a sibling inside a ScrollView would claim the whole viewport.
The grid cursor starts nil and only appears on the first arrow press, so a
touch user is never shown a selection they didn't ask for.
tvOS is excluded throughout: its focus engine already routes hardware arrows,
and these screens hand it navigation authority deliberately.
17 PunktfunkKit tests pass; macOS + tvOS typecheck; launcher and settings
verified rendering and navigating in the iPad Pro 13" simulator.
Carries the display-corner inset (previous commit) through the three screens
that pin the same legend pill — settings, add-host, pair — so they don't drift
from the launcher the moment the user opens one. Each of those trays is
`maxWidth: .infinity, alignment: .leading`, so the pill is left-aligned there
too and the iPad's short centre indicator is never in its way; the keyboard
tray sits ABOVE the legend in the stack, so pulling the tray down doesn't put
keycaps over the strip.
And the settings tab strip now takes its padding from the row metrics instead
of two fixed numbers. It was the other half of the iPad sizing complaint: the
tier work grew the fields under it while the tab pills stayed at phone scale,
which read as a tab bar that belonged to a different screen.
macOS + tvOS typecheck.
Re-landed on the probe spelling: the corner inset is read from
`\.displayBottomInset` (DisplayBottomInsetProbe via ContentView), never asked
of UIKit during body — the original spelling froze the update graph of any
view that called it on device (see the legend-pinning commit).
Trust-on-first-use had no pad wiring at all, and it appears at the worst
possible moment for that: mid-connect, with capture already disabled so
ContentView can blur the stream and stop forwarding. A pad-only user pressed A
to connect, got this card, and found the controller in their hands doing
nothing — the only way past was to reach for the screen. A trusts, B cancels,
X runs the PIN ceremony instead.
The legend only appears with a pad attached; naming controller buttons to a
trackpad user would be describing hardware they don't have. `GamepadManager` is
observed rather than read once so the bar arrives if a pad wakes up while the
card is already open.
Safe to poll unconditionally while the card is up: capture is off for its whole
lifetime and the home screens are unmounted behind the session view, so nothing
else is reading the pad. The A press that STARTED the connect is typically
still held when this mounts — `GamepadMenuInput` adopts the held state without
firing on start, so it doesn't auto-trust.
tvOS is untouched: the focus engine already drives these buttons.
macOS + tvOS typecheck.
On an iPad the controls pill sat two to three times further from the bottom of
the screen than from the left, so it read as floating rather than tucked into
the corner. `safeAreaInset` places its content INSIDE the safe area, so the
pill's bottom margin was stacking on top of the device's own ~24 pt
home-indicator inset while the leading margin had nothing to stack on.
Subtracting that inset is the fix; GETTING the inset is where this commit's
history is. Three spellings failed before this one, each silently:
- a `GeometryReader` carrying `.ignoresSafeArea()` — a proxy reports no
inset for an edge it has been told to ignore, so it can only answer 0;
- `.ignoresSafeArea(.container, edges: .bottom)` on the inset CONTENT,
which does not move content the inset mechanism itself placed;
- asking UIKit for the key window (`UIApplication.shared.connectedScenes…`)
DURING body — which answers the right number and then KILLS the asking
view. On a physical iPad (never the simulator) that walk re-enters UIKit
layout mid-render and SwiftUI silently severs the view's update graph:
from then on every `@State` write lands in storage without `body` ever
running again. That is exactly how Settings and Add Host stopped opening
while their triggers kept firing and `showSettings` kept reading true —
no AttributeGraph warning, nothing in the log. Found by bisecting device
builds; a build that opens the screen programmatically renders green with
input-driven navigation completely dead, so only a real press counts.
So the inset is measured where it is knowable: DisplayBottomInsetProbe, a
UIView that reads its WINDOW's inset from UIKit's own callbacks
(didMoveToWindow / safeAreaInsetsDidChange / layoutSubviews), hops out of the
current update, and publishes through `\.displayBottomInset` from ContentView.
The legend reads the environment — proven safe on glass — and
`gamepadLegendBottomPadding` is pure arithmetic.
Tablets only. The padding goes negative to pull the pill back down through the
indicator strip, which is safe there because the pill is left-aligned and an
iPad's indicator is a short bar in the middle. A phone's indicator is taller
and its legend runs most of the width, so the same move would cross it —
phones keep the plain margin, and the tier comes from the size classes, so an
iPad in a narrow Stage Manager window is treated as the in-hand case it is.
macOS + tvOS typecheck; margins and Settings/Add Host opening from a real
controller press verified on the iPad itself.
`.alert` and `.confirmationDialog` are UIKit/AppKit surfaces: a game controller
cannot move through their buttons or press one. On iOS/macOS that made every
prompt in the connect path a dead end for a pad-only user, and they are not
incidental prompts — "Pairing required" is the FIRST thing an unpaired host
shows (so pairing was unreachable before it even got to a PIN), "Connection
failed" strands the console UI behind a modal only a finger can dismiss, and
"Waiting for approval" owns the only Cancel for a connect that may never
complete. GamepadPromptView renders those states as a console card with a
focus list of actions; the system alerts stand down while it is up. tvOS keeps
them — the focus engine drives them natively there, which is exactly why this
gap was invisible from that platform.
Two things are deliberate rather than incidental:
The gate is "not STREAMING", not `model.connection == nil`. A connection
object exists well before a stream does — through the pair-required and
approval handshakes, precisely when these fire — so gating on the connection
would hand those cases back to the system dialog. Streaming is the one state
that must keep the alert: there the pad belongs to GamepadCapture.
And the overlay hangs off `driven`, not `home`, for the same reason: `home`
renders only while the connection is nil, so a prompt mounted there would be
skipped in the very case it was written for.
The launcher stands down from the controller poll while a prompt is up
(`promptActive`) — without it the host carousel keeps scrolling underneath the
modal and one A press reaches both.
macOS + tvOS typecheck; console UI verified opening Settings in the iPad
simulator with the prompts wired in.
Reported from the iPad: the cards arrive, come to rest, and then flash. They
did — and it was not the motion. Every card sat at the WRONG fill for as long
as the entrance animation ran, then snapped to its real one in a single frame
once SwiftUI tore the animation down, roughly a third of a second AFTER the
last card had stopped moving.
`rotation3DEffect` was the cause. A real 3D transform renders its subtree
through an offscreen layer, and these cards carry translucent glass, which
resolves differently in there — so the strip showed the aurora through itself
for the duration and went opaque the instant the layer was dropped.
Measured on an iPad Pro 13", sampling the centred tile: it held #4a3d87 across
twelve frames in which nothing moved, then stepped to #423970 — 23/255 of blue
in one frame, on every card at once, while the backdrop beside them stayed
exactly #713fa0 throughout. Stretching the timeline from 1.02 s to 2.82 s moved
the step from 0.70 s to 2.50 s after launch: the same 0.32 s before the end in
both runs, which is what proves it is the animation ENDING and not any state
the tiles were waiting on. Deleting the rotation deleted the step outright.
`compositingGroup()` above and below the transforms each changed nothing, so
this is the 3D layer specifically, not compositing in general.
The turn is now projected instead of rendered: cos(angle) as a horizontal
squeeze IS the orthographic projection of a Y-axis rotation, hinged on the edge
the card fans from — which also puts the code behind the doc comment that
already described the fan that way (the sign of a rotation carried it before;
cos is even, so the anchor has to). Affine, so there is no offscreen pass and
no layer to drop. What's lost is the perspective trapezoid, which at card size
was never what sold the motion; the fan-out, the stagger and the overshoot are
untouched.
Also drops the temporary PUNKTFUNK_DIAG_OPEN hook that gated each step of this
branch in the simulator.
284 tests pass; macOS + tvOS typecheck; entrance re-recorded on the iPad Pro
simulator — the tile now settles monotonically (#4153cf → #383c77 → #39396e →
#393a6e) and holds, with the fan-out still reading as a turn.
PairSheet is a `Form` with two `TextField`s. On tvOS the focus engine drives
those natively, but on iOS/macOS a controller cannot reach a text field, type
into it, or press the button underneath — so for anyone in the console UI,
pairing (the ONE thing between a fresh install and a first stream) ended at
"now touch the screen".
GamepadPairView is the same ceremony in the gamepad UI's own vocabulary: the
vertical focus list the settings and add-host screens use, A on a field to
open GamepadKeyboard in a bottom tray, B to peel one layer. It mirrors
GamepadAddHostView field for field, because it is the same interaction and
someone who has added a host should recognise it immediately.
The ceremony itself moved to a shared `PairCeremony` used by both
presentations, so they can never disagree about what a wrong PIN means, what
a host rejection says, or when a late result must be discarded.
On iOS it is a shell layer like settings and add-host, and it LEADS the
shell's screen order: it blocks a connect the user already asked for and can
be raised from on top of the library (launching a title on an unpaired host),
so it has to win; backing out reveals whatever it interrupted. macOS has no
shell, so its sheet switches content by mode instead. tvOS is untouched.
macOS + tvOS typecheck; console UI verified opening Settings in the iPad
simulator with the pair screen wired into the shell.
Every hint cell that names an action is a real button on iOS/iPadOS/macOS.
The legend already lists every action a screen has, in one fixed place, so a
user without a pad in their hands — an iPad on a stand, a Mac driven by
trackpad, anyone running `gamepadUIMode == "always"` — was reading a complete
menu they could not press.
tvOS keeps them inert deliberately. There is no pointer there, so a tappable
cell would have to be FOCUSABLE, and that puts six new stops in the path of a
focus engine whose flow on these screens is load-bearing and hard-won — while
every action in the legend already has a native route (select, Menu,
Play/Pause, the focusable tab pills).
Cells that name an INPUT rather than an action stay labels: "↔ Adjust" is the
stick itself, and "A Type" over the on-screen keyboard has no tap equivalent
because a touch user types by tapping the keycap.
Two details that are load-bearing rather than tidy: the decorative hairline
gets `allowsHitTesting(false)` (it sits on top of the cells), and the press
style's `contentShape` sits below its `scaleEffect` so shrinking the artwork
cannot move the target out from under a resting finger and lose the touch-up.
macOS + tvOS typecheck; 272 tests pass; console UI verified opening Settings
in the iPad simulator.
`/api/v1/local/summary` (and the console card behind it) read the live-stats mode
slot, which bring-up seeded from the NEGOTIATED mode:
let live_mode = Arc::new(AtomicU64::new(pack_mode(
mode.width, mode.height, interval_hz(interval))));
The refresh was already corrected there — the comment says so, because KWin caps
a virtual output's rate — but the SIZE was still the request. Only a mid-stream
resize ever fixed it: the rebuild path below publishes `delivered_mode(frame..)`,
and bring-up never did.
Attach is what makes this matter rather than being pedantry. On a box with a
physical display the gamescope backend logs
gamescope: box drives a physical display — attaching at its own mode (no
re-mode) client_w=5120 client_h=1440
and streams the panel's size. Measured on home-nobara-1 with a 1080p HDMI panel
attached: the capture negotiated 1920x1080 and NVENC opened 1920x1080@240, while
the summary reported 5120x1440 — the console confidently naming a resolution
nobody was watching, which is exactly the shape of the stale attach-path report
noted on .41 in July ("reusing w=5120 h=1440" while the session was really 1080p).
Seeding the slot from `delivered_mode(frame.width, frame.height, interval)` uses
the same helper the rebuild path already trusts, and changes only the two fields
that were wrong — its refresh term IS `interval_hz(interval)`, so that half is
bit-for-bit what it was.
This publishes the STATS slot only. It deliberately does not send the client a
corrective `Reconfigured`: that remains owed exactly where it already was, under
`adopted_at_bringup`, because an ordinary connect's mode came from the Welcome
rather than from an accept the client has already acted on.
Verified on home-nobara-1, attach session against a 1080p panel:
summary session: {"width":1920,"height":1080,"fps":240}
actually captured: pipewire format negotiated width=1920 height=1080
Before the change the same session reported 5120x1440.
`punktfunk-host` died twice on home-nobara-1 with the same stack:
__strlen_evex <- av_vbprintf <- format_line <- av_log_default_callback
<- ff_cuda_check <- ff_nvenc_encode_init <- avcodec_open2
<- NvencEncoder::open <- NvencEncoder::reset <- virtual_stream
once as an outright SIGSEGV mid-session, and once as a thread wedged in that
stack so the service never answered SIGTERM and systemd escalated to SIGABRT
("State 'stop-sigterm' timed out. Aborting."). Both times a client's session was
rebuilding its encoder. The blast radius is the whole host process — every other
client's session goes with it.
The fault is in libav, not here. `ff_cuda_check` logs the failing CUDA call as
`"%s failed -> %s: %s"` using an `err_name`/`err_string` pair the error lookup
does not always fill, and glibc then walks whatever was on the stack. We cannot
patch the distro's FFmpeg, so the fix denies it the chance to format: the guard
already used by the 4:4:4 probe drops the level to AV_LOG_FATAL across the open,
and `av_log_default_callback` returns on the level check before `format_line` —
these messages are AV_LOG_ERROR. The failure is not swallowed; it still comes
back as `Err(e)` and is reported with our own context, which now says the libav
text was deliberately silenced so nobody hunts for a message that will not come.
Scoped to the `open_with` call ALONE. The ENOSYS arm immediately below recurses
into `Self::open`, and `QuietLibavLog` holds a non-reentrant global mutex —
wrapping the whole `match` would have deadlocked the intra-refresh retry.
Verified on home-nobara-1 (fc44, libavcodec 62). With CUDA made unavailable so
the open fails inside the CUDA layer, the old binary prints
[hevc_nvenc @ ..] cuInit(0) failed -> CUDA_ERROR_NO_DEVICE: no CUDA-capable
device is detected
— that line IS `ff_cuda_check` formatting the two `%s` — and the fixed binary
does not; both exit 1 with our error instead. A successful open is unaffected on
both the direct-SDK and the libav paths (90/90 frames, identical output size).
What this does NOT claim: the uninitialized-pointer condition itself was not
reproduced on demand — it depends on the CUDA error lookup failing to fill the
strings, and in the forced case above it filled them fine. What is demonstrated
is that the formatting call which faulted is no longer reached during the open.
GamepadFormMetrics had exactly two tiers, tvOS and everything-else, so a 13"
iPad Pro drew its settings rows, section tabs and legend at iPhone scale in
the middle of a very large display — the field verdict was that the sizing
"does not adapt to larger screens". There is a third tier now, between the
in-hand and 10-foot sets.
Chosen from the SIZE CLASSES, not the device idiom: an iPad in a narrow Stage
Manager or Split View window keeps the in-hand numbers, because what is being
read is the window, not the panel behind it. Phone sizing is untouched.
Published from ContentView — the app root — rather than only from
`gamepadPaletteInk`. A screen that applies that modifier itself sits ABOVE its
own copy of the environment, so its `@Environment` resolves against its parent
and would read the bare phone default no matter what the modifier published.
The screenshot harness publishes it too, for the same reason: it mounts these
screens with no ContentView above them, so an iPad capture used to render the
whole gamepad UI at iPhone scale.
macOS + tvOS typecheck; the console UI verified in the iPad simulator opening
Settings at the tablet tier.
The tab strip's selected pill is FILLED with the palette accent, and
`ink.onAccent` — chosen by that accent's own luminance, and documented for
"a filled pill's label" — has existed for exactly this since the palettes
landed. The strip was using the plain foreground instead, which is white on
every dark palette and therefore looked fine on all but one: Graphite's
accent is a light grey (luma ≈ 0.80), so its selected tab read white on
white.
Four things the field reported on glass, all in the console's visual layer.
The tray scrim is GONE, not re-tuned. It laid `.ultraThinMaterial` across the
top and bottom of every form screen, and a material by definition lifts and
tints whatever it blurs — so it read grey, and washing it with the palette's
ground only made it read coloured. There is no public way to blur a backdrop
without that tint, so the layer had to go. The darkening it also provided was
never its to provide: the backdrop's own legibility scrim
(0.38/0.06/0.08/0.40 — the same gradient pf-console-ui bakes into its shader)
has always been one layer down, which is why the console has no tray band.
Pale palettes rendered every row, pill and card as a flat white slab, and
lowering the tint did nothing, because the opacity came from the glass BODY:
`.regular` is a bright, high-body material and a pale palette's `ink.glass` is
literal white. Pale palettes take `Glass.clear` now, with a light wash to keep
dark ink legible.
A focused settings row changed colour in a hard jump, a beat late, while its
scale animated smoothly beside it — the focus tint rode `Glass.tint`, and a
Glass value is opaque to SwiftUI's animation system. It is a plain fill
between the glass and the label now, so the existing animation covers it.
The host cards appeared to be swapped for different ones as their entrance
landed: `CardEntrance` swings each tile in on a rotation3DEffect, and Liquid
Glass samples the backdrop through its own layer, which it cannot do under a
3D transform. Those tiles take the material path (`forceMaterial`).
And the option band never turned like a cylinder because the band MASKED
itself: a mask rasterises what it covers, flattening `rotation3DEffect`'s
perspective, so the projection was computed and discarded every frame. The
soft edge is folded into each option's own opacity instead. Its ±1 neighbours
stay hidden at rest — showing them reproduced the documented overlap defect
("2752 × 2064" with "280 ×" through it), which is why they were dropped.
Also fixes pale palettes washing out: the calm mix added a plusLighter wash of
a near-white ground on top of a field already mixed toward it, saturating the
form screens to white.
macOS + tvOS typecheck; console UI verified opening Settings in the simulator,
with no tray band.
Every other background reads as a place or a material — Violet, Nebula,
Abyss, Ember, Moss, Graphite, Holo, Sunset, Bloom, Dawn, Mint, Opal — and
"OLED" was a spec sheet sitting among them, naming the panel it suits rather
than what it looks like. It looks like black with a thin violet corona at one
corner, which is an eclipse, and that puts it beside Nebula and Abyss.
The ID stays "oled" in all three clients: it is the stored ui_palette value
AND the cross-client key, so renaming it would orphan every saved choice and
silently desync Apple, Android and the console UI. Only the label moved. No
test or doc pinned the old string.
Palette tests pass; macOS builds.
`buttonGlyph` asked the LIVE controller for its `sfSymbolsName` and fell back
to generic letter glyphs, so the moment a DualSense slept, disconnected or
ran flat, every ✕/◯ hint turned into A/B under its owner's hands. Under
`gamepadUIMode == "always"` — the console UI up with no pad by design — they
were never right at all.
GamepadManager now remembers the family of the last controller that was
actually attached (`DefaultsKey.lastGamepadKind`, written on selection and
deliberately never cleared on disconnect), and the legends resolve live →
remembered → generic. Not a user-facing setting: a glyph-style picker is one
more row asking a question the app can answer itself, and the remembered pad
is right essentially always.
GamepadGlyphs maps by POSITION, not by name, because that is what
`GCExtendedGamepad`'s buttonA/B/X/Y are. So ✕ is role `.a` (the bottom
button), and a Switch pad gets Nintendo's transposed labels rather than Xbox
letters printed on the wrong physical buttons.
The test asserts every symbol name RESOLVES on the running OS. That is the
load-bearing one: `Image(systemName:)` draws an unknown name as nothing at
all — no crash, no log, no red build — so a typo would silently blank a
legend on device and no amount of building would catch it.
macOS + tvOS typecheck; the 5 glyph tests pass; the console UI verified
opening its Settings screen in the iPad simulator.
v5's verdict was `max - min` over the sampled fd counts with a default tolerance
of 0. An encode worker's fd count legitimately moves by one when a dmabuf fd is
in flight at the sampling instant, so the spread was permanently 1 and the leg
failed on a perfectly healthy box — reported, like every red leg here, as "a
shipping blocker, not a flake".
Measured on home-nobara-1 (KDE, RTX 5070 Ti), 33 samples over 480 s:
54 54 54 54 54 54 54 54 55 55 54 54 54 55 54 54 55 54 54 54 55 54 …54
It oscillates and ENDS on 54, exactly where it started. Nothing accumulates.
The replacement is median-of-thirds: median(last third) - median(first third).
That is strictly MORE sensitive to what R2 is actually about — a steady leak
moves the trend just as much as it moves the spread, while bounded jitter moves
only the spread — so this is not the tolerance being widened to get a green.
The spread is still printed, now labelled as jitter when the trend is flat. The
warm-up window already covers the one-off first-sight-of-each-buffer cost, so a
plateau inside it is by design not a leak; a step that never comes back still
trends and still fails.
The self-test grows the cases that force this to be a real assertion: the
measured oscillation must trend to zero, a synthetic leak must still trend up, a
flat series must be flat, and a step that never returns must be caught. Writing
them is what caught my own arithmetic — the first draft asserted a leak trend of
12 where the reader correctly says 10.
Also records what the v5 log now makes obvious: `--minutes` does NOT set the wall
clock. `spike` is frame-count bounded (`seconds * fps`), and a KWin virtual
output being driven hard delivers ~197 fps against a `--fps 60` budget, so a
"10 minute" run ended after 182 s. Ask for more minutes than you want.
v4.e killed the worker mid-session and then required "the encode worker died
mid-session" in the spike's log. That line, and the respawn that follows it, are
emitted by `RemotePyroWave::reset` — and the only caller of `Encoder::reset` is
the real session's `reset_stalled_encoder` loop in native/stream.rs. `spike` is
a dev tool with no recovery loop at all: it does
encoder.submit(&frame).context("encoder submit")?
and exits. So a worker killed under the spike can never reach reset, the line
can never appear, and the leg reported
FAILED — a red leg here is a shipping blocker, not a flake.
for a ladder rung the product implements correctly. A false negative in the one
place that must not have one: this kit exists to refuse false PASSes, and a
false FAIL spends exactly the same credibility.
Verified on glass first, so the rung is not being excused on a reading of the
source. home-nobara-1 (KDE, RTX 5070 Ti), real client session, worker pid 44249
killed with -9: `video_streaming` stayed true across the kill, and the host
logged
pyrowave: respawned the encode worker after a mid-session death
worker=/usr/bin/punktfunk-encode-worker priority=Granted(Realtime)
encoder submit failed — encoder rebuilt in place, forcing an IDR
error=... Broken pipe (os error 32) reset=1 max=5
v4.e now asserts the half the spike can actually observe — the death surfaces as
an ATTRIBUTABLE worker-IPC error naming the worker, after real encode windows,
and the host process does not die with it. A hang, an unexplained failure, or a
dead host still fails. The respawn half is printed as the human follow-up, in
the same idiom v1 already uses for its on-glass half, and written into `recipe`
with the two commands that close it.
A managed gamescope session on Nobara 44 (VM 123) died on essentially every
client connect. The visible symptom was a black screen; underneath,
`punktfunk-gamescope` was SIGABRT crash-looping — 11 coredumps in three minutes
— until `gamescope-session-plus` ran out of retries and came up on the *stock*
`/usr/bin/gamescope` at its default 1920x1080, which looks like a working game
mode and carries none of our capture patches.
punktfunk-gamescope: ../src/pipewire.cpp:88: void destroy_buffer(
pipewire_buffer*): Assertion `false' failed.
#4 __assert_fail
#5 destroy_buffer(pipewire_buffer*).cold
The abort is a use-after-free wearing an `assert(false); // unreachable`.
`pw_buffer->user_data` is associated with its `pipewire_buffer` in exactly one
place, at the bottom of `stream_handle_add_buffer` — after all four `goto error`
paths, whose label is a bare `delete buffer`. And `stream_handle_remove_buffer`
clears `buffer->buffer`, the only route back to the `pw_buffer`, while a still-
`copying` buffer is deleted later on the steamcompmgr thread with no way to
reach the slot. PipeWire recycles `pw_buffer` slots across renegotiations, so
the next remove reads `buffer->type` out of freed memory, falls off the end of
the switch and aborts.
The host sets the session to the client's mode on connect, and that mode change
is what renegotiates the stream — which is why "every connect" was the trigger.
Patch 0007 fixes the association rather than the symptom: set `user_data` at
allocation so it is valid on every path out of `add_buffer` and clear it on the
error path; clear it in `remove_buffer`, the last point both halves are known;
null-check the two consumers. The `default:` arm then logs instead of aborting.
Offered upstream — nothing about it is punktfunk-specific.
Two traps this cost time on, both now written down in the README:
* It is NOT HDR-specific. The abort was first seen right after a 10-bit
stream negotiated, so `PUNKTFUNK_GAMESCOPE_HDR=0` looked like a workaround.
The failing argv carries no `--hdr-enabled` at all.
* `gamescope-session-plus` hides it by falling back to stock gamescope, so a
session existing proves nothing — read the banner.
`.pfhdrN` moves to 5 even though no capability moved: every deployed pfhdr4
binary crash-loops, so an operator needs to be able to tell them apart. All
`>=` thresholds in the host's probe are unaffected.
Also documents `libstdc++-static` as a build dependency — it is punktfunk's
requirement (the script links the C++ runtime statically on purpose), so no
`dnf builddep` will ever pull it, and without it meson fails with a message
naming neither the flag nor the package.
Verified on VM 123 with the patched binary installed: 5 rapid connect/
disconnect cycles plus 3 further sessions, zero new gamescope coredumps (43
before, 43 after), Steam game mode streaming real content at 5120x1440, and
`/tmp/chimeraos-short-session-tracker` never created — the short-session latch
that used to strand the box in plasma was downstream of this crash.
Field-diagnosed on Nobara (fc44, canary g13179011), where Game Mode became unstartable and the box
was handed to plasma. #144's bind works — the patched build genuinely reaches a session script that
hardcodes /usr/bin/gamescope — but a mount namespace in a systemd USER unit is also a USER namespace,
and only this uid is mapped in it. Measured on the box:
on disk / in a unit without the bind : drwxrwxrwt 2 0 0 /tmp/.X11-unix
in a unit WITH the bind : drwxrwxrwt 2 65534 65534 /tmp/.X11-unix
uid_map inside : 1000 1000 1
wlroots checks that /tmp/.X11-unix is "owned by root or us", sees nobody, and refuses:
wlserver: [xwayland/sockets.c:100] /tmp/.X11-unix not owned by root or us
wlserver: [xwayland/sockets.c:217] No display available in the first 33
-> SIGSEGV in run_pipewire
Three ~10 s failures then feed chimeraos' short-session tracker, session-plus stops even trying, and
steamos-session-select rewrites the user's session to plasma. So the symptom an operator reports is
"thrown onto KDE and I can't get back" — two removes from the cause.
Two further bugs found while fixing it, each worse than the one reported:
* THE BIND WAS ARMED EVERYWHERE. The condition was only `gamescope_bin() != /usr/bin/gamescope`,
so every box with punktfunk-gamescope installed took a namespace it has no use for — Bazzite,
SteamOS-likes, the Deck. The blast radius was every gamescope box, not just the hardcoded-path
ones the mechanism exists for. Now the host READS the session script and arms only where it
never mentions GAMESCOPE_BIN and names /usr/bin/gamescope outright; everything else is
bit-for-bit pre-#144, no namespace at all. An unreadable script does not arm.
* THE DROP-IN OUTLIVED ITS SOURCES. It was written to ~/.config/systemd/user/ on the TEMPLATE, so
it also applied to the box's OWN autologin unit at every boot — while both paths it binds live in
tmpfs. After a reboot the drop-in survives and its sources do not, and BindReadOnlyPaths= with a
missing source fails the unit outright. THAT is why the field symptom survived a reboot. It now
lives in $XDG_RUNTIME_DIR (dies with the login session), removal covers both the runtime and the
legacy $HOME path, and restore_takeover_on_startup does that removal unconditionally at host
start — which is the upgrade path for every box already running canary g13179011. Without it,
updating the host would not un-brick them.
* A bind was armed even when gamescope_bin() fell back to the bare name "gamescope". The wrapper
execs `gamescope` through PATH inside the unit — onto the path we just bound the wrapper over.
Fork bomb. Refused ahead of even the operator's force.
Where the bind IS armed it now carries its own compensation: a user-owned $XDG_RUNTIME_DIR/punktfunk-x11
bound read-WRITE over /tmp/.X11-unix (Xwayland creates the socket there), so the ownership check sees
"us". Skipped when that directory is already ours or absent — neither is the hazard. Stale sockets are
pruned by connect-test so a SIGKILLed session cannot walk the 33 display slots away.
And rather than trust that reasoning, the host now ASKS THE BOX before arming: it runs the field
reproduction with the real property set — `systemd-run --user --wait --collect --property=<the same
args> -- stat -c %u /tmp/.X11-unix` — and arms only if the answer is our uid. Anything else (65534, a
rejected property, no user manager, a blown 10 s budget) means no bind, and the session runs stock
gamescope: no HDR, no in-node cursor, but it STARTS. A runtime backstop disarms and relaunches if a
session launched with the bind armed produces no node in its window, latching one-way per process.
The XFixes-cursor concern that argued against relocating the socket does not hold: the only host-side
X client is spawned under `plan.gamescope_cursor`, which is `gamescope && !gamescope_composites_cursor()`,
and our shipped +pfhdr4 build is patch level 4 — so on the very route where the bind arms, that reader
is never constructed.
`cargo clippy --all-targets -- -D warnings` over the shipped drivers (the step that
enforces the unsafe-audit gates) fails on main since #149 landed: clippy 1.96's
`manual_range_patterns` fires on all five `4 | 5 | 6` device-type arms, and `-D warnings`
turns each into an error, so `pf-gamepad` fails to compile as both lib and lib-test and
the whole step never reaches the other five crates.
Device types 4/5/6 are the Xbox Wireless / One S / Elite Series 2 identities added by
#149 — contiguous by construction, so `4..=6` is the same set. Purely a lint fix: no arm
gains or loses a device type, and the comments that already record *why* the three share
one report shape, one descriptor and one vendor string are untouched.
The `0xCA` wire already carries the two Xbox impulse-trigger motors (v3), and the Rust decode path
already parses them; `datagram_task.rs` dropped them on the floor with a comment naming exactly this
work as what remained. The blocker was the C ABI: every non-Rust client pulls rumble through
`punktfunk_connection_next_rumble_cmd`, whose out-params cannot carry two more channels.
PunktfunkStatus punktfunk_connection_next_rumble_cmd2(
PunktfunkConnection *c, uint16_t *pad, uint16_t *low, uint16_t *high,
uint16_t *left_trigger, uint16_t *right_trigger,
uint32_t *backstop_ms, uint32_t timeout_ms);
⚠️ ADDED, not widened. `_cmd` keeps its signature and its values bit-identical for handle-only
traffic — out-of-tree embedders depend on it and `docs/embedding-the-c-abi.md` documents it, so
silently changing an exported symbol would break every consumer at once. `nm` on the staticlib shows
all four rumble entry points still exported. `ABI_VERSION` 17 → 18; every other site reads it
dynamically, so there are no hardcoded mirrors to drift.
⚠️ ONE HONEST BEHAVIOURAL DELTA, documented in `abi.rs` and pinned by a test: against a
trigger-driving host a `_cmd` caller now receives commands with `low == high == 0` where the demux
previously dropped the update entirely. They are idempotent handle stops, and the redundant-stop
suppression cannot fold them because the command as a whole is not silent. Zero cost today —
nothing sources non-zero triggers.
The dedupe-jitter proof was RE-DERIVED rather than widened, which is the kind of thing that quietly
rots when a tuple grows: the nudge touches only `low` by ±1 LSB and `emit` is only reached with a
non-silent level, so the nudged tuple can collide with the four-field stop sentinel only at
`(1,0,0,0)`. A test pins both directions — refuse at `(1,0,0,0)`, flip freely at `(1,0,lt,0)`.
Apple renders them: `RumbleRenderer` gains `Motor?` slots at `GCHapticsLocality.leftTrigger` /
`.rightTrigger` beside the existing handles. A controller without trigger actuators degrades
silently — a nil engine yields a nil slot and `reconcile` no-ops — and absent localities are never
logged, because on most pads that is the normal case rather than a fault. The macOS DualSense
raw-HID branch stays a deliberate no-op: a DualSense has ADAPTIVE triggers, not trigger rumble
motors, and inventing a mapping there would buzz the wrong thing.
🛑 BUILT AHEAD OF A PRODUCER, DELIBERATELY, AND NOTHING HERE CLAIMS OTHERWISE. Nothing can currently
source trigger rumble on Windows and that is measured, not assumed: `XINPUT_VIBRATION` has two
members, and GameInput — the only four-motor API — does not enumerate an xinputhid-promoted Xbox pad
at all, verified against a REAL Microsoft Elite which is equally invisible to it while classic
XInput reads it live. So this path has never been exercised end to end and the comments say so.
VERIFIED
* `cargo test -p punktfunk-core --features quic --lib` 378 passed on macOS, 203 on Windows;
clippy `-D warnings` clean with and without default features; `cargo fmt --all --check` clean.
* The generated header is regenerated and idempotent on re-run (CI diffs it).
* SWIFT ACTUALLY COMPILES AND RUNS: `swift build` clean and `swift test` 262 passed / 0 failures
in `clients/apple`, against a locally built xcframework. (Editor SourceKit errors about
`PunktfunkCore`/`DualSenseHID` are index noise from that gitignored artifact — a real build
resolves both, and the `DualSenseHID` references are untouched by this change.)
* `cargo build -p punktfunk-host` clean on Windows.
NOT VERIFIED
* End to end — see above; there is no producer.
* Whether a real Xbox pad on Apple actually reports the two trigger localities. The degrade needs
no code, but the positive case is untested.
* `pf-client-core` (the SDL renderer) does not build on macOS at baseline and is unbuilt here. It
only reads `RumbleCommand` fields and never constructs one, so added fields cannot break it, but
it still calls `_cmd`; wiring `SDL_RumbleGamepadTriggers` is separate work.
ANDROID: NOT DONE, and it should stay that way for now. `pack_rumble` packs pad/backstop/low/high
into bits 0..52 of a `jlong` with `-1` reserved as a sentinel — two more `u16` do not fit. The right
fix if ever wanted is the direct-`ByteBuffer` shape `nativeNextHidout` already uses in the same file
(zero-allocation, caller-owned, the established idiom), not a second `jlong` (racy across two calls)
nor `long[]` (an allocation per pull). But no Android device exposes trigger actuators at all, so
there is nothing to render. Separately stale and also not fixed: `NativeBridge.kt`'s KDoc still
documents the v2 `ttl_ms` layout rather than `backstop_ms`.
Debugging the on-glass session, a devtest run died with
error=create gamepad bootstrap mailbox Global\pfds-boot-0: Zugriff verweigert (0x80070005)
(install/repair: punktfunk-host.exe driver install --gamepad)
and then — this is the part that cost real time — kept printing "virtual Xbox One S Controller up",
streamed frames into nothing, and let the operator measure the INCUMBENT pad on that index. The
XInput packet count sat frozen and read as "the pad is dead", which was a wrong conclusion drawn
from a harness that had already failed and not said so.
WHAT IT ACTUALLY WAS. Pad lifetime is deliberately tied to the SESSION (native/input.rs: "the
gamepads are created and torn down with the session"), and a live session's pad legitimately owns
`Global\pfds-boot-0`. The mailbox's SDDL is `D:P(A;;GA;;;SY)(A;;GA;;;LS)` — SYSTEM and LocalService
only — and the host service runs as LocalSystem while a hand-run devtest runs as an elevated
Administrator, which is in neither ACE. `CreateFileMappingW` over an existing name is really an
OPEN, access-checked against the incumbent's DACL, so it returned ACCESS_DENIED and bailed at the
`?` BEFORE reaching the `ERROR_ALREADY_EXISTS` branch that already had the right sentence. That
branch only ever fires when both processes run as the same account.
The name is per-index on purpose and stays that way: `Global\pfds-boot-{index}` is the rendezvous
the driver polls, and its existence doubles as host-liveness. Making it per-process would let two
hosts build two devices on one wire index — the "the game sees two controllers" bug. The collision
is correct; only the diagnosis was wrong.
* `gamepad_raii.rs` classifies the failure: on ACCESS_DENIED it probes with `OpenFileMappingW`,
which separates what the OS collapsed — object-manager lookup precedes the access check, so
absent gives FILE_NOT_FOUND and present-but-forbidden gives ACCESS_DENIED. It now says the
mailbox belongs to a live session's pad and that nothing is wrong with the drivers.
* `pad_slots.rs` carries that as a typed `PadCreateFault` through the anyhow chain, so `ensure`
prints the fault's remedy instead of the per-backend reinstall hint, plus the pad index.
* `devtest.rs` now BAILS when no pad was actually built, instead of announcing success. This is
the fix that matters: every probe an operator runs next will still find a device on that index.
* `native.rs` names what a detached input thread still holds, since that is one of the ways a pad
can outlive its session.
DELIBERATELY NOT CHANGED, with reasons: the session-scoped pad lifetime (intentional and
documented); the mailbox naming (load-bearing, above); the retry/backoff (latching would resurrect
the `broken` flag `PadGate` exists to kill); the 10 s thread-detach in `serve_session` and the
service's `TerminateProcess` shutdown — both are real ways a devnode can outlive its owner, but
neither is evidenced in the field case and inventing a fix for an unobserved path is how you get a
regression instead of a bugfix.
`pf-inject/lib.rs` drops the `cfg(any(linux, windows))` gate on `pad_gate`/`pad_slots`. Neither
touches an OS pad API, and the gate meant a classification whose entire subject is a `cfg(windows)`
failure could not be tested on a dev machine at all.
VERIFIED
* ON WINDOWS (.173): `cargo test -p pf-inject --lib` 109/109; `cargo build -p punktfunk-host`
clean. Both agents' Windows code was compile-UNVERIFIED before this run.
* macOS: 5 new tests, including one that pins the anyhow downcast through the exact three-layer
context chain the Windows code builds — the assumption that could not otherwise be checked.
* `cargo fmt --all --check` clean.
NOT VERIFIED
* That a LocalSystem-owned mailbox really answers `OpenFileMappingW` with ACCESS_DENIED rather
than FILE_NOT_FOUND from an Administrator token. That is reasoned from the object manager's
lookup-then-access-check order, not measured. Repro on .173: hold a session pad on index 0, run
the devtest from an elevated console, and check the new sentence appears.
Field triage on Nobara, 2026-08-09. Every connect degraded to ATTACH — which on that box mirrors a
game-mode session the host never configured, and looked like a black screen on every connect. The
host said:
the packaged pf-dm-helper polkit action is missing or was denied (reinstall the punktfunk
package, or install the display-manager polkit rule from the docs)
Every clause of that was wrong. The action was installed, `allow_any`, and its exec.path annotation
matched the installed helper; pkexec authorized it and RAN the helper. The helper refused, and said
exactly why:
pf-dm-helper: user 'nobara-user' is not in the 'punktfunk' group — refusing.
Grant it with: sudo usermod -aG punktfunk nobara-user (then re-login)
That text never reached the log, because `dm_helper` ran the helper with `.status()` — which
discards stderr and collapses the exit code to a bool. The one thing that would have ended the
investigation in seconds was thrown away at the call site, and the caller then guessed. Neither
suggested remedy adds anyone to a group, so a reader who followed both stayed broken and learned the
docs were useless. It fails soft, with no error and no failed unit, so nobody finds it on purpose.
Now: `.output()`, and four failure modes that stay distinguishable because they need different
fixes — helper not installed, pkexec could not run it, polkit denied it (pkexec's own 126/127), and
the helper ran and refused, whose stderr rides through VERBATIM rather than being re-described. Null
stdin too, so a pkexec that decides to prompt gets EOF instead of parking a stream thread on a tty
read.
The same gate gates the `linger` verb, so on a sessionless host an unjoined user fails there first —
carrying the reason there as well, or the misdiagnosis just moves one message earlier.
A new startup preflight says it before a stream is being built rather than during one, gated so it
cannot nag a box that would never attempt a takeover: not root, a display-manager alias exists, a
managed session launcher exists, a packaged helper exists, and the user is not in the group. It reads
membership from the user database rather than this process's groups, deliberately: that is what the
helper reads (it runs as root and resolves the caller from the database), so `usermod -aG` satisfies
the DM gate immediately and the warning stops. Using `getgroups()` would keep warning on a box where
the takeover already works.
Packaging said the group was for "the virtual Steam Deck pad (usbip)" — so anyone without a Deck pad
correctly skipped it and landed here by following instructions properly. All three scriptlets now
lead with Game Mode, name both grants, and record that creating the group is necessary and NOT
sufficient. Docs get the same treatment: the group is an admonition above the DM-flavor list in
gamescope.md, a black-screen entry in troubleshooting.md that tells the reader to read the quoted
reason FIRST, and the per-distro install pages no longer frame it as pad-only.
`win-input-matrix` covered four of the five rows and said so; GameInput was the gap, because it has
no binding in the `windows` crate and needs hand-written COM. This adds it: `--gameinput` reports
whether GameInput has a reading, and `--gi-rumble l,h,lt,rt [--gi-pid PID]` drives
`SetRumbleState`. Every vtable slot is taken from the SDK header, not guessed — a COM vtable is
positional, so a wrong slot calls a different method with the wrong signature.
WHY RUMBLE AND NOT JUST ENUMERATION. `XINPUT_VIBRATION` has two members, so classic XInput can never
exercise an Xbox pad's two IMPULSE-TRIGGER motors. `GameInputRumbleParams` has four
(`lowFrequency`, `highFrequency`, `leftTrigger`, `rightTrigger`), which makes GameInput the only API
that can settle `design/trigger-rumble-plane.md` §2.1's open question — the `enable`-mask bit
assignment for the two trigger actuators, where bits 2/3 (the handles) are measured and bits 0/1
(the triggers) are inferred from field order and nothing else.
TWO THINGS MEASURED ON .173, 2026-08-09:
1. ⭐ GameInput's device enumeration is ASYNCHRONOUS, and the first `GetCurrentReading` reliably
returns nothing even with pads actively reporting. This is the GameInput analogue of `wake_wgi`:
the API looks like a query and is really a cache someone else fills. A bounded poll fixes it.
⚠️ Focus is NOT the cause, and the header rules it out rather than my guessing:
`GameInputDefaultFocusPolicy` is 0 and every `GameInputFocusPolicy` flag is a RESTRICTION, so the
default already admits background input. Do not "fix" this with `SetFocusPolicy`.
2. 🛑 **GameInput never sees our pad.** Hunting by product id for six seconds with the pad live and
sweeping, it enumerated `054C:0CE6` (DualSense) and `3434:D031` (8BitDo) — both plain HID pads —
and never `045E:02FD`, ours, while classic XInput was reading ours live in the same moment.
⇒ THE TRIGGER ENABLE BITS REMAIN CONJECTURE, but for a better reason than before: it is not that
nobody has tried, it is that on this box NOTHING CAN DELIVER a four-motor rumble to our pad. XInput
structurally cannot; GameInput can but does not see it.
⚠️ The obvious suspicion is that `xinputhid` claiming the HID collection exclusively is what hides
the pad from GameInput — which would mean promotion costs us the API most Game-Pass-era titles use,
a trade we have shipped by default. **That is NOT established here.** The decisive control is cheap
and has not been run: power on the REAL Xbox Elite, which Microsoft's own driver promotes the same
way, and see whether GameInput enumerates it. If a real promoted Xbox pad is also absent, this is a
property of GameInput in a non-interactive session and not our defect — the same shape as the WGI
`ts=0` row, which a real Elite reproduced.
VERIFIED
* `cargo fmt --check` clean; `cargo clippy --target x86_64-pc-windows-msvc --all-targets
-- -D warnings` clean (cross-checked from macOS).
* Builds and runs on .173; `GameInputCreate` succeeds, readings arrive after the poll, and
`SetRumbleState` is accepted.
* The runtime is loaded by name, so a box without GameInput reports "unavailable" rather than
failing to link or crashing.
NOT VERIFIED
* That `SetRumbleState` reaches ANY pad's motors — it was accepted for the DualSense but nothing
observable was checked on that device, and it never reached ours.
* `GameInputDeviceInfo` is read only for `vendorId`/`productId` (offsets 4 and 6). The rest of the
struct has variable-size members whose layout would have to be mirrored exactly; nothing here
needs them. `supportedRumbleMotors` is in there and would answer "does GameInput think this pad
has trigger motors" — worth adding if this line of enquiry continues.
Found on glass, first real streaming session: everything worked except the right stick, and Steam
correctly showed "Xbox One S Controller". `XBOX_RDESC` declared the right stick as `Rx`/`Ry`.
`xinputhid`, which translates our HID collection into XUSB, maps `Z`/`Rz` to the right stick and
does not treat `Rx`/`Ry` as one, so those two axes reached nothing.
Two usage bytes. Left and right were declared identically here — same collection, same globals,
same size and count — so the usages were the entire difference, which is what makes the diagnosis
airtight rather than plausible. Note `DUALSENSE_RDESC`, a real capture, also uses `Z`/`Rz` for its
right stick and puts the TRIGGERS on `Rx`/`Ry`; that is most likely where the original mistake came
from.
⚠️ Byte offsets are unchanged — still 16×2 at bit 5.0 — so `xbox_proto`'s layout tests and the
host-side packing are untouched. This is a pure relabelling.
🛑 THE REAL LESSON IS THE HARNESS, AND IT IS FIXED HERE TOO. This survived every bench measurement
because `dualsense-windows-test` drove LS-X and the A button and left the other five analogue axes
at zero. `XInputGetState` read `RX [0..0]`, which I read as "the devtest doesn't move it" — true,
and useless: a harness that exercises one axis cannot tell "this axis is not mapped" from "nothing
is driving it", and the two are indistinguishable in every consumer. The devtest now sweeps all six
axes on distinct phases and ramps both triggers, so one run shows which axes arrive AND that they
are not crosstalking onto each other's bytes.
MEASURED ON .173, same run shape before and after, devtest sweeping all six axes:
before: LX [-11264..24576] LY [-32768..31744] RX [0..0] RY [-1..-1] LT [0..248] RT [7..255]
after: LX [-8192..26624] LY [-32768..31744] RX [-32768..31744] RY [-24576..10240] LT [0..248] RT [7..255]
VERIFIED
* `cargo test -p pf-inject --lib` 104/104 on Windows; `xbox` subset 11/11 on macOS — the layout
tests still pass because nothing moved.
* Driver rebuilds and signs; the descriptor is still 223 bytes so the `wReportLength` const assert
is undisturbed.
* `cargo fmt --all --check` clean.
NOT VERIFIED
* Not yet re-tested in a real streaming session — that is the next on-glass run.
* ⚠️ A leftover finding from the same session, unrelated to this fix and NOT investigated: the
session's pad devnode SURVIVES client disconnect and keeps the `Global\pfds-boot-0` bootstrap
mailbox, so a devtest run afterwards fails with `Zugriff verweigert (0x80070005)` and silently
measures the stale pad instead. Restarting the service releases it. Worth its own look.
Until now there was one Xbox identity, `device_type = 4` / `045E:0B13`, and Windows folded a
client's `XboxOne` request onto it because the only Windows Xbox backend was the XUSB companion,
which presents one fixed 360 identity and cannot vary it. The HID backend can, so the fold goes and
two identities join it:
devtype 4 045E:0B13 pf_xboxwireless Xbox Wireless Controller
devtype 5 045E:02FD pf_xboxones Xbox Wireless Controller (One S)
devtype 6 045E:0B22 pf_xboxelite Xbox Elite Wireless Controller Series 2
`GamepadPref::XboxElite` takes wire byte 11 — the first unassigned one, and the round-trip test
previously asserted `from_u8(11) == Auto` with a comment saying assigning it must update that; the
sentinel moved to 12. The C ABI mirror and the generated header moved with it.
⭐ ALL THREE SHARE ONE REPORT DESCRIPTOR, deliberately. In HID terms they are the same pad; the
descriptor is the report shape, not the identity. §3 of the handoff records that our single
hand-written descriptor already cost three separate bugs, and inventing two more would multiply
that debt for no measured gain. They differ in VID/PID, product string, hardware id and Device
Manager description only.
⚠️ All three install `pfGamepadXbox`, the section that attaches the `xinputhid` bus filter. That
was the open risk: Microsoft's `xinputhid.inf` promotes by an explicit hardware-id allow-list
containing `02D1, 02DD, 02E3, 02EA, 0B00, 0B0A, 0B13, 02FF` — and NEITHER `02FD` NOR `0B22` is on
it. Measured on .173: promotion does not care, because it comes from our own AddReg rather than
from matching Microsoft's ids. All three gain `IG_00`, register an XUSB interface, and are read
live by classic XInput. Had this gone the other way the two new identities would have been strictly
worse than the one they joined.
The XUSB escape hatch needed a runtime degrade to stay honest. `pick_gamepad` is compile-time only,
so with `PUNKTFUNK_XBOX_BACKEND=xusb` the host would have resolved and echoed `xboxelite` in its
`Welcome` while actually building a 360 pad. `degrade_xbox_identity` folds the identity back at
runtime, mirroring `degrade_if_no_uhid`.
VERIFIED ON WINDOWS (.173 — none of this compiles on macOS; the driver needs the WDK and the rest
is `cfg(windows)`):
* `cargo test -p pf-inject --lib` 104/104 — including `hwid_matches_inf`,
`hwid_devtype_table_matches_the_driver` and `only_the_xbox_identity_installs_the_xinputhid_section`,
all now sweeping the whole identity set and asserting the section split in both directions.
* `cargo test -p punktfunk-core --lib gamepad` 7/7; `cargo check -p punktfunk-host` clean.
* Driver builds and signs; the descriptor/`wReportLength` const asserts still hold with the
descriptor shared three ways.
* ON GLASS, per identity, via the new `--xboxones` / `--xboxelite` devtest legs: each gets its own
devnode (`PF_XBOX_0` / `PF_XBOX_ONES_0` / `PF_XBOX_ELITE_0`), each HID child gains `IG_00`, each
registers an XUSB interface, and XInput reads each live (packets advancing, `buttons=0x1000`).
* macOS: `cargo fmt --all --check` clean in both workspaces.
NOT VERIFIED / NOT DONE
* **Elite paddles are NOT implemented.** `BTN_PADDLE1..4` would need descriptor buttons, and once
`xinputhid` promotes the pad it claims the HID collection exclusively — XInput has no paddle
fields and the HID consumers that do may be locked out, so the buttons would likely reach
nobody. The decisive measurement is cheap and named in the code: hold a paddle bit set and see
whether a user-mode HID reader still gets reports. Until then the Edge remains the only virtual
pad with native back-button slots and nothing should be advertised otherwise.
* **No client picker offers the Elite**, and none can auto-detect it — SDL3's `GamepadType` has no
Elite variant. It is reachable today only via `PUNKTFUNK_GAMEPAD=xboxelite` or a hand-edited
client setting. All five clients ship the same curated six options by deliberate parity, so
adding one is a cross-client UX change, not part of this.
* Nothing here has run in a real streaming session; every measurement came from the devtest.
`--codec pyrowave` selects the ENCODER. The capture pipeline picks its consumer from
`ZeroCopyPolicy::pyrowave_session`, which on the spike path is fed only by the global
`PUNKTFUNK_ENCODER=pyrowave` lab lever (punktfunk-host/src/capture.rs). Without it, .21 resolved
capture pipeline resolved: cuda-import -> nvenc capture_arm="cuda-import" consumer="nvenc"
zero-copy: dmabuf imported to CUDA (no CPU copy) nv12=true
and the wavelet encoder refused the payload on its first submit: "unsupported FramePayload (need
Dmabuf or Cpu RGB)". That is not a worker bug — the arm that failed was the pure in-process one.
It reproduces only where the A/B actually lives. An AMD box has no CUDA arm to pick, so .25 resolved
straight to dmabuf-passthrough and the kit looked correct there. With the lever set, .21 resolves
`dmabuf-passthrough -> pyrowave` and both arms encode 2700/2700 frames.
V3b then passes on .21 (RTX 5070 Ti, GRID 2 at ~100% GPU, 5120x1440 — the portal captures the real
monitor, --width/--height being synthetic-only):
in-process, refused p50 2.85 ms p99 8.39 ms (10 windows)
capped worker, granted p50 2.65 ms p99 4.10 ms (11 windows)
p99 delta -4.29 ms
The worker reports `priority=Granted(Realtime)` with `ext=VK_KHR_global_priority` on the FIRST
attempt and logs no fallback line; the refused arm logs "every global queue priority class was
refused". So the capability still buys the lever from a SEPARATE process, with the IPC hop in the
loop — 8.39 -> 4.10 ms is a 51% p99 cut, against PW1's in-host 6.4 -> 4.4 at 1080p. Different
resolution and a harder load, so treat the class as confirmed and the absolute numbers as not
comparable to PW1's.
The V3b run on .21 died with `open portal capturer: timed out waiting for the ScreenCast portal` —
a GNOME consent dialog nobody answered — and the kit reported "arm A is not the in-process arm".
That is false: the arm was constructed correctly (`PUNKTFUNK_ENCODE_WORKER=off` is right there in
the captured env header), it simply never reached encoder-open, so the line the assert looks for
could not exist. A red that points at the wrong thing costs the same debugging time as a green that
hides a real one.
`spike_failure_reason` now runs BEFORE any arm-identity assert in v2, v3a and v3b, and names the
actual cause: the portal timeout gets its own message saying the dialog appears on the HOST's own
screen and cannot be answered from inside a stream — which is precisely the situation that produced
this failure, since the operator was watching the box through a game session at the time.
Falls back to the first ERROR line, then to "no PUNKTFUNK_PERF window at all", so a spike that dies
some other way still reports that rather than a misattribution.
Three changes that only make sense together: the HID backend becomes the default now that it is a
superset of the XUSB one, the rumble datagram grows the two Xbox impulse-trigger motors, and the
INF-shape tests learn about the Xbox identity's own install section.
WP-E — `PUNKTFUNK_XBOX_BACKEND` now defaults to `hid`; `=xusb` is the escape hatch.
The knob existed for exactly one reason, recorded in its own doc comment: the HID pad could not
reach classic XInput, so defaulting to it would trade a known-working path for an unproven one.
That objection is gone — with the `xinputhid` bus filter the INF now attaches, the HID pad is
promoted like real hardware and keeps classic XInput while gaining everything XUSB never had
(Steam, SDL, RawInput, DirectInput, joy.cpl, WGI) plus rumble, which XUSB could not source at all.
The escape hatch stays because promotion leans on Microsoft's inbox `xinputhid.inf`; if a servicing
update changes it, one env var restores the old behaviour with no reinstall. An unrecognised value
takes the DEFAULT rather than the opt-out, so a typo cannot silently drop a user onto the path with
no HID collection.
WP-D — the `0xCA` rumble datagram gains a v3 form:
v1 7 B: [0xCA][u16 pad][u16 low][u16 high]
v2 10 B: … [u8 seq][u16 ttl_ms]
v3 14 B: … [u16 lt][u16 rt]
v3 is built FROM v2's bytes rather than restating the layout, so the prefix relationship is
structural instead of a convention two encoders have to keep agreeing on, and every reader gates
with `>=`. The four levels share one seq and one ttl on purpose: they are one statement of the
pad's feedback at one instant, and sharing means the whole v2 apparatus — renewal cadence, stop
burst, the client's seq gate, the lease clamp — governs the triggers with no new code. The new
`RumbleUpdate` fields are plain `u16`, not `Option`: on a level-triggered plane "absent" must mean
zero, because "absent → keep the previous value" is the stuck-rumble bug in a new costume.
Only one backend can ever source them — the Windows HID Xbox pad, whose output report 0x03 carries
them. `XINPUT_VIBRATION` and evdev `FF_RUMBLE` have two members and no third, so every other
producer sends `lt = rt = 0`.
⚠️ The two TRIGGER `enable`-mask bits remain CONJECTURE. Bits 2/3 = left/right handle are measured;
bit 0/1 = the triggers are inferred from field order and nothing else. `parse_xbox_output` says so
inline, and no test asserts them — every test vector uses masks (0xFF, 0x00, 0x0C, 0xF3) whose
expectations hold whichever bits turn out to be right. XInput cannot settle this: it has two
motors.
The INF tests — `hwid_matches_inf` matched the install section by the exact string `=pfGamepad,`
and so stopped seeing the Xbox hardware ids the moment that identity moved to its own
`pfGamepadXbox` section. It failed loudly, which is the good outcome; it is now prefix-matched and
tolerant of further per-identity sections. Added
`only_the_xbox_identity_installs_the_xinputhid_section`, which asserts the split in BOTH
directions: the Xbox line must not install the shared section, and no other line may install the
Xbox one. Merging them back is a one-line edit that looks like tidying and would hand a DualSense
to Microsoft's Xbox translator.
VERIFIED
* ON WINDOWS (.173, the only place this code compiles): `cargo test -p pf-inject --lib` 104/104,
including the new trigger tests and both INF tests; `cargo check -p punktfunk-host` clean.
* macOS: `cargo fmt --all --check` clean; `cargo test -p punktfunk-core --features quic` rumble
suite 22/22, including v3 round-trip and v3<->v2 cross-version parsing.
* The pre-existing `c_abi_harness_round_trips` failure on macOS is `ld: library 'opus' not found`
and reproduces with these changes stashed.
NOT VERIFIED
* No trigger rumble has ever been observed end to end — nothing can drive it yet (see the
conjecture note above), and no client renders it.
* The default flip has NOT been exercised in a real streaming session; every measurement so far
came from the devtest harness. That is the on-glass run.
* Non-Rust clients do not decode v3. They are blocked on a C ABI entry point first
(`punktfunk_connection_next_rumble_cmd` has fixed out-params, ABI_VERSION 17); Apple could
render it via GCHapticsLocality.leftTrigger/.rightTrigger, Android structurally cannot (its
packed jlong is full) and has no trigger actuators anyway.
The field report: uninstalling punktfunk left "Punktfunk Speakers",
"Punktfunk Microphone" and the per-pad "Wireless Controller" endpoints
sitting in Windows' Sound settings forever.
They have no installer payload behind them, which is why nothing in the
uninstall touched them. The host mints them at RUNTIME as extra devnodes
on Valve's streaming-audio drivers, and both providers deliberately
re-resolve their devnode across restarts instead of re-minting it — so
they persist by design. Persistent across restarts must not mean
permanent: the .iss even documented leaving them behind as a decision.
New `driver uninstall --audio` leg (a third Inno [UninstallRun] entry,
after the two driver legs and well after `service uninstall`, since a
live host re-mints on its next wiring pass):
* restores the default playback device first, if a host that died
mid-stream left it parked on our loopback sink — otherwise Windows
re-picks by its own ranking rather than giving the operator back the
device they had;
* removes every MEDIA-class devnode carrying one of our three durable
owner markers (pad slot, minted role, probe), phantoms included;
* deletes each endpoint's MMDevices record, resolved through the
devnode link BEFORE the devnode goes.
Marker-matched, never name-matched: our instances are name-identical to
Steam's own, and Steam's devnodes, its drivers, and a VB-CABLE from the
era when we bundled one carry no marker and stay untouched. A ROOT\
enumeration guard means a marker-shaped value on a real sound card can
never cost the user their hardware.
The registry half is best-effort: those keys are SYSTEM-owned and the
uninstaller runs elevated but as a user, so on a stock box the record
survives as an inert NOTPRESENT entry that Sound settings only shows
behind "Show Disconnected Devices". The device itself is gone either
way, and seizing ownership of SYSTEM registry keys from an uninstaller
is a worse thing to ship than that scrap.
`launch_session` spawns a transient unit and can hand `systemd-run` the
`BindReadOnlyPaths` directly, but a box that owns an autologin
`gamescope-session-plus@<client>.service` is RESTARTED IN PLACE instead — no `systemd-run`,
so that path kept running Nobara's hardcoded `/usr/bin/gamescope` and the previous commit
fixed only half the problem. Found on the box: after a reboot the host took the
`ensure_box_gamescope_mode` path (the autologin unit was live) rather than the managed one.
Deliver the same two fixes as a drop-in on that unit — the bind, and the WSI opt-out when the
box's layer was built for a different gamescope — plus `PF_HZ`/`PF_HDR_ARGS`, which the
wrapper reads and would otherwise default to 60 Hz. `daemon-reload` before the restart or
systemd runs the old unit. Best-effort: a failure to write it must not block a restart that
would otherwise work, and it is a no-op on a box already resolving to `/usr/bin/gamescope`.
⚠ REMOVED on restore, deliberately. Leaving it would put the patched build — and our HDR and
cursor flags — under the user's ORDINARY game mode, which is exactly what
`packaging/gamescope/README.md`'s "sits BESIDE the distro package" rule exists to prevent. The
bind is ours only for as long as we are driving the session.
`ensure_box_gamescope_mode` grows an `hdr` param to build those args; both call sites already
had it in scope (`self.hdr`, and `create_managed_session`'s parameter).
Gate: `scripts/xcheck.sh linux clippy` clean (0 warning/error lines), `cargo fmt` clean.
The field report that started this work was an Xbox controller that no game could see on a Windows
host for two weeks. Root cause was that our Xbox pad reaches no Windows input API a modern title
uses. This is the fix, and it is two registry values.
Windows promotes Xbox pads with `xinputhid`, whose INF is an explicit hardware-id ALLOW-LIST — its
own comment says "we can not use a Compatability ID for the loading of this driver, and so rely on
individual hardware IDs". A software-enumerated devnode can never match those ids, so we write what
the matching install sections would have written. `045E:0B13`, the PID this identity already
claimed, is on that allow-list twice, so the identity choice turned out to be exactly right.
🛑 THE PAIRING IS THE WHOLE FINDING, AND THE TWO VALUES GO IN DIFFERENT KEYS. `UpperFilters` is a
`.HW` AddReg (hardware key); `DevicePropertyFlags` is a DDInstall AddReg (software key). A live A/B
on .173: removing `DevicePropertyFlags` alone reverts EVERYTHING — no `IG_00`, no XUSB interface, no
XInput, no WGI entry — while `UpperFilters` alone is completely inert. `1` = `BusDevice`, which
Microsoft glosses as "a focused bus filter driver for the IG_ problem". It is not a description of
the device, it is the switch. An earlier session installed the filter WITHOUT it, measured a device
that produced nothing, and recorded "never ship it". The filter was never broken; it had never been
switched on. That conclusion is now retracted.
⚠️ The Xbox line gets its OWN DDInstall section, `pfGamepadXbox`. All five identities previously
shared `pfGamepad`, so an AddReg there would have handed a DualSense, DualShock 4, Edge and Steam
Deck to Microsoft's Xbox translator. The regression check below exists for exactly that.
MEASURED ON .173 (Win11 26200), INF-SHIPPED — no hand-written registry values:
* `UpperFilters=xinputhid` lands on the hardware key and `DevicePropertyFlags=1` on the software
key, applied by the INF at install.
* The HID child gains the `IG_00` token: `HID\PUNKTFUNK&IG_00\...`.
* An XUSB interface appears: `\\?\hid#punktfunk&ig_00#...#{ec87f1e3-...}`.
* classic XInput reads it live — packets ADVANCING, `buttons=0x1000` (the devtest's A), and the
stick sweeping. XInput had NEVER seen this backend before.
* `XInputSetState` rumble round-trips: `rumble from game: pad=0 low=65535 high=32767`.
* REGRESSION CHECK PASSED: with the DualSense identity up, its devnode has an EMPTY
`UpperFilters` and no `DevicePropertyFlags`. The PlayStation pads are untouched.
WGI `Gamepad` lists the pad but reads `ts=0`. That is NOT ours: a real Xbox Elite Series 2, promoted
by Microsoft's own driver on the same box, reads `ts=0` in WGI at the very moment classic XInput is
reading live data from it (`buttons=0x1000 LY=-32768`). Our pad is behaviourally indistinguishable
from real hardware here; the row is a property of the non-interactive session.
NOT VERIFIED
* On-glass in a console session. Everything above ran over ssh, which is what makes the WGI row
unreadable; the real-Elite control is what settles it, not a clean WGI reading.
* GameInput — no binding in the `windows` crate, still unmeasured for this backend.
* `PUNKTFUNK_XBOX_BACKEND` still defaults to XUSB. This changes what the HID backend CAN do; it
does not change which backend is chosen. That is WP-E and it is a separate decision.
* Trigger-actuator enable bits, still conjecture — `XINPUT_VIBRATION` has two members and cannot
exercise them.
Two independent reasons a Nobara box could never stream from a gamescope session,
both found on glass (VM 123, Nobara 44, RTX 5070 Ti).
**1. The session ran a stock gamescope, so the host refused it.**
Nobara's `gamescope-session-plus` builds its command as
GAMESCOPECMD="/usr/bin/gamescope \
and reads `GAMESCOPE_BIN` NOWHERE. All three of our spawn levers miss at once: the env
var is ignored, and an absolute path cannot be redirected by a PATH shim. So the session
ran stock gamescope, the capability probe rejected it, and every session died with
"pipeline build failed (out of retries) … it ignored GAMESCOPE_BIN / the PATH shim".
`~/.gamescope-cmd.log` — which the script writes with the exact command it ran — settles
that in one line, and is the first thing to read on any such report.
Fixed by binding our wrapper over `/usr/bin/gamescope` inside the transient unit's mount
namespace (`BindReadOnlyPaths`). Deliberately a bind, not a replacement: punktfunk-gamescope
ships under its own name precisely so it sits BESIDE the distro package, and the bind is
scoped to the session — nothing outside it sees the redirect and nothing is written to
`/usr`. Skipped when the resolved binary already IS `/usr/bin/gamescope`.
**2. With the patched gamescope finally running, every Vulkan client died — black screen.**
The box's `VkLayer_FROG_gamescope_wsi` ships with the DISTRO's gamescope and speaks its
`gamescope_swapchain` protocol. Ours disagrees, so the compositor rejects the client's
`swapchain_feedback` ("message too short") and drops it. Steam never paints; there is no
other symptom, which is what makes it expensive to find.
Measured with `vkcube` under each build, layer on:
ours 3.16.25-17 ON -> 1 rejected client
ours 3.16.25-17 OFF -> 0
OLD pin 3.16.25-4 ON -> 1 rejected client
stock 3.16.23.2 ON -> 0
⭐ The upstream protocol XML is BYTE-IDENTICAL between the distro's commit (5cdb5b0) and
our pin — same interface version, same `uuuuuus` signature — so this is the distro patching
gamescope, not a version bump. Hence the gate is "do the upstream triples differ", not a
floor, and an unreadable version on either side leaves the layer alone rather than degrading
a box that works (Bazzite/SteamOS, where it has always been fine).
⚠⚠ The old pin fails identically, so REVERTING the pin bump fixes nothing here — this is
pre-existing, not a regression from 5fb8dce4.
Verified against the UNPATCHED distro script, reproducing exactly what this code emits:
the session's own log reports `punktfunk-gamescope version 3.16.25-17-ga87390d+pfhdr4`,
with 0 swapchain_feedback errors, 0 client-communication errors and 0 aborts.
Gate: `scripts/xcheck.sh linux clippy` clean (0 warning/error lines), `cargo fmt` clean.
Non-vacuity re-verified per the xcheck note — a planted type error in the new function
produced 3 errors, and removing it went back to Finished.
Still open, deliberately NOT addressed here: a 10-bit HDR stream aborts gamescope in
`destroy_buffer` (upstream `pipewire.cpp:88`), which is a separate defect.
`XBOX_RDESC` declared no OUTPUT item — zero `0x91` bytes. hidclass routes an output report only if
the descriptor declares one, so `on_output_report` never fired, `publish_output` never wrote the
out-ring, and `parse_xbox_output` in `inject/windows/xbox_windows.rs` was unreachable code. The
entire host-side rumble plane was already built, wired and tested, and was simply never fed. The
HID Xbox pad therefore had NO rumble whatsoever, not merely no trigger rumble.
This appends the PID-page `Set Effect Report` collection, report id `0x03`, 8 payload bytes, sized
to exactly the layout `parse_xbox_output` and `design/trigger-rumble-plane.md` §2.1 already
specify. It is declared AFTER the final Input item and re-states every global it uses, so the
16-byte input layout `xbox_proto`'s tests pin is untouched.
⚠️ PROVENANCE: hand-written, and it could not be otherwise. The Elite capture taken for WP-A
reports `OUTPUT items: 0` — Windows exposes no literal descriptor bytes and the reconstruction
carries no output collection for that pad — so there was nothing to copy. The comment says so and
asks for a Linux hidraw capture to replace it.
Also adds a compile-time assert pairing every descriptor with its HID-descriptor `wReportLength`.
Those are two copies of one length, edited in different places, and a mismatch fails SILENTLY:
hidclass asks for `wReportLength` bytes, parses whatever it got, and the pad either enumerates
truncated or not at all with nothing naming the cause. It now cannot build out of step. This
caught nothing today because I updated both by hand, but it is exactly the trap this descriptor
has already sprung twice in other forms.
MEASURED ON .173 (Win11 26200), with the pad promoted via the WP-B0 xinputhid bus-filter config:
* `XInputSetState(0xFFFF, 0x8000)` produced, on the host side,
`rumble from game: pad=0 low=65535 high=32767`
`rumble from game: pad=0 low=0 high=0`
i.e. XInputSetState -> xinputhid -> HID output report 0x03 -> on_output_report -> out-ring ->
parse_xbox_output -> PadFeedback. First rumble this backend has ever delivered.
* The round-trip values confirm the descriptor's `Logical Maximum (100)` percent domain is
right: 0x8000 -> 50% -> 32767. A 0..255 domain would have produced different numbers.
* This also answers `trigger-rumble-plane.md`'s WP0 gate — YES, Windows writes output reports
to a synthesized 045E:0B13 — which was blocking the whole trigger plane.
* classic XInput reads the pad fully: packets advancing, `buttons=0x1000` (the devtest's A), and
`LX [-32768..31744]`, the complete sweep. LY/RX/RY frozen is correct; the devtest drives only
LS-X and A.
VERIFIED
* `cargo test -p pf-inject --lib xbox` 11/11 — the input layout is byte-identical, as intended.
* `hid-descriptor-dump --rust-source ... --symbol XBOX_RDESC` decodes it clean: input report
0x01 unchanged at 16 bytes and the same offsets, new output report 0x03 at 9 bytes on the
wire, feature 0x85 unchanged, `structure: OK`.
* Driver builds and signs on .173 with the WDK; the new const asserts compile, so all five
descriptor/wReportLength pairs agree.
* fmt clean on both tools; .173 fully reverted afterwards.
NOT VERIFIED
* The enable-mask bit assignments for the two TRIGGER actuators. `XINPUT_VIBRATION` has only two
members, so XInput can never drive them and this run could not exercise them. Still open, as
trigger-rumble-plane.md WP0 says.
* That this equals the real pad's output collection, byte for byte. Needs Linux hidraw.
* Nothing about the INF is changed: `pf_gamepad.inx` still has no AddReg, so none of the
promotion config ships. The rumble descriptor is inert until something drives it.
The Xbox-pad-on-Windows programme is a five-row matrix — classic XInput, WGI `Gamepad`, WGI
`RawGameController`, GameInput, and the HID/DirectInput/Steam family — and nothing in this tree
measured any of it. Every reading in the handoff came from ad-hoc off-tree tools, which is why
several could not be reproduced later and why one was a false positive. `win-input-matrix` makes
the matrix a command you can run twice and diff.
Two traps are baked into it because both have already cost this programme a wrong conclusion.
`--watch` samples repeatedly and reports LIVE vs MUTE per device, because an API listing a pad that
never reports is the exact failure mode here — worse than not listing it, since a title that binds
the first gamepad latches a dead one. And the doc comment insists on a baseline with the virtual pad
STOPPED: a real Xbox pad owns XInput slot 0, which is how `rc=0 LX=-885` was once read as success
with our pad already killed.
⭐ `wake_wgi()` is not optional and is commented as such. `Gamepad::Gamepads()` and
`RawGameController::RawGameControllers()` return a cache filled by WGI's device-watcher, which a
GUI app has already started and a console app has not. Without subscribing to the Added events
first, BOTH collections come back empty with real controllers attached — measured here: a DualSense
sitting in the HID interface class, `RawGameControllers` count=0. A probe missing this reports "WGI
cannot see the pad" when WGI could not see anything.
WHAT IT FOUND (full record in measurements/2026-08-09-xbox-hid-xinputhid-busfilter.md): with
`UpperFilters=xinputhid` on the pad's PARENT devnode AND `DevicePropertyFlags=1` in that parent's
SOFTWARE key, the HID Xbox pad is promoted for the first time — the child gains the `IG_00` token,
an XUSB interface appears, classic XInput admits it, and WGI `Gamepad` lists it. All four had never
happened on this backend. A one-value A/B proves `DevicePropertyFlags` is the decisive half:
removing it alone reverts all four. That retro-explains the earlier "the filter installs fine and
produces nothing" result — the filter was loading without ever being put in bus-filter mode, which
is what `BusDevice = 0x1` means in Microsoft's own comment in `xinputhid.inf`.
Not a workspace member, for the same reason as `hid-descriptor-dump`: it is a Windows-only
bring-your-own-hardware tool with no business on a CI leg.
VERIFIED
* `cargo fmt --check` clean; `cargo clippy --target x86_64-pc-windows-msvc --all-targets
-- -D warnings` clean (cross-checked from macOS; the target is installed).
* Builds and runs on .173 (Win11 26200).
* Self-checked against known-good hardware before any conclusion was drawn from it: baseline
reads the USB DualSense as LIVE in both WGI collections and the resting 8BitDo as MUTE.
* The A/B was run in both directions on the same box in one session.
* `cargo metadata` on the root workspace resolves and does NOT list this crate.
* .173 fully reverted: registry values removed, devnodes removed, oem100.inf deleted, both certs
delstored, 6 pre-existing pf_gamepad packages and the production service untouched.
NOT VERIFIED
* GameInput — no binding in the `windows` crate, needs hand-written COM vtables. Not covered;
the doc comment says so.
* That the promotion survives a reboot or a devnode re-create from a shipped INF `AddReg` rather
than a hand-written registry value. Nothing is shipped: `pf_gamepad.inx` is UNCHANGED and still
contains no AddReg of any kind.
* WHY the promoted pad still translates no data. Enumeration is fixed; translation is not. The
evidence points at the report descriptor, which is gated on the §3.3 decision.
`XBOX_RDESC` is the only report descriptor in `pf-gamepad` that was hand-written rather than
captured off hardware, and its own provenance warning has now come true three times. The fix for
that class of bug is not another careful reading — it is a tool that goes and asks the device.
`tools/hid-descriptor-dump` does that: it dumps a real HID device's report descriptor, decodes it
into an annotated item listing plus a bit-offset LAYOUT TABLE, and can decode a blob we already
ship through the same decoder (`--rust-source <file> --symbol <NAME>`) so the two are diffable
line for line. `--read N` pulls live wire bytes, which is the only ground truth a reconstructed
descriptor cannot give you.
Deliberately NOT a workspace member — it pulls `hidapi`, a C library wanting libudev on Linux,
which has no business in `cargo build --workspace` or on a CI leg with no pad attached. It is a
bring-your-own-hardware tool and it is excluded in the root manifest, so CI never sees it.
The captured Elite disagrees with our blob in four ways, and the dangerous one is field ORDER:
the real pad reports sticks, ONE combined 16-bit Z trigger, then BUTTONS, then the hat, in an
UNNUMBERED 15-byte report; ours declares Report ID 1, two Simulation-page trigger axes, then the
hat, then 15 buttons. Since we claim a genuine Microsoft VID/PID and SDL/Steam/Windows all apply
stock mappings keyed on it, that ordering difference is exactly how every control silently lands
on the wrong action. The driver comment now records the diff and the two blockers that stop the
capture from simply being pasted in.
VERIFIED
* `cargo fmt --check` clean, `cargo clippy --all-targets -- -D warnings` clean (macOS).
* The tool builds and runs on macOS and on .173 (Windows 11 26200, cargo 1.96, MSVC, no WDK).
* TOOL VALIDATED AGAINST A KNOWN-GOOD CONTROL: pointed at the live DualSense on .173, it
reproduces the real `DUALSENSE_RDESC` layout exactly (input 0x01, 64 B, X,Y,Z,Rz,Rx,Ry at
bytes 1..6, hat 8.0, 15 buttons 8.4, output 0x02, the full feature ladder), and `--read`
returned live len=64 reports with sticks centred at 80 80 80 80 and the counter incrementing.
* `cargo metadata` on the root workspace still resolves and does NOT list this crate.
* The Elite capture is reproducible: `--vid 045E --pid 0B22`.
NOT VERIFIED
* That the capture equals the pad's NATIVE report map. Windows exposes no API for a device's
literal descriptor bytes, so hidapi reconstructs from `HidD_GetPreparsedData` — faithful in
structure, item order and bit offsets, not byte-exact (measured: the DualSense's real 273-byte
descriptor reconstructs to 467). `xinputhid` also filters that pad, and the captured shape is
the legacy DirectInput view. A byte-exact answer needs Linux hidraw.
* Why the Elite returned ZERO input reports across two runs (72 s and 90 s) while the DualSense
streamed fine on the same code path — untouched pad, or exclusive claim by the XInput
translator. Unresolved.
* Nothing here was built on Windows as a driver: `XBOX_RDESC` itself is UNCHANGED, so no
behaviour changes. The only edit to the driver is its provenance comment.
`XBOX_RDESC` declared only Input report 1. The sealed pad channel delivers its DATA section
over a vendor Feature report `0x85` (`ProofTransport::HidFeatureReport`), and the proof
handler's own comment records the assumption that made this invisible — "0x85 is already
declared as a Feature report in all three captured descriptors". True of the captured
PlayStation blobs; false of this hand-constructed one.
So hidclass rejected the host's `HidD_GetFeature` before the driver ever saw it, the host
refused to hand over the section, and the pad answered every read with its neutral report.
The HID Xbox pad had never delivered a single input report since it was written.
Declaring `0x85` with a 63-byte payload (1 id + 63 = 64 = FeatureReportByteLength) fixes it.
Verified on glass on .173: `gamepad driver attached to the shared section proto=3 late=false`,
and WGI's RawGameController path then reads the pad live — advancing timestamps, the devtest's
left-stick sweep, buttons toggling. Before the fix: 12 consecutive samples, one frozen
timestamp, every axis at dead centre.
This is the descriptor-provenance warning in this file coming true. It is still CONSTRUCTED
rather than captured, and that remains the open risk — `xinputhid` appears to validate the
descriptor and refuses ours, and a real Elite is a multi-collection device where ours has one.
Codec layout tests still 11/11; fmt clean. Only device_type 4 is affected, which nothing
shipping uses yet.
Three things, one delivery path — a Fedora/Nobara box getting the patched gamescope.
**The pin moves 8c676c39 -> 5fb8dce4** (3.16.25-1 -> 3.16.25-11). The commit that matters
is ff6b924, `rendervulkan: fall back to XBGR2101010 when XRGB2101010 is unsupported`: it
probes `linearTilingFeatures` for STORAGE+SAMPLED and captures as XBGR2101010 where
A2R10G10B10 linear storage is unavailable — which is every NVIDIA. That covers the paths
that are upstream's rather than ours: the RGB intermediate `paint_pipewire()` acquires when
the stream is YCbCr, and AVIF screenshots. #143 fixed our own node host-side; this is the
other half, and its commit message asked for exactly this bump.
All six patches rebased. Only 0006 conflicted: upstream's f8be7ee added
`vulkan_has_drm_modifiers_for_features()` immediately above the `g_device` declaration our
patch turns into a reference — both kept. 0003 and 0005 come out byte-identical; 0006 also
picks up the `--zero-commit --no-signature` form 0001-0005 already used.
**Patch 0001 now offers `xBGR_210LE` BEFORE `xRGB_210LE`**, mirroring the host-side
`HDR_FORMAT_ORDER` rationale on the producer end. A consumer takes the first pod it can use,
and we were handing third-party consumers (OBS and friends) the one format NVIDIA fills
byte-reversed under a correct-looking label. Deliberately NOT done by calling upstream's
`vulkan_get_rgb10_capture_format()`, which is what pw_pods.rs proposes: that symbol landed
after 3.16.25, so it would break `packaging/nix/gamescope.nix` — which applies these patches
to whatever gamescope nixpkgs pins — with an opaque C++ error instead of a patch conflict.
The reorder gets the same outcome on any base. Note added there so the next reader does not
"fix" it.
**And the RPM was never signed.** `Sign RPMs` runs right after `Build RPM`; the gamescope
RPM is built ~90 steps later, behind its own ~10-minute cache, so it missed the signing pass
entirely — every punktfunk-gamescope RPM ever published went out unsigned. The repo file we
tell users to install carries `gpgcheck=1`, so `dnf install punktfunk-gamescope` failed with
"The package is not signed" on every Fedora and Nobara box. The package was in the channel
the whole time and could not be installed from it, which is worse than absent: the notes and
the docs-site both say it is there. `sign-rpms.sh` now takes explicit paths (defaulting to
`dist/*.rpm` as before) and a second pass signs this one before publish, fail-closed on a tag
like the first.
Verified on Nobara 44 (VM 123, RTX 5070 Ti passthrough), canary 0.27.0-0.ci12611.g516a2954:
* Builds clean in the fc44 CI image; banner `3.16.25-17-ga87390d+pfhdr4` (11 upstream + our
6), so the marker the host probes still reads 4 — no capability moved, hence pkgrel 3 and
`.pfhdrN` staying put.
* `pw-cli enum-params` on the live node: BGRx, NV12, **xBGR_210LE (81), xRGB_210LE (80)** —
8-bit consumers still negotiate bit-for-bit, 10-bit now leads with the safe one.
* All four patched flags present, `--pipewire-composite-external-overlay` included.
* Patch 0006 confirmed working by comparison, which is the only way to see it: the new build
exits 0 where both the pre-0006 `+pfhdr2` build and the stock 3.16.23.2 abort with 134.
* Signing fix proven with a throwaway key: `Signature: (none)` -> `digests signatures OK`.
* Host health on the canary: synthetic spike 300/300 encoded, loopback 300 recovered, 0
mismatches.
One unexplained one-off: the very first headless run after install segfaulted at exit
(SIGSEGV, after "Primary child shut down!"). Not reproduced in 11 subsequent runs across
every flag combination, so it is recorded rather than diagnosed — the binary is stripped and
there is no symbolised core.
The two things this driver's README has always listed as the missing WGI/GameInput work,
both user-mode, neither needing a bus driver:
`IOCTL_XUSB_WAIT_FOR_INPUT` is now pended on a manual queue and completed by the periodic
timer on a dwPacketNumber edge, answering with the same 29-byte GET_STATE payload the
synchronous path serves. Declining it was enough for classic xinput1_4, which just falls
back to sync GET_STATE polling — that is why the pad has always worked there. It is not
enough for WGI/GameInput, which poll asynchronously: to them a decline is a refusal, not
a fallback. Completion is edge-gated because releasing a waiter on an unchanged packet
spins its caller at timer rate. WAIT_GUIDE_BUTTON stays declined — we have no state to
signal on.
The INF adds UpperFilters=xinputhid on the XUSB devnode. Note the earlier attempt put
that filter on the HID child of the *other* backend, which was simply the wrong devnode:
XInput does not read HID at all, it enumerates GUID_DEVINTERFACE_XUSB, which is what this
driver registers.
Verified on .173: build + sign + catalog exit 0; infverif "INF is VALID"; the devnode
starts Status OK with UpperFilters=xinputhid readable back from its enum key; and XInput
still sees the pad (slot 1 live alongside the box's real Elite in slot 0), so the async
queue is no regression to the path that already worked.
NOT yet measured: whether WGI/GameInput now admit the pad. `IG_` is the wrong probe for
this driver — it is a HID-path artifact and pf-xusb is System-class with no HID child, so
its absence says nothing either way. That needs a real WinRT/GameInput enumeration test.
The broken inset on the Displays configuration card was the symptom. The cause is
structural, and it had already been diagnosed at least twice in-tree without being fixed.
Two faults, both in components/ui/card.tsx:
1. The padding was a RESPONSIVE COMPOUND: `p-4 pt-0 sm:p-6 sm:pt-0`. tailwind-merge
resolves conflicts only within a variant, so any call-site override won at the base
and lost at `sm:` — correct on a phone, wrong on every desktop. Measured on the
Displays card before this change: padding-top 24px at 500px, 0px at 1440px.
2. `pt-0` encoded an assumption about a SIBLING that nothing enforced — "a CardHeader is
above me and supplies the top inset". Delete the header, which is exactly what tabbing
a page does since the tab label replaces the card title, and the top inset silently
vanishes at ≥640px.
Fix:
- One single-variant utility, `p-padding-card` — the same `--spacing-padding-card` token
@unom/ui's own Card uses, so nested cards finally agree on their inset. A single
variant cannot half-lose an override.
- Top inset is now self-correcting: `[&:not(:first-child)]:pt-0`. Ask the DOM instead of
the author. A headerless CardContent keeps its inset with nothing to remember.
Seven call sites had grown their own compensation in five dialects — `p-6`,
`p-card pt-card sm:pt-card` (×3), `p-4 sm:pt-6` (×3), `pt-4 sm:pt-6`, and my own `pt-6`
from the tabs commit. All removed; they are the symptom-fixes this replaces. LogsCard
even carried a six-line comment correctly describing the trap and working around it
locally — that comment is now three lines saying it no longer needs saying.
`flush` stays: full-bleed content is a real intent, expressed as a prop the component
honours rather than a utility that has to out-argue the one already there.
Guarded by UI/Card → "Inset with and without header", a headered/headerless pair that has
to look identical on every side. It must be checked at BOTH widths — a single width
cannot show this class of bug, which is why it kept surviving.
Verified by measuring computed padding at 500px and 1440px: first child 20px on all four
sides, after-a-header 0px top and 20px elsewhere, identical at both widths. tsc clean,
biome clean on every touched file, 9/9 server tests, build + i18n clean, 32/32 screenshots.
A sysext upgrade is driven by the script from the OLD image -- /usr/bin/punktfunk-sysext
is replaced by the very `systemd-sysext refresh` that runs mid-upgrade -- so a
post_merge step ADDED in the new release is executed by nobody. The old script
does not have it, and the new script never gets a turn: from then on `update`
matches the "already on $cur" branch and returns before post_merge. The step is
permanently unreachable on exactly the installs that need it, and nothing says so.
Field-proven on the Bazzite host that took 0.25.0 -> 0.26.0 (2026-08-09). The
casualty was the `punktfunk` group, which post_merge learned to create in 0.26.0
(62a6fa9f): 0.25.0's script ran the upgrade, so the group was never created, and
every `punktfunk-sysext update` since has said "nothing to do". `pf-dm-helper`
gates on membership in that group, so it refused every caller -- pkexec authorised
it and the helper then declined itself -- and every managed gamescope takeover fell
back to "stopping the display manager needs privilege", leaving sddm's autologin
Relogin loop churning logind sessions for the whole stream.
Re-run post_merge when already current. Everything in it is idempotent (guarded
getent/groupadd, `install` of /etc mirrors, udevadm reload/trigger, sysctl,
modprobe), so convergence is the honest behaviour and "nothing to do" was a lie
about host state. Add an explicit `reapply` verb too, so the steps a sysext image
cannot carry can be re-applied without reinstalling the image.
Also print the membership hint. Creating the group is necessary but NOT sufficient
and the difference is invisible until a stream fails: joining stays opt-in by
design (writing vhci `attach` materialises an arbitrary emulated USB device), so
post_merge now names the exact usermod when SUDO_USER is not a member. Matched with
`grep -qx` so `punktfunk-update` does not read as `punktfunk`.
bash -n clean; shellcheck clean apart from the pre-existing SC1091 on
`. /etc/os-release`, which fires on the unmodified file too.
Wires `xbox_windows` into the per-pad router so an Xbox-family pad can be built as a real
HID device instead of the XUSB companion, and adds the knob that selects between them.
Opt-in rather than the new default, deliberately. XUSB is what classic-XInput games read
today; the HID pad buys the Steam / WGI / GameInput / DirectInput visibility XUSB can
never have, but whether Windows promotes it into an Xbox-profile device that XInput and
WGI Gamepad accept is still the open question. Flipping the default before that is
settled would trade a known-working path for an unproven one. The two backends are
mutually exclusive per pad by construction — one match arm or the other — because
presenting both hands a game two controllers for one pair of hands.
Verified on .173: cargo check -p punktfunk-host exit 0, clippy -D warnings clean,
`cargo test -p punktfunk-host gamepad` 8/8 green, fmt clean.
Same pill strip the plugin UIs use, via @unom/ui's Tabs: Configuration | Live displays.
The page was two stacked cards, and the configuration card ALONE is taller than the
viewport — the existing comment on the unsaved badge says as much, because that height
is how pending edits went unnoticed. The live-display list sat below all of it, so in
practice it was off screen.
Two details that are not cosmetic:
- The dirty marker moved from the card header onto the Configuration TRIGGER. Behind a
tab the old badge would vanish entirely while Live was open — a strictly worse version
of the problem it was added to solve. On the trigger it survives both tabs, and the
Custom block keeps its own inline badge for when the tab IS open.
- The strip is extracted as a presentational `DisplayTabs` rather than inlined in
`DisplaySection`. The container calls `useBlocker`, which needs a router, so it cannot
render in Storybook — and this page's story exists specifically to pin the MOTION
NESTING of the preset grid (a card sets no delayChildren, so tiles nested one level
deeper stop staggering). Inserting tabs changes that ancestor chain, so the story has
to render the real one or it passes for the wrong reason.
Adds Pages/Displays → "Unsaved on other tab", which switches to Live with a dirty draft:
if the marker ever goes silent there, the warning is gone exactly when it matters.
Verified: tsc clean, biome clean, `bun test server/` 9/9, vite build + i18n check clean,
Storybook builds, 32/32 screenshots.
gamescope's capture textures are mappable, hence linear-tiled, and NVIDIA does
not implement linear-tiled STORAGE for A2R10G10B10_UNORM_PACK32. Upstream says
it plainly in rendervulkan.cpp: "imageStore lands in XBGR order there, swapping
R/B". So the composite writes XBGR bytes into a buffer still LABELLED
XRGB2101010, and our patch's spa_format_to_drm() derives that label from the
negotiated SPA format alone, never asking the hardware what it can actually
write.
The host then believed the label, correctly at every step:
xRGB_210LE -> PixelFormat::X2Rgb10 -> NV_ENC_BUFFER_FORMAT_ARGB10. DRM
XRGB2101010 really is "B in the low 10 bits" and NVENC ARGB10 really is "B in
the lowest 10 bits"; the Windows twin (R10G10B10A2 -> ABGR10) is correct by the
same rule. Every mapping audits clean because the label was right and only the
CONTENT was wrong -- which is why this survived a full trace of both ends.
Fix the preference host-side: offer xBGR_210LE FIRST. The first compatible
consumer pod wins, so that is what a gamescope session lands on, and an
XBGR2101010 texture is one NVIDIA writes in its own order -- label and content
agree. It costs nothing elsewhere: A2B10G10R10_UNORM_PACK32 is the universally
supported packed-10 format, it is what upstream's own fallback picks, and
X2Bgr10 has a first-class encoder path (NVENC ABGR10, VAAPI X2BGR10LE).
xRGB_210LE stays as the second pod so a producer offering only it can still
negotiate HDR instead of dropping to the SDR downgrade.
Doing it here rather than in the patch set is deliberate: the real fix is for
spa_format_to_drm() to offer only what vulkan_get_rgb10_capture_format()
reports, but that function landed after 3.16.25 and the pin is
3.16.25-7-g60561e2+pfhdr4 (0 "2101010" strings in the shipped binary), so the
deployed gamescope cannot self-correct. This ships in the host binary with no
gamescope rebuild.
Field-confirmed on the RTX 5070 Ti Bazzite host with 0.26.0, and confirmed
host-side rather than client-side by reproducing the identical swap from two
unrelated clients (16" MacBook Pro and Mac Studio). SDR was never affected --
it takes no packed-10 path.
Gate (pf-lxcheck2, linux/amd64): fmt clean, clippy --all-targets -D warnings
clean, cargo test -p pf-capture 60 passed / 0 failed incl. the new
hdr_offers_xbgr_before_xrgb order pin.
`root\pf_xboxwireless` alongside the plain id, mirroring the DualSense model line — the
INF already documents that variant as the one devgen/devcon tests bind, and without it
the Xbox identity could only be exercised through a running host.
Verified end to end on .173 (Windows 11 26200, WDK 10.0.26100.0):
- build-gamepad-drivers.ps1 builds + signs + catalogs the driver, exit 0
- infverif /v /w on the generated pf_gamepad.inf: "INF is VALID"
- pnputil stages the package; devgen creates the devnode; it starts clean:
Status OK, Class HIDClass, "Punktfunk Virtual Xbox Wireless Controller"
- it enumerates a HID child, Status OK, carrying HID_DEVICE_SYSTEM_GAME and
HID_DEVICE_UP:0001_U:0005 — Windows parsed the constructed report descriptor and
classified the pad as a Game Pad (usage page 0x01, usage 0x05), which is precisely
what pf-xusb could never do
Test devnode, phantom child, driver package and both certs were removed afterwards.
Two build gotchas worth knowing, both already handled inside build-gamepad-drivers.ps1
and both of which cost a cycle here: CARGO_TARGET_DIR pointing outside the workspace
breaks wdk-sys (wdk-build walks up from OUT_DIR looking for a Cargo.lock and finds
none), and the WDK version must be pinned via Version_Number=10.0.26100.0 or bindgen
picks SDK 10.0.28000.0, which ships no km/crt headers.
Still open: the SwDeviceCreate USB identity (HID\VID_045E&PID_0B13) cannot be checked
through a devgen node, which has no USB hardware ids — that needs the host path. So the
WGI-promotion question is still unanswered, and host routing is still unwritten.
Found by running it. The first V3a run on .25 encoded 2700 frames in BOTH arms, at 59.6 fps, with 22
perf windows each — and the kit reported "fewer than 3 usable perf windows", because `tracing`'s fmt
layer wraps field NAMES in SGR escapes. The bytes on disk are `p99_us\e[0m\e[2m=\e[0m4601`, so
`s/.*p99_us=\([0-9][0-9]*\).*/\1/p` never matched. The message text is plain, which is why the
window COUNT was right and only the numbers vanished — and why the fixtures never caught it: they
were hand-written, and cleaner than reality.
Anything matching a field breaks the same way, so this was not only V3a: v2's `priority=Realtime`,
the demotion `reason=`, and v4's rungs all read fields. Every log read now goes through one
`log_cat` that strips SGR, and the spike is launched with NO_COLOR=1 so fresh logs are plain at the
source too — a human grepping a red leg by hand is defeated by those escapes exactly as the parser
was.
The self-test gains the same four perf windows a second time, ANSI-wrapped, asserting an identical
result: same numbers, same expectation, so a failure there can only mean the stripping broke. That
fixture caught its own first draft, which built the line in one printf with 27 placeholders against
23 arguments and emitted empty escapes — hence the field-at-a-time helper.
With this, V3a self-reports on .25 (sway headless, real dmabuf capture, AMD 780M/RADV, 2700 frames
per arm, both arms at default GPU priority):
in-process p50 2.08 ms p99 4.18 ms (21 windows)
uncapped worker p50 2.07 ms p99 3.52 ms (21 windows)
p99 delta -0.66 ms -> PASS
R1's pre-registered abandonment gate does not fire: the process boundary is not merely under the
+1.0 ms ceiling, it is measurably FASTER at the tail, while p50 is unchanged (2.08 vs 2.07). An
earlier hand-extraction of the same logs gave -0.43 ms, so the direction reproduces across runs.
Caveat for whoever reads this later: idle iGPU in a KVM guest, RADV, no GPU-bound load. This bounds
the IPC hop; it says nothing about V3b, which still needs .21 under GRID 2.
Brings the console onto the current design system. 0.9.x adds the Badge, Spinner,
Skeleton, Switch, Table, EmptyState and CodeBlock primitives, and 0.9.2 carries the
form fixes found while overhauling the rom-manager plugin UI:
- Select's border and focus ring resolved to `--main`, which is the FOREGROUND here
(`--main: var(--foreground)` in web/src/styles.css), so the trigger wore a near-white
border and a 3px near-white focus ring. Its chevron and placeholder were painted
`--secondary`, a SURFACE colour, and all but vanished. Now on `--input`/`--ring`, the
same tokens InputText already used.
- InputNumber declares a color-scheme, so the browser-drawn spinner arrows stop being
near-black on a near-black field.
Both defects were live in this console too — the console palette is what exposes them.
Verified: codegen + vite build clean, `tsc --noEmit` clean, `bun test server/` 9/9,
Storybook builds, 31/31 screenshots. A probe over all 61 stories reports ZERO page
errors, and the two stories containing a Select now render it at h-input-height with
`border: rgb(42, 33, 72)` (the input token) and a muted-foreground chevron.
Note: the console's components/ui/ wrapper layer is unchanged and still required —
@unom/ui's DialogContent remains a surface with no Portal or placement, which is
exactly what web/src/components/ui/dialog.tsx supplies.
WP3 of design/gpu-priority-capability-worker-implementation-plan.md. Five legs, the first of which is
the test that would have caught the field incident: in a KDE session with the worker installed and
capped, `getcap` on the host must be EMPTY, its CapPrm all zeroes, `readlink /proc/<pid>/exe` must
resolve, and `punktfunk-host probe-compositor` must exit 0 — which on KWin succeeds only when the
privileged zkde_screencast_unstable_v1 global was actually advertised to this client.
Read-only by default; the one mutating rung (kill -9) is behind --allow-mutate and kills only a
worker that is a child of the spike the script itself started. It NEVER calls setcap: the uncapped
arms use a plain copy of the worker, which does not carry security.capability, verified uncapped
before use. So no leg needs root and none restores state. A skip is never a pass — exit 2 means
incomplete, distinct from 1 (failure).
V3 is split, which the plan did not do. Its stated form compares against PW1's in-process-capped
baselines, and those exist only on .21 under GRID 2:
* V3a is the pre-registered abandonment gate and needs no capability at all — in-process versus an
UNCAPPED worker, both at default priority, so the only difference is the process boundary. Fails
if the worker's p99 exceeds inline by more than --gate-ms (1.0). This runs on any box with a GPU.
* V3b is the lever itself, capped worker versus the refused in-process arm, and says plainly that
an idle GPU makes it meaningless.
The false PASS this kit exists to refuse: a CPU-backed frame makes the proxy pin itself in-process
for the session, so a synthetic source would quietly turn the "worker" arm into a second in-process
arm and pass the gate for the wrong reason. The worker arm is only accepted with a dmabuf-passthrough
capture, a capability-carrying-worker line, and no fallback line anywhere in the log.
Also asserts the host and worker are different inodes — a hardlink shares the file capability, which
is the same incident by another route.
Rewrites the "GPU scheduling priority" section around the split: punktfunk-encode-worker carries
cap_sys_nice=ep, punktfunk-host carries nothing on any channel, ever. The KWin identification
mechanism is spelled out in plain words and the failure line is quoted verbatim
("KWin does not expose zkde_screencast_unstable_v1 to this client") so someone searching for their
symptom lands on the explanation.
The warning names all three ways an operator would reach for the capability — hand setcap, a systemd
AmbientCapabilities= line, a NixOS security.wrappers entry — because all three put it in the same
permitted set and all three cost KDE desktop streaming. That is the failure mode that made this
worth documenting: it looks exactly like a missing .desktop and survives reinstalling both ends.
configuration.md gains PUNKTFUNK_ENCODE_WORKER (path, or `off` to force the in-process encoder) and
re-describes PYROWAVE_QUEUE_PRIORITY as an intent forwarded to whichever process does the encode.
kde.md gains one line on the troubleshooting bullet someone actually lands on: getcap on the host
must print nothing.
The published 0.26.0 notes are deliberately untouched — they are the record of what shipped. The
flipped phrasing lives in v0.27.0's notes instead; v0.26.0.md:37 ("a system privilege that turns out
to stop KDE recognising the host at all") is the line that goes stale when this ships.
767e67ca's per-channel mechanics were correct; they were aimed at the wrong binary. Each one is
restored here pointed at punktfunk-encode-worker, and every host-side removal from #136 stays
verbatim. All grants remain best-effort — an uncapped worker still encodes, at default priority, so
a failed setcap must never fail an install.
* Arch: setcap in post_install AND post_upgrade (a replaced binary is a new inode).
* RPM: %caps(cap_sys_nice=ep) in %files, never a %post setcap — %caps applies, restores and
verifies, and covers Fedora as well as Bazzite via rpm-ostree layering.
* Bazzite + Arch sysext: setcap on the staging tree before mksquashfs, which does record
security.capability. The assertion is amended, not removed: host EMPTY is still a hard fail, and
the worker must carry exactly cap_sys_nice=ep — missing is fine, anything else is not.
* deb: setcap in postinst.
* NixOS: security.wrappers for the WORKER plus PUNKTFUNK_ENCODE_WORKER in the unit. A file
capability cannot live on a store path, and an ambient grant is right here precisely because
nothing ever identifies the worker. The host's ExecStart stays on the store path.
* Steam Deck: setcap the worker; the .desktop the script writes stays valid this time.
Four things the plan's channel table missed:
* packaging/arch/build-sysext.sh had no capability handling at all, and a sysext can never run a
pacman scriptlet — the SteamOS image would have shipped the lever permanently inert.
* scripts/steamdeck/update.sh had none either. It rebuilds both binaries, so a new inode drops the
grant, and it is the documented steady-state path: the lever would have died on the first update.
It also never healed a Deck already capped by 0.26.0-1.
* A capped worker is AT_SECURE, and glibc drops $ORIGIN-expanded RPATH entries for secure binaries
unless they normalise into a trusted system dir. Copying the host's rpath under BUNDLE_FFMPEG=1
would have left the capped worker unable to find libavcodec on exactly the channel that bundles
it. Absolute DT_RPATH instead.
* Nix crane scopes by -p, so the worker would not have been built at all, and it needs its own
addDriverRunpath.
scripts/ci/assert-cap-matrix.sh mechanizes the lesson from 0.26.0-1 — verify the PACKAGE, never the
board. It unpacks the built Arch package, the deb, the rpm and the mounted sysext raw and asserts one
matrix: the host carries NOTHING (hard fail), the worker exactly cap_sys_nice=ep. The sysext reader
first proves it can round-trip a capability through mksquashfs/unsquashfs at all, so an unreadable
artifact fails rather than issuing a blind PASS, and --self-test red-teams the assertions themselves.
Red-teaming the leg found a real bug: setcap originally ran BEFORE the assertion, so "the worker
arrived carrying something unexpected" was unreachable and a stray %caps would have been silently
overwritten. Both sysext scripts now assert, then grant, then assert again.
PyroWave encodes on the same GPU shader cores the game saturates, and an elevated
VK_KHR_global_priority queue is the compute-preemption lever for it — measured on .21 (RTX 5070 Ti,
GRID 2 loop): encode p99 6.4 -> 4.4 ms. Every driver refuses every priority class without
CAP_SYS_NICE, on NVIDIA and on RADV alike, so the lever is decoration on a packaged host.
0.26.0-1 granted that capability to punktfunk-host and killed desktop streaming on every KDE box:
KWin identifies a client by resolving /proc/<pid>/exe and matching an installed .desktop's Exec=,
the kernel refuses that readlink to a reader whose effective set is not a superset of the target's
PERMITTED set (cap_ptrace_access_check), and KWin holds no capabilities. #136 revoked it everywhere.
The capability therefore cannot live in the process that fronts KWin. It lives in a new, deliberately
small binary — punktfunk-encode-worker — which owns the priority-elevated Vulkan device and talks to
nothing but the socket its parent spawned it on: no Wayland, no D-Bus, no network, no plugins. It is
a SEPARATE FILE and must stay one; a hardlink or a hidden host subcommand shares the inode, hence the
capability, and silently re-creates the incident. That rule is written where someone would break it,
in the worker crate's own Cargo.toml.
`open_inner` is reused verbatim in the worker — the same REALTIME->HIGH->none ladder, the same
refusal-never-fails-open invariant, the same PUNKTFUNK_PERF split — so the A/B stays comparable with
PW1. The only in-process change is a flag for whether THIS process prints the INERT warn, plus an
out-parameter reporting the class that was granted.
Three things the design did not anticipate:
* An AU cannot ride in the message body. MAX_MSG is 64 KiB and bodies are serde_json, which
renders a Vec<u8> as one decimal per byte: a 1080p60 AU is ~333 KB of JSON and 4K ~3.3 MB, and
the minimum per-frame budget is already 64 KiB. So the AU crosses on a memfd the worker creates
once and pwrites each frame; the fd crosses once, in Ready. A test pins the arithmetic so nobody
"simplifies" the memfd away. Cursor bitmaps take the same route, only when their serial changes.
* set_wire_chunking has to cross the wire even though poll_chunk does not. Chunking changes the AU
BYTES, not merely how they are handed out — it feeds rate_budget()'s deflation and build_au's
windowed framing — so a proxy-local copy would have the host cutting dense AUs at boundaries that
are not window boundaries. Forwarded and mirrored. poll_chunk itself needs no protocol: the
identical AuChunker runs host-side on the whole AU the worker returns.
* CPU-backed frames really do reach this encoder (force_cpu_for_nvenc_444, and the raw-dmabuf
degrade latch), and a 1080p BGRA frame is ~8 MB. The first non-dmabuf frame pins the session
in-process with one warn rather than putting 480 MB/s on a socket.
Every rung falls back to the in-process encoder exactly as today with one warn and never a dead
session: PUNKTFUNK_ENCODE_WORKER=off, binary missing, spawn failure, handshake timeout, proto or
workspace-version skew (host and worker are different files now, so that check is load-bearing),
InitErr, a refused frame, and socket EOF mid-session — which respawns once, then pins inline.
Also: recv retries EINTR with the REMAINING deadline, not a fresh one. With SO_RCVTIMEO the kernel
returns EINTR rather than restarting, so a signal would otherwise read as a dead worker; re-arming
with the full budget would instead let a steady signal rate defer a real hang forever.
The encode worker (design/gpu-priority-capability-worker.md) needs exactly what the zerocopy worker
already has — SEQPACKET framing, fds as SCM_RIGHTS, a pinned-exe spawn that survives an on-disk
replacement, and a reaper that never blocks session teardown on a wedged child — but it must NOT
inherit the zerocopy protocol. Its messages are its own and version independently.
So `imp/proto.rs` keeps the vocabulary (PROTO_VERSION, ImportKind, Request, Reply, BufferDesc) and
all transport moves to `imp/ipc.rs`, reachable as `pf_zerocopy::ipc`. No behaviour change for the
zerocopy worker: client.rs now calls `ipc::self_exe()`/`ipc::spawn_worker()` and keeps the same fd-3
dup2 slot, PR_SET_PDEATHSIG, kill-then-reap-outside-the-lock, bounded reap with a D-state re-park,
and per-generation zombie sweep it had before.
Two real changes underneath the move:
* The cmsg store was sized for exactly one fd (CMSG_SPACE(4) = 24 B). A multi-planar dmabuf can
carry up to four, so it is now CMSG_SPACE(4*4); `send_fds`/`recv_fds` take a slice while `send`
and `recv` keep their single-fd shapes as the fast path. An over-long fd list is rejected with
io::Error rather than asserting — that is how MAX_MSG overflow is already handled — and the
receive cap is enforced by the kernel through msg_controllen, so a 5-fd peer trips MSG_CTRUNC.
* The old recv loop read only the FIRST i32 of each SCM_RIGHTS control message. Nothing sends two
fds yet so it never fired, but every descriptor after the first in a multi-fd message would have
leaked into the process. It now reads all of them.
Spawn takes the executable path as a parameter instead of assuming /proc/self/exe. The zerocopy
worker keeps self-exec; the encode worker passes its own binary, which must be a separate FILE and
never a subcommand — a shared inode shares the file capability.
Adds `xbox_windows` — the host half of the HID Xbox pad: the sealed-channel open under
the Bluetooth identity (SwDeviceCreate `pf_xboxwireless` + `USB\VID_045E&PID_0B13`, so
hidclass derives the real-pad `HID\VID_045E&PID_0B13` child ids), device_type 4 stamped
before the magic, and the `PadProto` impl that publishes through `xbox_proto`. No rich
plane: an Xbox pad has no touchpad, lightbar, adaptive triggers or IMU in its HID
contract, so apply_rich/clear_rich/neutralize_gyro are deliberately no-ops.
Rumble comes back off the driver's republished output reports. The Bluetooth rumble
report carries magnitudes on a 0..100 scale, not 0..255 — assuming otherwise silently
costs 60% of the range — and the enable mask gates each motor independently.
The two INF/driver guard tests now cover the new identity. `hwid_devtype_table_matches
_the_driver` caught the addition on its vacuity count, which is exactly what it is for.
Verified on the Arc laptop (.221, Win11 26200): `cargo test -p pf-inject --lib` 100/100
green, `cargo clippy --lib --profile test -- -D warnings` clean, fmt clean. Note
`clippy --all-targets` fails there on a PRE-EXISTING issue unrelated to this change —
tests/motion_contract.rs imports the linux-gated `switch_proto`.
Still unbuilt: the driver itself (.221 has no WDK) and the host routing that would send
an Xbox pad here instead of to XUSB. The report descriptor remains constructed rather
than captured — diff it against a real pad before shipping.
`pf-xusb` registers only GUID_DEVINTERFACE_XUSB and exposes no HID collection, so
Steam's hidapi enumeration, DirectInput, joy.cpl and WGI/GameInput cannot see the pad
at all — only classic XInputGetState via xinput1_4's interface walk ever does. A
reporter spent two weeks on a dead controller for exactly that reason; switching the
client to DualSense, a real HID pad through the pf-gamepad UMDF driver, fixed it in
seconds.
This gives the Xbox pad that same footing: a new device_type 4 on the existing HID
minidriver, identified as a Bluetooth Xbox Wireless Controller (045E:0B13). The wired
ids the tree already uses (045E:028E, 045E:02EA) are vendor-class XUSB/GIP devices with
no HID interface on real hardware, so a HID child claiming one is a device that has
never existed and has nothing for Windows to promote.
Driver: identity, a constructed 132-byte Game Pad report descriptor, neutral report,
strings and the pf_xboxwireless hardware id. Host: `xbox_proto`, the byte-exact codec
mirroring that descriptor, with 11 layout tests.
One shared-path fix falls out. The timer completed every pended READ_REPORT with the
full 64-byte slot, and `copy_to_output` REFUSES a source longer than hidclass's buffer
rather than truncating it — so a pad declaring a shorter report would have failed every
read and looked dead. Report length is now per-identity; it returns 64 for all four
pre-existing pads, so their behaviour is provably unchanged.
NOT BUILT AND NOT RUN ON WINDOWS — no box was reachable. The Rust codec and its tests
pass on macOS; the driver, the INF and the report descriptor have never been compiled,
infverif'd, or seen by a real pad. The descriptor is constructed rather than captured,
which matters because we claim a real Microsoft VID/PID and SDL/Steam/Windows carry
stock mappings keyed off it — diff it against a capture before shipping.
Field report, macOS client, host-independent: start a stream with AirPods in, take them
out — nothing on the speakers; put them back in — nothing in the AirPods either. Only
restarting the whole stream brought audio back.
An AVAudioEngine does not follow the audio hardware. When the output device changes under
a running engine, its IO unit sees the new hardware, THE ENGINE STOPS ITSELF, and it posts
AVAudioEngineConfigurationChange. It stays stopped until somebody starts it again, and
nothing here ever did — no error, no log line, just a session rendering silence from that
moment on. Putting the AirPods back in is a second stop, not a recovery, which is exactly
why that half of the report looked so strange.
Measured on the client's own playback topology (source node -> main mixer, 48 kHz stereo)
by moving the default output device programmatically: render callbacks go from ~94/s to
zero the instant the device changes, and both restarting the same engine and building a
fresh one resume them.
The fix watches the hardware and rebuilds the topology the session was started with, on
whatever device is there now. Three triggers, because no single one covers the ground:
- the engine's own configuration-change notification, every platform — the direct
signal, but it can only be posted BY an engine, so it cannot report a rebuild that
failed to start;
- a CoreAudio HAL default-output-device listener on macOS — independent of any engine
and of the engine's topology. This is what makes the recovery work for the
voice-processing engine, which is the DEFAULT macOS configuration (mic and echo
cancellation both default on) and whose notification behaviour could not be verified:
no Mac in the fleet can initialize VPIO at all;
- route-change and media-services-reset on iOS/tvOS, where the session rather than the
device is what moves. The route observer is now installed for mic-off (.playback)
sessions and on tvOS too — it used to be iOS-and-mic-only, for the earpiece steer,
but every platform has engines a route change can stop.
They collapse into one debounced rebuild (one switch produces a burst), with a floor
between rebuilds so a device that renegotiates in a loop cannot spin the session, and a
short retry ladder for a device caught mid-transition — a rebuild that fails leaves no
engine to post the next notification, so that path must not simply give up. The ring is
deliberately carried across: the drain thread keeps decoding through the switch, and the
ring's overflow policy has already dropped whatever went stale while the engine was down.
A rebuild is only ever done when it concerns us. A healthy engine that followed the change
on its own is left alone, and somebody changing the system default while this session is
pinned to a named speaker is none of our business — rebuilding for that would cost an
audible gap for nothing.
The trigger wiring is split into AudioDeviceWatcher for one reason: an end-to-end test of
the recovery needs a live session, which needs a host, and punktfunk-host does not build
on macOS — so the part where a silent failure costs the session ALL of its audio would
otherwise ship unverified. On its own the watcher is pointed at the real hardware from a
unit test: a real default-output-device move must reach the owner, our engine's
notification must get through, a foreign engine's must not. Neutralizing the wiring fails
both positive tests and neither negative one.
AudioDeviceSwitchTests drives the real SessionAudio through the out-and-back switch
against the loopback host; it skips wherever that fixture cannot run (which is every Mac,
today) and the open host's frame budget is raised so it outlives the switch.
The gate #135 added fails the job at the gamescope BUILD step. In deb.yml that
step runs before "Publish to the Gitea apt registry" and "Attach the host .deb
to the Gitea release", so failing it skipped both.
Consequence on the v0.26.0 tag, and it is the worst thing in this release so
far: the host .deb on the release is from 00:17 — re-point #1, BEFORE #136
revoked CAP_SYS_NICE. Every other .deb is from 08:29-08:31. So the published
Debian host still runs `setcap cap_sys_nice=ep` in its postinst, which is
exactly what makes the host unidentifiable to KWin and kills every KDE desktop
session. A gate meant to protect the release withheld the fix for it and left
the broken artifact in place.
rpm.yml has the identical latent bug and only escaped it because Fedora went
green: a gamescope failure there would skip the sysext image, the feed publish
and the release attach, withholding the punktfunk RPMs and .raw images too.
Both now warn at the build/package steps and gate as the LAST step of the job,
after everything has published. A missing EXTRA must never stop a good artifact
shipping — go red afterwards instead.
Also: name noble's dependencies outright. `apt-get build-dep gamescope` gives it
almost nothing (the distro has no comparable package), which is why this peeled
one dep per CI cycle — wayland-protocols, then xdamage. The full set is derived
from the Arch package's depends+makedepends, which is the build that demonstrably
works, plus wlroots' own (it is a forced fallback subproject).
One `apt-get` per name on purpose: a single transaction aborts wholesale on one
unknown package, installing NOTHING and hiding the real gap behind a name typo.
Per-package, best-effort, with the missing name echoed; the end-of-job gate is
what actually decides.
⚠ Verification: both YAML files parse; every gamescope-touching `run:` block is
`bash -n` clean with matrix placeholders substituted (9 blocks); the .deb glob
matches build-gamescope-deb.sh's documented output
(`dist/punktfunk-gamescope_<version>_<arch>.deb`) and the RPM glob excludes
debuginfo/debugsource exactly as the attach loop above it does. The noble dep
NAMES cannot be proven from macOS — that is what the next tag run decides, and
it now decides it without holding the host .deb hostage.
`silent_without_capabilities` called the real `capability_denial_hint()` and
asserted it returns "", on the strength of a doc comment that read "The test
process has no capabilities."
That is true on a dev box and false in CI, where the runner container is root
with a full permitted set. main went red on 0f79587d with:
left: " — NOTE: this process carries capabilities (CapPrm=0x000001ffffffffff) …"
right: ""
Nothing was wrong: the hint fired correctly, on a process that really did hold
every capability. The test was reading the ambient environment and calling it a
property of the code.
`permitted_caps_from_status` had already been split out for exactly this reason
— "so that shape is testable without a capability-carrying process to point at"
— but only the PARSE half. The message half still went to /proc/self/status.
This finishes the split: `capability_denial_hint_for(Option<u64>)` holds the
formatting and takes the mask, `capability_denial_hint()` reads /proc and
delegates. Both keep their callers, so neither is dead code.
Also adds `names_the_mask_and_the_repair_when_capped`. Without it the silent
case passes just as well against a function that returns "" unconditionally —
which is the failure mode this repo has been bitten by before, and the reason
every decode fix carries a counterfactual.
No behaviour change: the three error paths call the same function and get the
same string.
⚠ Verification is CI. `kwin.rs` is `#[cfg(target_os = "linux")]`, so it does not
compile on the macOS host this was written from; `cargo fmt --all --check` is
clean and a Linux container check was attempted but the stock rust image has no
cmake for audiopus_sys, so it never reached the test. ci.yml going green on main
is the proof — and unlike the case it replaces, this test now fails or passes
for reasons that have nothing to do with the machine running it.
Does not touch the v0.26.0 tag: ci.yml runs on `push: branches: [main]` and
`pull_request` only, and no tag leg runs cargo test.
The user-facing v0.26.0 notes said, of the PyroWave GPU-priority lever:
"it is now, and the package grants the host the permission that switch needs"
That was true of 0.26.0-1 and is now the opposite of true. Granting CAP_SYS_NICE
made the host unidentifiable to KWin and killed desktop streaming on every KDE
box across all five Linux channels, so 0.26.0-2 revokes it everywhere and must
keep doing so. The lever is wired natively on Linux for the first time — that
part stands — but it is dormant on an ordinary install, and the notes have to
say so rather than advertise a speed-up nobody gets.
CHANGELOG.md was already corrected in #136 (the 0.26.0-2 note under PW1 and the
qualifier on the owed A/B). This is the user-facing half, which #136 did not
touch:
* the PyroWave bullet now leads with what DID land (two encoder handles, the
capture buffer headroom) and describes the priority switch as present but
dormant, with the reason.
* a new Fixed entry for the KDE breakage itself. Worth telling users even
though the release was never announced: 0.26.0-1 packages did reach the
registries, and anyone who pulled one has a desktop session that fails with
a missing-screencast error surviving a clean reinstall. It also explains the
dormancy the bullet above now refers to.
Deliberately NOT written as a "Before you update" action: upgrading strips the
capability by itself on every channel, so there is nothing for a reader to do.
Commit count 47 -> 52.
Voice check clean (0 internal-vocabulary hits above "## For developers"); notes
67 lines.
The Arch fix in the previous commit was incomplete. 0.26.0-1 granted the host CAP_SYS_NICE through
every Linux channel we ship, and each one breaks KWin identification the same way:
* packaging/rpm/punktfunk.spec .......... %caps(cap_sys_nice=ep) in %files <- Fedora AND Bazzite
via rpm-ostree layering
* packaging/bazzite/build-sysext.sh ..... setcap on the staging tree, recorded by mksquashfs
* packaging/debian/build-deb.sh ......... setcap in the postinst
* packaging/nix/nixos-module.nix ........ security.wrappers with capabilities = "cap_sys_nice=ep"
* scripts/steamdeck/install.sh .......... setcap on $BIN, six lines after writing the .desktop
whose Exec= it thereby voids
Bazzite was NOT a separate fault, as first reported here — it is this one. Verified by mounting the
published punktfunk-0.26.0-1-x86-64.raw: `getcap usr/bin/punktfunk-host` reports cap_sys_nice=ep,
stored as security.capability in the squashfs. The claim in packaging/arch/build-sysext.sh that
"file capabilities don't survive this squashfs path" is false and is corrected here; mksquashfs
records them, which is exactly why the image shipped one.
NixOS deserves its own note: a security.wrappers entry does not dodge the problem. The wrapper
raises the capability into its AMBIENT set before exec'ing the store binary, precisely so it
survives — which lands CAP_SYS_NICE in the exec'd process's permitted set and fails the readlink
identically to a file capability. ExecStart now points at the store path directly, which is also the
path packages.nix substitutes into the .desktop's Exec=, so the two finally agree.
Measured blast radius of holding a capability, same-uid reader, CachyOS kernel 7.1.6:
/proc/PID/exe ....... EPERM <- KWin's identification. Desktop sessions die.
/proc/PID/root/* .... EPERM <- xdg-desktop-portal reads .flatpak-info here to resolve an
app id; the wlroots and Hyprland backends go through it
/proc/PID/environ ... EPERM
/proc/PID/cgroup .... OK
/proc/PID/status .... OK
/proc/PID/cmdline ... OK
Compositor backends, by exposure: KWin is broken outright (proven, field-confirmed). gamescope has
no identity gate and was never affected, which matches the field — only Desktop mode was reported.
Mutter drives Mutter's own D-Bus API, not the portal, and looks unaffected. wlroots and Hyprland go
through the ScreenCast portal, whose app-id resolution reads a path the capability blocks — a real
exposure, not something I reproduced end to end.
The sysext build now HARD-FAILS if a capability is staged, rather than trusting that the RPM payload
never carries one: a merged sysext's /usr is read-only squashfs, so a bad image cannot be repaired
on the box, and the spec was one %caps() away from baking one in again.
Docs corrected, because they advertised the capability as a feature:
* docs-site running-as-a-service "GPU scheduling priority" — rewritten: the host carries no
capability, why it must not, and how to clear a 0.26.0-1 install (Bazzite needs a new image)
* docs-site configuration.md — the PYROWAVE_QUEUE_PRIORITY row no longer claims the packages grant it
* packaging/bazzite/README.md — §6.5 still described the kde-desktop-setup.sh behaviour from
before it stopped writing KWIN_WAYLAND_NO_PERMISSION_CHECKS and started REMOVING it; plus a
note that 0.26.0-1 Desktop mode cannot be repaired in place
* packaging/arch/README.md — the false "capabilities don't survive the sysext" line
* CHANGELOG v0.26.0 PW1 — annotated with the 0.26.0-2 correction rather than rewritten, and the
owed PyroWave-under-load A/B now says it needs a gamescope-only box
Verified: bash -n on all five changed shell files; nix-instantiate --parse on nixos-module.nix and
packages.nix; the published 0.26.0-1 sysext mounted and its capability read; getcap on an uncapped
file exits 0 with empty output, so the new build assertion cannot false-positive.
0.26.0-1 setcap'd `cap_sys_nice=ep` on /usr/bin/punktfunk-host so the encoder could open an
elevated global-priority Vulkan queue. On every KDE box that ended desktop streaming outright:
KWin virtual output failed: KWin does not expose zkde_screencast_unstable_v1 to this client
reported from CachyOS on NVIDIA and on AMD, surviving a clean reinstall of host and client, and
worked around only by KWIN_WAYLAND_NO_PERMISSION_CHECKS=1.
The two cannot coexist. KWin hands out its restricted protocols — zkde_screencast_unstable_v1,
which mints our virtual output, and org_kde_kwin_fake_input, which injects input — only to a client
it can IDENTIFY, by resolving that client's /proc/<pid>/exe and matching it against an installed
.desktop's Exec=. The kernel refuses that readlink to any reader whose effective set is not a
superset of the target's PERMITTED set (cap_ptrace_access_check), and KWin holds no capabilities.
So the instant the binary carries one, KWin's executablePath() is empty, nothing matches, and the
global is never advertised — presenting exactly as a missing or mis-installed .desktop file.
Measured on CachyOS (kernel 7.1.6), same-uid reader, cap_sys_nice=ep on the target:
no capability .............................. readlink /proc/<pid>/exe OK
capability ................................. EPERM
capability + prctl(PR_SET_DUMPABLE, 1) ..... EPERM <- dumpable is NOT the gate
capability dropped + PR_SET_DUMPABLE(1) .... OK <- only an uncapped process works
The third row also rules out the reflex fix of moving the grant to systemd AmbientCapabilities=,
which lands CAP_SYS_NICE in the very same permitted set. Nothing short of not holding the
capability restores identification, so the host does not get one.
The cost is pacing only. pf-zerocopy's device create already walks REALTIME -> HIGH -> default when
a priority class is refused, and pf-frame's thread nice is a documented best-effort no-op without
the capability — so this is 0.25.0's behaviour exactly, which is the behaviour that worked.
* packaging/arch/punktfunk-host.install: grant -> revoke. post_upgrade strips the capability from
boxes that already ran 0.26.0-1's scriptlet. A pacman upgrade writes a new inode and file
capabilities do not survive that, so this is belt-and-braces for reinstall/downgrade paths.
* pf-vdisplay kwin.rs: all three "KWin does not expose zkde_screencast" errors now read
/proc/self/status and, if this process holds ANY capability, name it with its CapPrm mask and
the `setcap -r` that repairs it. The failure stays impossible to diagnose from the Wayland side
otherwise, and it is not unique to our own packaging — a hand-rolled setcap does it too.
Verified on 192.168.1.21 (CachyOS): the capability/dumpable matrix above; cargo check and
cargo clippy --all-targets -- -D warnings clean for pf-vdisplay; both new unit tests pass; and the
hint itself exercised end-to-end, silent uncapped and firing with CapPrm=0x0000000000800000 under
cap_sys_nice=ep. The shipped punktfunk-host-0.26.0-1-x86_64.pkg.tar.zst was unpacked to confirm its
.INSTALL carries the setcap on both post_install and post_upgrade.
Ships as 0.26.0-2 — packaging plus one crate, no version bump.
v0.26.0's notes and docs-site say the patched gamescope is now installable on
Fedora and on Debian/Ubuntu. Neither package exists on the release. Both builds
failed inside best-effort steps that emit `::warning::` and return 0, so every
job stayed green and the only evidence was a warning nobody reads. Arch built
fine, which is why it is the sole gamescope package attached.
Two distinct missing build deps, same root cause: `dnf builddep gamescope` /
`apt-get build-dep gamescope` resolve the DISTRO'S OLDER PACKAGED gamescope,
which does not need what the pinned master tree needs.
Fedora (f43 AND f44)
/usr/sbin/ld: cannot find -lstdc++
have you installed the static version of the stdc++ library ?
ERROR: Compiler sccache c++ cannot compile programs.
build-punktfunk-gamescope.sh appends `-static-libstdc++ -static-libgcc` to
LDFLAGS deliberately, so the binary still starts on SteamOS's older libstdc++.
Without libstdc++-static that trips meson's very FIRST sanity check, so
nothing builds at all.
Debian/Ubuntu noble
protocol/meson.build:7:17: ERROR: Neither a subproject directory nor a
wayland-protocols.wrap file was found.
The tree carries no wrap fallback for wayland-protocols.
Both proven deps are installed WITHOUT `|| true` so a rename is loud. The
remaining Arch makedepends the older packaged gamescope may not pull (glm,
cmake, libXcursor, wayland-protocols-devel on Fedora) stay best-effort, since
meson finds fallbacks and a name that moves between releases should not fail
the job.
And the part that actually matters: on `refs/tags/v*` a missing gamescope is
now an ERROR, not a warning. A release must not be able to make a claim its own
CI silently dropped. Gated in two places per platform — the build step, and the
packaging step that is authoritative and also covers the cache path (the build
step is skipped entirely on a cache hit, so a stale cache would otherwise reach
packaging and skip in silence). Canary keeps the old best-effort behaviour.
Deliberately NOT gated: the sysext leg. The notes make no claim about gamescope
inside the sysext, and with the build fixed gs-cache is populated so it gets the
binary anyway — gating it would add release-blocking risk with no matching
promise.
⚠ Verification is CI itself: both YAML files parse, and every gamescope-touching
`run:` block is `bash -n` clean with the matrix placeholders substituted. The
dep names cannot be proven from macOS; the rpm and deb legs on the next tag are
the proof, and they are now hard-gated, so a wrong name fails loudly instead of
shipping another empty promise.
The v0.26.0 tag went red on windows-host at the clippy step, after a clean
build:
error: function `wire_sequence` is never used
--> crates\pf-encode\src\enc\pyrowave_wire.rs:68:15
= note: `-D dead-code` implied by `-D warnings`
`pyrowave_wire` is cfg'd for linux OR windows and is genuinely shared —
`packet_boundary` and `stamp_color_bits` each have callers on both backends.
`wire_sequence` does not: every call site is in `enc/linux/pyrowave.rs`, which
is `#[cfg(all(target_os = "linux", feature = "pyrowave"))]`. Alternating
encoder handles are a Linux-side concern (PW5); the Windows backend drives
pyrowave's compat device with a single handle and never needs the counter. The
module's own `#[cfg(test)]` block does not reference it either, so on Windows
the item has zero callers in every target and dead_code is correct — it is the
`-D warnings` promotion to a hard error that stops the lib compiling.
Scoped to the one item rather than the file, and expressed as
`cfg_attr(not(target_os = "linux"), ...)` rather than a bare `allow`, so
dead_code stays LIVE on Linux — where the caller lives, and where this function
quietly losing its last caller would be a real finding rather than noise.
⚠ Not reproducible off a Windows box: cross-compiling to
x86_64-pc-windows-msvc from macOS dies in openh264-sys2's build script
(clang++ rejects `-fPIC` for that target) long before the lint stage. The
mechanism is nonetheless exact — one item, one cfg, zero callers behind it —
and the windows-host and windows-msix legs are the proof.
No behaviour change on any platform: this adds a lint attribute and eight
lines of comment.
47 commits since v0.25.0, most of them from field reports on 0.25.0 itself,
plus Wave 2 of the PyroWave Linux host-performance program.
Nothing breaks: the wire protocol stays at 2 and the C ABI stays at 17, so
this release adds no call, no message and no capability bit. pf-driver-proto
is byte-for-byte identical to v0.25.0 and to v0.24.0.
Four new environment variables (PUNKTFUNK_OVERLAY_MASK,
PUNKTFUNK_GAMESCOPE_REFRESH_RATES, PUNKTFUNK_PYROWAVE_CHUNK_KIB,
PUNKTFUNK_PYROWAVE_STREAMED_AU), verified new by git grep at the v0.25.0 tag
rather than assumed. plugin-kit goes 0.3.2 -> 0.4.0 for the `plugin` launch
kind; the SDK goes 0.1.2 -> 0.1.4; gamescope patch level +pfhdr2 -> +pfhdr4.
Two behaviour changes make a client advertise LESS than it used to, both
deliberate: VIDEO_CAP_444 is now probed against the driver rather than ridden
off the setting alone (every Steam Deck with "Full chroma" on was losing HEVC
entirely, not crispness — no AMD silicon decodes HEVC 4:4:4), and the Decky
client-update check now reports a failure instead of dressing it up as
"up to date".
Bump is the same four files as 0.25.0: Cargo.toml, Cargo.lock,
docs/releases/v0.26.0.md, docs/releases/whatsnew/v0.26.0.txt — plus the
CHANGELOG.md section, which the split at 0.25.0 made part of the ritual.
Gates run locally, all green:
* cargo fmt --all --check clean
* cargo metadata --locked resolves
* Cargo.lock diff versions-only, 70/70 changed lines, 35 crates
* Play whatsnew gate 398/500 chars, not byte-identical to any other release
* notes voice check 0 internal-vocabulary hits above "## For developers"
Notes are 66 lines against 0.25.0's 83, covering 47 commits.
Still owed on glass and recorded in the CHANGELOG's verification table:
iPhone + Bluetooth listen, Apple TV stats overlay, MacBook audio listen, the
Deck HEVC/4:4:4 retest, a Windows wake-from-sleep cycle, and the
PyroWave-under-game-load A/B with CAP_SYS_NICE actually granted.
On a Deck in Gaming Mode the Steam menu and the QAM are driven by the SAME
physical controller the client forwards, so opening either one moved the game
on the host as well as Steam's UI — a second, invisible player. Steam Input
masks a normal game here; it cannot mask us, because masking happens on Steam
Input's virtual pad and we deliberately forward the REAL one (28DE:1205 — the
virtual pad has no gyro, trackpads or paddles).
SDL ships the exact behaviour we want and it is on by default: presses are
dropped while the process has windows but no keyboard focus, releases still get
through. It CANNOT fire on a Deck. gamescope resolves focus per Xwayland ctx
and the client sits alone in its own, so the Steam overlay — which lives in the
root ctx — never takes our X focus away and no FocusOut is ever generated.
Measured on glass: with the QAM open, X input focus inside the client's ctx
stayed on its window for the whole 4 s, while GAMESCOPE_FOCUSED_APP flipped to
769 (Steam) and GAMESCOPE_FOCUSED_APP_GFX stayed on the app.
So the signal is explicit. `overlay_focus` watches those two atoms on the
gamescope root ctx — which is NOT our own $DISPLAY under `--xwayland-count 2`,
hence the socket-directory walk and the flatpak filesystem line — and the
presenter ORs it with window focus into one `set_masked`.
Masking is deliberately not `set_forwarding`: that closes the slot and sends
GamepadRemove, so the game would see a controller UNPLUG every time somebody
opened the QAM. This keeps every slot open and only stops the transitions,
after flushing what the host believes is held so a stick deflected at
overlay-open stops steering instead of freezing at its last value. On the way
back, held buttons are adopted rather than replayed — the A that picked a QAM
row must not fire in the game as it closes — while axes are re-sent, since a
stick has no press to ghost and SDL only speaks on change.
Fails open throughout: no gamescope, no X, or an unreadable signal all leave
forwarding exactly as it was. `PUNKTFUNK_OVERLAY_MASK=0` opts out.
`serveUi({launch})`, `PluginLaunchTarget` and `makeLaunchHandler` (#129) are new API, so this is a
minor bump rather than a patch. It also carries `SyncError.message`, without which a host refusal
reaches a plugin's own UI as the bare tag `SyncError` and nothing else.
Unblocks rom-manager, whose main is currently RED: it merged the consuming change while still
pinning `^0.2.0`, so `bun install --frozen-lockfile` there resolves a kit without these exports and
the typecheck fails on all three. Publishing this and then bumping that pin is the fix — in that
order, because the lockfile cannot resolve 0.4.0 until it exists on the registry.
Tag `plugin-kit-v0.4.0` to publish; the workflow asserts the tag matches this version.
Wave-2 PW5, the stage-6 experiment. Shipped behaviour is UNCHANGED: `max_inflight` is still 1.
Stage 6 is the frame-corruption stage, and its gate is an on-glass tear-hunt with a live compositor,
a real client and ten minutes of moving content. That is not runnable from here. But the depth-2
risk has two halves, and one of them lives entirely in this crate — the per-slot resources
(`cmd`/`fence`/`csc_set`/y/uv/cursor) and the alternating encoder handles — so that half can be
answered now, on the GPU, and the answer is worth having before anyone attempts the other.
The experiment drives the backend with two frames genuinely in flight (submit N+1, then poll N) and
compares the result against the encoder's OWN synchronous output over the same 16 moving frames.
Its own depth-1 decode is the honest reference: pyrowave's raw AU bytes are not reproducible
run-to-run (see the stage-3 commit), but its decoded planes are.
RESULT, .21 / RTX 5070 Ti (GPU idle at 180 MHz of 3090 — the slow-clock worst case on this card):
depth-2 vs depth-1 over 16 frames: worst-case PSNR identical (inf)
Bit-identical luma, every frame, in order. So stages 4 and 5 between them are sufficient for the
encoder side: doubling the six single-slot resources and alternating two `pyrowave_encoder` handles
under one monotonic wire sequence really does make overlap invisible to the decoder.
The test is built to fail rather than to pass. Content MOVES every frame (flat fills are the
documented false-green trap — a torn frame stitched from two halves of a static card is invisible),
it asserts two frames were ACTUALLY in flight rather than silently proving nothing, it asserts the
AU count is unchanged, and it carries an off-by-one discriminator that raw PSNR would miss: each
overlapped frame must match its own reference BETTER than it matches the previous one, so a
pipeline delivering frames one position late fails even though every individual PSNR looks fine.
It reaches `max_inflight` directly instead of through a shipped knob, precisely so the shipped
value stays 1.
⚠ WHAT THIS DOES NOT COVER, stated here so the next person does not read it as a green light for
stage 6: the CAPTURE side. `.process` hands the SPA buffer back to the compositor at callback
return while the encode thread holds only a dup of its dmabuf fd, so a second frame in flight
widens the window in which the producer may overwrite a buffer we are still reading by a full frame
period. Nothing in this crate can test that — it needs a live producer. Stages 1 and 2 are what
make it answerable (the pool census says how deep the producer's ring is; the Choice range asks for
headroom), and the on-glass hunt is what would settle it.
Gates green at CI parity.
Wave-2 PW5 stage 5. Depth is STILL 1 — the handles alternate per frame, one in flight.
PyroWave's `Encoder` cannot hold two frames. Not "probably not" — structurally not. `Encoder::Impl`
owns ONE each of `wavelet_img_high_res`, `bucket_buffer`, `meta_buffer`, `block_stat_buffer`,
`payload_data` and `quant_buffer`, and `Impl::encode` OPENS by discarding them: an image barrier
with `VK_IMAGE_LAYOUT_UNDEFINED` as the old layout — a written promise that nothing else is reading
it — plus three `fill_buffer` clears. Two encodes recorded into two command buffers and submitted
to one queue have no execution dependency in Vulkan (submission order orders the START, not the
completion), so N+1's DWT would overwrite the wavelet bands and zero the RDO buckets while N's
block packing still reads them. Content-dependent, silent.
So overlap means TWO handles on one device, alternated — one per slot. Every resource above is
then private per handle, and within a handle the encodes stay strictly serialized (a slot's next
frame is recorded only after that slot's previous one retired), which leaves patch 0004's
scratch-pool invariant intact without touching it.
THE LANDMINE, and it is the reason this stage is its own commit: `sequence_count` ALSO lives on
`Impl`, and it is the 3-bit counter stamped into every block header. Two handles each count
1,2,3... alone, so the wire sees 1,1,2,2,3,3.... The decoder restarts a frame only when the value
CHANGES (`diff = (hdr.sequence - last_seq) & 0x7; restart = diff != 0`), so a repeat reads as MORE
BLOCKS OF THE SAME FRAME: `clear()` never runs, `decoded_frame_for_current_sequence` stays true,
and the second frame of each pair is swallowed. Half frame rate, occasional mixed-frame blocks, no
error anywhere — on every client, since pf-client-core and the Apple Metal hand-port parse the same
field.
`patches/0007-encoder-sequence-override.patch` (new, ~38 lines) exposes
`Encoder::set_next_sequence` + a `pyrowave_encoder_set_next_sequence` C entry + a
`PYROWAVE_SEQUENCE_MASK` define, so ONE monotonic counter on the Rust side is stamped regardless of
which handle encodes. The setter stores `(seq - 1) & mask` because `Impl::encode` pre-increments —
its contract is about the next ENCODE, not the next store. Inert when unused, so the whole Windows
backend is untouched. No `.def` change: the C API is a static archive.
PREDICTED, THEN OBSERVED. A negative control on .21 (the override call removed, nothing else) reads
the wire out at exactly:
[1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 0, 0, 1, 1, 2, 2]
which is the analysis's prediction character for character, and with the override:
+1 mod 8, all 20 frames, through the 3-bit wrap.
THE GATE, `wire_sequence_increments_across_alternating_handles`, checks three things over 20 frames
because any one alone could pass while the stream is broken: the wire counter advances by 1 mod 8;
ONE persistent decoder (its `last_seq` carried across every push, exactly like a client's) reports
every AU decodable; and consecutive decoded pictures DIFFER. Content moves every frame — and the
first run caught a trap in the harness itself rather than the encoder: `test_card` starts its LCG
at `seed | 1`, so seeds 2 and 3 build a byte-identical card and the test faked the very repeat it
hunts. Odd seeds only now, with the reason written down.
A runtime self-check backs the test up where the test cannot reach: after packetize, the stamped
sequence is compared against what we asked for, and a mismatch logs once per process naming patch
0007. A re-vendor that loses the patch would not fail to build — it would fail on glass, subtly,
and this makes it loud instead. Two byte reads per frame.
`reset()` rebuilds both handles and `Drop` destroys both, each with the same null-immediately
discipline the single handle had (`pyrowave_encoder_destroy` is a bare `delete` with no null
check, so a stale pointer left in the field is a double free).
Vendored-patch discipline: patch 0007 re-applies clean to a pristine vendor checkout (verified by
stashing the vendor tree and re-applying), and `git diff crates/pyrowave-sys/vendor/` touches
exactly the four intended files.
VERIFIED ON GLASS (.21, RTX 5070 Ti, GPU idle at 180 MHz of 3090): all 8 `#[ignore]`d GPU tests
pass, including the new gate and the 4:2:0 / 4:4:4 / 24-bpp PSNR smokes.
Gates green at CI parity.
Wave-2 PW5 stage 4. Pure capacity — `max_inflight` is STILL 1, nothing overlaps yet.
The plan named the y/uv images as the thing to double. Reading the backend found five more, and
each is a correctness problem under overlap rather than a performance one:
* `csc_set` — ONE descriptor set, rewritten every frame by `bind_rgb`. Updating a set still bound
by a PENDING command buffer violates VUID-vkUpdateDescriptorSets-None-03047, and on most
drivers that is a wrong picture rather than an error.
* `y_img`/`uv_img` — the CSC of N+1 storage-writes exactly the images pyrowave is still sampling
for N. The barrier comment ("the previous frame's encode already completed under our
synchronous fence") was load-bearing and said so.
* `cursor_img` + `cursor_stage` — the struct comment stated the assumption outright: *"Single
(not ring) because PyroWave encodes one frame synchronously — no in-flight overlap to race."*
* `cmd` + `fence` — you cannot record into a PENDING command buffer at all.
* `cpu_img`/`cpu_stage` (software capture / tests) — the host writes staging while the previous
frame's copy is still pending.
All of it moves into a `Slot`, and the encoder now owns `SLOTS` of them. Two, because Granite caps
the overlap at two for us: the pyrowave device defaults to `init_frame_contexts(2)` and
`next_frame_context()` — called at the top of every `encode_gpu_synchronous` — waits the context it
rotates into. A third slot would need a vendored `init_frame_contexts(3)` that is not exposed.
`bitstream` and `import_cache` are deliberately NOT per-slot, and the `Slot` doc says why so a
later sweep does not "fix" it: `bitstream` is only touched during packetize, i.e. only on the poll
side one frame at a time, and `import_cache` retaining the VkImage/VkDeviceMemory per dmabuf inode
is precisely what makes it safe for two slots to sample the same imported buffer. `cpu_expand` is
shared for the same reason — it is copied into staging before `submit_frame` returns, so no GPU
work ever reads it.
Each frame carries its slot index in `InFlight` rather than recomputing it, so `wait_and_packetize`
cannot wait the wrong fence — the failure that would look like corruption rather than an error.
`reset()` now waits EVERY in-flight fence, not just one, which matters the moment depth rises.
WHAT IT COSTS, measured from the driver's own memory requirements rather than estimated (.21,
RTX 5070 Ti, and there is now an `#[ignore]`d test that prints it on any GPU):
1080p 4:2:0 3872 KiB per slot 7744 KiB for both
4K 4:2:0 12992 KiB per slot 25984 KiB for both
4K 4:4:4 24992 KiB per slot 49984 KiB for both
So the extra slot costs ~3.8 MiB at 1080p and ~24 MiB at 4K 4:4:4 — an order of magnitude under
the plan's ~25-35 MB / 100-150 MB estimate, because that estimate included pyrowave's internal
wavelet and scratch buffers, which stage 5's second encoder handle will add and this stage does
not. Affordable on an iGPU. The open line now logs `slots`, `slot_kib` and `slots_kib` so this is
visible per session and not only in a test.
VERIFIED ON GLASS (.21, GPU idle at 195 MHz of 3090 — slow-clock, the worst case on this card):
all 6 `#[ignore]`d GPU tests pass, and all NINE decoded-plane hashes (`ref-dense-{y,cb,cr}`,
`ref-chunked-*`, `ref-dense444-*`) are bit-identical to the pre-PW5 base. Decode identity is the
meaningful gate here — the raw AU bytes are not reproducible run-to-run even from an unmodified
binary, which stage 3's message documents.
Gates green at CI parity.
Wave-2 PW5 stage 3. Depth is STILL 1; this is the shape change alone.
`encode_frame` recorded CSC+encode, queue-submitted, waited the fence and packetized, all inside
`Encoder::submit`. Every other backend in this crate puts the wait on the POLL side. That
difference is the whole reason the host loop's cadence folds around this encoder: with the wait
inline, `submit` returns only after the GPU is done, so the arrival-anchored floor absorbs the
encode only while it stays under 0.9x the frame interval.
Split into `submit_frame` (ingest -> CSC -> pyrowave encode -> queue-submit -> return) and
`wait_and_packetize` (fence wait -> packetize -> AU), with an `InFlight` deque between them capped
by `max_inflight`, which is 1. **One is the only value the resources can support today** — `cmd`,
`fence`, `csc_set` and the y/uv images are one each, so a second concurrent frame would record into
a PENDING command buffer and storage-write images pyrowave is still sampling. `submit` therefore
drains to `max_inflight - 1` before recording, which states that invariant in one place instead of
leaving it implicit in "the encode is synchronous".
The subtle part is the command-buffer state machine, and it is unchanged: the record-and-submit
closure still resets `cmd` on every PRE-submit failure (RECORDING/INVALID/EXECUTABLE, never
PENDING), and the fence wait still does NOT reset on failure, because a timeout leaves the buffer
PENDING where a reset violates VUID-vkResetCommandBuffer-commandBuffer-00045. What changed is that
a failed wait now also leaves the entry IN FLIGHT — which is precisely what tells `reset()` there
is live GPU work to re-wait before the pyrowave encoder object may be destroyed. `gpu_pending` is
gone; `!inflight.is_empty()` is the same fact, and cannot drift from it.
The split opened two windows that did not exist when everything ran inline, both closed here:
`reconfigure_bitrate` and `set_wire_chunking` can now land BETWEEN a submit and its poll, so the
packetize boundary and the bitstream cap are snapshotted into `InFlight` at submit time. Reading
the live fields would have let a mid-flight bitrate drop turn a perfectly good frame into
"unexpected packet count", and a mid-flight chunking change into an AU with the wrong
`chunk_aligned` flag.
`flush()` is no longer a no-op — it drains the in-flight frame, so the trait's poll-until-None
contract still returns every AU (the `spike` subcommand and the hardware smoke tests are the real
users).
The perf instrument still measures submit->AU, stamped at submit and taken when the AU becomes
readable, so `92326312`'s numbers stay directly comparable; the log line now carries `depth` and
says plainly that above depth 1 the number legitimately grows by about one loop period.
VERIFIED ON GLASS (.21, RTX 5070 Ti, GPU idle at 180 MHz of 3090 — so these are slow-clock runs,
which is the worst case on this card, not the best): all 6 `#[ignore]`d GPU tests pass — the
4:2:0, 4:4:4 and 24-bpp PSNR smokes, the mode-mismatch refusal, the fd-leak check and the golden
dump.
Byte-identity, honestly: the AU bytes are NOT reproducible, and were not before this commit
either. Three runs of the SAME unmodified binary produced three different `au-dense.bin` hashes
(ab7ecaf6 / 8735700e / 933b3d40) — the vendored 4:2:0 encoder emits run-varying bytes that the
decoder ignores. So the meaningful gate is DECODE identity, and that holds exactly: every decoded
plane (`ref-dense-{y,cb,cr}`, `ref-chunked-{y,cb,cr}`, `ref-dense444-{y,cb,cr}`) is bit-identical
between the pre-split base and this commit, across four runs. 4:4:4 AUs are additionally
bit-stable and match the checked-in Apple fixture exactly.
Gates green at CI parity.
Two Steam Deck findings from a field report of "the decoder was not found, it
fell back to H.264 — but sometimes HEVC worked".
**The 4:4:4 advertisement was a promise nothing checked.** `VIDEO_CAP_444` rode
the "Full chroma" setting alone. That was safe while a software HEVC decoder
existed underneath it; M8 removed one (there is no permissively licensed HEVC
CPU decoder, so `software_decodable_codecs()` is H.264|AV1). The host grants
4:4:4 on HEVC ONLY, and answers the resolved chroma in the Welcome before the
client builds a decoder — so on a device with no 4:4:4 decode the toggle did not
cost crispness, it cost the entire codec: the Vulkan rung refuses the shape at
construction, VAAPI refuses it too, there is no CPU rung, and the session
reconnects on H.264. AMD has no HEVC 4:4:4 decode on any silicon, so every Deck
with that switch on lost HEVC. It is per-profile and default-off, which is
exactly why it looked intermittent — a "Work" profile lost HEVC where "Game"
kept it, same box, same host.
Gated on `hevc_444_hardware_decodable`, which asks the driver through the SAME
code the rung uses at construction (`VkH265Decoder::probe_stream_support`), so
the advertisement and the rung that must honour it cannot disagree. Both depths
are required, not either: with HDR on the host may resolve 4:4:4 10-bit, and a
device offering YUV444_8 but not YUV444_10 would land in the same hole.
Answering from the Vulkan rung alone is exact rather than approximate — it is
the only rung in this build that implements 4:4:4 at all (`pf_vaadec::profile_for`
errors on chroma_format_idc 3, pf-dxvadec refuses anything but 4:2:0, the CPU
rung is 8-bit 4:2:0). Deliberately NOT extended to VIDEO_CAP_10BIT/HDR: all
three rungs implement 10-bit 4:2:0, so a Vulkan-only probe there would withdraw
HDR from boxes whose VAAPI/DXVA rung decodes it perfectly — a real regression
against a case never observed.
The bit arithmetic moves into `video::video_caps_for` so the part that was
wrong is testable without a GPU, a host or a Hello; the test is verified
non-vacuous against the planted original defect.
**`--probe-decode` described a different device from the one that streams.** The
RADV video-decode opt-in sat AFTER the --list-adapters/--probe-decode/--list-audio
/--pair early exits, so the triage tool never had it. Measured on a Deck
(canary e22af40f), same binary back to back: bare `--probe-decode` printed
"vulkan video decode: no", "driver decode ops: none (0x0)", "no queue family
advertises VIDEO_DECODE"; with RADV_PERFTEST=video_decode in the environment,
"YES" and "H.264, H.265, AV1, VP9". Any Deck triage that consulted it reached
the opposite of the truth. Hoisted to the top of `run`, ahead of every early
exit — nothing touches Vulkan before it (`main` calls `run` directly).
Gates, in the Linux container: fmt, plain `cargo build` (not only
--all-targets), `clippy --all-targets -D warnings`, and 185 tests.
The QAM has offered to update the client since 0.24, and on every Deck it has
answered "up to date" — including right now, with a client a day out of date.
The check asks flatpak for the remote's commit and compares it to the installed
one, and it named the app id with no branch: `flatpak remote-info punktfunk-origin
io.unom.Punktfunk`. The punktfunk remote publishes `stable` AND `canary`, so that
ref is ambiguous and flatpak refuses it — "Multiple branches available" — rather
than picking one. One branch INSTALLED does not help; the ambiguity is on the
remote. The call failed on every box, every time, and the failure returned
`available=False`, which the panel renders as good news. Hence: the plugin
appeared to update only itself.
Every query now names the ref in full, resolved once by `_flatpak_ref()` off the
exported tree (no subprocess — `_client_argv` is on the path of every headless
call). That resolution also carries the SCOPE, so a system-wide install is no
longer invisible to a check that hardcoded `--user`, and the launcher pins the
same `--branch=`, so the client we start is the client we check and update.
A check that cannot run now says so instead of reporting up-to-date: the flatpak
leg reports `client_error` exactly as the native leg already did. Dressing that
failure up as good news is the whole reason this went a week unnoticed.
Also: the button no longer promises "+ client" when the client is manual-only and
the tap can only print a command.
Verified on the Deck (192.168.1.253, canary, user scope) by running both code
paths against the real install, minutes apart:
pre-fix available=False remote=''
post-fix available=True remote=ca010668 (installed e22af40f)
and `flatpak {info,remote-info,update}` all accept the `id//branch` form there.
37 backend checks pass, 6 of them new and about exactly this.
Wave-2 PW5 stage 2, on the number stage 1 just made visible.
`build_dmabuf_buffers` set `SPA_PARAM_BUFFERS_dataType` and stopped there — no
`SPA_PARAM_BUFFERS_buffers` at all, so the pool depth the whole zero-copy safety argument rests on
was entirely the producer's choice, and we never even expressed a preference. This asks for 8
(min 2, max 16).
A **Choice Range**, deliberately, not a fixed count. SPA intersects the consumer's and producer's
Buffers params, so a fixed 8 against a producer that can only afford 4 empties the intersection and
the link stalls in "negotiating" with no error anywhere — the exact trap that cost this codebase
the entire Linux cursor channel once, when a 256^2 cursor-meta max failed to intersect Mutter's
fixed 384^2 offer. With a range the producer clamps into it and negotiation still succeeds; the
min stays at 2 so nothing that works today stops working.
The numbers, and what they are not: 8 buffers is ~133 ms of pool at 60 Hz and ~33 ms at 240 Hz,
well past the ~3-4 ms capture-to-fence latency PW3/PW4 measured, with room for a second frame in
flight. 16 is a ceiling rather than a request — a 4K 4:4:4 buffer is ~25 MB, so 16 of them is
~400 MB of compositor allocation. These are the values we ASK for; what a producer actually
allocates is what stage 1's census line reports, and that line is the one to trust.
Scoped to the dmabuf pod only. The mappable and SHM-only builders are untouched: their consumers
copy out of the buffer inside `.process`, so pool depth is not part of their correctness argument.
A test pins the pod SHAPE — Choice, Range, Int children, values default-first — so a later
simplification cannot quietly turn the range back into a number and take the negotiation down with
it.
Gates green at CI parity; on-glass negotiation on each producer is stage 2's own gate and is
reported with the stage-1 census numbers.
Wave-2 PW5 stage 1, and the one stage with no risk at all.
The zero-copy capture path dups the dmabuf fd, publishes the frame, and hands the SPA buffer
straight back to the producer at `.process` return — while the encode thread has not yet imported
it, let alone read it. The code says so itself ("content stability across the brief import/encode
window relies on the compositor's buffer-pool depth, like any zero-copy capture"). That depth is
therefore load-bearing: it is the ONLY thing standing between us and the producer overwriting a
buffer mid-read.
And it had never been measured. Not logged, not asserted, not even requested — `build_dmabuf_buffers`
set `SPA_PARAM_BUFFERS_dataType` and nothing else, so whatever the producer picked is what we got,
silently.
This adds the `add_buffer`/`remove_buffer` stream callbacks PipeWire has always offered and logs the
count once per distinct depth: `pool_depth`, `high_water`, and the latest-frame-only `drained`
count beside it. One line per session on a stable pool (`.process` runs at the capture rate — an
unconditional log would be 240 lines a second of the same number), a second line if a
renegotiation changes the depth.
`high_water` is tracked separately from `live` because a renegotiation frees the pool before
re-allocating it: any decision keyed on the live count would read that dip as "the pool shrank".
`remove` saturates at zero rather than wrapping, so an unmatched remove cannot report `u32::MAX`
buffers.
Measurement only — no behaviour change, and no consumer of the number yet. PW5's later stages need
it (a deeper encode pipeline widens the overwrite window by a full frame period), but the number is
worth having regardless of whether those stages ever land: it is the answer to "is our zero-copy
capture actually safe on this compositor", and until now the honest answer was "nobody knows".
3 tests pin the once-per-depth logging, the renegotiation dip, and the saturating remove.
Gates green at CI parity.
Closes PW4's one remaining gap. The Steam Deck switched to Desktop Mode gives KWin on RADV, the
combination none of the earlier legs covered, and it reports no implicit fence like every other:
gamescope + NVIDIA (RTX 5070 Ti) NoFence
Mutter + NVIDIA (RTX 5070 Ti) NoFence
gamescope + RADV (Deck VANGOGH) 300/300 NoFence, mean 23us, p99 <=100us
KWin + RADV (Deck desktop) no fence (older build's wording: waited=false)
That is every compositor x vendor this fleet has. PW4 retires with no outstanding doubt rather
than "probably fine except one box we never tried".
Measured with the Deck's OWN already-authorized binary rather than a scratch build, because KWin
grants zkde_screencast_unstable_v1 per EXECUTABLE PATH: it resolves /proc/<pid>/exe against a
.desktop's Exec= and caches the grant on first connect, so an unregistered path is refused outright
and registering one needs a re-login. The fence probe is pre-existing capture-path code, so a build
from July answers the outcome question perfectly well — and nothing of the user's was modified to
get it.
Comment-only; no behaviour change. fmt + pf-capture clippy -D warnings green.
Field report on 0.25, iOS: "no audio over Bluetooth ... plays through speakers
if Mic input is enabled".
Both halves are one bug. `micEnabled` and `echoCancel` both default to true
(EffectiveSettings.swift), so the DEFAULT iOS session is `.playAndRecord` — and
that branch set `.defaultToSpeaker`. That option is not the polite preference it
reads as: it is an output OVERRIDE, and it outranks an A2DP route. Wired
headphones beat it, Bluetooth does not, so a cable is the one way to test it and
get the right answer — which is what the comment sitting on it asserted
("headphones/BT still win"). Every Bluetooth listener on the default settings got
the phone's own speaker instead. Turning the mic off was the accidental
workaround the reporter found: that path takes `.playback`, which routes to A2DP
happily and always did.
The earpiece problem `.defaultToSpeaker` was reaching for is real —
`.playAndRecord` really does park the built-in output on the receiver. So solve
it against the route we were ACTUALLY given rather than pre-emptively: after
activation, if the current output is `.builtInReceiver`, override to the speaker;
anything external (Bluetooth, wired, CarPlay, AirPlay) is left strictly alone.
That override is a property of the current route — iOS drops it whenever the
route changes, which is exactly what lets a newly-connected headset win — so it
has to be re-applied per route. Hence the route-change observer: without it,
dropping Bluetooth mid-stream would hand the game to the earpiece. Registered
only for a `.playAndRecord` session (a `.playback` one needs no steering),
removed in stop() before the session deactivate, with deinit as a backstop.
Deliberately NOT adding `.allowBluetooth`: it would make a headset's mic usable,
but buys that by dragging the whole route onto HFP/SCO and collapsing game audio
to narrowband. High-quality A2DP output plus the built-in mic is the better trade
for a game-streaming client.
Verified: builds clean on arm64-apple-ios17.0 (the triple that actually compiles
these `#if os(iOS)` blocks — a plain `swift build` is macOS and skips them),
arm64-apple-tvos17.0, and macOS; 257 Swift tests pass, 0 failures.
On-glass iPhone + Bluetooth listen still owed.
The 2026-08-05 review made `launch.kind = "command"` operator-only, and a reconcile refuses
on the FIRST offending entry — so rom-manager, whose every ROM is `<emulator> <args> <rom>`,
stopped putting anything in the library at all. Playnite hit the same wall and was rescued
with a typed kind the host resolves itself; there is no fixed scheme for "whichever emulator
the operator configured, with the core and flags they chose", so that trick does not
generalise.
So the entry now carries an opaque key and nothing executable, and the host asks the plugin
that owns it what to run — at launch time, over the loopback UI port and per-boot secret it
already registered. A stolen plugin token stops being command execution: planting an entry is
not enough, because the live plugin answers 404 for a key it never published. Nothing
executable is persisted or served to a client, and an emulator that moved is picked up on the
next launch instead of leaving a dead tile (the same reasoning as `xbox` resolving its AUMID
at launch time).
The host still SPAWNS it, because only the host can put the process where the stream can see
it: on Linux the line is either gamescope's own argv or a spawn carrying the session's
compositor env, and the returned child is what session-game-lifetime tracks to know the game
exited. A plugin spawning the emulator itself would land it outside both.
- library/plugin_launch.rs — the ask: blocking ureq, bounded body, absolute cwd, no control
characters, and a log line for every way it can come back empty
- library/launch.rs — `plugin_recipe` tried before both per-OS resolvers, plus
`launch_is_resolvable` so the async handshake probe never makes the blocking call
- native.rs — the session's `resolve_launch` moves onto `spawn_blocking`
- plugin-kit — `serveUi({launch})` serves `POST /__launch`; and `SyncError` finally renders
its cause, which is why a host refusal with a fully explanatory 403 could reach a plugin's
own UI as nothing but "Decode error"
An Apple TV session had no way to the stats overlay at all. Every other client
cycles it in-stream — Ctrl+Alt+Shift+S on the desktops, a three-finger tap on
touch — and tvOS has neither a keyboard nor a screen to tap, so the only route
was Settings before connecting (or a profile). The docs' own "cycle with" table
simply had no row for it.
Two surfaces, because an Apple TV may have a controller in the room or only the
remote:
- Select + X on a controller, cycling one tier per completion. Built like
Android's mic chord (Select + Y) and deliberately disjoint from the escape
chord — X is none of its four buttons, so reaching for one can never trip the
other. Read off the wire mask like the escape chord, so a Select the
hold-Select gesture has turned into a guide can't cycle the overlay on its way
past. Available on every Apple platform: a controller in both hands is exactly
the case the keyboard combo and the three-finger tap can't serve.
- Hold Play/Pause on the Siri Remote. Its right-click is therefore deferred until
the press resolves — a tap still right-clicks, delivered on release with the
release trailing by TAP_PRESS — because a right button held for half a second
is a context menu on every desktop this streams.
A non-forwarding slot now claims the stats chord's elements too, alongside the
escape chord's: on tvOS an unclaimed button's press stays the system's and the
chord would silently never complete.
Tests pin both chords' masks against their GameController alias lists, that the
two overlap only on Select, and that the claim list covers both without
duplicates — the failure mode is nothing happening, with nothing logged.
Wave-2 PW7a: a PyroWave session on a proven-jumbo LAN should START at the big shard, because it
is the one codec that can never be re-keyed mid-stream (its client parses chunk-aligned AUs in
windows of the `Welcome` value, read once over the C ABI). At an 8908-byte shard that is ~6×
fewer datagrams per frame — ~49k → ~8k pps at 550 Mb/s — and proportionally less window-tail
padding.
THE BLOCKER FOUND FIRST: the whole jumbo leg was dead code, not just the missing half. quinn
caps a peer's MTU-discovery search at `min(MtuDiscoveryConfig::upper_bound, the OTHER side's
advertised max_udp_payload_size)` (`quinn_proto::connection::mtud::SearchState::new`), and
`EndpointConfig::max_udp_payload_size` defaults to 1472. Nothing in the repo had ever touched
`EndpointConfig`, so raising the host's PROBE ceiling — all `stream_transport_idle` did — could
never make discovery settle above 1472, and the shipped mid-session grow's
`settled >= sealed_datagram_bytes(target)` gate was unreachable on every path that has ever
existed. Two smaller contributors, fixed here too: the watcher stopped sampling the moment
`settled >= 1472`, discarding the very climb the proof needs, and a session sealed ABOVE the
1500-byte default was never checked against the path at all.
The advertisement is raised on the CLIENT endpoint, under the same `jumbo_wire_mtu()` opt-in as
the probe ceiling, because it is not free: quinn sizes its endpoint receive buffer
`max_udp_payload_size × max_receive_segments × BATCH_SIZE`, so on a GRO-capable Linux/Android
client that is ~2.9 MiB at the default and ~18 MiB at jumbo (47 KiB → 288 KiB on Apple/Windows).
Consequence: jumbo now needs the opt-in on BOTH ends. Without it, every byte on the wire and
every byte of buffer is exactly what it was.
WHY THE GROW IS AS SAFE AS THE CLAMP, which is not obvious — the failure modes are opposite. A
stale clamp only makes datagrams smaller than they had to be; a stale grow seals an oversized
datagram onto a 1500-byte path, where it is silently dropped, and a PyroWave session cannot
recover from that for its whole life. Mirroring the clamp's keying is therefore NOT sufficient.
So the memory is demoted: the persisted verdict only decides whether it is worth WAITING for a
proof, and what authorises the grow is a LIVE re-proof on the very connection being welcomed —
`conn.stats().path.current_mtu` ≥ the sealed target, i.e. a datagram of exactly that size acked
by this client, on this connection, seconds ago. The moved laptop cannot inherit anything: its
new path's live MTU is 1472 and the grow does not happen, whatever the memory says.
The remembered half is keyed strictly anyway — `(local_ip, peer_ip)`, so a verdict earned over
the host's 10 GbE NIC does not apply to the same peer over Wi-Fi or a VPN — and carries the
operator target it was proven under plus a 6 h TTL. It is erased by any contrary evidence: a
lower settle, a session that ended before the window closed (what a client staring at black
does), a changed opt-in, or a constrained-path clamp that disagrees.
The proof-wait is on the bring-up critical path (`handshake.rs` sends the `Welcome` and only
then kicks the display prep), so it is bounded at 300 ms, exits the instant the proof lands, and
is entered ONLY for a path a previous session already proved. Its worst case is the moved
laptop, and that is self-limiting: that session's watcher erases the verdict.
MEASURED, NOT ARGUED: `mtu_discovery_climbs_only_as_high_as_the_peer_advertises` (`#[ignore]`d,
loopback — whose own MTU is 64 KiB, so configuration is the only thing that can stop the search),
on .21:
leg A (server opted in, client NOT): settled at 1472 B UDP payload <- the dead-code proof
leg B (both opted in): reached 8972 B in 5 ms <- the fix, and its speed
Leg A is the finding restated as an experiment. Leg B says the climb costs ~5 ms once both sides
advertise it, so the 300 ms proof-wait is ~60x the loopback convergence time — enough headroom
for a real LAN's RTT and per-probe ack delay across the ~11 probes the search takes.
Still owed: the A/B on a real jumbo LAN segment (9000-MTU NIC + switch on both ends) — pps per
frame, wire/pin ratio, and a PyroWave session observed starting at 8908. Not runnable without
the hardware.
PW6 shipped behind a knob because one pre-registered risk was unmeasured: a
streamed frame whose FINAL block is lost has no totals, so where the whole-AU
path hands the consumer a usable blurred partial, a streamed frame may deliver
nothing. PyroWave clients opt into partial delivery unconditionally, so this
would have been a live behaviour change for every one of them. Measured now,
three ways, instead of reasoned about.
`tools/loss-harness` gains a partial-delivery leg: FEC pinned OFF, chunk-aligned
AUs, deliver_partial ON, realistic 1408/200 geometry, and AU sizes swept across
the whole 1..=200-shard range of FINAL-block sizes — because the final block's
size is what bounds the exposure. Loss is injected per packet from a seeded
xorshift rather than through `loopback_drop_period`, whose deterministic 1-in-N
would systematically always-or-never hit the final block, which is the entire
question. `tc netem` on `lo` was deliberately not used: the in-process model
gives exact per-frame attribution, needs no sudo, cannot disturb a box running a
live desktop session, and — decisively — can drop precisely the final block.
Leg 1, deterministic (drop exactly the last block, 200 frames): whole-AU
delivers 200 partials and 0 losses; streamed delivers 0 partials and 200 total
losses. The trap is real and, when it fires, total.
Leg 2, random loss, 20 000 frames per cell, same seed and sizes for both shapes.
At 2 % the two are indistinguishable — 20000/20000 partials and ZERO vanished
frames on both, matching the analytic bound E[loss^k] over final-block sizes k
(~1e-4). The gap only appears at 30 % (99.94 % vs 100 % rescue) and 50 %
(99.79 %). `complete` is 0 throughout by construction: with FEC off and ~500
packets per AU, essentially every frame is damaged — which is the regime the
partial path exists for.
The spike gains `--wire-chunk` and a streamed loopback path, so the wire shape
is reachable end to end outside a real client: `poll_chunk` drains the AU,
`begin_streamed_frame_at`/`seal_streamed_chunk`/`seal_streamed_finish` seal each
piece, and the client byte-compares the reassembly. On 120 real PyroWave AUs the
streamed legs (56.5 and 2.0 chunks/AU) and the whole-AU control emit a
byte-identical 47 373 568-byte stream with 0 mismatches — the cut changes the
wire shape and not one byte of content, and with the knob unset it does not
engage at all.
A new `#[ignore]`d GPU test closes the picture question on real hardware with a
BUSY card (gradients + checker + noise), never a flat fill: chunks are whole
windows, exactly one `first` and one `last`, the AU decodes through the client's
own window walk, and luma PSNR lands at 40.2 dB. Unset the knob and the test
refuses to run, which is the default-off claim verified rather than asserted.
Verdict recorded in the plan: KEEP IT OFF. The 2 % tie is an argument about
typical loss, but the failure is not graceful when it fires and the measured win
is host send-side pipelining that nobody has yet put a millisecond number on.
PW6 was gated on one question: what happens to the client's newest-wins
draining when a PyroWave AU arrives in pieces, given that
`Session::set_deliver_frame_parts` refuses to combine with an all-intra
stream. The answer is that the doc and the plan conflated two different
axes, and the question never applied to this package.
Host STREAMED_AU chunks change only the WIRE shape. The reassembler
completes such a frame exactly like a whole one (`block_count != 0 &&
blocks_ok == block_count`) and hands up ONE Frame, so the frame channel
still sees one entry per AU and the drain is untouched.
What newest-wins genuinely cannot survive is the client's SEPARATE prefix
delivery, and the mechanism is sharper than "assumes whole AUs" said:
`FrameChannel::pop` counts QUEUE ENTRIES and takes one entry to be one AU.
With parts on, one AU pushes several, so `len > 1` stops meaning "the
consumer is behind" — the drain fires mid-AU, returns a SUFFIX and clears
that same AU's prefixes. For PyroWave that is fatal rather than lossy: the
sequence header lives in window 0 of every AU (`au_dims` reads it there), so
every frame would arrive headerless, and `FramePart`'s own orphan contract
would have a correct consumer abandon essentially all of them. Written into
`pop`, `set_deliver_frame_parts` and the handshake, together with what a fix
would take (skip whole SUPERSEDED AUs, never split one).
That answer shrinks what this package may claim, so the code says so
plainly. `encode_frame` is synchronous: the whole AU exists before the first
chunk can be polled, so `poll_chunk` is not "emit as produced" and there is
no encode/send overlap here (PW6 ⟂ PW5, confirmed). And with the client
still receiving one whole Frame there is no decode-while-arriving either —
the "~7 ms, decouple e2e latency from AU size" framing needs client work
this commit does not do. What IS left is real and host-side: the whole-AU
path FEC-protects, packetizes and seals the entire ~830 KB AU before its
first datagram may leave the socket, while the streamed path seals and paces
each FEC block as it completes.
All of the cutting lives in the shared `pyrowave_wire` helper, which
compiles and unit-tests on every platform, so both backends' `poll_chunk` /
`supports_chunked_poll` are thin delegations — the Windows backend cannot be
compiled from a Linux box, and logic written into it directly would ship
unverified. Chunks are whole numbers of framing windows because `build_au`
gives each window exactly ONE kind; that also makes them shard-aligned for
free, which is what the sealer's sentinel bases require. Dense mode never
streams (no window framing to cut on). `poll()` now errors while a chunk
cursor is live — the trait's one-drain-method-per-AU contract, where
double-emitting would put the same bytes on the wire twice under one frame
index — and `reset()` drops the cursor so a rebuild cannot splice a dead
AU's tail onto a fresh one. No new Encoder trait method, so neither the
TrackedEncoder forwarding trap nor the EncoderCaps default trap is in play.
Shipped OFF: `PUNKTFUNK_PYROWAVE_STREAMED_AU=1` arms it,
`PUNKTFUNK_PYROWAVE_CHUNK_KIB` tunes the 256 KiB target. The pre-registered
partial-delivery trap is real and now has a named cost — an unpinned
streamed frame (final block lost) is excluded from partial delivery, where
the whole-AU path still hands the consumer a usable blur, and PyroWave
clients opt into partials unconditionally. The netem loss-harness leg is the
prerequisite for default-on and has not been run.
Each gamescope-backed session left a coredump behind. It happened after the
compositor had finished its work — "Primary child shut down!", then the crash —
so the stream itself looked fine and it surfaced only as a steady drip of
coredumps and a non-zero exit from the spawn.
It is a static-destruction-order bug, not a race and not anything gamescope does
wrong at runtime. `g_device` (CVulkanDevice) and `g_output` (VulkanOutput_t) were
plain globals, so glibc ran their destructors from `__run_exit_handlers` once
main() returned. Those destructors call back into the driver —
`~CVulkanCmdBuffer` -> `vk.FreeCommandBuffers`, `~CVulkanTexture` -> `vk.Destroy*`
— but the Vulkan ICD has already been torn down and unloaded by then, so each
call jumps through a function pointer into an unmapped page. The faulting address
equalling the instruction pointer is the signature:
#0 0x00007fe8fd1d1070 in ?? ()
#1 CVulkanCmdBuffer::~CVulkanCmdBuffer at rendervulkan.cpp:1543
#9 std::vector<unique_ptr<CVulkanCmdBuffer>>::~vector (g_device+1792)
#10 CVulkanDevice::~CVulkanDevice at rendervulkan.hpp:768
#11 __run_exit_handlers / exit()
Patch 0006 gives both globals storage that is constructed exactly as before but
never destroyed; a union member is destroyed only if the union's destructor says
so, and ours deliberately does not. Nothing needs freeing there — the process is
exiting and the kernel reclaims the device, its command buffers and every GPU
allocation. Both objects are needed: pinning only the device relocated the fault
into ~VulkanOutput_t.
The `.pfhdrN` level deliberately stays at 4. It is a capability tier the host
probes before it spawns, and this patch adds no capability — bumping it would
advertise a tier that does not exist. Per the PKGBUILD's own rule this ships as a
`pkgrel` bump instead.
Verified on an NVIDIA box, all six patches `git am`-ing onto the pinned upstream
commit and then a RELEASE build (the shipped configuration):
version banner 3.16.25-7-gea635c1+pfhdr4 (marker intact)
patched, real spawn shape (2752x2064@120 --steam --xwayland-count 1)
6/6 exit 0
distro control, same shape
SIGSEGV
Not filed upstream, though it is not punktfunk-specific and would apply as-is.
Unrelated and left alone: `--xwayland-count 0` dies much earlier, in main() at
wlserver.cpp:3215, dereferencing a null `gamescope_xwayland_server_t`. Punktfunk
always spawns with `--xwayland-count 1`, so that path is never taken here.
Wave-2 PW1's exit criterion, and the instrument it needed.
VAAPI and direct NVENC both log a PUNKTFUNK_PERF submit split. PyroWave did not — which meant the
single encoder the GPU-priority work exists to defend was the one you could not put a number on.
Adds per-frame timing of the synchronous encode (whole `submit`: CSC + encode + fence wait +
packetize, which for this backend IS the encode), summarised every 2 s as mean/p50/p99/max.
p99 rather than mean-only on purpose. The failure patch 0005 describes is a TAIL event — frames
going ~2 ms to 15-18 ms at 95 % game load while the mean barely moves — so a mean-only readout
would report "fine" straight through the thing being measured.
WHAT IT MEASURED — .21, RTX 5070 Ti (610.57.04), GRID 2 benchmark loop saturating the GPU at
54-87 %, PyroWave 1080p, same binary both arms (only CAP_SYS_NICE differs), 30-frame windows with
the warm-up window dropped:
arm p50 p99 worst frame
default priority (refused) ~2.6 ms ~6.4 ms 9.5 ms
REALTIME granted ~3.2 ms ~4.4 ms 5.4 ms
REALTIME granted (repeat) ~3.35 ms ~4.8 ms 5.1 ms
p99 down ~30 %, worst frame roughly halved, for ~0.6 ms on the median. For a streaming encoder
that is the right side of the trade — the tail is what becomes a visible hitch.
This CONTRADICTS the patch's only prior datum (RTX 4090 / Windows / WDDM: "did not reduce the
spikes"), so patch 0005's header now records the Linux/NVIDIA result beside it, with an explicit
"do NOT delete this patch on the strength of the WDDM result — the two stacks disagree". Header
prose only; the diff hunks stay byte-identical and `git diff crates/pyrowave-sys/vendor/` is
untouched by this commit.
Caveats recorded rather than buried: the arms were not interleaved and the game load drifted
between them, capture was frame-starved (~2.5 fps) so this is encode latency under contention and
not a full-rate stream, and it is two granted runs against one refused run. The direction held
across all 25 windows.
Also worth knowing for anyone repeating this: `encode_fps` is a VACUOUS metric on this rig. A
headless gamescope with no real content emits ~12 fps, so both arms simply report the capture rate.
Measure latency, not throughput.
Gates green at CI parity.
60-punktfunk.rules chgrp's the usbip vhci attach/detach nodes to a dedicated
`punktfunk` group (security-review 2026-08-05 M-4: writing `attach` materialises
an arbitrary emulated USB device, so it must not ride on `input`). Four of the
six install paths shipped that rule in 0.25.0 without ever creating the group.
chgrp then failed, the nodes stayed root:root 0644, and the virtual Steam Deck
pad silently never attached — while `usermod -aG punktfunk` failed outright with
"group 'punktfunk' does not exist".
Affected and fixed:
* arch — post_upgrade() called only _ensure_update_group, so every box that
reached 0.25.0 by `pacman -Syu` missed it; post_install was correct.
* nix — no users.groups.punktfunk at all, though host.users' own description
already promised the usbip/vhci pad. Declares it now and adds
host.users to both groups.
* bazzite sysext — a group is host state and cannot ride an image, and the
deb/rpm scriptlets that would create it never run there.
* steamdeck install.sh/update.sh — handled `input` only. Both now create the
group and join it: running that script IS the statement "make my
Deck a host with native pad passthrough".
deb and rpm were correct throughout (one postinst/%post for install + upgrade).
Also on the Deck path: web.env secret hygiene. install.sh's `chmod 600` sat
inside the create-only branch despite a comment calling it "the idempotent belt
for a pre-existing file", and update.sh never touched the config dir at all — so
an install set up once and only updated since kept web.env world-readable
(0644) with the console password and session secret in it. Both scripts now
harden ~/.config/punktfunk to 0700 and web.env to 0600 on every run, and say so
loudly, because a chmod does not un-leak an already-readable secret: the
password still needs rotating.
Both group blocks are `if ensure_group ...` rather than `ensure_group || true`:
a failed groupadd must not fall through to a usermod against a nonexistent
group, which under `set -e` aborted install.sh after the long build and
update.sh before the service restart (verified: exit 6, no restart).
Docs: the group is now documented where people actually look — the per-distro
guides, install.md, steamos-host.md, a new troubleshooting entry for "pad
arrives as an Xbox 360 controller", and the uninstall pages. The 0.25.0 notes
gain the "group does not exist" caveat and turn the password bullet from
"consider rotating" into a real instruction, and CHANGELOG records the known
issue against the breaking change that introduced it.
Verified: bash -n on all four scripts; the arch scriptlet's post_upgrade driven
in a container (creates the group, idempotent on re-run); the ensure_group
helper and both membership branches, including a control that reproduces the
original bug (chgrp to a missing group leaves the node root:root 0644); the
find -perm /0077 probe across 0644/0640/0604/0600/0400 on GNU findutils;
`nix flake check --no-build` (the exact CI gate) and a NixOS eval showing
alice.extraGroups == ["input","punktfunk"]; docs-site build + typecheck.
`PUNKTFUNK_COMPOSITOR` is documented as "which backend to drive", but it also
quietly discarded `game_session=dedicated`: `resolve_compositor` gated the
dedicated route on `!overridden` and logged nothing either way. A host whose pin
was a forgotten validation leftover therefore went on displaying "dedicated" in
the console while every launch landed in the desktop instead — for 30 days on the
box that surfaced this, the only evidence being the ABSENCE of a log line.
The pin still wins, since it is the operator's explicit hand-set knob, but it now
says so and names itself.
Two further holes the same triage turned up:
- The pin put its backend into `available()` unconditionally AND skipped
`apply_session_env`'s `XDG_CURRENT_DESKTOP` scrub, so `pick_compositor` could
never return `None` — the one place `try_recover_session()` is called from. A
pinned host whose gnome-shell had segfaulted therefore spent every connect on 8
doomed `RemoteDesktop.CreateSession: ServiceUnknown` retries while the
operator's configured `PUNKTFUNK_RECOVER_SESSION_CMD` sat unreachable behind
that arm. Liveness is now read on both paths, and a pin aimed at a dead session
takes the recovery exit with an error naming the pin. `needs_live_session()`
exempts gamescope, which stands its own session up — pinning it on a headless
box stays supported.
- A mode switch accepted before the pipeline existed was served the long way
round: build at the now-stale mode, then immediately rebuild at the new one in
the stream loop. That burns a display create, capture attach and encoder open
on every such connect, and because the rebuild is deliberately
create-before-drop it stands up two Mutter `RecordVirtual` monitors ~400 ms
apart — which segfaults mutter 50.4 inside `meta_monitor_manager_rebuild` and
takes down the whole desktop session, along with the game just launched into
it (so the GAME looks like what crashed). Bring-up now adopts the newest queued
mode and builds once, carrying over the H2/H3 correction ack that the replaced
rebuild would have sent.
Verified on a real Linux host (192.168.1.21, x86_64): `cargo clippy --workspace
--all-targets --locked -- -D warnings`, `cargo fmt --all --check` and the
punktfunk-host + pf-vdisplay test suites all clean. The gate was proved
non-vacuous against a planted `compile_error!`.
`update.sh --pull` could abort with "Your local changes to the following files
would be overwritten by merge: web/bun.nix" — before a single service was
restarted — and the only way past it was to delete the file by hand.
The updater did it to itself. web/bun.nix is generated (bun2nix, a pure
function of web/bun.lock) but committed, because the Nix build fetches
node_modules only from it. The web step ran `bun install --frozen-lockfile`
without --ignore-scripts, so web's `postinstall` (`bun2nix -o bun.nix`)
rewrote that tracked file on every update. Harmless while the committed file
is in sync — but main carried a stale web/bun.nix from 1db8f763 to b79d90b4,
so any Deck updated in that window had it rewritten to the *correct* content
and has been sitting dirty ever since. The SDK step has always passed
--ignore-scripts, which is why only web/bun.nix ever went dirty.
Two changes, both in install.sh and update.sh:
* the web install now passes --ignore-scripts and runs `bun run codegen`
explicitly. web has two install lifecycle scripts and we want exactly one:
`prepare` IS `bun run codegen` (orval + paraglide + the i18n check) and is
required, since src/api/gen, src/paraglide and src/routeTree.gen.ts are
gitignored and `prebuild` only re-runs orval; `postinstall` is the one that
writes a committed file. Equivalent to the old behaviour minus bun2nix.
* --pull restores web/bun.nix and sdk/bun.nix before pulling, which unsticks
the installs already broken out there. Deliberately NOT `git reset --hard`:
$SRC is the operator's own checkout and may carry real local work, so a
still-dirty tree now fails with a message that names the files and the way
out instead of git's raw abort. Discarding these two is provably lossless —
regenerating them from the lockfiles is exactly what bun2nix does.
CI already gates the drift that made this visible (scripts/ci/check-bun-nix.sh,
ci.yml), so main cannot ship a stale bun.nix again.
Wave-2 PW4's outcome. The package proposed moving the producer-fence wait off the PipeWire loop
thread, and was pre-registered to be ABANDONED if the wait turned out to already be free. It is,
on every producer and vendor measured — including the one where implicit sync actually exists.
Steam Deck, RADV VANGOGH, gamescope producer (built in distrobox pf2, run on the host):
samples=300 mean_us=23 max_us=48 p50=<=100us p99=<=100us
signaled=0 no_fence=300 timed_out=0 failed=0
p99 in the first bucket is the plan's own abandonment condition, and the outcome split explains
why: 300 of 300 buffers reported NoFence. Same on both NVIDIA producers (gamescope and Mutter's
virtual output — the exact no-explicit-sync case the comment cites as the reason the wait exists).
So `wait_read_ready` here is one ioctl and a return, not a block. Moving it to the consumer side
would buy nothing measurable and would take on the hazard the package itself names — a slot holding
a not-yet-ready dmabuf, and `repeat_last` re-waiting a fence it already consumed. Not a trade worth
making for 23 microseconds.
The 100 ms budget stays: it guards a producer that DOES fence, which is a real thing even if
nothing in this fleet does it. KWin/AMD is the one combination still unmeasured, and the histogram
from the previous commit is deliberately kept as the way to re-check — run with PUNKTFUNK_PERF=1
and read the p99 bucket.
Comment-only; no behaviour change. Gates green at CI parity.
Wave-2 PW4, step one of one-so-far. The package's own first line is "investigation step first
(measure, then decide)", and it is pre-registered to be ABANDONED if the wait's p99 is ~0 — so the
instrument ships before the change, not after.
A per-session histogram of `wait_read_ready`, taken on the PipeWire loop thread, which is exactly
where the wait is expensive: that thread is the compositor's consumer, so time blocked there delays
buffer recycling for the NEXT frame. Logged under PUNKTFUNK_PERF at the same cadence and gate the
encode backends use for their submit splits, so a perf run reads as one instrument: samples, mean,
max, p50/p99 bucket, and the Signaled/NoFence/TimedOut/failed split.
Buckets are coarse on purpose (100us -> 10ms, plus overflow). The decision this feeds is binary —
a p99 in the first bucket means the wait is already free and PW4 becomes a comment correction; a
p99 past 1ms is a real stall against a 16.6ms frame budget. Edges are placed so those two worlds
cannot be confused, and anything past the last edge reports as overflow rather than clamping into
the top bucket ("worse than 10ms" is a distinct finding).
The outcome split sits next to the timings because "the wait is short" and "there is nothing to
wait for" are different results with different consequences, and one data point already shows the
second: on gamescope/NVIDIA the probe reports NoFence, i.e. that producer attaches no implicit
fence at all.
5 tests pin the arithmetic, including that an empty histogram reports "no answer" rather than a
decisive-looking zero — the failure mode that would retire the package on no evidence.
No behaviour change: the wait still happens where it always did. Gates green at CI parity.
Wave-2 PW3.
The raw-dmabuf passthrough has two very different reasons to switch itself off, and they shared one
`AtomicBool`:
* the encoder repeatedly failed to import what this compositor allocates — unrecoverable, a
driver fact, and the reason this latch was written (it stops the encode-stall recovery
rebuilding the same doomed encoder five times and then ending the session, on every connection,
forever);
* the dmabuf-only capture offer never negotiated — which can simply mean the compositor was
mid-restart.
Sharing the flag made the second as permanent as the first. One timeout, and EVERY later session on
the host captured CPU frames until the process was restarted — including sessions against a
completely different compositor and a different node, which had never failed at anything. Nothing
said so; the arm line PW2 added would have shown `cpu` with no explanation.
Now the two causes have the lifetimes they should have, in a `RawDmabufLatch` that owns both:
* Import failures stay sticky. Unchanged threshold (3 consecutive), unchanged hazard coverage.
* Negotiation timeouts get a retry budget of 2 — one retry, deliberately small: each failure
costs a ~10 s stall, so a larger budget is paid by the user in dead air. One retry survives the
mid-restart transient; a compositor that genuinely never accepts keeps the same identity, so it
latches on the second try, one extra stall per host lifetime versus the old behaviour.
* A capture that negotiates credits the budget back, so an evening of reconnects against a
compositor that failed once cannot accumulate its way into a latch.
* BOTH are keyed to a capture identity (node id + portal bit). A new node — fresh virtual output,
compositor restart, the Bazzite Gaming↔Desktop switch — is a genuinely different question and
earns a fresh dmabuf attempt instead of inheriting a verdict about something else. The SAME
capture keeps its verdict, which is what preserves the 10 s-stall protection the latch exists
for.
The session-open line now carries the latch state, so `cpu` is no longer ambiguous between "this
host was never going to do dmabuf" and "something failed earlier and we are still living with the
verdict" — only the second is a bug worth chasing, and only the second is now visible as one.
Atomics rather than a lock because `note_import_ok` is on the per-frame import path; everything
else runs at pipeline build or on failure. The state machine is tested against a local instance
rather than the process-wide static — seven tests covering both lifetimes, the identity clear, the
same-identity hold, the budget credit, and the cause naming.
One honest note on the identity: it is the PipeWire node id, not the "(compositor-id, modifier
list)" pair the design sketched. Node id is what capture actually has at that point, and it changes
on exactly the events that matter here (new virtual output, compositor restart, session switch).
Keying on the modifier list too would need the list before the importer is built, which is the
wrong order.
Until now the patched gamescope reached exactly four kinds of box: the
Bazzite/Fedora-Atomic sysext, the Arch package, the SteamOS installer and a NixOS
option. Everyone else was told to build gamescope from source. A traditional
Fedora-family box — Nobara, plain Fedora, the HTPCs people actually stream from —
therefore ran stock gamescope by default, which streams SDR, cursorless, and
tells every game its display is 60 Hz. That is not a user error; there was no
package to install.
Both new packages REPACK the binary CI already builds rather than building
gamescope again: it is a ~10-minute meson compile of an unrelated tree, cached
per distro base because the binary is soname-coupled to it. The Arch PKGBUILD
stays the one recipe that builds from source, because that is what makepkg is
for.
- packaging/gamescope/punktfunk-gamescope.spec + build-gamescope-rpm.sh. Version
is derived from the binary's own banner (3.16.25.pfhdr4) — the only source that
cannot drift from what is in the package. rpmbuild's automatic ELF Requires are
what stop an f43 build installing on f44.
- packaging/debian/build-gamescope-deb.sh, same shape, with dpkg-shlibdeps for
Depends.
- rpm.yml packages and publishes it beside the host RPMs; deb.yml gains a cached
gamescope build (keyed on packaging/gamescope/** alone) and packages it into the
existing publish loop. Both legs are best-effort, matching the sysext's existing
rule: no binary, no package, and the host stays on its current SDR path.
Neither package Provides or Conflicts with gamescope — it installs as
/usr/bin/punktfunk-gamescope and only the sessions the host starts itself resolve
it, so a box's own Game Mode keeps using the distro binary.
Both refuse to package a binary without the +pfhdr marker. That marker is the
host's entire capability probe, so a build that lost the patches would install
fine and then silently stream SDR with no cursor.
Verified: build-gamescope-deb.sh produces an installable .deb from a stand-in
binary (correct version derived from the banner, 0755 tree, control fields) and
exits 1 on an unmarked one. The .spec is not yet exercised — no rpm tooling on
the box I had; CI's Fedora leg is its first run.
Pass --custom-refresh-rates (patch level 3+) and
--pipewire-composite-external-overlay (level 4+) on both spawn paths, with the
same probe-then-pass shape the HDR and cursor flags already use. A stock
gamescope has neither flag and gets neither, which is exactly today's behaviour.
New knob PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120 widens the set a session
offers in Steam's in-session display settings. The rate the session actually runs
at is always included, so it can only add options; junk entries are skipped
rather than failing the host, because the worst a typo can cost is the extra
option the operator wanted.
And the part that would have turned a week of field triage into one log line:
warn_if_mode_lost(). --nested-refresh is the ONLY refresh a headless gamescope
has, and it reaches a gamescope-session-plus solely through the GAMESCOPE_BIN
wrapper, which the session script is free to lose — a sessions.d file sourced
with `set -a` can reassign GAMESCOPE_BIN, and one that sets GAMESCOPECMD outright
skips the whole builder. When that happens the stream still runs, still looks
right, and the client's own fps counter still reads the negotiated rate (the
encode loop repeats the held frame), while the game underneath is capped to 60.
Nothing anywhere said so.
It warns rather than refusing, deliberately: verify_managed_spawn_flags refuses
because its retry resolves a different plan, but a relaunch here would hand the
session the same environment and lose the mode the same way, so refusing would
only loop. Fails open on the same rule as the flag check — nothing to compare
against says nothing.
Also corrects the comment above the launch env, which claimed
CUSTOM_REFRESH_RATES "generates the mode the session ADVERTISES … what makes
games see the real refresh". It never did: no upstream gamescope has
--custom-refresh-rates, so gamescope_has_option gated it off and the variable was
inert. That belief is why the real lever went unexamined.
configuration.md gains the new knob and a warning on PUNKTFUNK_MAX_FPS, which
also lowers the refresh the session REPORTS on gamescope — the docs said it does
not cap the stream, which is true of the wire and not of what games are told.
Linux-verified on Ubuntu: cargo check --all-targets, clippy -D warnings, 133
tests (2 new), cargo fmt --check.
Two new patches on the pinned upstream, and the marker patch moves last so the
banner is stamped after the capabilities it advertises.
0003 — headless: advertise the virtual display's mode and refresh rates.
A headless gamescope is how we give a game a display: we pass the client's exact
mode and the session runs at it. It never told anyone. CHeadlessConnector
returned empty spans from GetModes() and GetValidDynamicRefreshRates() and
reported GAMESCOPE_SCREEN_TYPE_INTERNAL, so update_mode_atoms DELETED the
mode-list atom (no resolution list) and wlserver fell through to a one-entry
refresh list built from g_nOutputRefresh (no refresh list). With --nested-refresh
absent that entry is Init()'s 60 Hz default — which is why a field report on a
1920x1080@120 client saw "gamescope only shows 60hz, and there's no other
option", and why Overwatch capped itself to 60 while the stream ran at 120.
Populate both from the resolved mode, report EXTERNAL, and add
--custom-refresh-rates so the offered set can be widened. gamescope-session-plus
has probed for that flag for years; upstream never had it, so the
CUSTOM_REFRESH_RATES env it plumbs was a no-op everywhere.
0004 — pipewire: optionally composite the external overlay into the capture
stream. That layer is mangoapp: the fps/frametime readout the Deck UI turns on.
paint_pipewire has never referenced it on any version, so a consumer whose only
view of the session is the node sees the overlay it just enabled not appear, with
nothing to configure. Behind --pipewire-composite-external-overlay, off by
default, same argument as the cursor flag. Its commit id joins the repaint test —
the numbers change while the picture behind them is static, exactly the case the
existing test skips.
Verified: the series git-am's cleanly onto the pinned 8c676c39, and both new
functions were extracted verbatim and compiled with -Wall -Wextra under C++23
against stubs, with unit assertions for the parser and the mode/rate publication
(sorting, dedup, the running rate always present, re-entrancy, zero rejected).
A full gamescope build was not run — no box here has its dependency set; CI's
per-Fedora-major leg is the first real compile.
Wave-2 PW1, second half. The companion commit wires `PYROWAVE_QUEUE_PRIORITY` into the Linux
PyroWave device; this is what makes it work on a packaged host.
Measured on .21 (RTX 5070 Ti, NVIDIA 610.43.02), same binary in both arms:
as packaged (no capability) every class refused, REALTIME *and* HIGH -> default priority
same binary, cap_sys_nice+ep granted REALTIME on the FIRST attempt, no downgrade
RADV behaves the same way. So this is not the RADV-specific "expect one downgrade to HIGH" the
plan predicted — without the capability there is no elevated priority at all, on any vendor, and
the knob is decoration.
Worth being precise about what is being granted, because it is a network-facing daemon.
CAP_SYS_NICE permits raising scheduling priority (nice, ioprio, affinity, RT class) and nothing
else: no filesystem access, no network privilege, no user switching, and it is NOT setuid. The
repo already ships exactly this capability on its gamescope binary for the same reason. Two side
effects that will otherwise confuse someone debugging: a capability-carrying binary is AT_SECURE,
so the loader ignores LD_LIBRARY_PATH/LD_PRELOAD for it (note this box was propped up by exactly
such a shim during the ffmpeg-9 soname break — that workaround would now be silently ignored), and
core dumps are suppressed by default.
Per packaging path, because none of them are the same:
- Arch: a `_grant_sched_capability` in the scriptlet, called from post_install AND post_upgrade —
a replaced binary is a new inode, so the capability does not survive an upgrade by itself.
- Debian: the same setcap in the postinst `configure` branch.
- RPM: `%caps(cap_sys_nice=ep)` on the binary in `%files`, which is the rpm-native form — rpm then
applies it on install, restores it on upgrade, and verifies it. A `%post setcap` does none of
those.
- NixOS: `security.wrappers`, because a store path is read-only and shared and cannot be setcap'd.
The unit's ExecStart moves to `config.security.wrapperDir` — without that the wrapper exists and
the service still runs the uncapped store path, which is the whole failure this fixes.
- Steam Deck: setcap in the installer's sudo block. That box needs it most (one small Van Gogh GPU
shared between the game and the encode). The binary lives under $HOME, so unlike the /etc
drop-ins it survives a SteamOS A/B update on its own and needs no atomic-keep entry — but it
does need re-applying after each rebuild, which re-running the installer does.
- Bazzite sysext: at IMAGE BUILD time, before mksquashfs. It cannot be done in the merge hook (a
merged sysext's /usr is read-only squashfs) and it cannot ride in from the RPM either — rpm keeps
capabilities in its own header and `rpm2cpio | cpio` carries only the payload, so the staged file
arrives with none. mksquashfs does record security.capability (only security.selinux is
excluded), so a setcap on the staging tree is what lands in the image. Needs root/CAP_SETFCAP;
a plain-user CI build warns and ships without it rather than failing a release over a
performance lever.
Every one of them is best-effort and cannot fail an install: a box without libcap, or a filesystem
that cannot store capabilities, simply runs at default priority exactly as it does today.
Documented in the same PR — the configuration row now says the packages grant it, and
running-as-a-service gets a section explaining what it is, how to check it (`getcap`), and how to
remove it (`setcap -r`, or just `PYROWAVE_QUEUE_PRIORITY=off`), including the two debugging side
effects.
Verified: the Arch scriptlet grants the capability from a fake package root exactly as pacman
would invoke it, and the resulting binary reaches REALTIME end to end on the RTX 5070 Ti; the RPM
spec's %caps line parses under rpmspec in a Fedora 41 container; the NixOS module parses under
nix-instantiate; all five edited shell scripts pass `bash -n`. No Rust file changed in this
commit, so the CI-parity Rust gates from the companion commit still stand.
Wave-2 PW1, first half = Wave-1 WP14 step 4, executed as specced.
PyroWave encodes on the same GPU shader cores a game saturates, and that is measured to hurt:
patch 0005's header records `encode_gpu_synchronous` going from ~2 ms to 15-18 ms at 95 % game
load, with the stream frame rate collapsing. NVENC is immune because it has its own ASIC. The
lever for a compute workload is an elevated global-priority QUEUE — a process-priority raise only
reorders submission, not hardware preemption.
The vendored patch requests exactly that. It is gated `if (!inherit_info)`, and only Windows
leaves `inherit_info` null (`pyrowave_create_device_by_compat`, where Granite builds the device
itself). Linux passes its own create-infos into `pyrowave_device_create_info`, Granite's
`get_existing_create_info()` hands them back, `create_device` takes the inherit branch — and the
whole block is skipped. On Linux the knob has never done anything at all. Meanwhile pf-zerocopy's
VkBridge has shipped the identical ladder on Linux for some time and calls it "the actual NVIDIA
compute-preemption lever"; the encoder that needs it most did not have it.
This wires it natively in `open_inner`'s `DeviceHold`:
- The extension probe reuses the `dev_ext_props` already fetched for queue_family_foreign, and
takes KHR or the EXT alias — the same spelling pf-zerocopy probes, so the two cannot disagree.
- `queue_priority_candidates` is a pure fn with the grammar copied from the C patch: unset →
realtime, ASCII-lowercased, `off` alone disables, `high` asks for HIGH only, junk falls back to
the ladder rather than to off. One env var must not mean two things on two platforms — that is
the documentation trap this package exists to close — so the grammar is unit-tested against the
patch's, including where they are both deliberately un-clever (neither trims).
- The create ladder is REALTIME → HIGH → no-priority, stepping only on a refusal. A refused class
can never fail the open, which matters more here than on Windows: this path is reached only by a
NEGOTIATED PyroWave session, so a hard error is a dead stream, not a fallback to another encoder.
The subtle part is the write-back. `pyrowave_create_device` RETAINS `device_create_info` for the
device's lifetime and Granite reads the chain back. If the ladder ends on the no-priority attempt
while `_queue_ci[0].p_next` still points at the global-priority struct, Granite is handed a chain
the device was not created with. The `None` arm therefore nulls `p_next` before the final create,
and the field's doc says why. The enabled extension deliberately STAYS in the list: it really is
enabled on the device, it just carries no request.
One deviation from the plan, stated because it is a deviation: the ladder also steps down on
`ERROR_INITIALIZATION_FAILED`, not only `ERROR_NOT_PERMITTED_KHR`. The plan and the C patch handle
only the latter; pf-zerocopy's shipped ladder accepts both. Given a hard error here kills a
negotiated session, treating one extra driver-specific refusal as a downgrade is the cheap side of
that asymmetry.
Also corrects the two vendored notes, which claimed a Linux behaviour the gate made impossible,
and records that patch 0005's negative RTX-4090 result is Windows/WDDM and does not transfer to a
different driver stack. Patch hunks are byte-identical (header prose only) and
`git diff crates/pyrowave-sys/vendor/` is PUNKTFUNK-VENDOR.txt alone.
`PYROWAVE_QUEUE_PRIORITY` is now reachable on Linux, so it is documented in the same PR.
MEASURED ON GLASS, and it changes what this package is worth on its own — .21, RTX 5070 Ti,
NVIDIA 610.43.02, same binary in both arms:
as packaged (no capability) every class refused, REALTIME *and* HIGH -> default priority
same binary, cap_sys_nice+ep granted REALTIME on the FIRST attempt, no downgrade
So the lever is INERT on an unprivileged host, and that is not the RADV-specific downgrade the
plan predicted — on NVIDIA it is a downgrade to nothing at all. The ladder itself is proven good
across all three legs (unset / high / off): a refused class never fails the open, and `off`
enables no extension and logs nothing. It simply has nothing to grant yet.
The privilege needed is CAP_SYS_NICE on the host binary, which is NOT what Wave-1 WP3 ships
(RLIMIT_NICE, PAM limits, CPUWeight — all different things). That grant is a security-posture
change on a network-facing daemon, so it is deliberately NOT in this commit; the warn line now
names the capability so an operator is not left guessing, and the docs row says the setting has no
effect on most hosts today rather than implying it works.
The loaded-GPU encode_us p99 A/B is therefore not run: it needs a GPU-saturating game (hence a
desktop session the box does not currently have) and it is pointless before the capability lands,
since the unprivileged arm has no priority to measure.
NO unit test is possible for the device-create ladder itself — it needs a real Vulkan device. Its
coverage is the clippy pass, the grammar tests, and the on-glass log line. Stated here rather than
left for a reviewer to wonder about.
Opening a library source's settings did nothing, for every library plugin. Confirmed on
`.21` against the running console:
console origin :47992 /plugin-ui/lutris/__config -> 404
plugin origin :47993 /plugin-ui/lutris/__config -> 401
The drawer fetches a RELATIVE `/plugin-ui/<id>/__config`, so it resolves against the
console's own origin — where `middleware/auth.ts` answers 404 for `/plugin-ui/**`
unconditionally and by design. That refusal is the 2026-08-05 review's origin split
(H-3): plugin UIs moved to their own listener, and neither origin may serve the other's
paths. The drawer is the only consumer of `/plugin-ui` that is NOT an iframe — every
other caller builds an absolute URL from `pluginOriginFrom(uiConfig)` — so it was the
one thing the split broke, and nothing failed loudly enough to notice.
The fix is deliberately not to point the drawer at the plugin origin. That needs CORS
plus cross-site cookies, and it would put a plugin-controlled response inside a
credentialed cross-origin fetch — reopening exactly the hole the split closed. What
this drawer needs is DATA, not an embedded UI: `/api/plugin-config/<id>` reads the
plugin's `__config` server-side over loopback and returns the JSON same-origin, so no
plugin markup or script is ever served from the console origin and the per-boot secret
stays on the server, as with the `/plugin-ui` proxy.
`/api/**` is always session-gated (`isPublicPath`), so the new route inherits the gate
and answers 401 as JSON rather than redirecting to /login — which is what a `fetch`
needs and what the old path could never give it. It forwards only GET and PUT, reads
the body BEFORE the stale-credential retry (`readRawBody` drains the stream, so a
retried PUT would have saved `{}` over the operator's config), and passes the plugin's
own body through untouched so a 400's decode issue still reaches the operator.
Verified against the real built server: `/api/plugin-config/lutris` answers 401 — the
route resolves and is gated, and the BFF catch-all at `api/[...]` does not swallow it —
while `/plugin-ui/lutris/__config` still answers 404 on the console origin, i.e. the
split is intact. `/api/v1/status` still reaches the BFF. tsc clean, production build
clean, i18n 633 messages across en+de, biome clean on both touched files (the one
warning in SourceSettings.tsx pre-dates this change).
Found while taking PW2's on-glass measurement, and it is what made the measurement possible.
`spike` built its capture request from `OutputFormat::resolve`, the constructor shared with the
GameStream path, which hard-codes `pyrowave: false` ("GameStream never negotiates PyroWave").
On Linux that flag is not cosmetic: `capture_virtual_output` feeds it to `zero_copy_policy` as
`ZeroCopyPolicy::pyrowave_session`, which is what puts the capture on the raw-dmabuf passthrough.
So `--codec pyrowave` opened a PyroWave encoder over a capture negotiated for a different
consumer, and the only way to exercise the real path was the host-global
`PUNKTFUNK_ENCODER=pyrowave` lever.
That lever cannot stand in for the per-session flag, which is the part that matters here: it
resolves the backend to `Pyrowave`, and `linux_zero_copy_is_vaapi_for` returns true for that —
so it ALSO flips `backend_is_vaapi` on. A per-session PyroWave negotiation on an auto/NVENC host,
where `backend_is_vaapi` is false, was therefore unreachable from the CLI — and that is exactly
the configuration whose CPU downgrade logged nothing at all.
The spike now sets the flag from its own codec, the same comparison `session_plan::output_format`
makes for a real session. With it, the before/after on .21 is unambiguous: origin/main logs zero
capture-path lines on that configuration, this branch logs two (the resolved arm, and the named
downgrade with its cause and fix).
Publishing `@punktfunk/host@0.1.3` — the release that lets a library scanner register
`category`, so Lutris and Heroic stay out of the console nav — reached **no existing
install**. Measured on `.21`: the only thing that moved it was deleting `bun.lock` by
hand over ssh. A fix that needs an ssh session is not a fix.
**Why nothing reached it.** Every plugin resolves the SDK from the plugins tree, and
`bun.lock` pins it to an exact version with an integrity hash. Nothing in any
user-facing flow re-resolves that pin: installing a plugin, reinstalling it, and even
updating it to a newer release all leave the SDK alone, because the plugin's `^0.1.x`
range is already satisfied by what is locked. `bun update` does not help either — the
plugins are pinned exactly in the root manifest, so there is no direct dependency to
update through.
**Where the fix belongs.** The runner. It is bundled from this same `sdk/` at the
host's release commit (`packaging/arch/PKGBUILD` builds `src/runner-cli.ts` into the
punktfunk-scripting package), so `SDK_VERSION` is by construction the SDK matching the
host now on disk. A host upgrade is therefore the one moment that can carry an SDK fix
to already-installed plugins, and now it does — before any plugin loads, and with no
operator action at all.
**Why it re-resolves the whole lockfile** rather than pinning the SDK at the root: a
targeted `bun add @punktfunk/host@<v>` does NOT work while plugins declare the SDK in
their own `dependencies` (all six scanners do, though none import it). bun honours
their locked resolution and gives each a private nested copy that then SHADOWS the
root — measured, 5 nested copies, which is how I first "fixed" the box while leaving
every plugin still importing 0.1.2. A lockless resolve hoists one copy for everyone.
Once the plugins drop that spurious dependency this can become the targeted form.
Safety, because this runs unattended at boot on a tree the operator's plugins load
from: plugin versions are pinned exactly in the root manifest so a re-resolve cannot
move them (verified — lutris stays 0.1.0); the lockfile is backed up and restored if
the install fails or fails to deliver; and every failure is logged and swallowed, so a
dependency refresh can never stop working plugins from starting. The no-op path is the
one that runs on every healthy box, so it is tested first: same version, or no SDK at
all, touches nothing and logs nothing.
The SDK is bumped to 0.1.4 because its published content changed. Republishing 0.1.3
is impossible, and letting source drift from a published version is precisely the
defect that produced this whole chain — 0.1.2 was published before it forwarded
`category`, then the source changed underneath it without a bump. `version.test.ts`
fails if `SDK_VERSION` and `package.json` ever disagree.
Verified end to end on `.21` against a tree seeded from the operator's real pre-fix
backup: 0.1.2 → 0.1.3 automatically, one hoisted copy, no nested copies, plugin
versions preserved, and a second run is a silent no-op. SDK 79 tests pass (5 new),
typecheck clean.
The control-device sharing contract was 'bare HANDLE copies, never
closed for the process lifetime': retired handles were deliberately kept
alive because the pinger/linger threads and the capture delivery
closures held raw copies whose soundness depended on no-close. The cost
surfaced in the 2026-08-08 field log: after a wake left the driver
hostless, every adapter reload came back REFUSED (Generic failure) —
and an open control handle is exactly what vetoes the PnP disable (and
can wedge the pnputil restart) the recovery leans on.
reset-pf-vdisplay.ps1 stops the whole host service precisely to get
those handles closed; the in-process recovery could not, because the
process could never close them.
Ownership is now Arc all the way out: ensure_device/device_handle/
control_device_handle hand out Arc<OwnedHandle> clones, every consumer
holds its clone across its IOCTLs (the capture closures each own one —
Arc<OwnedHandle> is Send+Sync, ending the isize smuggling), and
retiring drops only the manager's reference, so the handle CLOSES when
the last in-flight user drains. DeviceSlot::retired is gone. The
recovery path now releases the manager's reference at the first absent
sighting — the 3 s ABSENT_SETTLE doubles as the drain window — and
again before a not-ready-deadline reload, so the PnP cycle finally runs
against a device the host is no longer holding open.
The driver attaches no meaning to the control file closing (host-gone
is the IOCTL-liveness watchdog, EvtFileClose deliberately unhooked), so
the close has no driver-side side effects. Lock order note: RECOVERY →
device is now taken (the release hooks); the forbidden inverse still
never occurs — VdisplayDriver::open never reloads.
Wave-2 PW2 (design/linux-host-performance-wave2-pyrowave.md). Observability only — no
behaviour change to any capture decision — and it lands first because every later package
in the program is measured by an A/B whose "before" is currently unreadable.
The defect: the capture path's CPU-fallback warning was gated on `backend_is_vaapi`, which
reads the HOST-GLOBAL encoder pref. A PyroWave session is negotiated PER SESSION, so on an
NVIDIA/auto host that gate is false — and the session then fell out of every arm of the
negotiation log chain, emitting nothing whatsoever while paying a full-resolution CPU pixel
touch on every frame. A degraded host and a healthy one produced identical logs.
Four sites, matching PW2.1-2.4:
1. The CPU-path warning now asks the per-session question (`consumer_kind`) instead of the
pref, and names the consumer. Its gate widened to every GPU consumer and excludes only
the software encoder, whose native input IS CPU frames — an NVENC session silently on
the CPU path is the same defect, not a different one. `pyrowave_session` deliberately
outranks `backend_is_vaapi`, because a PyroWave pref flips `backend_is_vaapi` on too
(`linux_zero_copy_is_vaapi_for`'s `Pyrowave` arm), so testing vaapi first would swallow
every PyroWave session.
2. The raw-passthrough block in `consume_frame` had four silent exits — no format, an
SHM/MemFd buffer, no DRM fourcc, a failed `F_DUPFD_CLOEXEC` — each falling out of three
nested `if`s into the CPU de-pad path. It is now a labeled block that breaks with a named
`PassthroughFallback`, logged once per distinct reason per session with a running count,
so a persistent downgrade is distinguishable from a hiccup at renegotiation. `.process`
runs per frame, so the rate limit is the shippable part and is what the tests pin.
Note `NoFormat` does NOT fall back — the CPU path needs `ud.format` too and returns — so
the line says DROPPED for that one. Three of four downgrade; one loses the frame.
3. `force_cpu_for_nvenc_444` told a 4:4:4 PyroWave session it was "on the NVENC path", which
is false in every particular: the wavelet encoder never touches NVENC, never swscales to
YUV444P, and what it actually loses is the raw-dmabuf passthrough its design assumes.
4. One INFO line at pipeline build states the resolved arm and consumer
(`capture pipeline resolved: dmabuf-passthrough → pyrowave`). Nothing stated it before;
the 2026-08-08 triage reconstructed it from four files, and for the arm that matters most
there was no detail line to reconstruct it from.
Also: `spike --codec pyrowave`, so a PyroWave capture→encode pass can be driven without a
client. That is the harness the rest of this program measures on, and it did not exist.
Gates on .21 at CI parity: fmt, workspace clippy -D warnings, pf-encode clippy with
nvenc,vulkan-encode,pyrowave and without, workspace tests.
Field log 2026-08-08 (0.25.0, wake from sleep): every session died on
'the adapter devnode could not be reloaded (Generic failure)' — the WMI
catch-all — because the REFUSED branch reported only the Disable
exception and threw away everything that would identify the failure
mode: why the pnputil /restart-device fallback ALSO failed (its exit
code — 3010 'needs a reboot' is its own diagnosis), what state the
devnode was in, and whether the right devnode was even targeted.
That last one is a real trap, not just missing telemetry: Get-PnpDevice
lists not-present PHANTOM devnodes (upgrade/reinstall leftovers), and
Select-Object -First 1 could hand every recovery attempt a phantom —
whose disable and restart both fail exactly like the field log — while
a live node sat unexamined. The selector now prefers present nodes (OK
before problem-state), and a phantom-only state gets a truthful
refusal: no reload can revive a devnode record whose device is gone;
only reinstalling re-creates it.
The REFUSED line now carries devnode counts, the chosen node's PnP
Status + ConfigManager problem code, and the restart exit code, so the
next field log decides between handle-veto, phantom, and problem-state
instead of reading 'Generic failure'. Decode pinned by test.
Four changes to the client interface, kept together because two of them touch the same rows
and the last is a bug the first would have made far more visible.
A thirteenth `ui_palette` entry, `oled`. The palette table is hand-mirrored in three languages
(`pf-console-ui`'s `library.rs`, `GamepadPalette.swift`, `GamepadPalette.kt`), so it goes into
all three at index 1, directly after the brand default — which keeps `PALETTES[0]` the unknown-id
fallback and keeps the dark-to-pale cycling order intact. What earns the name is arithmetic, not
a darker shade of violet: the ramp's first two stops are literally (0,0,0) and the ground is pure
black, so the shaded half of the field is pixels switched off rather than "very dark grey", and
the calm mix the form screens sit under lifts toward nothing at all. Mean cell luminance is 0.019
against Violet's 0.254. The bright corner keeps a faint indigo-to-violet ember so the backdrop is
still a field with somewhere to go, and that ember carries enough chroma at that luminance
(60 degrees of hue travel across 13 of the 16 cells) to satisfy the existing multi-tone assertion
without adding `oled` to the near-neutral exemption Graphite and Opal take. Each port gains an
`oled_is_actually_black` test that measures the claim — pure-black corner cells, a mean under half
the darkest other field's — rather than restating the table.
A new device key, `gamepad_ui_mode`. The gamepad-UI switch had been deciding two things at once:
whether to offer the controller-optimized interface at all, and that it appears only while a pad
is attached. A user asked for the second half to stop applying. `"connected"` (the default, and
exactly what the lone Bool meant) and `"always"` separate them, surfaced as a "Show it" row
directly under the switch on all five settings surfaces and built only while that switch is on —
a picker whose every option decides nothing is worse than no picker. `GamepadUIEnvironment.isActive`
takes the mode with NO default argument on purpose: a call site that forgot it would silently
strand everyone who chose Always back on "only with a controller", which is the one bug this
parameter exists to make impossible. An unrecognized value waits for a controller, so a mode a
newer client wrote can never trap an older one in a layout it has no way back out of. It stays a
device preference on both platforms, never part of a profile: which interface this device wears
has nothing to do with how a host streams to it.
The smoothness buffer is hidden under Lowest latency, not dimmed. Everywhere else already hid it
— the GTK and WinUI shells, the Apple touch and tvOS screens, the Android touch screen — because
under that intent it names a quantity that does not exist. Two surfaces disagreed: Apple's gamepad
settings screen left the row live and steppable, and the desktop console dimmed it, having no way
to drop a row from a fixed list. That list is now rebuilt each frame through a `row_applies`
filter. The concern about a vanishing row moving everything under the cursor does not apply here
and the new test says why: the row it drops sits directly BELOW the row that drops it, so the only
cursor that can be present when the list shrinks is the one on the intent row, which does not
move. Two latent hazards went with it — `apply_row` had been indexing the row list on the
assumption the cursor is always in range, and nothing re-clamped that cursor when another writer
changed the intent behind the screen's back.
Pale palettes were unreadable on tvOS, reported from the field. `GamepadInk` was never the
problem: it flips correctly for a pale field, it is not platform-gated, and every tvOS gamepad
entry point already published it. The cause is that this app sets `preferredColorScheme` nowhere
and declares no `UIUserInterfaceStyle`, so every SYSTEM-derived colour landing on those screens —
a `.secondary` placeholder, a `.bordered` button's chrome, a NavigationStack title, a material's
frost — resolved against the DEVICE appearance, which the palette cannot reach. On iPhone, iPad
and Mac a great many users sit in Light mode, so under a pale palette those colours came out dark
and the theme looked correct by accident; an Apple TV is Dark essentially always, so every one of
them rendered white on a light field. The mirror image was broken too and had simply never been
reported: a dark palette on a Light-mode iPhone was already drawing dark on dark. The scheme is
now published beside the ink, once, in `GamepadInkModifier`, because the two are halves of one
decision and publishing only the ink silently loses every colour the frameworks draw on the app's
behalf. Two structural amplifiers went with it: `ConsoleGlass` had been scoping the scheme to the
fill inside its `.background {}` on the tvOS and pre-26 branches while the 26 branch put it on the
content, so no console row's own content ever saw it on tvOS; and `LibraryView`'s navigation
chrome and its loading, error and empty states sit above `LibraryCoverflowView` and so were never
inked at all on tvOS and macOS, where that view is presented directly rather than through the
iOS-only `GamepadLibraryScreen` wrapper.
That last one exposed a second tvOS gap worth closing in the same breath: `ui_palette` had no row
in tvOS's ordinary Settings, and the gamepad settings screen that owns it everywhere else needs an
extended-profile controller to open on tvOS. An Apple TV driven by the Siri Remote alone could not
reach the palettes at all, which would now include the OLED one. `SettingsView.tvBody` carries a
Background row.
Verified: pf-console-ui builds, passes `clippy --all-targets -D warnings` and runs 74 tests clean
under linux/amd64 (a Mac `cargo check` of that crate is vacuous — every module is cfg'd to
linux/windows); `cargo fmt --check` clean for it and pf-client-core. Android `:app` runs 80 tests
with 0 failures, including four new `gamepadUiActive` cases and the palette parity table. The
Apple package builds for macOS AND tvOS and its 9 palette/gamepad-UI tests pass — the tvOS
typecheck is possible because the checked-in xcframework already carries a `tvos-arm64` slice. The
tvOS RENDERING fix is compile-verified only; an on-glass Apple TV check under a pale palette is
still owed, and is the one thing here that a build cannot answer.
`sdk-v0.1.3` failed at the publish step with `bun2nix: command not found`, exit 127.
Nothing was published, so 0.1.3 is still free.
`bun publish` runs the `prepare` lifecycle script, and sdk's `prepare` is
`bun2nix -o bun.nix` — regenerating the nix dependency file. That tool is a
devDependency of the repo, not something the `oven/bun:1` publish container has, and
the workflow's own install is `--ignore-scripts`, so nothing put it on PATH either.
This was latent, not new. `prepare` gained the bun2nix call on 2026-07-27 (1db8f763,
"move the bun packages to bun2nix"), while the last SDK publish was 0.1.2, bumped
2026-07-20. So the hook has been broken for every SDK release since it landed, and
0.1.3 is simply the first one to try. `@punktfunk/plugin-kit` has no `prepare` and was
never affected, which is why kit 0.3.2 published fine in that window and hid this.
The fix is NOT to copy `web/package.json`, which does the same job from `postinstall`.
That is right for web — it is never published — and would be worse here: a published
package's `postinstall` runs in every CONSUMER's install, so every plugin depending on
`@punktfunk/host` would try to run bun2nix and fail. `prepare` is the correct hook for
a published package (it does not run for consumers); it just must not assume a
repo-maintenance tool exists wherever a publish happens.
So the script skips when bun2nix is absent — and ONLY then. A present-but-failing
bun2nix still fails the script, because swallowing that would publish with a silently
stale bun.nix, which is the exact hand-maintained-hash problem 1db8f763 set out to end.
Both directions measured against the same `sh -e` bun and the Gitea runner use:
absent → exit 0, present-and-failing → exit 3.
`bun publish --dry-run` now completes and reports `+ @punktfunk/host@0.1.3`.
The library had one visibility control and it was all-or-nothing: turn a SOURCE off
and every one of its games goes. There was no way to drop a single title — a Proton
tool the filter missed, a demo, a game someone doesn't want on the TV — short of
hiding the whole launcher it came from.
**Where the setting lives.** Not on the entry. Only manual custom entries are stored;
a scanner's and a plugin's titles are rebuilt from scratch on every scan and every
reconcile, so a flag written onto one would be erased by the next sync — silently, and
minutes later, which is the worst possible shape for a setting. So `library-hidden.json`
holds the ids, mirroring how `library-scanners.json` holds disabled sources. The id is
stable by construction (D2: a claimed store's entries keep `<store>:<external_id>`
across reconciles), so a hide survives a re-scan, a plugin restart, and a store's
built-in→plugin migration.
**Where it takes effect.** In `all_games`, which is the one place every play surface
already funnels through — the grid on a client, native clients, the GameStream app
list, and launch resolution. Putting it there rather than at each call site is
deliberate: a per-surface filter is a rule someone has to remember, and forgetting one
is precisely the class of bug the `file://` art asymmetry in the previous commit was.
Hiding is curation, not access control — nothing is deleted, and un-hiding is instant.
**The console is the one surface that still sees them**, or a hidden title could never
be brought back. That exception is a TYPE, not a flag: `GET /library` answers
`Vec<GameEntry>` on every lane but the operator's and `Vec<OperatorGameEntry>` on
theirs, so a hidden entry cannot reach a paired streaming client by someone forgetting
a filter — there is no field there to leak. `hidden` is skipped when false, so the
response is byte-identical to today's for a library with nothing hidden.
`PUT /library/hidden/{id}` is operator-only — neither the plugin lane nor a paired cert,
unlike the scanner toggle. A plugin has no business deciding what its operator sees, and
a client must not be able to hide a game on the host it is streaming from. The id is not
validated against the current library on purpose: a title can be legitimately absent at
that moment (launcher closed, plugin mid-sync, drive unmounted), and refusing the
operator's choice in that window is worse than storing an id that matches nothing today.
On the card, the poster dims and a Hidden badge says why — a faded tile with no label
reads as a broken cover. Its controls stay at full contrast and, unlike an ordinary
card's, are not hover-revealed: the un-hide button is the only way out of the state, and
hiding it behind a hover would strand anyone on a touch screen.
Verified on .21 (Linux): 469 host tests pass (5 new), clippy clean under `-D warnings`,
`cargo fmt --all --check` clean. The routing test is the one that earns its keep — every
library id contains a colon and Heroic's contain two, so a router that split on it would
404 the console against ids the host itself produced. Console: tsc clean, production
build clean, i18n 633 messages across en+de, biome clean on the touched files.
The env-var reference had fallen behind the v0.25.0 CHANGELOG table. Added, with
the semantics taken from the code rather than the changelog one-liners:
- PUNKTFUNK_JUMBO / PUNKTFUNK_WIRE_MTU (Network & discovery), with a note
explaining the ack-gated mid-session grow, the start-at-1500 behavior, the
NIC/switch prerequisites, and the sub-1500 shrink direction of WIRE_MTU
- PUNKTFUNK_AUDIO_QUALITY / AUDIO_REDUNDANCY / AUDIO_OUTPUT_MODE — the legacy
HOST_AUDIO / KEEP_DEFAULT rows are folded into the OUTPUT_MODE row as the
aliases they now are (follow_default wins when both are set)
- PUNKTFUNK_NO_AUDIO_MINT (Windows minted-endpoint opt-out)
- PUNKTFUNK_PAD_AUDIO / PAD_AUDIO_SLOTS (Gamepads — DualSense speaker+haptics)
- PUNKTFUNK_NVENC_SPLIT_ARBITRATE (Advanced performance tuning)
- PUNKTFUNK_UI_PLUGIN_PORT / PUNKTFUNK_LIBRARY_ART_ROOTS (Auth, API & paths)
- PUNKTFUNK_VAAPI_DEVICE (client-side table)
Verified against the actual read sites (pf-host-config, wire_mtu.rs,
config.rs jumbo_wire_mtu, pad_audio.rs, minted.rs, art.rs, bun-https.mjs);
the page's remaining vars all still exist in code. MDX-compiles clean with GFM.
The reap that keeps departed virtual monitors from exhausting the IddCx
monitor-slot budget launched pnputil by BARE NAME — under the LocalSystem
service's PATH that can miss System32, SilentlyContinue swallowed the
miss, and the Rust side logged only when the count was positive: a reap
that removed nothing and a box with no ghosts were byte-identical
(silence). Ghosts then ratcheted up with every sleep cycle until
IOCTL_ADD wedged at 0x80070490 and every session black-screened — and
the wedge self-heal shipped in 0.25.0 retried an ADD behind a reap that
could never remove anything, which is exactly a persistent post-sleep
"no connection" surviving the b6acbd09 probe fix.
Same family and same cure as the adapter-reload path one function down:
resolve pnputil via $env:SystemRoot (a SYSTEM process must not trust
PATH anyway — a planted pnputil.exe would run elevated), pre-seed
$LASTEXITCODE to failure before every launch, and report found AND
removed unconditionally so "no ghosts" and "removed nothing" are
finally distinguishable in a field log. The report parse is split out
and pinned by tests like classify_reload_output.
Three symptoms on .21, two defects. Lutris and Heroic appeared in the console sidebar
they explicitly opt out of; Lutris's settings were unreachable from the Library
screen; and Lutris and Steam logged `sync (startup) failed: HostRequestError`.
**The sidebar is a publish gap.** The console is correct — it keeps
`category: "library"` plugins out of the nav (`uiPlugins`, app-shell.tsx) — but the
host reports no category for them at all. `defineLibraryPlugin` sets it and
`sdk/src/ui.ts` forwards it; what SHIPS does not. `@punktfunk/host` was bumped to
0.1.2 on 2026-07-20 and `category` landed 2026-08-05 without a bump, so the registry's
0.1.2 is the pre-category build and every installed scanner registers without one.
Bumps the SDK to 0.1.3 — **inert until it is published**.
Because the field rides the untyped `pf.request` seam so an older host ignores it
rather than rejecting the registration, dropping it is silent by design. `serveUi` now
reads its own directory entry back and warns once when a requested category did not
land, the same way `defineLibraryPlugin` already warns when a store claim did not take.
That is what turns the next occurrence into a log line instead of a bug report.
**The missing settings and the failed sync are ONE defect: a write/read disagreement
about `file://`.** `local_art_bytes` decodes a `file://` value before testing
containment; `validate_art_paths` handed the raw value to `Path::new`, where
`file:///home/u/c.jpg` is a RELATIVE path whose first component is `file:`. It
canonicalized against the cwd, failed, and read as "outside every art root". So the
host refused every cover the kit's own `fileUrl` helper emits — the documented way for
a plugin to publish local art — while the read path would have served those same files.
That the two symptoms share a cause is not obvious and is why this is one commit: the
Library screen's settings control renders only for `origin: "plugin"`, and a source
becomes `plugin` only once it holds a store CLAIM, which is taken during a successful
reconcile. Lutris failed at entry 0 and Steam at entry 3, so neither ever claimed its
store, both stayed `origin: "builtin"`, and neither got a settings button. Heroic
reconciled (its art is http(s)) and has had its settings all along; rom-manager was
never affected because zero entries meant it never applied.
`art_path_is_servable` now decodes first, so both halves of the confinement judge the
same string. Confinement itself is unchanged: an out-of-root path is still refused in
`file://` clothing, which the test asserts alongside the accept case.
Diagnosing this took the HOST's journal, because both surfaces that should have
explained it lied. `HostRequestError` stringified to its bare tag, so the sync engine's
`${e.cause}` logged `HostRequestError` and discarded the method, the path and the
host's own message; it now renders all three, including an object-shaped cause that
used to print `[object Object]`. And the host logged "payload carries a field this lane
may not set" for BOTH refusals in `check_entry_fields`, so a 400 about an art path read
as an auth problem — it now logs the real reason and the entry title.
Verified on .21 (Linux): 463 host tests pass, clippy clean under `-D warnings`,
`cargo fmt --all --check` clean. The new art test fails without the fix and passes with
it. plugin-kit 71 and SDK 72 tests pass, both typecheck clean, biome clean.
A CachyOS / KDE Plasma 6.7.4 Wayland client with its 2560x1600@165 laptop panel at
150 % scaling negotiated 1706x1066 for "Native resolution" and streamed a visibly
blurry image. Two independent defects, and they stack — which is why forcing the mode
to 2560x1600 by hand did not fully fix it either.
1. `SDL_GetDesktopDisplayMode` reports a mode in SCREEN COORDINATES and hands the
pixels-per-point ratio back separately as `pixel_density`. We read `m.w`/`m.h` raw.
KDE advertises that panel as 1707x1067 points with a density of ~1.4997,
`render_scale::apply` even-floors both odd axes, and 1706x1066 goes on the wire —
exactly the mode in the reporter's handshake log. Multiplying by the density
recovers 2560x1600 to the pixel, because SDL derives it as the output's exact
pixels/points ratio. On X11 and Windows SDL never sets a density and `SDL_video.c`
normalizes the unset 0.0 to 1.0, so this is inert there: the bug needed a
compositor doing FRACTIONAL scaling.
2. The SDL window was created without `HIGH_PIXEL_DENSITY`, so the Wayland surface
stayed at buffer scale 1 — the Vulkan swapchain was built at 1707x1067 and KWin
upscaled it to the glass. Even a correct 2560x1600 stream was resampled down and
then back up. The same flaw silently shrank "Match window", which asks the host for
`size_in_pixels()`. The reporter's `SDL_VIDEO_WAYLAND_SCALE_TO_DISPLAY=1` workaround
is this same fix applied from outside SDL, which is why it helped.
The surrounding code was already written for pixels != points — the swapchain,
match-window and pointer mapping all read `size_in_pixels()` while window-size
persistence reads logical `size()` — so the flag only makes those two stop being the
same number. `display_scale()` starts reporting 1.5 into a swapchain that is 1.5x
larger, leaving the OSD the size it already was.
Also closes a smaller hole on the way past: only an `Err` from SDL reached the
1920x1080 fallback, so a display that reported a 0x0 mode sent a 0x0 request.
Verified on home-worker-5 (CachyOS — the reporter's distro, real SDL 3.4.14):
`cargo clippy --all-targets -p pf-presenter -- -D warnings` clean and 18/18
pf-presenter tests pass, three of them new and pinned to the field-reported numbers.
The 0.25.0 MacBook field report — audio jitter 'at certain points' — is the
jitter policy learning exclusively from audible failures, on both of its
sides. Growth needed THREE audible underruns before deepening the ring; the
A/V sync loop re-tested a shallower ring every five quiet seconds and paid an
audible starvation event every time it was wrong, forever; and a grown target
was never re-banked — growth raises a threshold, only a re-prime deepens the
ring — so a bunching link rode the knife edge, clicking once per bunching
period with the 'grown' target sitting inert. A ten-minute simulation of the
Wi-Fi power-save pattern (25 ms gaps / 300 ms, −50 ppm skew) measured ~2000
audible events under the shipped policy.
Three mechanisms, in JitterPolicy (Linux/Windows/Android) and mirrored in the
Swift AudioRing:
- NEAR-MISS: a read served with less than one protocol frame left over is the
same evidence as an underrun, heard by no one. It grows the target one step
per window, BEFORE the click — waiting for the third audible underrun means
the user heard two.
- SHRINK PROBES: every shrink is armed for five seconds; answered by an
underrun or near-miss it is undone on the spot, and a failed sync-driven
shrink is not retried for a doubling backoff (60 s → 8 min). A probe that
survives resets the backoff. Continuity outranks sync, now with a memory.
- HOLLOW RE-PRIME: an underrun while the depth AVERAGE runs more than a step
below the target re-primes immediately, spending the click it already cost
on the whole refill instead of limping. The average, not the instant, is
what separates a hollow ring from one late packet, and it is seeded on
prime so a fresh ring is never spuriously hollow.
Same simulation after: 9 audible events, tail clean but for the clock-skew
re-anchor (a genuinely slow host must re-bank every few minutes; only rate
adaptation would remove that, and no client has it). Neutralising the three
constants reproduces the ~2000 — the convergence tests fail against the old
behaviour.
Verified: 203 punktfunk-core tests, 254 Swift tests (5 skipped), clippy -D
warnings on punktfunk-core --all-features, cargo fmt --all --check.
The v0.25.0 rebuild published perfectly — registry has punktfunk-host 0.25.0-2 with
libavcodec.so=63-64, and it resolves on a real ffmpeg-9 box — then failed its last
step with
prune_release_assets: command not found
`. scripts/ci/gitea-release.sh` sources from the CHECKED-OUT TREE, and a release
rebuild checks out the OLD TAG. So the step could only ever see the helpers that
existed when that tag was cut, and the prune is gated on exactly that path: the
helper was guaranteed absent in the only case that calls it. Adding it to a shared
script made it look available at review time while being unreachable at run time.
Only the workflow file is read from the dispatched ref, so the logic moves there,
inline. Same reasoning documented at both ends, including the corollary worth knowing
before the next rebuild: a PKGBUILD fix made after a tag does NOT reach a rebuild of
that tag either — the packaging comes from the tag too.
Verified by executing the one-liner's exact bytes out of arch.yml under /bin/sh (the
shell Gitea actually uses): keeps the new -2 set and gamescope, drops the superseded
-1 packages and their .sha256 sidecars, leaves other legs' .dmg/.deb untouched. The
`'\n'` survives the shell quoting, which was the part worth proving.
Also drops the now-dead helper from gitea-release.sh rather than leaving a function
no caller can reach, and leaves a warning there against the next one.
Arch moved FFmpeg 8 -> 9 (every libav soname +1) hours before the release. PR #108
fixed the real bug — packaging/arch/PKGBUILD now binds punktfunk-host to the sonames
it actually linked, so pacman refuses an upgrade instead of bricking the install — and
re-keyed ci/arch-ci.Dockerfile so the builder would carry FFmpeg 9.
The tag was pushed four minutes later. arch.yml and docker.yml have no `needs:` between
them, and arch.yml deliberately runs no -Syu ("the image's snapshot IS the build
environment"), so the release build pulled the still-FFmpeg-8 `:latest` and published
punktfunk-host 0.25.0-1 depends: libavcodec.so=62-64, libavutil.so=60-64,
libavfilter.so=11-64, libavdevice.so=62-64,
libswscale.so=9-64
against a world that had moved to 63/61/12/63/10. It fails safely — pacman refuses,
nothing bricks — but it fails broadly: pacman prepares one transaction, so an
unsatisfiable dependency of OURS stopped affected users' entire `pacman -Syu`.
Nothing in the pipeline could have caught it. The existing assert proves the dep is
VERSIONED; it cannot prove the version EXISTS. So two guards, plus the lever to repair
a release that has already shipped:
* Preflight parity — compare the builder's libav `provides` against the live repos and
`-Syu` the container if they differ. The image is a cache and may lag; on this one
axis it may not. Syncs into a throwaway --dbpath so the container never sits in the
partial-upgrade state a bare `pacman -Sy` leaves.
* Publish gate — resolve every built package with `pacman -U --print` against a
PRISTINE --dbpath. Empty db means "nothing is installed", so every dependency must
come from the repos exactly as on a user's box. Resolving against the builder's own
installed set is what would hide this: a stale ffmpeg satisfies a stale bound.
gamescope stays best-effort (dropped from the upload with a warning, never fatal).
* workflow_dispatch(release_tag, pkgrel) — a published release cannot be repaired by
re-running its tag: pkgrel would stay 1, which is invisible to a box that already
recorded the broken build, and the workflow file at the tag can never carry inputs
added after it. Dispatched from main it takes the WORKFLOW from main and the SOURCE
from the tag, publishes to the stable repo at a higher pkgrel, and replaces the
release-page assets (prune_release_assets: upsert replaces by NAME, and a rebuild's
filenames differ, so the superseded package would otherwise stay one click away).
Verified on a real ffmpeg-9 box (.21, CachyOS) rather than reasoned about: the gate
rejects the published 0.25.0-1 host with the user-visible error verbatim, and passes
client, web, scripting and gamescope — 0 false positives across all five artifacts.
The parity snippet reads today's `provides` correctly (`-Si --dbpath` on an empty db
works; pacman does not wrap fields when piped). Version logic exercised on all four
paths: rebuild -> 0.25.0-2 stable, tag push and canary unchanged, pkgrel=1 refused.
Ships as punktfunk-host 0.25.0-2. README gains the pacman error and what to do about
it; CHANGELOG says plainly that 0.25.0's Arch packages were wrong.
`plugin-kit-v0.3.2` failed at its very first real step:
error: Duplicate package path
at bun.lock:71:5
InvalidPackageKey: failed to parse lockfile: 'bun.lock'
warn: Ignoring lockfile
error: lockfile had changes, but lockfile is frozen
`@punktfunk/host` was listed TWICE, byte-identically, at lines 69 and 71. I
introduced it: the lock had exactly one entry before 10a0ef32 and two after.
Running `bun install` to add the biome devDependency duplicated the `file:../sdk`
entry — the same `file:`-dependency lock corruption already recorded against the
web workspace's overrides.
Nothing else in the lock is wrong, so this removes the duplicate entry rather than
regenerating (a regenerate risks reproducing it, since the `file:` dep is the
cause).
Verified with the exact commands the publish workflow runs, in order:
`bun install --frozen-lockfile --ignore-scripts` (the step that failed) now
succeeds, then the file:-dep repair, `bun run check`, `bun run typecheck`,
`bun test` 67/67, `bun run build` — all clean.
No source change; 0.3.2 is unpublished, so the tag moves to this commit.
ffmpeg-next 8.1.0 could not accept FFmpeg 9 at all: ffmpeg-sys-next's version probe
covered avcodec majors 56..62 (the range is exclusive of its end), so libavcodec 63 fell
outside what it knew how to bind. 9.0.0 widens that to 56..63, which is what actually
unblocks Arch. Bump both pins — the unconditional Linux dep and the optional Windows
amf-qsv one — and the lock with them.
No API drift to fix. The crate major is a CEILING, not a target: one source tree still
spans FFmpeg 7.x/libavcodec 61, 8.x/62 and 9.x/63 via per-version cfgs, and every wrapper
symbol the NVENC-libav, VAAPI and amf-qsv backends name survives 8.1.0 -> 9.0.0
unchanged. The three hand-written #[repr(C)] hwcontext mirrors are the parts no compiler
checks, so they were re-read against the real headers rather than trusted:
AVCUDADeviceContext and AVD3D11VAFramesContext are byte-identical across 7.1/8/9, and
AVD3D11VADeviceContext gained two trailing UINTs in 8 that 7.1 lacks — which is why that
mirror deliberately stops at the common prefix, and why its assertions now say what they
do and do not buy you. They pin our layout, not libav's; a green build is not evidence.
The CI image is the step that makes this reach users. arch.yml deliberately runs no -Syu
("the image's snapshot IS the build environment"), so the builder stayed frozen on ffmpeg
8 no matter what Arch shipped, and a canary built from that snapshot could not satisfy the
soname dep the PKGBUILD now derives. Re-keying ci/ rebuilds it against ffmpeg 9.
Ubuntu and Windows deliberately stay put: the noble .deb bundles its own FFmpeg 8 behind
an rpath and strips the libav sonames from its Depends, and Windows bundles BtbN DLLs into
the signed installer — neither is exposed to the break, BtbN publishes no FFmpeg 9 build,
and moving either would re-qualify an encode stack to buy nothing.
Verified end to end on 192.168.1.21 (CachyOS, system ffmpeg 2:9.0-5, RTX 5070 Ti): host
builds clean and links libavcodec.so.63/libavutil.so.61/libavfilter.so.12/libswscale.so.10
with no unresolved sonames; the ffmpeg-8 compat shim is gone and the service runs with
NRestarts=0 and answers 401 on :47990; pf-encode's 67 tests pass; and a live synthetic
encode drives real NVENC hardware through FFmpeg 9's libavcodec to a decodable 1080p HEVC
stream (180/180 frames, FEC loopback 0 mismatches) with libavcodec.so.63 and
libnvidia-encode both mapped into the encoding process.
`depends=('ffmpeg' ...)` carried no version bound, and pacman is the only one of our
packaging formats that does not derive dependencies from ELF DT_NEEDED — rpm
auto-generates `libavcodec.so.62()(64bit)`, dpkg-shlibdeps emits `libavcodec62`, nix
pins the closure. So when Arch shipped ffmpeg 2:9.0-5 on 2026-08-08 and every soname
moved (libavutil .60->.61, libavcodec .62->.63, libavfilter .11->.12, libavdevice
.62->.63, libswscale .9->.10), a plain `pacman -Syu` walked every Arch/CachyOS install
straight across the break. The result is not a crash we can log: the dynamic loader
cannot start the binary at all, so it is exit 127 *before* main() in a systemd restart
loop, and because punktfunk-web is a separate bun service with no libav linkage it keeps
serving happily while :47990 has nothing listening — which reads as "the mgmt API is
broken" rather than "the host is not running". `ldd /usr/bin/punktfunk-host | grep
"not found"` is the one-line diagnosis.
Depend on the sonames instead of the package. Arch's ffmpeg declares the matching
`provides=(libavcodec.so=63-64 ...)`, and makepkg rewrites each bare `libfoo.so` listed
in depends into `libfoo.so=<soname>-<arch>` by reading the built binary's DT_NEEDED, so
the bound tracks whatever FFmpeg the builder linked against with nothing to hand-maintain
across the next bump. pacman now refuses the ffmpeg upgrade rather than bricking the
install. A hand-written `ffmpeg<2:9` would have gone stale on the very next major; not
bundling FFmpeg the way the .deb does, because that exists only because Ubuntu 24.04 LTS
is frozen on 6.1 and can never satisfy the dep, while rolling Arch always ships a current
one.
Verified on a real ffmpeg-9 box (192.168.1.21): the built package records
libavcodec.so=63-64, libavutil.so=61-64, libavfilter.so=12-64, libavdevice.so=63-64 and
libswscale.so=10-64, exactly matching DT_NEEDED, with the two libs --as-needed drops left
bare and satisfied by any ffmpeg.
The new arch.yml step asserts that expansion actually happened. If it ever stops — Arch
dropping the soname provides, someone tidying the entries out of depends — the dep
silently degrades to an unversioned name that any ffmpeg satisfies, which is exactly the
state that caused this, and it is invisible in a green build until a box bricks weeks later.
Found on hardware by the GOG plugin's own parity gate, on a box with exactly one
GOG game installed:
HKLM\SOFTWARE\WOW6432Node\GOG.com\Games -> 1 subkey (IRON NEST ...)
host's built-in scanner: 1 entry
plugin: detect: absent, 0 games
parity FAILED - 1 missing, exit 1
`reg.exe` ALWAYS echoes the full hive name in its output rows, never the
abbreviation it was given: query `HKLM\SOFTWARE\...` and every line comes back
`HKEY_LOCAL_MACHINE\SOFTWARE\...`. regSubKeys built its match prefix from the
`HKLM\...` string it was handed, so no line ever matched and it returned `[]` —
on every machine, for every key, always. Measured verbatim on .173:
reg.exe: [HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GOG.com\Games\2013434102]
regSubKeys: []
Its only consumer is the GOG plugin, so the symptom was "GOG reports no games
installed" rather than an error — the same shape as the SQLite reader in 0.3.1:
a total failure that every layer degrades into an empty library.
The contract was wrong too, and the hive bug hid it. regSubKeys returned whole
key PATHS while the GOG plugin uses each result as a bare NAME
(`const key = \`${GAMES_KEY}\\${id}\``, and the subkey name IS the product id
that becomes `external_id`). Even with the prefix fixed, paths would have
composed nonsense keys. It now returns names, which is what the sole consumer
and its own comment always assumed.
Parsing is split into an exported `parseRegSubKeys(stdout, key)` for the same
reason `parseRegQuery` is exported — this is a text format that breaks quietly,
and it had NO test coverage at all. Six added, using the verbatim .173 output:
names not paths, multiple subkeys, grandchildren ignored, the queried key is not
its own subkey, case-insensitivity, and empty/error input. Four of the six FAIL
against the old behaviour.
0.3.1 -> 0.3.2. Gates: biome clean, tsc clean, 67/67 tests, build clean.
The kit had NO biome config and no lint script, while every plugin repo that
consumes it has both. So its source quietly drifted — unused imports, unsorted
imports, formatting — with nothing to catch any of it. Running biome here for
the first time reported 20 findings across 8 files.
Adds `plugin-kit/biome.json` mirroring the plugin repos' (tab indent, double
quotes, recommended lint preset, organizeImports), a `check` script, and
`@biomejs/biome` pinned to the same `^2.5.2` the plugins pin — without that pin
`bunx biome` resolved 2.4.6, which rejects the 2.5 `rules.preset` key.
Two deliberate differences from the plugin repos' copy:
* no `vcs.useIgnoreFile` — those are standalone repos with a .gitignore beside
the config; plugin-kit is a directory inside this one, and biome errors with
"couldn't find an ignore file". The `files.includes` exclusions cover it.
* `!examples/**/dist` instead of `!ui/dist` — the kit has examples, not a UI.
`css.parser.tailwindDirectives` is carried over and is load-bearing: without it
biome cannot parse `@theme` in src/theme.css and reports three parse errors on
CSS that is perfectly valid Tailwind v4.
Everything here is formatter/import churn except two real findings, both fixed:
* `Layer` (library/define.ts) and `Cause` (sync-engine.ts) were imported and
never used;
* test/spike-httpapi.test.ts read `(reg?.body as …).ui.secret` one line after
`expect(reg).toBeDefined()`. The optional chain undoes the assertion: had
`reg` been undefined the `.ui` access would throw a TypeError instead of
failing the test readably. Now asserted to the type system too.
Wired into plugin-kit-publish.yml as a `Lint & format` step ahead of Typecheck,
so this cannot rot again.
Gates after: biome clean (42 files), tsc clean, 67/67 tests, build clean.
The playback process callback sized its writes from the mapped buffer's
capacity — PipeWire's quantum-limit, 8192 frames ≈ 170 ms — instead of
the graph's per-cycle ask (pw_buffer.requested). Every cycle therefore
queued up to 170 ms of PCM downstream of the ring, and, worse, taught
JitterPolicy that the device drains 170 ms per callback: the underrun
floor (want + one frame) rose above any depth the A/V sync loop may
request, so sync measured audio ~280 ms late and was forbidden — by its
own continuity rule — from draining it. The first on-glass run of the
latency overhaul showed exactly that: audio buffer 272 ms, a/v +284 ms,
stable.
Honor requested (capacity remains both the ceiling and the fallback for
requested == 0), and log requested-vs-capacity once per stream in the
shape of the host's per-capture-open quantum line, so the next on-glass
report can say which one is sizing the writes.
Needs libpipewire >= 0.3.49 (2022-03) for the requested field; every
ship target clears that.
Verified on .21: cargo clippy -p pf-client-core --all-targets -D
warnings clean, 167 tests pass, fmt clean.
The Windows host does not build:
error[E0425]: cannot find value `OFF_INPUT` in this scope
--> crates\pf-inject\src\inject\windows\dualshock4_windows.rs:65:48
error: could not compile `pf-inject` (lib) due to 1 previous error
`dualshock4_windows.rs` writes the neutral report straight to `OFF_INPUT` in its
bootstrap path — correctly, and exactly as the DualSense and Steam Deck backends
do: the devnode does not exist yet at that point, so there is no reader to race
and no seqlock to take. Its steady-state path already goes through
`publish_input`, which is the v2.3 seqlock.
But the import list only names `publish_input`. `steam_deck_windows.rs` imports
`OFF_INPUT` explicitly for the same bootstrap write; this one was missed when the
list was edited to add `publish_input`.
One word in a `use`. No behaviour.
WHY CI DID NOT CATCH IT: `pf-inject`'s Windows backends compile only for
`*-pc-windows-msvc`, and the crate is host-side, so the client Windows workflow
never touches it. A cargo check from a Mac cannot stand in either — pf-inject
pulls punktfunk-core and therefore ring, whose C build wants MSVC headers, so the
cross-check dies in cc-rs long before it reaches this file.
FOUND BY: running windows-host.yml's own build line on the CI runner (.133)
against the v0.25.0 release tree before tagging —
`cargo build --release -p punktfunk-host --features nvenc,amf-qsv,qsv`. It fails
at `pf-inject`, which is step 1 of the host job, so a v0.25.0 tag would have
produced no Windows host binary, no installer, and no host asset on the release.
main moved another 62 commits (a8a4b11f -> fca9f42c), taking 0.25.0 to 391 since
v0.24.0. Five PRs: decode aliasing (#102), A/V sync (#101), gyro correctness
(#99), web console sweep (#100), Apple ATS (#103).
THE CORRECTION THAT MATTERED. The notes carried "Audio that falls behind the
picture pulls itself back … Android was worst, with no correction at all",
describing the jitter ring's buffer-shedding as if it were sync. It never was.
The host has stamped `pts_ns` on every audio datagram since long before v0.24.0
and EVERY CLIENT DECODED IT AND NEVER READ IT — verified in the v0.24.0 tree
(`crates/punktfunk-host/src/native/audio.rs:162` stamps it; the client audio
paths ignore it). Lip-sync was an emergent property of buffer depth, and it got
WORSE as video got faster, which is why shaving milliseconds off the audio budget
had never helped. That bullet is rewritten to say what is actually true, and A/V
sync takes a TL;DR slot.
It displaces the settings-BOM bullet, which was the weakest of the six as a
HEADLINE: conditional (only if the file was ever saved by PowerShell), partly
duplicated by the Windows non-C: entry, and it survives verbatim in Fixed. A/V
sync affects every user, every session, every client, with sound on — and unlike
most of this release it shipped broken in EVERY release we have ever made.
GYRO NEEDS AN UPGRADE NOTE, so it got one. The pipeline was wrong end to end and
is now measured against a real controller, which MOVES AIM SENSITIVITY: a pad
presented as a DualShock 4 reported gyro 40x fast (host-side), and a PlayStation
pad on Android reported ~30% short (client-side). At 40x nobody could have
compensated — gyro aim was unusable, not miscalibrated — but the Android ~1.4x
change is exactly the size a real person tunes around, so `## Before you update`
names it specifically.
DELIBERATELY NOT PROMOTED. The decode-aliasing program (#102) reads like a
catastrophe — H.264 decoding into a surface it predicted from on 297 of every 300
access units of every stream we emit, on both rungs — but it NEVER SHIPPED:
`git ls-tree v0.24.0 crates/` has no pf-vkdecode/pf-dxvadec/pf-vaadec/pf-bitstream.
It is a ship-blocker that was cleared, and writing "your picture was subtly wrong"
would be false for every reader. It contributes one clause to the decode entry
(every path is now checked frame-by-frame against a reference decoder; Windows +
Intel AV1 routes through Direct3D) and a full section in the changelog. Same
reasoning already applied to #96 and the rav1d abort.
Changelog gains the A/V sync mechanism (including that video is the master and
continuity outranks sync — the ring refuses a sync request that would break audio
on a jittery link) and the aliasing section, with the four independent reasons
four gates missed it: a structurally-blind conformance vector, a test that had
encoded the bug AS CORRECT, a vacuous assertion that could not fail, and the fact
that it streamed clean on glass. gpu_parity is 11 legs, not the 9 an earlier note
claimed.
Verified after the merge: lock diff versions-only 35/35, `cargo metadata --locked`
resolves (39 members), `cargo fmt --all --check` clean in both workspaces, notes
body 0 internal-vocabulary hits, Play notes 497/500 by android.yml's own gate.
Wire 2, C ABI 17, no new capability bits in this range.
A doc paragraph in `pic_av1.rs` wrapped so that "first at frame / 6. Releasing…"
put `6.` at the start of a line. rustdoc reads that as an ordered-list item
starting at 6, which makes the following unindented `///` line a lazy
continuation — `clippy::doc_lazy_continuation`, denied by `-D warnings`.
Reflowed so the number cannot begin a line. Prose is byte-identical in content;
only the wrap points move. No code, no behaviour.
WHY THIS MATTERS FOR THE TAG. `pf-dxvadec` is Windows-only, and no Windows leg
runs on a push to main — so main being green proves nothing about this. The
failure surfaces for the first time in a release tag's fan-out, which is exactly
what happened to the FIRST v0.23.0 tag: it went red on Windows clippy for this
same lint, and the cure was a tag re-point.
Caught pre-tag by re-running the lazy-continuation scanner over the tree while
preparing v0.25.0 (0 hits before this commit's parent merged the new decode
crates, 1 after). Cannot be verified by compiling here — the crate does not build
on macOS — so the evidence is the scanner plus the lint's own rule, not a clippy
run.
Moving the management API onto Network.framework left one request per
connection, so a library grid paid a TLS handshake per poster where the pooled
URLSession had shared one. And the Apple client -- unlike Windows -- never
cached art at all, so it re-fetched every poster on every visit.
ArtCache: a size- and age-bounded blob cache in the CACHES directory (every byte
is re-derivable from the host, so the system is welcome to evict it). Keyed by
the SHA-256 of the absolute URL, so host-proxy paths and store CDN URLs share
one cache without colliding. Reads touch the entry, so eviction is by last USE,
not last write. Empty bodies and data: URLs are refused -- neither is worth a
file. Defaults: 128 MB, 30 days.
Connection pooling: MgmtConnectionPool keeps up to four keep-alive connections
per host and makes further callers wait rather than opening more, which is the
part that matters -- a grid can ask for dozens of posters at once. A connection
the host dropped since we last used it is indistinguishable from a live one
until we write, so a REUSED connection that fails is retried once on a fresh
one; a fresh failure is a real failure.
Keep-alive means a response can no longer be delimited by the peer hanging up,
so HTTPResponseParser.messageLength finds the end from the framing itself --
Content-Length or the chunked terminal chunk plus trailers. Getting that wrong
would truncate a response or bleed one into the next, silently, so it carries
the bulk of the new tests. A connection with bytes left over after a response is
dropped rather than reused: we never pipeline, so anything trailing means we are
out of sync.
LibraryView closes the loader's pooled connections on disappear instead of
leaving sockets open on a screen the user has left.
16 new tests: message framing (both encodings, partial reads, back-to-back
responses, close detection) and the cache (binary round trip, key separation,
refusals, expiry, LRU eviction).
The previous commit bought the library back on VPN/remote hosts by declaring
NSAllowsArbitraryLoads, which works but is blunt: it drops ATS for ALL of the
app's URLSession traffic, and the only other traffic is third-party cover-art
CDN fetches -- the one surface we never wanted to open. It cost the TLS-version
floor, forward secrecy, and the cleartext-HTTP block on URLs the host supplies
at runtime (custom entries and scanner plugins carry arbitrary ones).
So take the host out of the URL loading system instead. MgmtTransport speaks
HTTPS over Network.framework, which ATS does not govern, and states the trust
rule we actually mean in a verify block: the leaf must hash to the fingerprint
pinned during PIN pairing. That is the same rule punktfunk-core has always
applied on the QUIC stream plane -- which is exactly why streaming kept working
over Tailscale while the library did not.
With that, the ATS dict is gone and ATS is fully enforced again. Cover-art CDN
fetches keep ordinary URLSession with full system trust evaluation and no client
certificate. LibraryTLSDelegate is deleted; nothing pins through URLSession now.
Also here:
- HTTPResponse: just enough HTTP/1.1 to read one GET -- status, headers,
Content-Length and chunked framing (hyper streams the art proxy chunked). A
body shorter than Content-Length throws instead of returning partial JSON,
which would otherwise read as "this host has no games".
- LibraryError.pinMismatch, so a re-keyed host says "pair again" rather than
sending someone to debug their network.
- 403 joins 401 as "unauthorized": both are the host declining the certificate.
- baseURL brackets IPv6 literals; the old string interpolation did not.
- 11 tests covering the framings hyper emits and the failure modes that would
otherwise be silent.
Known trade-off: no connection reuse yet, so each poster costs its own
handshake where the pooled URLSession shared one. Fine on a LAN, worth revisiting
for large libraries over a high-latency link.
It led with `--mgmt-bind 127.0.0.1`, a rare cause, and never mentioned the one
fact that actually explains the symptom: the library rides the management API on
a different port (47990) than the QUIC stream plane (9777), so it can fail while
streaming to the same host works. Field triage of exactly that case spent hours
on the stream path before anyone questioned the port.
Leads with that now, and names browser-testing the port as the fast split between
"unreachable" and anything client-side.
The game library rides the management REST API over HTTPS (TCP 47990) through
URLSession, authenticated by mTLS and pinned by SHA-256 fingerprint. The app
declared no App Transport Security policy at all, so it ran under default ATS --
which exempts only "local" destinations (.local, unqualified names, RFC1918 and
link-local literals) and applies the full policy everywhere else. The host
certificate is self-signed (and carries no SubjectAltName), so it cannot satisfy
that policy: the library loaded at 192.168.x and failed at the TLS layer on any
other address.
Field-reported against a Tailscale host. 100.64.0.0/10 is CGNAT, not RFC1918, so
the library failed there while streaming to the very same address worked -- the
QUIC stream plane is raw UDP and never enters the URL loading system. A WireGuard
peer or a public-IP host breaks identically.
Declares NSAllowsArbitraryLoads, which must stay the dict's only key: iOS 10+ and
macOS 10.12+ ignore it whenever a more granular ATS key sits alongside it. Trust
is unchanged -- LibraryTLSDelegate still pins the host by fingerprint and hands
every other origin (cover-art CDNs) to full system trust evaluation.
Its rows still read "never frame-hash parity-checked: the rung exports a tiled
dmabuf with no CPU-readable image, so parity needs a readback path that does not
exist yet". That readback now exists, and all SEVEN legs came back bit-identical
to libavcodec on RDNA3: vendored H.264 250/250, our host's low-delay H.264
120/120, vendored H.265 250/250, host low-delay H.265 120/120, HEVC Main 10
50/50 as P010, vendored AV1 250/250 of 274 decoded, and host low-delay 4K
two-tile AV1 60/60.
The two arms collapse into one, because the thing that split them — AV1 having
evidence the other legs lacked — is gone. Every leg now has the same evidence.
It stays `verified = false`, and the note says why in the words the
unproven-rung test requires: it has NEVER run on a second vendor and has never
been soaked. That is a real limit rather than a formality — every other verified
pair in this table earned it on more than one part, and the D3D11VA AV1 row two
entries up is a rung that passed on one vendor's driver while failing on
another's.
The second reason is not about evidence at all, and it belongs in the record
rather than in a commit nobody reads later: flipping this flag is a ROUTING
change. `native_rung_admitted` is `verified || !below.verified`, so a verified
VAAPI outranks Vulkan Video on every Linux AMD and Intel client — the Steam Deck
included. The parity result justifies that change; it should still be made on
purpose, by someone who wants it, rather than arriving as a side effect of
writing down a test result.
Every other decode rung earns `verified` with frame-hash parity against
libavcodec. VAAPI could not: it hands out a DRM-PRIME dmabuf whose memory the
driver tiles, so nothing could read its decoded pixels back, and all four of its
legs sat at "never frame-hash parity-checked".
That was never bookkeeping. The D3D11VA AV1 rung decoded 250 frames, streamed
4K60 through a clean five-minute soak, and produced WRONG PIXELS for 186 of 250
frames on NVIDIA and 245 of 250 on Intel. It looked perfect on glass; only the
goldens caught it, and the same defect turned out to be in H.264 on two other
rungs. VAAPI was the one rung where that class of bug could still be sitting
with nothing able to see it.
It is not. Measured on .25 (Radeon 780M, RDNA3, radeonsi, Mesa 26.0.3, VA-API
1.23) on 2026-08-08, against the SAME golden files the Vulkan and D3D11VA rungs
are held to, read across the crate boundary rather than copied:
H.264 vendored vector 250/250 bit-identical (7 from the flush)
H.264 our host, low-delay 640x480 120/120 bit-identical (3 from the flush)
H.265 vendored vector 250/250 bit-identical (2 from the flush)
H.265 our host, low-delay 640x480 120/120 bit-identical (0 from the flush)
HEVC Main 10, P010 50/50 bit-identical (2 from the flush)
AV1 vendored vector 250/250 delivered of 274 decoded, and
display frame 0 byte-identical to
libavcodec's own PIXELS
AV1 our host, 4K two-tile 60/60 bit-identical
⚠ ONE vendor. AMD/radeonsi only; no Intel iHD box has run these legs.
The readback that made it possible:
* `pf-vaadec`'s `va` module gains `VAImage` and `VAImageFormat`, hand-declared
with every size and offset measured off libva 2.23.0's real headers by
`layout-probe.c` and pinned as compile-time assertions — the same discipline
the decode buffers already keep. The trap: `VAImage::width`/`height` are
16-bit, so `data_size` sits at 60 and not at the 64 counting 32-bit fields
gives, and every field after them is two bytes earlier than it looks.
* `pack_two_plane` is the pure geometry — the crop to the picture, the padding
columns dropped per row, and the chroma plane taken from the driver's OWN
`offsets[1]` rather than from `pitch * display_height`, which is the 1088-row
smear this program has already paid for once. It needs no device, so ten CPU
tests cover it on macOS and in the container.
* `video_vaapi_native::parity` drives the seven streams above through the
production entry point and hashes what the rung DELIVERS, in delivery order,
tail included — so the delivery path is under test as well as the decode, and
a frame's surface comes from its own release token rather than from an
inference about which pool entry holds which picture.
THE READBACK CANNOT REACH THE PRODUCTION PATH, and that is structural rather
than a promise. `vaDeriveImage`, `vaCreateImage`, `vaGetImage`, `vaMapBuffer`
and the rest are resolved by a `#[cfg(test)]` type that dlopens libva itself;
the production `Libva` gains no field; `sha2` is a dev dependency. A CPU test
scans this file's own source and fails if any of those symbols is dlsym'd
outside the harness, so a refactor cannot quietly undo it.
Derive is not guaranteed, so both routes are implemented and neither is
optional: `vaDeriveImage` first, `vaCreateImage` + `vaGetImage` as the fallback
(which also detiles), and if neither yields the pool's own fourcc the leg FAILS
naming what the driver gave it. There is no skip path — a parity test that
passes because it could not read anything is the failure mode this program has
been bitten by three times. Both answer on radeonsi, the first frame of every
leg is read through BOTH and they must agree, and `PF_VAAPI_READBACK=getimage`
reproduces the H.264 leg's 250/250 through the copying route alone, so the
fallback is exercised rather than merely written.
And it can fail — proven, not asserted. Planting the real geometry defect this
driver's layout makes visible (rows read contiguously, ignoring the 512-byte
pitch behind a 320-wide picture) fails at display frame 0 with the full
localisation: 68312 luma and 14998 chroma samples differing, max |delta| 255,
luma bounding box (0,1)..(319,239) — and with the goldens forced through one
route, 250/250 diverging with "suspect the readback geometry". `compare` and
`localise` also have CPU counterfactuals, and a hardware leg proves the readback
reads real and DISTINCT pixels and localises a one-byte flip to the exact pixel.
⚠ One thing the hardware legs do NOT cover, found by planting the other defect
and watching it do nothing: radeonsi's decode surfaces for every fixture here
have no VERTICAL padding — `offsets[1]` is exactly `pitch * height` — so the
chroma-plane trap is untested on this driver, and `pf-vaadec`'s
`reading_chroma_at_the_display_height_would_have_been_caught` is the only place
it is checked at all. `probe_this_machines_readback_routes` now prints the
derived layout and says which of the two it is, so the next driver answers for
itself instead of being assumed.
The Apple half of the A/V sync overhaul; the Rust half is 12a53183 and this
mirrors its policy rather than re-deriving one.
The host stamps `pts_ns` on every audio datagram and the client decoded it into
`AudioPCM` — and then never read it. Video's `pts_ns` is used end to end (the
end-to-end meter computes a true glass-to-glass `displayed + clockOffset − pts`
per presented frame), so audio free-ran at whatever depth its jitter ring
happened to reach, video was presented on an independent path, and nothing ever
compared them. The A/V offset was an accident of buffer depths: it moved
whenever the ring ratcheted under underrun pressure, and it got WORSE every time
video got faster, because a quicker decoder lowers the video leg and leaves
audio's exactly where it was.
Video is the master:
audio_e2e = (now + buffered_ahead + clock_offset) − pts_ns
av_offset = audio_e2e − video_e2e (> 0 ⇒ audio behind the picture)
`AvSync` smooths that with an EWMA, ignores what sits inside a deadband no
listener can detect, refuses the implausible outright rather than clamping it (a
wall-clock step must not steer the ring), and proposes a depth. Swift refuses
one thing Rust does not have to: the arithmetic itself. The Rust controller
works in i128, while Swift has no Int128 at this tools version, so the terms are
combined with overflow-REPORTING arithmetic instead of the `&-` the latency
meters use. That is not defensive padding — `ptsNs = 1 << 63` reads as
`Int64.min`, the difference lands on exactly `Int64.min`, and `abs()` of that
has no representable result, so checking the overflow flags AFTER the sanity
limit does not mis-measure the stream, it aborts the process from the audio
drain thread. The guard's short-circuit ordering is what makes the sanity check
safe to run at all.
Continuity outranks sync, always. `AudioRing.setSyncTarget` only ever takes a
REQUEST, clamped between the existing underrun-driven floor and the hard cap. A
link whose jitter genuinely needs more buffer than the picture is away keeps its
buffer and the residual is reported. `nil` is the default and reproduces the
previous behaviour exactly. The clamp raises its ceiling to the floor rather
than using it as-is: a device whose callback quantum alone exceeds the hard cap
makes floor > cap, and a plain `min(max(s, floor), cap)` would then hand back
the CAP — quietly below the continuity floor, inverting the exact ordering this
exists to guarantee, on the awkward hardware it exists to survive. (Rust's
`Ord::clamp` announces that condition by panicking; Swift would just get it
wrong, which is worse.)
The reference is the other half, and without it the loop is inert — which is why
this was split out rather than shipped alongside the Rust side. `LatencyMeter`
now publishes its most recent sample as a LEVEL, so the end-to-end meter the
presenter already writes per presented frame becomes the video figure the audio
plane reads. Both present paths (arrival and deadline) feed it without either
knowing audio exists, and the stage-1 fallback presenter — which stamps no
present at all — offers nothing, so the loop correctly declines to correct. The
level EXPIRES, unlike the Rust atomic: this client has a backgrounded keep-alive
that keeps audio playing and drops video decode entirely, and a reference with
no expiry would go on steering the ring against a figure minutes old and frozen.
And the reason none of this was visible: `bufferedMS`/`targetMS` existed only in
a periodic log line, absent from anything a surface could render. The HUD's
detailed tier now carries `audio buffer N ms · a/v ±N ms` and the 1 Hz stats log
gains the same pair, appended last so existing parsers are unaffected — both
numbers, because a deep ring on a jittery link is correct and only the offset
separates that from audio held late.
`PUNKTFUNK_NO_AV_SYNC=1` disarms the loop without a rebuild, as on the Rust
clients.
Verified: swift build + 225 tests (5 skipped) green. Every new gate was proven
non-vacuous by planting its own defect and confirming the gate caught it — the
continuity invariant, the clamp inversion, the deadband, both refusal paths, the
evidence threshold, the sync-pressure relax, the reference's staleness and its
survival of a drain, and `setSyncTarget` being live at all rather than dead
code, which is how the previous pass in this area shipped a correction that was
structurally unreachable with a green test. Two gates came back VACUOUS on the
first sweep and are the reason their inputs look so specific: the overflow test
was being caught by the sanity limit instead of the overflow guard, and the
refused-reference test was being caught by `latestSample`'s own `> 0` check
rather than by where the publish sits.
`finish` showed `outputs.last()` and retired every other picture an access unit
bumped out of the DPB without ever displaying it, and nothing flushed the DPB at
end of stream. Measured on .25 against the vendored vectors: 225 of 250 frames
for H.264, 204 of 250 for H.265, 45 of 50 for HEVC Main 10. D3D11VA and Vulkan
deliver every frame, so this was the rung's alone. All four legs now deliver
250 / 250 / 50 / 250.
The same function carried a second defect. `DmabufFrame::keyframe` was stamped
with the CURRENT access unit's `is_idr`, not the flag of the picture it was
about to display, and on a reordering stream those are different pictures: the
IDR is bumped out several units after it decodes and arrived flagged `false` on
all three legs' first frame, while a later AU draining the DPB flagged some old
trailing picture as a keyframe. That field is `DecodedImage::is_keyframe`, the
pump's post-loss re-anchor signal, so a mislabel re-anchors on the wrong frame.
Three changes, all inside this rung:
* **A deliverable queue**, the same shape as `video_vk_native`'s — extend, ship
the front, trim the oldest past the bound, count and rate-limit the drops into
`DecodeHealth::dropped`. Its DEPTH is derived differently and the divergence is
documented: the Vulkan rung's bound is `HOLD_HEADROOM - PIPELINE_HOLD` = 1
because a queued frame there counts against the pool ON TOP of the DPB's own
residency. Here the three claims are disjoint and a bumped picture MOVES from
`pending`/slot to `held`, so the queue inherits the claim rather than adding
one. The bound is the DPB's depth — the deepest carry-over a bump can leave —
and the measured cost is at most one surface (zero on H.264, whose three
seven-picture IDR drains are the deepest bursts these vectors have). A bound of
1 would have left 235 of 250 on H.264, most of the defect still in place.
* **An end-of-stream flush.** This rung has no EOS signal and cannot have one:
the pump feeds access units until the session ends and then drops the decoder.
So `flush` has the two honest callers — `Drop`, where nothing can be presented
and the job is to release the queue's surfaces and the DPB's before the pool
goes, and a caller that KNOWS the stream ended, which today is the conformance
harness. One walk, not a production path and an untested teardown path. AV1
needs none: it shows at most one frame per temporal unit and buffers nothing,
which its 250/250 says out loud.
* **`PictureFacts` recorded when a picture decodes**, and read back when it is
displayed. `keyframe` was the defect; `color` and `display` are the same
mistake one field along — an in-band HDR switch changes the VUI mid-stream and
AV1's render region is per-frame, so a queued frame shown two units later would
have been drawn with the newest picture's signalling.
Concealment answers `Ok(None)` and deliberately does NOT drain the queue, which
is the Vulkan rung's order and is load-bearing: `clears_demotion_streak` is
`delivered || !concealed`, so shipping a queued frame on a concealed AU would
zero the streak and take away the escape hatch that stops a rung concealing
forever from holding a frozen picture.
The three delivered-count assertions moved with the fix, and so did the CPU
derivation that reproduces them without a GPU — it now simulates the whole
delivery model (ledger, queue, one-per-AU hand-off, flush) in the order `decode`
does it, and carries the old behaviour beside the new one as a counterfactual:
a queue bound of 0 with no flush still reproduces 225/204/45 exactly, and the
test fails if it ever stops being SHORT. `settle` was split out as the pure half
of `finish` so the claim walk, the display ordering and the picture facts are
all assertable with no device; `the_queue_never_needs_a_surface_the_pool_does_not_have`
runs the surface-lifetime arithmetic over the real vectors and pins the peak
claims (9 of a 16-surface pool on H.264, 8 of 14 on both HEVC vectors), with an
unbounded queue as the counterfactual that shows the bound doing its job.
Gates run: `cargo fmt --all -- --check`, `cargo clippy -p pf-client-core
-p pf-vaadec --all-targets --features sdl3/build-from-source -- -D warnings`,
`cargo test -p pf-client-core --lib --features sdl3/build-from-source` (176
pass), the same filtered to `video_vaapi_native -- --include-ignored` (23 pass,
0 ignored) and `cargo test -p pf-vaadec` (48 pass) — all on .25 (Radeon 780M,
RDNA3, radeonsi, Mesa 26.0.3, VA-API 1.23); plus `cargo fmt --all -- --check`
and `cargo clippy --workspace --all-targets -- -D warnings` in pf-lxcheck2.
`ci.yml` runs `cargo clippy --workspace` on the HOST, where
`clients/android/native` and every `#[cfg(target_os = "android")]` module
elsewhere compile out, and `android.yml` only ever built. So the Android target
was never linted at all — not once. Five lints were sitting in
clients/android/native when this was noticed, in code no gate had ever read.
The gate is a Gradle task rather than a YAML step because cargo-ndk needs a
specific discovery environment (NDK sysroot, SDK cmake 3.22.1 for libopus,
`LIBOPUS_STATIC`, Ninja) and duplicating it into the workflow would let the lint
drift from the build — a lint that ran against a different toolchain is a lint
about a different program. `registerCargoNdkClippy` reuses the build task's
environment verbatim via the extracted `cargoNdkEnvironment`, so local and CI
runs are the same invocation.
It lints BOTH pointer widths, and that is load-bearing rather than thorough:
arm64-v8a is 64-bit and armeabi-v7a is 32-bit, so a cast that is redundant on
one can be required on the other. Linting only the primary ABI would license
"fixes" that break the 32-bit build — the shipping ABI for the many 32-bit
Google TV / Android TV boxes this client targets. x86_64 is skipped: it is
emulator-only and shares its width with arm64, so it costs lint time for no
signal the other two do not already carry.
The five resident lints:
* `audio.rs` / `mic.rs` `type_complexity` — the open-attempt closures now return
named `OpenedPlayback` / `OpenedCapture` aliases. The two tuples are mirror
images of each other (playback sends, capture receives), which the aliases now
say out loud.
* `vsync.rs` ×2 `unnecessary_cast` — **not** taken. `timespec`'s fields are
32-bit on armv7 and 64-bit on arm64, so the casts are REQUIRED on one shipping
ABI and redundant on the other; following the suggestion would break the
32-bit build. `i64::from`/`.into()` do not escape it either, they trade
`unnecessary_cast` for `useless_conversion` on the 64-bit side. Answered with
a documented `#[allow]` at the expression instead of in whichever build breaks
first.
* `pad_audio.rs` `needless_range_loop` — iterator form, preserving the
`channels < 2` no-op the range had.
Verified: `:kit:cargoNdkClippy` green on both ABIs, host-lane clippy for the
crate still clean, `cargo fmt --all --check` clean. The gate was proven
non-vacuous by planting `1i32 as i32` in an android-only module and confirming
it fails the task, then reverting.
The core, Linux, Windows and host halves of the audio latency overhaul landed
with Android deliberately left inert: `JitterPolicy`'s sync target defaults to
`None`, so this ring kept behaving exactly as it always had. What was missing
was not the loop but its REFERENCE — nothing here published where a frame
actually reached glass, and a controller with no reference is the mechanism you
can prove is present but that cannot act. This wires both halves.
The decode thread now reads the host capture `pts_ns` that every `AudioPacket`
has always carried and that this client, like every other, dropped on the floor.
Against the ring depth (published by the AAudio callback through the shared
`AudioSyncCell`) and the video plane's end-to-end figure it computes
audio_e2e = (now + buffered_ahead + clock_offset) − pts_ns
av_offset = audio_e2e − video_e2e (> 0 ⇒ audio behind the picture)
and asks the ring for a depth that closes it. Only ASKS: `set_sync_target` is
clamped between the underrun-driven adaptive floor and the hard cap, so a link
whose jitter genuinely needs more buffer than the picture is away keeps its
buffer and the residual is reported instead of being taken out of the listener's
stream. Continuity outranks sync, on this ring as on the others.
The reference comes from `DisplayTracker`'s `OnFrameRendered` callback — the one
place in the client that knows a frame truly latched — and it is computed ABOVE
the HUD gate now. A sync loop that only ran while the overlay was up would be
off on exactly the devices that report latency; the stats LOCK stays gated,
which is what that early-return was really protecting. Both decode loops feed
it, so sync works with "Low-latency mode" off as well.
Two deliberate refusals:
* The figure is published RAW. The HUD shaves the OS present floor off its shown
display/end-to-end numbers — metrics report what Punktfunk controls — but sound
has to reach the ear when the light reaches the eye, and a floor-shaved
reference would place audio a whole latch period early on every device.
* Below API 33 there is no render callback, so there is no confirmed present and
the loop stays inert (target `None` ⇒ today's behaviour exactly). The release
instant is NOT substituted for it: a release targets a FUTURE vsync and runs a
whole latch period (8-21 ms measured) ahead of glass, well outside the loop's
deadband — it would place audio early on every frame while looking like it was
working.
The plane is also no longer invisible. Ring depth and the smoothed offset ride
the stats array at 33/34 and the Detailed HUD carries `audio buffer N ms · a/v
±N ms`, the same wording the desktop HUD uses — both numbers, because a deep ring
on a jittery link is correct behaviour and only the offset separates that from
audio simply held late. The 1 Hz logcat line gains `av_ms` beside its depth, and
the depth itself now has ONE publisher: the counter copy is gone in favour of the
sync cell both readers already share.
The escape hatch is two levers. `PUNKTFUNK_NO_AV_SYNC=1` keeps the contract the
desktop clients document, but an app launched from the launcher inherits no
environment, so the one a field tester can actually reach is
`adb shell setprop debug.punktfunk.no_av_sync 1` — no rebuild, exactly like
`debug.punktfunk.presenter`. A loop that steers playback has to be bisectable on
the device that reports the regression.
Verified: `cargo ndk -t arm64-v8a check` clean; `cargo clippy -p
punktfunk-client-android --all-targets -- -D warnings` clean on the host lane CI
lints, and the Android target introduces no new findings (5 pre-existing lints in
audio/mic/pad_audio/vsync are unchanged — the android-gated modules are never
linted by the host workspace); `cargo fmt --all --check` clean;
`./gradlew :app:testDebugUnitTest` green. The new HUD test was proven
non-vacuous by planting the defect first — dropping the render call fails its
three positive assertions and leaves the three absence assertions passing, which
is the shape a test that "passes for the wrong reason" would not have.
design/audio-latency-overhaul.md W4. Apple (W6) still keeps today's behaviour.
The host stamps `pts_ns` on every audio datagram and the client decoded it
into `AudioPacket` — and then never read it. Video's `pts_ns` is used end to
end (the presenter computes a true glass-to-glass `displayed + clock_offset −
pts`), so audio free-ran at whatever depth its jitter ring happened to reach,
video was presented on an independent path, and nothing ever compared them.
The A/V offset was an accident of buffer depths: it moved whenever the ring
ratcheted under underrun pressure, and it got WORSE every time video got
faster, because a quicker decoder lowers the video leg and leaves audio's
exactly where it was. That is what a field report on the Steam Deck heard as
"the audio delay is way too high", and it is why shaving milliseconds off the
audio budget had not helped.
Video is the master. In a game streamer the video leg is the input-feel budget
and must never be inflated to satisfy the audio clock, while audio tolerates
small crossfaded corrections that are inaudible — and `crossfade_drop` already
applies them. So audio moves:
audio_e2e = (now + buffered_ahead + clock_offset) − pts_ns
av_offset = audio_e2e − video_e2e (> 0 ⇒ audio behind the picture)
`AvSync` smooths that with an EWMA, ignores what sits inside a deadband no
listener can detect, refuses the implausible outright rather than clamping it
(a wall-clock step must not steer the ring), and proposes a depth.
Continuity outranks sync, always. `JitterPolicy::set_sync_target` only ever
takes a REQUEST, clamped between the existing underrun-driven floor and the
hard cap. A link whose jitter genuinely needs more buffer than the picture is
away keeps its buffer and the residual is reported — sync can never starve the
ring into dropouts. `None` is the default and reproduces the previous behaviour
exactly, so the four client rings can adopt this one at a time without
diverging.
Two upstream defects found on the way, both prerequisites:
* The host stamped `pts_ns` at ENCODE time, inside the loop draining an
already-accumulated chunk, so every frame of a chunk carried near-identical
timestamps describing when we got round to encoding. Harmless while nothing
consumed it; a sync loop regulating against it would regulate against a
fiction. It now comes off the capture clock.
* The host did not pace. One capture callback hands over a whole quantum — 5 ms
when the graph honours our ask, 21.3 ms on a VM, where stock PipeWire raises
`min-quantum` to 1024 — and the loop drained all of it into back-to-back
`send_datagram` calls. The wire carried a 4-5 frame burst then ~21 ms of
nothing, and a ring can only absorb that by standing a burst period deep.
Frames now leave on the audio clock, which costs no average latency.
And the reason none of this was visible: `buffer_ms`/`target_ms` existed only
as a `tracing::debug!` line, absent from `Stats`. On a Deck the client runs
under Steam's `reaper` with stdout on a pipe nobody can read, so the one number
identifying a deep ring was unobtainable on the device reporting the latency.
The HUD now carries `audio buffer N ms · a/v ±N ms` — both, because a deep ring
on a jittery link is correct and only the offset separates that from audio held
late. The host also reports its negotiated quantum against the one it asked
for, per capture open rather than once per process.
Verified: 364 core + 40 presenter tests on Linux, clippy -D warnings clean on
punktfunk-{core,host} + pf-{client-core,presenter}, fmt clean. New tests pin
the safety invariant (sync cannot pull the target below the continuity floor on
any preset), that `None` leaves the policy bit-identical, and that a device
quantum exceeding the hard cap does not panic `Ord::clamp` inside a realtime
callback.
Android and Apple keep today's behaviour (the `None` default) until their
presenters publish a video figure to align against; design/audio-latency-
overhaul.md carries the plan.
The H.264 and H.265 rows still read "NEVER decoded a frame on any hardware".
That stopped being true on 2026-08-07, in the same session that proved AV1:
every access unit of the vendored H.264 (250), H.265 (250) and HEVC Main 10
(50) vectors was accepted on .25 (Radeon 780M, RDNA3, Mesa 26.0.3) with no
decode error — NV12 for the 8-bit legs, P010 for Main 10, all on the same tiled
AMD modifier — and probe_this_machines_libva reports VLD decode for all three
profiles.
The row records the delivered counts honestly rather than rounding them up:
225/204/45 against 250/250/50 access units, because `finish` shows
`outputs.last()` and drops the other pictures an AU bumps, and nothing flushes
the DPB at end of stream. That is this rung's own behaviour — D3D11VA delivers
all 250 — and it is invisible on punktfunk's zero-reorder host output. It is
recorded and asserted rather than fixed: changing the one-frame-per-AU contract
touches the pump's deliverable queue, an end-of-stream flush, and the
`keyframe`-labels-the-access-unit defect in the same function, so it belongs in
a commit that moves all three.
Still `verified = false` for all four, and the note says why in the words the
unproven-rung test requires: never frame-hash parity-checked. That is not
pedantry — the D3D11VA AV1 row two lines above is a rung that decoded 250
frames and produced wrong pixels for every one of them. Parity is what
distinguishes them, and this rung exports a tiled dmabuf with no CPU-readable
image, so it needs a readback path nothing has written yet.
Every AV1 frame either decode rung has ever been measured against is `tile_cols =
tile_rows = 1`. The vendored vector is single-tile on all 274 of its frames, so every
tile array the conversions fill — `tiles.widths`, `tiles.heights`, the per-tile records
— had only ever been written at index 0, and a conversion that wrote tile 0 and left
the rest zero would pass the whole suite. Our encoder splits 4K into TWO TILE ROWS.
**The fixture.** `lowdelay-3840x2160.ivf.av1`, 261 KB, 60 frames — `punktfunk-host
spike --source synthetic --codec av1 --width 3840 --height 2160 --fps 60 --seconds 1
--bitrate 1` on .21 (NVENC, RTX 5070 Ti), wrapped to IVF with `ffmpeg -f obu … -c copy`
so `common::split_av1_aus` (the vendored parser's own `IvfIterator`) frames it exactly
as it frames the vector, with no second splitter that could disagree.
**4K is not a size choice, it is the only shape with the property.** Measured on the
same box with the same command: 1280x720, 1920x1080 and 2560x1440 all give `tile_cols =
tile_rows = 1`; 3840x2160 gives `tile_cols = 1, tile_rows = 2` with
`width_in_sbs_minus_1 = [59]`, `height_in_sbs_minus_1 = [16, 16]`, and both tiles in ONE
Tile Group OBU. 60 frames instead of 120 pays for the resolution: 261 KB, under both the
282 KB H.264 and 270 KB H.265 low-delay fixtures.
Goldens are libavcodec's software decode, cross-checked between ffmpeg n8.1.2 (Arch
x86_64, libdav1d) and 8.1.1 (Homebrew, macOS arm64, libdav1d) whose 746,496,000-byte raw
outputs are BYTE-IDENTICAL, not merely equal per frame. 60 of 60 digests distinct.
**AV1's frame accounting is asserted, never derived.** The vendored vector is 250
temporal units carrying 274 coded frames of which 24 are hidden; this stream is 60 units,
60 coded, 60 shown, 0 hidden, 0 `show_existing_frame`, 1 key frame. Neither is the
general case, so both parity harnesses now take units / decoded / shown as three
independent parameters instead of computing one from another, and the CPU guard states
all six numbers.
**A CPU gate that needed no hardware at all.** `pic_av1`'s new
`a_two_tile_frame_fills_both_row_entries_and_leaves_the_rest_zero` pins the second row
entry against its OWN `height_in_sbs_minus_1`, requires the two rows to tile the frame
exactly, and requires TWO tile RECORDS out of ONE tile group with rows (0,0) and (1,0) —
the transposition a square grid could never reveal — each spanning real bytes. The
existing one-tile test asserts index 0 is right and `1..` are zero, which a broken
multi-tile conversion also satisfies.
⚠⚠ **This is a file, and on AV1 that distinction has already cost a release.** "250/250
delivered frames bit-identical to libavcodec" was true for the entire period the host was
shipping only the FIRST TILE of every 4K frame: the verification ran against a vendored
file while the truncation lived in packetisation, and the suite stayed green throughout.
This fixture closes the multi-tile gap on the DECODE rungs and closes nothing about
fragmentation, reassembly, loss or AU boundaries — the golden header, both module docs
and the leg docs all say so, at length, so the next reader does not inherit the same
false confidence.
Legs: `low_delay_host_av1_every_frame_hashes_bit_identical_to_libavcodec` on the Vulkan
rung (11 ignored legs now) and on the D3D11VA rung, plus two non-ignored CPU tests.
Verified: 11/11 Vulkan parity legs on .21 (RTX 5070 Ti, 610.57.04), the new one 60/60
bit-identical; workspace clippy `-D warnings` and `cargo fmt --all --check` clean on .21.
Follow-up to a85e8452, closing the three items that sweep flagged and left.
SIXTEEN BROWSER DIALOGS, GONE. Every destructive action in an otherwise fully
branded console handed off to `window.confirm` — a grey OS box with the page's
URL in it, no brand, no red on a delete, and untouchable by any story or
screenshot, which is part of why it survived this long.
They are replaced by one promise-based surface (components/dialogs.tsx) rather
than a dialog per call site. The native calls were EXPRESSIONS — `if
(!confirm(…)) return;` — threaded through mutation handlers; rewriting each into
"hold the pending action in state, render a dialog, run it from onConfirm" would
have put dialog machinery in every section file and turned each linear handler
inside out. Returning a promise keeps them the shape they already were, and it
is what let the navigation guard come along too: TanStack's `shouldBlockFn`
accepts `Promise<boolean>`. `beforeunload` necessarily stays native — a reload
is the browser's dialog to draw, and it will not wait on ours.
No warning copy was rewritten. Each message was SPLIT at its existing sentence
boundary: the question becomes the dialog's title, the consequence its body,
and "Continue?" is dropped where the affirmative button now carries the verb
("Delete", "Uninstall", "Unpair", "Stop every session"). 16 new keys, en and de
in parity at 629.
Verified by driving the real dialogs in a headless browser — all seven contract
checks pass, including the two that would be invisible until they bit: Escape
SETTLES the promise (an unsettled one would hang a mutation handler forever with
no error), and a cancelled prompt resolves null rather than "", so a caller can
still tell "backed out" from "cleared the field".
FOUR OF THE SEVEN NUMERIC FIELDS became InputNumber; three deliberately did not,
and now say why in place. The layout X/Y pair had a real defect: a screen left
of the origin has a negative coordinate, and `Number("-") || 0` rewrote the lone
minus sign to "0" before the digits could be typed. Measured on the built page:
the field can now be emptied to retype instead of snapping to its floor, and 900
in a 1..=16 field clamps to 16. The three left alone cannot take it — the grace
seconds field writes to the HOST on blur (InputNumber commits while typing, so
its clamp would race the apply), and the library's year/players are OPTIONAL,
where `value: number` has no way to say "unset" and would invent a year for
every entry without one.
The select's highlighted row moves off @unom/ui's neutral grey onto the brand
wash the nav and the preset cards already use.
The Displays story earned its keep immediately: adding `useDialogs` to that page
broke it in Storybook, because the provider was mounted in __root and nowhere
else. It belongs beside the other app-level providers in .storybook/preview.
The D3D11VA and Vulkan rungs both decoded into a surface they were predicting
from, on 117 of 120 access units of our own host's low-delay H.264 (`1c54d099`
for AV1, `834b2443` for H.264). `pf-vaadec` feeds `reference_frames` from the
same `plan.dpb_refs` snapshot, releases its whole `removed` list inline exactly
as the two broken conversions did, and neither fix commit touched it. It is
still exempt — this is the evidence, and the thing that keeps it true.
**Measured on the CPU, no GPU needed.** `walk_for_aliasing` drives the planner
and `plan_to_va` over both streams and counts four shapes. On
`lowdelay-640x480.h264` the aliasing PRECONDITION is fully present: 117 of 120
access units remove a picture their own `dpb_refs` still names, and on the same
117 the setup picture is handed the slot of a picture that access unit READS —
the D3D11VA/Vulkan defect verbatim, in this conversion, today. On the vendored
conformance vector both counts are 0, which is why that vector proved nothing
on two other backends for two milestones. Aliased submissions: **0 on both**.
**Why.** A slot is not a surface here. `plan_to_va` never invents one — every
reference it can name is read out of the `surfaces` table it is handed — and
the decode target is a separate parameter the caller takes from OUTSIDE that
table. `setup_surface` reaches the submission at exactly one field per codec
(H.264/H.265 `curr_pic.picture_id`, AV1 `current_frame` and
`current_display_picture`); HEVC is doubly safe, because its per-slice
`RefPicList` stores an INDEX into `reference_frames` rather than a surface.
AV1's documented substitution fallback is the one place the target can be named
as a reference, and only where the store resolved nothing at all to prefer.
**The exemption was incidental; it is structural now.** It needs the reference
table and the decode target to come from ONE snapshot of the bindings, and the
rung had that only by writing `free_surface()` and `surface_table()` adjacently
at three call sites. Split them and this rung acquires the defect exactly: the
table must be the PRE-removal one (that is where the references are), while a
free list consulted after the removals offers precisely the displaced picture's
surface. `Session::acquire_target` now returns the index, the surface and the
table together from `&self`, so a later edit cannot move one call and not the
other. No behaviour change: same order, same values, same refusal message.
Tests. `no_submission_names_its_decode_target_as_one_of_its_own_references`
(both streams, 0) with
`taking_the_decode_target_from_the_slot_table_aliases_on_the_low_delay_stream`
as the counterfactual that reproduces the defect on 117 of 120 — so the walk
demonstrably CAN see it when it is there.
`the_low_delay_stream_reassigns_slots_whose_pictures_it_still_reads` pins 0/250
and 117/120 so neither can drift silently.
`the_decode_target_can_never_be_a_surface_the_reference_table_names` sweeps
every binding state a 4-surface/3-slot pool can hold, and
`taking_the_free_surface_after_the_removals_would_hand_out_a_referenced_surface`
is the ordering counterfactual.
⚠ One existing test lost a VACUOUS half.
`the_setup_picture_routinely_inherits_a_just_freed_slot` asserted the decode
target was never also a reference while handing every picture its own
never-reused surface id — distinct integers cannot collide, so that assertion
could not fail whatever the conversion did. Its real measurement (225 of 250
access units reuse a just-freed slot, which is why the target is a parameter)
is kept; the collision half is gone, and the doc says where the question is
actually answered and why a recycling pool is what it takes to answer it.
Gates, run on `.25` (Radeon 780M, radeonsi, Mesa 26.0.3, VA-API 1.23), this
rung being Linux-only: `cargo fmt --all -- --check`; `cargo clippy -p
pf-client-core -p pf-vaadec --all-targets --features sdl3/build-from-source --
-D warnings`; `cargo test -p pf-client-core --lib --features
sdl3/build-from-source` (171 passed); the same filtered to `video_vaapi_native`
with `--include-ignored` (18 passed); `cargo test -p pf-vaadec` (48 passed).
Plus the pf-lxcheck2 container for the cross-platform half — fmt, clippy and
`cargo test -p pf-vaadec`, all clean.
All four VAAPI legs still decode with the refactor in place, not one access
unit refused: H.264 225 of 250 access units delivering a frame, H.265 204 of
250, HEVC Main 10 45 of 50 (P010), AV1 250 of 250 — the same counts and the
same tiled modifier 0x200000010401b04 those legs recorded before it. ⚠ The
H.26x legs live on `fix/vaapi-h264-h265-hardware-proof`, not on this branch, so
they were run by overlaying that commit's test module onto the scratch tree;
only the AV1 leg and the libva probe are reachable from here. This is a decode
measurement, not frame-hash parity — the rung exports a driver-tiled DRM-PRIME
dmabuf, so there is no CPU-readable image to hash. The alias assertions above
are the real evidence and they need no device.
⚠ NOT taken: `finish`'s `outputs.last()`, which ships one frame per access unit
and drops the rest of what a bump displaces (225/204/45 against 250/250/50),
with no end-of-stream flush. It cannot bite punktfunk — hosts emit zero-reorder
output, so `outputs` never holds more than one picture — and fixing it changes
`decode()`'s one-frame-per-access-unit contract with the pump (it wants a
deliverable queue, which `video_vk_native` already keeps) plus an end-of-stream
flush and the `keyframe`-labels-the-access-unit defect in the same function.
It is recorded and asserted on that other branch, whose three delivered-count
assertions any fix has to move in the same commit; doing that from here, blind
to them, would be worse than leaving it.
`fd6241a2` made HEVC's freedom from the release-ordering defect falsifiable on CPU and
recorded what was still missing: no low-delay HEVC stream was vendored, so the exemption
rested on a structural argument plus one throwaway measurement. This vendors the stream,
and the exemption HELD.
**The fixture.** `lowdelay-640x480.h265`, 270 KB, 120 pictures — `punktfunk-host spike
--source synthetic --codec h265 --width 640 --height 480 --fps 60 --seconds 2 --bitrate 1`
on .21 (NVENC, RTX 5070 Ti, driver 610.57.04). Deliberately the H.264 sibling's resolution
and frame count: the two are then directly comparable, 640 and 480 are both multiples of
MinCbSizeY so there is no conformance window and a hash mismatch can only be decode rather
than readback geometry, and 270 KB sits alongside the 282 KB already accepted for H.264.
Goldens are libavcodec's software decode, cross-checked BIT-IDENTICAL across ffmpeg n8.1.2
(Arch, x86_64) and 8.1.1 (Homebrew, macOS arm64), 120 of 120 digests distinct.
**The exemption held, measured rather than argued.** `sps_max_dec_pic_buffering_minus1 = 4`
against the four pictures 8.3.2 keeps marked in steady state, `sps_max_num_reorder_pics = 0`,
`numRefL0 = 1` — a five-picture DPB filled exactly by four references plus the current
picture. 115 of the 120 access units retire a picture, and `removed ∩ dpb_refs` is **0 of
120**. A 300-picture 1080p stream from the same host reports the same shape: 295
retirements, 0 intersections. It is the encoder and not the resolution, exactly as for
H.264.
**A zero proves nothing on its own, so the fixture is pinned by its counterfactual.**
`test-25fps.h264` reported zero for two milestones while every stream we ship aliased on
99% of its frames. So the guarantee here is not "we looked and it was fine": hand
`plan_to_dxva_h265` the marked DPB as it stood BEFORE `decode_rps` — the mutation a
snapshot move would cause, reconstructed exactly as `dpb_refs(N-1) ∪ {stored(N-1)}` — and
the alias appears on **115 of 120** access units, driven through the real conversion rather
than through planner arithmetic. If a regeneration ever produced a stream that reordered,
or a DPB deeper than its reference count, that 115 collapses to 0 and the tests say so
instead of continuing to pass.
**The two rungs are exempt for different reasons, and the asymmetry is now a gate.** DXVA
binds the whole marked DPB — `RefPicList` is spec-defined that way, and an RFI long-term
anchor has to survive in it — so its exemption really is `H265Planner`'s snapshot ordering,
one call away from being untrue. `plan_to_vk_h265` never reads `dpb_refs` at all:
`pReferenceSlots` is the slots the operation uses, so it binds the current RPS sets, which
`decode_rps` itself derives and which therefore cannot name a picture that same RPS just
dropped. A new test feeds that conversion the identical widened snapshot and asserts
nothing changes, so a future change making the Vulkan rung bind the marked DPB — a
legitimate thing to want, since a *Foll* anchor invisible to the hardware is the RFI
failure shape — fails loudly instead of silently acquiring the defect.
What the Vulkan pixel leg adds is therefore NOT aliasing coverage, and its docs say so:
it is the first HEVC frame either rung has decoded from our own encoder, under a DPB that
retires and reissues a slot on 115 of 120 access units back to back, where the vendored
vector's reordering keeps that eviction slack.
Legs: `low_delay_host_h265_every_frame_hashes_bit_identical_to_libavcodec` on the Vulkan
rung (10 ignored legs now, up from 9) and on the D3D11VA rung, plus three non-ignored CPU
guards that run in ordinary CI.
Verified: 10/10 Vulkan parity legs on .21 (RTX 5070 Ti, 610.57.04), the new one 120/120
bit-identical; workspace clippy `-D warnings` and `cargo fmt --all --check` clean on .21.
A pre-release sweep of the management console for two things that no type check
and no diff can catch: primitives that were never @unom/ui's, and animation
that a nested motion parent quietly cancelled.
THE PRESET TILES ALL LANDED ON THE SAME FRAME. @unom/ui's <Section> sets
`delayChildren: stagger(...)`, so a page whose cards are direct descendants of
it staggers for free — which is why every page but one looked right. An
<AnimatedCard> is ALSO a motion element and sets no `delayChildren`, and the
Virtual displays preset tiles are cards nested INSIDE that page's config card,
so that card became their timing group. Measured in a headless browser: the
opacity spread between the first and last tile was 0.00 across the whole
animation (six tiles in lockstep), and is 0.98 now — a ~100 ms cascade matching
the rest of the console. The four hand-rolled copies of the stagger container
collapse into one `<Stagger>` that carries the explanation.
FIVE FILES IMPORTED THE WRONG BUTTON. `@unom/ui/button` exports both a plain
`Button` and the `AnimatedButton` that this console's wrapper re-exports under
the same name — so `import { Button } from "@unom/ui/button"` compiles, renders,
and silently opts out of the mount animation and the hover/tap response.
Displays, SessionGame, GPU, Update and PendingDevices had dead buttons sitting
next to live ones.
THREE PRIMITIVES HAD NO WRAPPER, SO NOBODY REACHED FOR THEM. @unom/ui ships
form/select, form/textarea and form/checkbox; components/ui did not, and the
gap was filled with browser-chrome `<select>`, `<textarea>` and
`<input type="checkbox">` in the add-hook modal and both library forms. Select
needs the same token correction Tabs needed — upstream `text-secondary` is a
text colour, but here `--secondary` is a SURFACE, so the trigger's chevron and
placeholder rendered at near-zero contrast on the card behind them.
The hook timeout also stops accepting a value the host rejects: `min`/`max` on
a controlled `<input type="number">` are decoration (no form validation ever
runs), so 900 went into a field capped at 600 and failed later, at run time.
@unom/ui's InputNumber clamps on blur and lets the field be empty while you
retype instead of snapping to the fallback.
Storybook gains the page that had no story at all — the console's largest
config surface, and the reason this shipped unseen. Its <Card> wrapper is load
bearing: it reproduces the motion nesting that IS the bug.
Four findings, all real.
**`SlotMap`'s own docs had become false.** "feed it every `DpbUpdate` in decode order
(via `Self::apply` or `plan_to_vk`, which applies internally)" — `plan_to_vk` no longer
applies internally, which is the entire point of the change, and `release`'s docs named
it as one of the two things that may free a slot. A reader following those docs would
build the next caller wrong in exactly the way this commit's parent fixed. Both now say
which conversions defer, which one does not, and why H.265 is the one that does not.
**The deferred release warned on a legitimate event.** `release_deferred` warned per id
when a deferred release found no slot — but a renegotiation replaces the whole
`Session`, and with it the slot map, INSIDE `plan`, while the planner's own drain
reports every drained picture in that same access unit's `removed`. Every one of those
ids then misses, and nothing is wrong. `debug!`, with the legitimate cause named so the
illegitimate one stays diagnosable.
**HEVC's exemption was asserted only in its consequence.** `the_current_picture_is_
named_by_curr_pic_and_never_aliases_a_reference` checked that no reference shares the
decode target's slot — which on the vendored vector holds whether or not the reasoning
behind it does. That is precisely how the H.264 leg passed for two milestones. The test
now also asserts the PLANNER property the exemption rests on (`removed ∩ dpb_refs = ∅`,
falsified by moving `dpb_snapshot()` above `decode_rps`), and records that the low-delay
measurement was 0 of 300 against H.264's 297 of 300 from the same host and the same run.
It also records what is still missing: no low-delay HEVC stream is vendored, so HEVC's
freedom is a re-derivable argument plus one measurement, not a standing hardware leg.
**Two stale cross-references.** Both AV1 conversions told the reader the H.264/H.265
zero was "measured on reordering vectors and not a proof" — the open question this
commit's parent closed. They now say what the answer was.
The AV1 review round flagged the H.264 leg as "plausibly the same defect, traced in
source, not reproduced" and deliberately did not touch it. It is reproduced now, and
it is worse than the AV1 one: it fires on 297 of 300 access units of every stream a
punktfunk host emits, at 720p, 1080p and 2160p alike, on BOTH the DXVA rung and the
Vulkan one.
**Decided on the CPU, no GPU needed.** `H264Planner` snapshots `dpb_refs` in
`begin_picture`, BEFORE `finish_picture` runs 8.2.5's marking and C.4.5.3's bump, so a
picture the sliding window unmarks and the bump then evicts lands in both `dpb_refs`
(which `RefFrameList` is built from) and `dpb.removed`. The conversion released the
whole `removed` list and then assigned the decode target a slot; `SlotMap::assign`
takes the lowest free slot, which is the one just vacated. `CurrPic = N` and
`RefFrameList[k] = N`, in one submission.
The two conditions have to coincide in ONE access unit, and low-delay H.264 is exactly
what makes them: `max_num_reorder_frames = 0` means the evicted picture has already
been output, which is what makes it evictable at all. NVENC seals it by writing
`max_num_ref_frames = 3` ALONGSIDE `max_dec_frame_buffering = 3` — a DPB exactly as
deep as its reference count — so the window unmarks the oldest reference in the very
unit whose bump drops it. The aliased picture is `ref_idx 2` of a three-entry
`num_ref_idx_l0_active` list: addressable by any macroblock, not a spare.
**Why two hardware-proven codecs and four GPUs never saw it.** `test-25fps.h264` is
level 1.3 with no VUI `bitstream_restriction`, so `dpb_limit` falls back to A.3.1's
level ceiling and gives a 7-frame DPB against 2 reference frames — the window unmarks
two units before the bump can evict — and it REORDERS, which keeps an unmarked picture
alive past the unit that unmarked it. Two independent reasons, both properties of that
vector rather than of H.264. It measured zero and passed 250/250 throughout.
`data/lowdelay-640x480.h264` is vendored to close exactly that: our own host's output,
120 pictures, goldens from libavcodec cross-checked bit-identical across two ffmpeg
builds on two architectures.
**The fix is the AV1 fix.** `DecodePlanDxva` and `DecodePlanVk` grow
`release_after_decode`, the conversions hand the removals back instead of applying
them, and the callers release them once the decode op is issued. It costs no slot the
map does not have: `SlotMap::new` allocates `max_dpb_frames + 1` and the DPB never
exceeds `max_dpb_frames`, so a free slot always exists with the whole `removed` list
still held — measured, peak 4 of 4 on the stream that defers on 117 of 120 units.
The Vulkan rung breaks on it in both DPB modes and neither loudly: DISTINCT hands the
aliased reference the same array layer the setup writes; COINCIDE clears
`slot_image[setup]` in the binding sync and the reference then resolves to no bound
image, dropping out of `pReferenceSlots` with a `trace!`. Its deferred release runs on
the FAILURE paths too — the fallible region's Result is held rather than `?`-ed,
because seven exits sat between the conversion and the release and each would have
leaked a slot.
`a_full_dpb_bump_reuses_the_slot_but_the_pool_model_binds_a_fresh_image` asserted the
aliasing as "the planner's normal behaviour": an authored depth-1 stream whose AU1
references the picture it evicts. It now asserts the opposite, which is the defect in
two lines.
New evidence, all of it runnable: the CPU proof pins BOTH numbers (0 on the vector,
117 of 120 on the low-delay stream) so neither can drift silently; the ledger-pressure
test measures the peak; and a low-delay parity leg is added to `pf-vkdecode`'s
`gpu_parity` and `pf-client-core`'s `video_d3d11_native::parity` so both rungs are held
to what they stream rather than only to what they conform to.
The `damaged` path has cleared `Session::held[setup_slot]` since M7, for a reason
that now applies to the failure path too: the slot map says the surface holds THIS
picture while the surface still carries whatever the previous occupant decoded, so
a later `show_existing_frame` naming it blits the old picture's pixels with the old
picture's geometry and colour. The failure path never reached that far before —
`decode_into`'s error returned straight out of `frame_av1` — and the previous commit
made it continue so the slot releases could run.
Five findings from the adversarial pass, all real.
**The deferral predicate was vacuous.** `plan.dpb.removed` is ALWAYS a subset of
`plan.dpb_refs`: `Av1Planner::plan_frame` snapshots `dpb_refs` before any mutation
and `refresh_slots` can only report a picture that was in `self.slots` at that
moment. So `filter(|id| dpb_refs.contains(id))` was a condition that is never
false, the eager-release loop beside it could never release anything, and the test
assertion "only a picture the submission points at earns the reprieve" could never
fire. Now: defer every removal, say why in terms of the planner, and assert the
PLANNER's property (`removed ⊆ dpb_refs`) — which is falsifiable, and whose failure
would mean the conversion is releasing a surface `ref_frame_map` points at.
**The failure-path claim was overstated.** Holding the decode's `Result` closes
this frame's leak, not the unit's: `decode_av1` returns on the first failing frame
and abandons the rest of the temporal unit's plans, so their removals are never
released. 24 of 250 units carry a second frame. Named rather than fixed — what to
do with the frames after a failure is the pump's question.
**⚠⚠ The H.264 leg plausibly has the same defect, and the comment this change added
said it could not.** `pic.rs` builds `RefFrameList` from `plan.dpb_refs`, and
`H264Planner` snapshots that in `begin_picture` — BEFORE 8.2.5 marking and the DPB
bump. The vendored bump drops a picture the sliding window just unmarked once it
has been output, so a picture can land in both `RefFrameList` and `dpb.removed`:
the AV1 aliasing shape exactly. Measured zero on the vendored vector — but that
vector REORDERS, which is precisely what keeps an unmarked picture alive past the
AU that unmarked it. A punktfunk host emits LOW-DELAY H.264, where output happens
as each picture is decoded, which is the condition that makes eviction and
unmarking land in the same access unit. Traced end to end in source, not
reproduced (no low-delay vector). NOT fixed: changing a hardware-proven codec on an
unreproduced suspicion is the worse risk two commits before a release. Instead
`no_au_removes_a_picture_its_own_reference_list_names` makes the assumption
falsifiable, and its message says what to do when it fires. HEVC is structurally
safe and now says why: `H265Planner` snapshots `dpb_refs` AFTER `decode_rps`.
**Four more stale promotion sites**, past the four already fixed: `Backend::
NativeD3d11va`'s variant doc, `Decoder::new`'s Windows rung comment, `lib.rs`'s
module note and `clients/session/README.md`. Two sites that used the AV1 leg as
the live EXAMPLE of an unproven rung are marked as expired rather than deleted —
the reasoning is what the next bad-evidence leg will need.
**The AV1 dump was missing.** `PF_DXVA_DUMP` wrote h264 and hevc only, for the one
codec whose libavcodec capture has never been taken and where the dump is
therefore the only tool.
Reverts half of 1eab4b66 and closes G10's open frame question, both settled by the same
measurement.
1eab4b66 made two corrections to the Apple phone-gyro mirror. The negation was right and
stays: Apple reports the gravity VECTOR, pointing down, while an accelerometer measures
proper acceleration, pointing up at rest, and the wire carries the latter. The frame
change was wrong, and this removes it.
The mistake was a name collision. Two different frames are both called "the controller
frame". GCMotion reports a CONTROLLER in (Right, Forward, Up) — measured on a real
DualSense — which is not the wire's frame, which is why `GamepadCapture.forwardMotion`
converts. The mirror's orientation remap resolves THIS DEVICE into the frame its header
describes, x right, y up, z out of the screen. For the pose that mirror exists to serve —
a phone clipped upright with the screen facing the player — "out of the screen" points at
the player, so that frame is (Right, Up, Backward), which IS the wire's. It was already
correct. Applying the controller path's conversion on top rotated it out of true: a phone
sitting still would have reported gravity as −1 g on the roll axis rather than +1 g up,
i.e. claimed to be lying on its edge.
Reasoning by analogy is what produced it — "the mirror says controller frame, the capture
path says controller frame, so the same fix applies". Both files say it; they mean
different things.
What caught it was measuring the Android twin, which does the same thing straight through.
On glass: a DualSense on Bluetooth to a phone, streaming to a Linux host, reads +1 g on the
up axis end to end. Had the Apple mirror needed a conversion, the Android one would have
needed the same one and would have been visibly wrong. It is not.
The same run settles G10's frame, which shipped straight-through and explicitly unverified
because nobody had put a Bluetooth pad in front of the platform sensor framework. Now
somebody has. `PadSensors`' own first-sample log read `accel 0, 10000, 0` — exactly 1 g on
slot 1 — and at the far end hid-playstation published gravity as +0.991 g on ABS_Y, with
every rotation driving its correctly-named axis and the signs agreeing with gravity's
independent witness on 95 of 100 rotating samples. Android hands a controller's sensors
over in the pad's own frame, as documented. No remap, and the comment now says measured
instead of assumed.
Worth recording why the earlier suspicion was wrong, since it is the same trap in the other
direction: Android's DEVICE sensor frame really does put +z out of the screen, so a flat
phone puts gravity on z — but a CONTROLLER's sensors are reported in the controller's
frame, not the phone's. One platform, two conventions, chosen by what the sensor is
attached to.
Gate: Apple macOS `swift build` + full suite (215 tests, 5 skipped, 0 failures) and the
iOS-triple typecheck, which is what actually compiles `DeviceGyro.swift`; Android
`:kit:compileDebugKotlin`, `:kit:testDebugUnitTest`, `:app:compileDebugKotlin`. Green.
Still owed: `DeviceGyroRemap`'s four orientation matrices remain derived — this run used a
controller's own sensors, not the mirror, so it says nothing about them. They need a
gyro-less pad on wire index 0 and a phone turned through all four orientations.
`the_evidence_table_says_exactly_which_rungs_have_run_on_hardware` asserts the
same fact a third way — a proven list and a NOT-proven list, both spelled out —
so promoting the rung in the three places the handoff named still left a test
saying "the DXVA AV1 leg FAILS parity on two GPUs — claiming otherwise is the
dishonesty this program must not ship". It was right to fail; the pair moves
lists here.
Three prose sites that still described the leg as decoding wrong pixels move
with it: `native_supports_av1`'s device-facts note, `log_rung`'s honesty-surface
docs, and the OPEN question in the Windows Intel arm of `pick_native` — that last
one is marked CLOSED rather than deleted, because the question it raised (the
evidence filter asks "any evidence", and has no answer for BAD evidence) is a
real gap in the rule that outlived this particular leg.
The evidence table said these legs "have still never decoded a frame anywhere",
and VAAPI is the rung every Linux AMD/Intel client lands on. They have now
decoded, on `.25` (Radeon 780M / Phoenix1, RDNA3, radeonsi, Mesa 26.0.3, VA-API
1.23, /dev/dri/renderD128):
H.264 250/250 access units accepted, 225 frames delivered, NV12
H.265 250/250 accepted, 204 delivered, NV12
HEVC Main 10 50/50 accepted, 45 delivered, P010
(AV1, unchanged: 250/250 accepted, 250 delivered, NV12)
all on the same tiled AMD modifier (0x200000010401b04). Not one access unit of
any vector was refused.
Three `#[ignore]`d legs modelled on the AV1 one, plus the Annex-B access-unit
splitters they need — ported verbatim from `video_d3d11_native`'s test module so
the two platform rungs are driven over the same access units rather than over two
splitters free to disagree. Main 10 earns a third leg rather than a variation on
the second: ten bits is a different VAAPI profile, a different render-target
format and a different surface fourcc, and that leg's fourcc assertion is the
only thing that would catch a driver quietly handing back NV12 for a ten-bit
stream.
This is NOT frame-hash parity, and the doc comments say so rather than letting
the test names imply it. The Vulkan and D3D11VA legs hash every frame against
libavcodec because both can read their decoded surface back; this rung exports a
DRM-PRIME dmabuf whose memory the driver tiles, so there is no CPU-readable image
to hash without a `vaDeriveImage`/`vaGetImage` path production neither uses nor
wants. What these legs prove is that every access unit is accepted, that the
expected number of frames comes back, and that each one is a real exported
surface of the right shape and fourcc — enough to turn "never decoded a frame
anywhere" into a measurement, not enough to promote the rung to `verified`.
Two findings the run surfaced, neither of which bites punktfunk's own streams:
* The delivered counts are 225/204/45, not 250/250/50, and that is the RUNG, not
the driver. `finish` shows `outputs.last()` and never more, so an access unit
whose plan bumps several pictures out of the DPB displays the last and drops
the rest — 18 dropped at the H.264 vector's three draining IDRs, 45 on the
H.265 vector's 45 two-picture bumps — and there is no end-of-stream flush.
Hosts emit zero-reorder low-delay output with no B pictures, so `outputs` never
holds more than one picture in the field. A CPU-only test derives all three
counts from the planner alone, on any Linux box with no GPU, so they stay
explanations rather than recordings.
* `DmabufFrame::keyframe` labels the ACCESS UNIT, not the picture delivered:
`finish` is handed the current AU's `is_idr`. On a reordering stream the IDR is
bumped out several access units after it decoded and arrives flagged `false`,
while the access unit that drains the DPB at a later IDR flags whichever old
picture it displays as a keyframe. That flag is `DecodedImage::is_keyframe`,
the pump's post-loss re-anchor signal. Asserted so that fixing it is noticed,
not so that it is preserved.
Gates, all run on `.25` (this rung only compiles on Linux): `cargo fmt --all --
--check`; `cargo clippy -p pf-client-core --all-targets --features
sdl3/build-from-source -- -D warnings`; `cargo test -p pf-client-core --lib
--features sdl3/build-from-source` (169 passed); the same filtered to
video_vaapi_native with `--include-ignored` (16 passed). Plus the pf-lxcheck2
container's workspace-wide `cargo fmt --all -- --check` and `cargo clippy
--workspace --all-targets -- -D warnings`, both clean.
The evidence table in `video.rs` still says these legs have never decoded a
frame. It is being edited concurrently, so its replacement row is handed over
rather than raced for here.
Two halves.
**The harness.** `libav_picparams_parity` covered H.264 and HEVC only, which is
exactly the gap that let a wrong AV1 submission ship. It now plans, converts and
packs all 274 frames of the vendored AV1 vector and checks what needs no capture:
the three-buffer descriptor set with no quantization matrix (AV1's matrices are
selected by index, so `dxva2_av1_end_frame` passes NULL/0 and there is no buffer
to submit), no macroblock count anywhere, the 912-byte picture-parameter buffer,
and the tile records — which unlike H.264/HEVC slice records do NOT abut, because
a `DXVA_Tile_AV1` addresses a tile PAYLOAD and consecutive payloads are separated
by their `tile_size_minus_1` fields.
The one that matters most is `no_av1_submission_names_its_decode_surface_in_the_
reference_store`: the invariant the previous commit fixed, over the submitted
BYTES rather than over the plan. libavcodec cannot produce that shape — it fills
`RefFrameMapTextureIndex` from the pre-refresh store and takes
`CurrPicTextureIndex` from a frame the reference update has not run on — which is
the argument for calling it a defect rather than a convention.
`AV1_FIELDS` reaches into the eight nested blocks (`tiles.widths`,
`segmentation.feature_data`, …) so a future capture reports a field and not "260
bytes of tiles differ"; `field_table!` grew nested-path support for it. The
`#[ignore]`d `our_av1_picture_parameters_match_libavcodecs` and the capture recipe
are in place, and `the_dump_and_the_parser_agree…` now self-compares AV1 too.
⚠ NO libavcodec AV1 capture was taken and the module docs say so rather than
leaving an absent result to be read as a pass: `.221` has no MSYS2, no gcc and no
make, so a patched FFmpeg there is a toolchain bring-up, not a build. Everything
this file claims about libavcodec's AV1 side is READ out of `dxva2_av1.c` (n8.1).
That reading did turn up one live divergence, recorded at `pic_av1.rs`'s
`pp.width` and deliberately NOT changed: libavcodec sends `avctx->width`, which is
FrameWidth (pre-superres), where this crate sends UpscaledWidth. The two are equal
whenever superres is off, which is every stream that exists here, so the 250/250
result says nothing either way and a blind change would be unmeasured.
**The promotion.** `(D3d11va, CODEC_AV1)` is `verified` — 250/250 delivered frames
bit-identical to libavcodec on an RTX 3500 Ada AND an Intel Arc. All three places
move together: the evidence arm, the module table and
`every_rung_runs_and_the_unproven_ones_are_named`, whose `unproven` array loses the
pair and whose proven list gains it.
⚠ This changes rung SELECTION, not just a label. `verified` is what lets `auto`
pick D3D11VA ahead of Vulkan Video, so Windows Intel and unknown-vendor boxes —
where the ladder is `native-d3d11va → native-vk → sw` — now decode AV1 on D3D11VA
where they previously fell to Vulkan. Taken deliberately: ~10x the Vulkan leg's
speed, and the parity that promoted it was measured on an Intel Arc, which is the
vendor family the change moves. Still no soak on the goldens, and the notes say so.
Also: `frame_av1` holds the decode's `Result` instead of `?`-ing it, so both slot
releases run on the failure path. `decode_av1` notes an error and keeps the
session rather than rebuilding the slot map, so an early return leaked a surface
per failed frame and hit `SlotError::Full` after nine.
AV1 applies `refresh_frame_flags` AFTER the frame is decoded (7.20), so a frame
that reads a reference slot and then overwrites it is the ORDINARY case, not an
exotic one: 268 of the vendored vector's 274 frames do it, first at frame 6.
`plan_to_dxva_av1` released every displaced picture inside the conversion — which
is what the H.264 and H.265 siblings do with their whole `removed` list — and then
assigned the decode target a slot. `SlotMap::assign` takes the lowest free slot,
and the lowest free slot is the one just vacated. So the submission said
`CurrPicTextureIndex = N` and `RefFrameMapTextureIndex[k] = N` in the same breath,
on 268 of 274 frames: decode into the surface you predict from.
Neither vendored H.264 nor H.265 vector ever produces that shape (measured: zero
on 250 AUs), which is why an eager release survived two hardware-proven codecs and
opened on the first AV1 frame past the key frame's neighbourhood. HEVC even has
the invariant under test already — `the_current_picture_is_named_by_curr_pic_and_
never_aliases_a_reference` — and AV1 had nothing.
The Vulkan rung already carries the fix; this is the same contract, and the DXVA
constraint is the STRICTER of the two: Vulkan binds only the references a frame
names, while `RefFrameMapTextureIndex` declares the whole store, so every picture
the store still names has to survive the conversion. `DecodePlanDxvaAv1` grows
`release_after_decode` and `frame_av1` applies it once the decode op is issued —
next to the `refresh_frame_flags == 0` release that already waits for the same
reason. Peak surfaces held goes 7 of the 9 the pool allocates, so the spare slot
`SlotMap::new` adds is doing exactly the job it exists for.
Measured on hardware before the fix: Intel Arc got 245 of 250 delivered frames
wrong — 47% of luma at the first bad frame, max |delta| 242, chroma wrong too, a
frame predicted from the wrong picture — and the only late frame it got right was
the one intra frame, which names no reference and so could not alias. That reads
as a `primary_ref_frame` defect and is not one: PRIMARY_REF_NONE and "has no
references to alias" are the same frames.
main went from 35ba64ca to a8a4b11f while this branch sat open — 190 more commits,
taking 0.25.0 to 327 since v0.24.0 and making it the largest release so far. That
scale is what forced the restructure.
THE SPLIT. Through v0.24.0 the engineering detail lived in an `## Under the hood
(for developers)` section at the bottom of the notes. It had grown to 21 dense
bullets sitting under the user-facing half — the exact burying the voice rules
exist to prevent, and it would only have got worse here. So:
* `CHANGELOG.md` at the repo root is now the technical half, newest release
first. It opens with a version table that lists every protocol number
INCLUDING the unchanged ones, because "did the driver protocol move?" is the
question an embedder most often needs answered and "no" is a real answer.
Then breaking changes, capability bits, wire planes, env vars, and the rest.
* `docs/releases/vX.Y.Z.md` keeps a short `## For developers` pointer and
otherwise contains no internal names at all.
* The link targets the file AT THE TAG, not at main. A release's notes are
frozen; a main link would silently start describing a later release.
* README.md and TEMPLATE.md now document this as the ritual rather than
leaving it a one-off, including a new rule 7 requiring the TL;DR.
The notes are SHORTER than before the merge — 83 lines against 108 — while
covering three times as much. That is the point.
TL;DR, six bullets: FFmpeg gone from the desktop clients; AV1 at 4K shipping half
of every frame; HDR/10-bit leaving half the encoder idle; Windows hosts minting
their own audio devices; the controller sweep; and settings silently resetting.
The last bullet points at `## Before you update` rather than restating it.
WHAT I DELIBERATELY DID NOT PROMOTE. #96 (HEVC DPB) and the rav1d half of #97 are
ship-blockers for the NEW decode stack, not live field bugs — verified, `git
ls-tree v0.24.0 -- crates/` has none of pf-vkdecode/pf-bitstream/pf-dxvadec/
pf-vaadec and `dpb_limit` did not exist at the tag. Nobody on a released build
has ever hit them. They are in Fixed and in the changelog, not the TL;DR, which
is reserved for things a reader is living with today. (A memory note claiming
shipped 0.24.x clients carry the HEVC bug was wrong and has been corrected — it
was about to drive a release decision.)
Also kept out of user-facing Fixed: the plugin-UI empty panel (fixes the origin
split from earlier in THIS release, so it folds into that change — but its 47993
firewall consequence IS in `## Before you update`, because an upgraded host keeps
a 47992-only rule and every plugin interface goes blank), and the pad-audio
WASAPI path fix (repairs a Windows build break in never-shipped code).
HONESTY CARRIED FORWARD rather than smoothed. The notes say plainly that the
Android overlay change did not make the stream faster, that nothing is
recoverable from the Windows non-C: settings loss, that Deck HDR still needs
Steam's own display setting, and that VB-CABLE should be left installed. The
changelog's verification section says the FFmpeg-deletion milestone never
executed on a GPU, pf-vaadec has never decoded a frame anywhere, openh264 has
never run on glass, and controller audio has never touched a real DualSense.
Play's "What's new" refreshed and re-fitted: swapped the TV-profiles line for the
safe-area/notch fix, which is visible to every modern phone user. First draft came
out at 525 chars; trimmed to 497/500, verified with android.yml's gate logic.
Re-verified after the merge (Cargo.lock conflicted — took main's and regenerated
the bump): lock diff versions-only 35/35 against origin/main, `cargo metadata
--locked` resolves (39 members; fec-rs, pf-driver-proto, usbip-sim and the newly
vendored cros-codecs keep their own versions), `cargo fmt --all --check` clean in
both workspaces, doc lazy-continuation scanner 0 hits over 579 files, notes body 0
internal-vocabulary hits above `## For developers`.
Wire 2. C ABI 14 -> 17 (15 rumble floor, 16 pad audio, 17 session end reason).
Driver protocol 6 and gamepad channel 3 untouched. host_caps is down to its last
free bit (0x80) and video_caps has been full since 0.23.0 — both now stated in the
changelog rather than left to be rediscovered.
The follow-up was framed as "build the frame-hash parity harness the D3D11VA AV1
rung is missing, then flip hardware_verified to true". Both halves were wrong.
The harness was never missing. `video_d3d11_native`'s `parity` module has carried
`av1_every_delivered_frame_hashes_bit_identical_to_libavcodec` since M7 wired the
rung — wired to the SAME libavcodec goldens the Vulkan AV1 leg passes against,
with the display-order model that handles the vector's 24 hidden frames, sitting
`#[ignore]`d beside the H.264/H.265/Main10 legs. It had simply never been run on a
device; .173 was powered off the day it was written. What the old evidence note
called a missing harness is real about pf-dxvadec the CRATE, which cannot host one
— it links no D3D11 — but the device half lives here and was already done.
Run on .221, it FAILS, on both GPUs, deterministically (three runs each, identical
first-divergent frame and identical hashes): 186/250 diverging display frames on an
RTX 3500 Ada, 245/250 on an Intel Arc.
It is the decode that is wrong, not the measurement, and three independent checks
say so. H.264 and H.265 pass 250/250 and HEVC Main 10 50/50 through the same
harness, the same readback geometry, the same crop and the same slot map on those
same two GPUs. pf-vkdecode's Vulkan AV1 leg reproduces the same golden file 250/250
on the same box. And the goldens regenerate byte-for-byte from the ffmpeg build
their own header names.
Two signatures, and they are not one defect wearing two faces. NVIDIA is bit-exact
for display frames 0..=63 and then loses ONE 16x24 luma block — 174 pixels, max
|delta| 8, chroma untouched — on the frame whose order_hint first reaches 64, after
which every remaining frame is downstream of it through prediction. The stream
parks the key frame (order_hint 0) in BWDREF and ALTREF2 for its whole length, so
64 is where the distance to it reaches the edge of what get_relative_dist can
represent at OrderHintBits = 7. Intel is structurally wrong from display frame 4 —
47% of luma, max |delta| 242, chroma wrong too, a frame predicted from the wrong
picture — and the only later frame it gets right is the one whose primary_ref_frame
is PRIMARY_REF_NONE.
None of this is visible on glass, which is the whole argument for goldens: the rung
streams 4K60 on both parts with a clean five-minute soak at roughly ten times the
Vulkan leg's decode time. The 2026-08-07 field sessions that looked clean were
looking at wrong pixels.
So hardware_verified stays false, and the note now says why in the strongest
available terms — it prints at warn on every session that lands here, and "decodes
AV1 to wrong pixels" is what a support engineer needs to read. The pair stays in
`every_rung_runs_and_the_unproven_ones_are_named`'s unproven array; its note still
contains NEVER, because the pair has never PASSED parity, which is now a measured
statement rather than an absence.
Left deliberately unchanged: `auto` on Windows can still reach this rung for AV1,
and on Intel it is the arm that fires, because that vendor advertises no SAMPLED
usage on any decode profile so zero-copy Vulkan Video cannot run there. Barring it
trades visibly-wrong AV1 for the software rung, which cannot keep up at 4K and is
itself unproven. Which way that trade goes is a product call, so it is recorded at
the admission site rather than made silently here.
`av1_divergence_map` is kept, cleaned up and documented: it is what turned "186
frames differ" into a lead — one line per display frame, its verdict beside the
plan facts that could explain it, and an opt-in raw-NV12 dump. At a frame where one
vendor hashes correctly, that vendor's bytes ARE libavcodec's bytes and so a valid
reference for the other's, which is how "how badly" was answered without new
goldens. The tool that would localise the rest does not exist: pf-dxvadec's
libav_picparams_parity covers H.264 and HEVC only, so the AV1 conversion has never
been compared against libavcodec at the picture-parameter level either. That is the
next step, not another session.
Also in this file, since it is the same table and the same day: the VAAPI rung's
AV1 leg has now decoded 250/250 of the vendored vector on RDNA3 and its arm is
split from the H.264/H.265 ones, which genuinely have still never decoded anything.
It is unverified for the same reason as ever — no parity — and the D3D11VA row
above is exactly why that distinction is worth keeping: a rung can decode 250
frames and still be wrong.
Follow-up to the G10 merge. The new sensor path was written against main, which does not
carry this branch's G8 work, so it forwarded motion unconditionally — the one thing G8
exists to stop.
`deviceMotion` checked `forwarding` and nothing else. A Bluetooth DualSense in a session
that resolved to an X-Box backend would stream ~200 Hz of samples the host parses and
discards, for the whole session, exactly as the USB capture path did before G8. Not a
regression against shipped behaviour — the path is new — but it would have shipped the
defect back into a client that had just been taught not to have it.
`Slot` now carries `motionReaches`, asked once at open off the kind that pad DECLARED, in
the same shape `ExternalPad` already used. Per pad, not per session: under Automatic the
handshake carries the active pad's kind, so a couch with an X-Box pad on slot 0 and a
DualSense on slot 1 must not have slot 1's working gyro suppressed by slot 0's answer.
The notice moved to where the truth is known. `openSlot` knows only what kind a pad
declared, not whether it physically has a gyro — that is discovered later, when
`PadSensors` finds a gyroscope and calls `setDeviceHasSensorMotion`. Raising it there is
the only placement that both tells a player whose gyro is being dropped and stays silent
for the pads that never had one.
Also unified the last duplicate scale in the module. G10 hoisted the wire units into
`Gamepad` and pointed `DeviceGyro` at them, but `DsDevice` kept its own `20L` / `10000L`
— and `Gamepad`'s new comment claims every sender goes through one place, which was not
yet true. Two copies of a unit constant in one module is precisely the defect this program
opened with (a DualShock 4 blob 40× hot because a second copy had drifted), so the claim
and the code now agree. `val` rather than `const val` only because widening to Long is not
a constant expression; Long is deliberate, since the calibration arithmetic overflows an
Int before it divides.
Proven non-vacuous rather than assumed: changing `Gamepad.MOTION_GYRO_LSB_PER_DEG_S` from
20 to 16 now fails four named cases across three classes —
`DsDeviceTest.calibrationRescalesRawCountsOntoTheWireUnits`,
`.theHostsOwnBlobIsAPassthrough`, `.parseStateAppliesTheCalibration` and
`DeviceGyroTest.wireUnitConstants`. Before this change `DsDevice` would not have noticed.
The gate itself has no test, for the reason the surrounding code already documents:
`GamepadRouter` needs Android plus a live JNI handle, there is no Robolectric in this
module, and a mock would test the mock. It is argued at the call sites instead.
Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest`, `:app:compileDebugKotlin`,
`:app:testDebugUnitTest` — kit 75 / app 67, 0 failures, counts read out of the JUnit XML.
The merge reconciles: 62 on this branch, plus 6 from main's DeviceGyroTest, plus G10's 7.
Android had two motion sources and both of them are USB claims. DsCapture
takes a Sony pad's HID interface away from the kernel; Sc2Capture does the
same for a Steam Controller 2. Everything else — a DualSense, a DualShock 4,
a Switch Pro, an 8BitDo, paired over Bluetooth — arrives as an ordinary
InputDevice. Its buttons worked, its sticks worked, and its gyro was dead,
silently, with no log line and nothing in the UI to suggest the pad had a
sensor at all. That is not one controller, it is the whole class of
controllers people actually pair to a phone.
The platform has had the answer since Android 12: InputDevice.getSensorManager
hands back a SensorManager scoped to that one controller, carrying its
TYPE_GYROSCOPE and TYPE_ACCELEROMETER. PadSensors registers a listener per
forwarded pad that has a gyroscope and sends the samples on that pad's wire
index. Below API 31 it registers nothing and the pads behave exactly as they
did.
It is built on DeviceGyro's shape, because the phone mirror had already paid
for these lessons. One dedicated HandlerThread, never the main one. Batching
off (maxReportLatencyUs = 0) — batching would trade away precisely the latency
gyro aim exists to avoid. 200 Hz requested, which is also the ceiling the
framework grants an app without HIGH_SAMPLING_RATE_SENSORS, so asking for more
would only be capped. And a feed that lets go of a pad still alive parks its
rotation at zero first: the host holds motion as state and re-emits it in every
virtual-pad report, so an angular velocity left behind is a pad that rotates
forever.
Two writers on one pad's motion is the failure this program has spent the day
unpicking, so the coordination is explicit in three places. A USB capture wins:
DsCapture.startUsb already calls releaseDevice at claim time, that closes the
slot, and the close now also takes the sensor listeners off — the claim makes
the InputDevice vanish anyway, but going through the explicit teardown is what
makes the ordering deterministic instead of a race against the platform's own
removal callback. The phone-gyro mirror stands down: registering flips a bit
the router reports through padHasOwnMotion, which DeviceGyro re-reads on every
sample and answers with its own zero park. And a pad with an accelerometer but
no gyroscope is deliberately NOT taken — it could only send gravity while
pinning rotation at zero, on a pad the mirror is otherwise entitled to speak
for, which is the same fight in a quieter costume.
The wire units are measured fact (punktfunk_core::input::gamepad: 20 LSB/deg·s,
10000 LSB/g), and they now live in exactly one place on this client:
Gamepad.motionGyroWire / motionAccelWire, which DeviceGyro was hand-inlining a
second copy of. The gyro program's first finding was a client sending 40x hot
because a second copy of a number had drifted, and the merge that followed
found a sender nobody remembered to correct. One function, both callers.
THE AXIS FRAME ON THIS PATH IS NOT VERIFIED, and the mapping is deliberately
straight through rather than guessed at. What is known: the wire is a unit
passthrough into a virtual DualSense report, and that report's frame was
measured over raw HID on 2026-08-07 as (Right, Up, Backward-toward-the-player)
carrying (pitch, yaw, roll), right-handed — which is why the USB path forwards
the pad's own order un-remapped and is correct to. Android documents its sensor
frame for a handheld device as +x right, +y up, +z out of the face, the same
frame once "the face" is read as the one the player looks at. So straight
through is what the documentation implies. What nobody has done is put a
Bluetooth DualSense in front of the platform sensor framework and compare —
those numbers come through a HID driver and InputFlinger's sensor mapper,
either of which could permute or negate without saying so. A plausible-looking
wrong remap is exactly the bug this program keeps finding, so the code says
unverified and names the measurement that settles it, and each feed logs its
first converted sample so the cheapest half of that measurement — which slot
gravity lands on with the pad flat and still — costs a logcat line.
PadSensorsTest pins the scale, the clamp, the rounding and the straight-through
order, mutation-checked four ways: 20 to 16 fails gyroScaleFromRadiansPerSecond
and straightThroughFrame, reversing the axis order fails straightThroughFrame,
truncating instead of rounding fails roundsToNearestNotTowardZero, and negating
the accel fails restingPadIsTheHostNeutral. Its frame expectations are written
to change together with any remap that lands, not to be edited around one.
GamepadRouter needs Android and a live JNI handle and there is no Robolectric
here, so its half is argued in comments beside the code, as DsCapture's claim
ordering already is.
Gates: kit 65 tests (58 before, plus 7), app 67 unchanged, 0 failures, read out
of the JUnit XML rather than off a green build.
G17's motion half. The docs described what the CLIENT sends and stopped there, which
made a promise the host does not always keep.
The support matrix said a desktop client forwards motion from any pad SDL exposes a gyro
on "and the host injects it into the matching virtual pad". The first clause is true; the
second is only true when the virtual pad has a motion plane. The X-Box 360 and One
backends do not — no gyro in their HID contract — so the host parses every sample and
discards it. That is where *Automatic* lands anything it does not recognise as Sony or
Valve, an 8BitDo with a perfectly good gyro included, and where a Switch Pro lands on a
Windows host with no `hid-nintendo` backend to fold it into.
A reader following the old text would conclude their gyro was broken. The failure has no
other symptom: motion just does nothing.
So both pages now say what to do about it — pick a DualSense-class type — and the
client-settings page says it where the choice is actually made, next to the degrade
paragraph that explains why a session ends up on an X-Box pad in the first place.
The Deck's Steam-Input requirement moves out of Decky's settings blurb, which is the one
place a Deck user streaming FROM the Deck would never look. With Steam Input on, Steam
hands the app its own virtual X-Box pad, so no controller-type choice can help: there is
no gyro on the pad the client can see.
The picker help text now mentions motion on GTK and Android, which is where it was
missing — Windows already said it and Apple says it in its own words. One sentence, the
same sentence, so the four clients answer the question the same way.
This is the doc side of the on-screen notice that shipped earlier in this branch. The two
exist for the same reason and now agree: the client says it when it detects the case, the
docs say it when someone goes looking.
Not covered: the preset COUNTS in note 1 ("Android and the console home offer six …
Windows and Apple offer five") are still unverified against the four pickers, and the
Apple picker's missing Steam Deck entry is a code gap rather than a doc one. Both are
noted in the plan and left for their own change rather than guessed at here.
Gate: Linux CI image fmt + `clippy --locked --all-targets -D warnings` on
punktfunk-client-linux (the GTK string is compiled) plus the core crates and their tests;
Android `:app:compileDebugKotlin` + `:app:testDebugUnitTest`. Green.
The evidence table has said "native VAAPI: has never decoded a frame anywhere
(M6/M7)" since the rung was written. That is no longer true. Measured on `.25`
(Radeon 780M / Phoenix1 RDNA3, radeonsi, Mesa 26.0.3, VA-API 1.23, Ubuntu
26.04 — headless, no display server needed):
VAAPI AV1 rung constructed: native-vaapi av1
VAAPI AV1: 250 frames delivered, first 320x240 fourcc="NV12"
modifier=0x200000010401b04
250 of 250 displayed frames, first try, on the same vendored vector the Vulkan
and D3D11VA AV1 legs walk. The count matters as more than a smoke test: the
vector carries 274 coded frames in 250 temporal units — 24 units carry two, and
those extras are HIDDEN (decoded, referenced, never shown) — so 250 delivered is
this rung agreeing with the other two about which frames are output. A tiled AMD
DRM modifier rather than a linear one says the surface is a real decode target,
not a fallback.
Two changes, both in the rung's own file.
**The probe never asked about AV1.** `probe_this_machines_libva` walked H.264
High, HEVC Main and HEVC Main 10 and stopped there, which is part of why "never
decoded a frame" could stand so long without anyone noticing what had not been
asked. It now covers both AV1 profiles, and this box answers:
H.264 High: VLD decode AV1 Profile 0: VLD decode
HEVC Main: VLD decode AV1 Profile 1: no (VAProfile not supported)
Profile 1 being refused is correct — 4:4:4 AV1, which radeonsi does not do — and
it is the negative case that proves the probe reports rather than assumes.
**`av1_decodes_the_vendored_vector_on_this_machines_vaapi`** is the decode
itself, `#[ignore]`d beside the probe.
It is deliberately WEAKER than the Vulkan and D3D11VA AV1 legs, and the docs say
so rather than letting the name imply parity: those two hash every frame against
libavcodec's goldens because both can read their decoded surface back. This rung
hands out a DRM-PRIME dmabuf whose memory the driver tiles, so there is no
CPU-readable image to hash without adding a vaDeriveImage/vaGetImage path that
production neither uses nor wants. So it asserts what can be asserted honestly —
every temporal unit accepted, the right number of frames back, each a real
exported surface of the right shape, the first flagged as a keyframe — and it is
NOT frame-hash parity. Promoting this rung to `verified` still wants parity, and
parity wants a readback path first.
It fails loudly rather than skipping when the device has no AV1 entry point. It
is `#[ignore]`d, so it only runs when someone points it at a box that is supposed
to have one, and a silent pass there is exactly the invisible-failure mode this
program exists to end.
Gates: on `.25`, fmt clean, `clippy -p pf-client-core --all-targets -D warnings`
green under the Linux cfg where this rung actually compiles, the whole lib suite
167/167, and all 11 VAAPI tests green with `--include-ignored`. Workspace fmt +
clippy + lib suite also green in the Linux container.
⚠ Not touched here on purpose: the evidence table in `video.rs`. Its VAAPI row
still reads "never decoded a frame anywhere" and now understates what is known —
but a parallel agent is editing that same file for the D3D11VA AV1 row, so the
row is left for whoever lands second to update once, rather than conflicting.
Note for anyone reproducing on `.25`: it has no system SDL3 and no passwordless
sudo, so the test binary links only with `--features sdl3/build-from-source`
(SDL3 is gamepads, irrelevant to decode; production Linux still links the system
one). Its disk sits at ~99% full, and the tree there is a `git archive` export
with no `.git`, so `git apply`/`git checkout --` silently do nothing.
Surfaced by the merge. `DeviceGyro`'s header states the contract plainly — "units and
axis semantics match `GamepadCapture.forwardMotion` exactly … the same convention, so a
future sign/scale correction lands in one place for both sources" — and this branch made
two such corrections in only one of the two places. That is a promise the code stopped
keeping the moment the controller path was fixed.
Both were true parity when #88 was written; both broke here.
**The negation.** `GamepadCapture` sends `-(gravity + userAcceleration)` because Apple
reports the gravity VECTOR, pointing down, while an accelerometer measures proper
acceleration, pointing up at rest — and the wire carries the latter. The mirror sent it
un-negated, so a phone lying still told the host it was accelerating downward at 1 g.
The comment above that line even claimed the convention matched.
**The frame.** The mirror's remap targets the controller frame its own header describes —
x right, y up, z out of the screen — which is exactly GameController's frame, and that is
not the DualSense report frame the wire is defined in. So the same change of basis the
controller path now takes applies here, after the orientation remap rather than instead
of it: the remap resolves which way the phone is being held, and the basis change
translates the result into the pad's language. Two different jobs that happen to compose.
Order matters for the closing sample too. `stop` replays `lastAccel` beside a zero gyro
so "rotation stopped" does not also read as free fall; `lastAccel` is recorded after both
conversions, so what gets parked is what was actually sent.
Left alone deliberately: `DeviceGyroRemap` itself and `DeviceGyroRemapTests`. The
orientation matrices answer a different question — which way is the phone being held —
and nothing measured this evening bears on them. They remain derived-not-verified, as
their own doc says, and the on-glass pass that owes the controller path a check owes them
one too, in all four orientations.
Gate: macOS `swift build` + full suite (215 tests, 5 skipped, 0 failures) and the
iOS-triple typecheck green — the latter is what actually compiles this file, since the
whole thing is `#if os(iOS)`.
main moved ~60 commits while this branch was in progress, and one of them matters
here: PR #88 (the phone-gyro mirror) landed, touching the same motion path.
One conflicted file, `GamepadCapture.swift`, in three places — all of them the two
changes meeting rather than disagreeing:
- **Slot fields.** #88 added `motionSent` + `lastAccel` for its flush-parks-motion fix;
this branch removed `lastMotionNs` with the 4 ms drop-throttle. Kept both decisions:
the parking state stays, the throttle field goes.
- **forwardMotion's head.** #88 added the mirror stand-down (`pad 0` yields while the
phone speaks for it); this branch deleted the throttle guard. Kept the stand-down,
dropped the guard.
- **The send.** This branch converts into the DualSense report frame; #88 records what
went out so `flush` can replay it beside a zero gyro. Both, with the recording placed
AFTER the conversion — `flush` replays `lastAccel`, so it has to be the vector that
actually went on the wire, or a still pad's gravity gets parked in the wrong axis.
The two features compose exactly, which is worth stating because it is not luck: this
branch gates motion capture on `hasRotationRate`, and #88 engages the phone mirror when
`hasRotationRate != true`. They are complements — a pad either drives its own gyro or the
phone mirrors for it, never both and never neither.
Everything else auto-merged. Note `DeviceGyroRemapTests` is `#if os(iOS)`, so the macOS
suite reports the same 215 as before the merge rather than gaining #88's six — checked,
not assumed.
Gates re-run against the merged tree rather than trusting either side's: Linux fmt +
build + `clippy --locked --all-targets -D warnings` + punktfunk-core and pf-inject
suites; Apple 215 tests and the iOS-triple typecheck; Android kit + app compile and
tests. All green.
Re-measured against a host carrying #95, from .21 (RTX 5070 Ti, av1_nvenc) to
.221, on glass:
Intel Arc, auto -> native-d3d11va 4K60, decode 1.4 ms, e2e 16.7 ms p50
RTX 3500 Ada, pinned native-d3d11va 4K60, decode 1.0 ms
RTX 3500 Ada, pinned native-vulkan 4K60, decode 11.6-16.7 ms
Plus a 5-minute Arc soak: 297 stats lines, 60 fps, decode 1.3 ms, e2e 10.9/14.8 ms
p50, and exactly one WARN in the whole run — the hardware_verified=false notice
itself. No refusals, no demotions, no concealed runs.
Three things that follow.
The rung is no longer a one-session curiosity: it decodes 4K60 AV1 on TWO
vendors and survives a soak. The Arc leg matters twice over, because the Arc
advertises no SAMPLED usage on any decode profile — zero-copy Vulkan Video
cannot work there — so `auto` demoting to D3D11VA and then decoding is the
whole demotion path working as designed.
It is roughly 10x faster than the Vulkan AV1 leg on the SAME NVIDIA GPU. That
is the strongest argument yet for eventually letting `auto` pick it ahead of
Vulkan Video, which is exactly what `verified` gates.
And it stays `verified = false` anyway, because the missing piece is specific:
there is no frame-hash parity against libavcodec. Every other verified pair in
that table earned it with one, and pf-dxvadec has no harness that could produce
one — `libav_picparams_parity` compares picture parameters on the CPU and never
decodes a frame. Building that harness is the work that promotes this rung; a
fourth session is not. The evidence string now says so, so the next reader does
not have to rediscover which half is missing.
The VAAPI row is corrected in the same spirit rather than left as a bare "NO":
the reachable VAAPI box (.25, RDNA3) reports VAProfileAV1Profile0 /
VAEntrypointVLD and advertises no Vulkan AV1 decode at all, which makes it the
right box to prove that rung on and an unambiguous oracle when it happens. What
stopped it is recorded too — no punktfunk checkout there and 4 GB of usable RAM.
Documentation only — no behaviour change, and no flag flipped.
#95 disarmed sub-frame readback for AV1, which means AV1 forgoes the latency
win HEVC gets from shipping slice 1 while slice 2 encodes. The follow-up was to
teach the reader AV1's units: cut on OBU boundaries rather than byte counts and
arm from the driver's reported unit count. Measured on .21 (RTX 5070 Ti,
av1_nvenc) before writing any of it, and the measurement closes it rather than
scoping it.
Reading the frame headers av1_nvenc actually emits at 4K:
width_in_sbs_minus_1[0] = 59 one tile column, the full 3840
height_in_sbs_minus_1[0..1] = 16, 16 two tile rows
tile_start_and_end_present_flag = 0 BOTH TILES IN ONE TILE GROUP OBU
That last flag is the finding. "Cut on OBU boundaries" presumes the tiles are
separate OBUs and they are not — there is no boundary between them to cut on.
Shipping tile 1 early would need the HOST to re-author AV1 syntax per chunk,
synthesising a fresh Tile Group OBU header with tile_start_and_end_present_flag
= 1 and its own tg_start/tg_end. That is bitstream surgery on the encode path,
not the reader change it was assumed to be.
And the prize would be small even then, because split encode already spent it.
The two tile rows go to two split-encode engines that run CONCURRENTLY, so they
complete at nearly the same moment — the win is bounded by the skew between
engines, not by half a frame. Whole-frame encode measures 3.3-3.6 ms at 4K60
against a 16.7 ms p50 end-to-end, so even the sequential-tiles fantasy caps near
1.7 ms and the real number is a fraction of it. HEVC's win is bigger for a
structural reason that does not transfer: forced split and sub-frame are
mutually unsupported, so HEVC's slices genuinely are produced one after another.
1080p settles it further: tile_cols_log2 = tile_rows_log2 = 0, a single tile, so
there is nothing to pipeline at the commonest streaming resolution at all.
Recorded next to the disarm with the reopen condition named — NVENC emitting one
OBU per tile, or setting tile_start_and_end_present_flag = 1 — so this is closed
on evidence rather than left as an open maybe.
Documentation only — no behaviour change.
#97's frame-context floor closes the one rav1d abort we hit and can prove. It
does not make the rung panic-proof and nothing at that call site can, because
rav1d's public surface is dav1d's C ABI: any reachable panic crosses
`extern "C"` as `panic_cannot_unwind` and becomes `abort()`, past every
`catch_unwind`, rung demotion and typed refusal we have.
Counted across rav1d 1.1.0's 60 source files: 285 `unwrap()`, 214 `assert!`,
19 `unreachable!`, 11 `expect()`, 10 `panic!`. 539 sites that end the client if
a stream can reach them. #97 fixed one of them.
Process isolation is the only defence that actually works, and this records the
decision NOT to build it, with the reasoning, so it is not re-argued from
scratch each time someone reads that number:
* the defect is upstream's and is one line (memorysafety/rav1d#1497, filed
2026-08-07 with the fix and a reproducer; still open, no PR, as of today);
* 539 is an unbounded number, not a risk estimate — none of those sites is
known reachable from a punktfunk stream, and the honest next step is to
fuzz the rung and find out, which is cheap, rather than buy insurance,
which is not;
* the cost lands on the video path across Linux, Windows and Android (the
Apple clients decode through VideoToolbox and never reach this code), each
needing its own shared-memory frame transport, child lifecycle and
backpressure, and it adds a scheduling boundary to the slowest rung on the
ladder while zero-copy is a hard requirement;
* an abort here costs a session that was already degraded — this rung exists
because the GPU rungs failed first.
The trigger to revisit is named as an event rather than a feeling: a SECOND
distinct abort in the field, or a fuzzer finding a reachable panic. Either
makes it a class of bugs instead of one, and a class is what would justify the
architecture.
Documentation only — no behaviour change.
H.264 derives its DPB size the same way HEVC did before #96 — from a level
ceiling that says what a stream MAY use, not what it needs — and the ceiling
saturates at 16 frames, which is 17 hardware slots with the picture in flight.
That is the exact arithmetic that cost 720p and 1080p their HEVC.
Measured on real encoders (2026-08-07) rather than assumed: H.264 escapes it
twice over, and both escapes belong to the encoders, not to the format.
encoder level picked VUI restriction
NVENC (RTX 5070 Ti, 610.57.04) 3.2/4.2/5.1/5.2 present, buffering 3
VAAPI via libavcodec (RDNA3, 26.0.3) 4.1/4.2/5.1/5.2 present, buffering 1
openh264 (the software rung) 3.2/4.2/5.1/5.2 present, buffering 1
Every one picks a level proportionate to the picture AND states its real need
in the VUI bitstream restriction, so the ceiling is never reached and never
consulted. Nothing is broken today, and clamping would be wrong: with the
restriction present the number IS the stream's own statement, and a stream that
genuinely asked for a deep DPB would decode wrong if we shrank it.
So this does not change what any stream decodes. It gives the arithmetic one
named home (`dpb_limit`, the twin of `h265::dpb_limit`) carrying the evidence
and the reasoning, and it adds the signal that was missing: when an SPS carries
no restriction AND its level ceiling would demand more slots than mainstream
hardware provides, the plan now says so with `PlanWarning::LevelDerivedDpb`
instead of a user silently losing the codec the way #96's users silently lost
HEVC. It is not an integrity warning — the picture is intact; what fails is
opening a session — so `is_integrity_warning` classifies it false.
One thing the sweep corrects about how the follow-up was framed: it is SMALL
pictures that saturate the ceiling most easily, not 720p specifically. 640x360
at level 3.1 computes 16 as readily as 720p at level 5.0, because the ceiling
is MaxDpbMbs divided by the picture's macroblocks. The authored 64x64 test
fixtures land there too, which is why they now assert through `picture_warnings`.
Guards, as the missing consumer-end half of pf-encode's
`rfi_dpb_fits_a_mainstream_vulkan_decoder`:
* every_reachable_h264_stream_fits_a_mainstream_slot_pool — the measured
(picture, level, declaration) pairs, asserting slots <= 16
* the_level_ceiling_alone_would_reproduce_96_and_is_warned_about — the same
resolutions at levels that saturate, pinned WITH the warning
* a_proportionate_level_fits_even_without_a_vui_restriction — so neither
escape looks like it is doing all the work alone
Gates: fmt + clippy -D warnings clean; pf-client-core 167/167; pf-bitstream
84/84; and gpu_parity 8/8 bit-identical to libavcodec on the RTX 5070 Ti, which
is the gate that matters for anything touching the bitstream layer.
G13 — the three capture-fidelity findings from the gyro sweep, two fixed and one
argued.
**The 4 ms floor was a DROP, and it was shedding real rotation.** A sample arriving
3.9 ms after the last one was discarded outright. That is the wrong shape for this
signal: buttons and sticks are absolute state, so a dropped frame costs nothing — the
next one says everything it would have. Angular velocity is a RATE, and a consumer
integrates it into an angle, so a dropped sample is rotation that happened and can never
be recovered. GameController's delivery jitters around the pad's own ~250 Hz, so a floor
set AT that rate does not shed a rare extra sample; it sheds a steady fraction of every
turn. And the error is one-signed, so it accumulates — aim drifting short, which reads
as bad sensitivity rather than as a bug.
Nothing needed the ceiling. GC delivers at the sensor's rate rather than faster, the SDL
client has always forwarded every sample, and the host's idle watchdog is a 100 ms
timeout this cannot outpace. The throttle's two fields went with it: `lastMotionNs` was
left set-but-never-read once the guard was gone, and `motionIntervalNs` had no other
consumer. (Notes elsewhere say `flush` parks motion and reads it — that is PR #88's
branch, not this one. Checked rather than assumed.)
**An X-Box pad was streaming gyro it does not have.** Capture attached to any `GCMotion`,
and an X-Box controller exposes one that reports gravity and NOTHING else. So the client
sent a permanently-zero `rotationRate` to the host as authoritative gyro, under a
declaration saying this pad has one. That is worse than having no motion plane at all: a
game sees a controller being held perfectly still forever, and there is nothing to fall
back to and nothing to notice. Now gated on `hasRotationRate`, which is GameController's
own answer to the question we actually mean.
The settings badge had the same bug from the same cause — `hasMotion` was
`motion != nil`, so an X-Box pad got a gyroscope icon. It now reads `hasRotationRate`
too. One wrong predicate was driving both the UI promise and the wire behaviour, which is
why they were wrong together.
That also simplifies G8's "your gyro can't reach this session" notice, which had to test
`hasRotationRate` itself to avoid nagging about a gyro the pad never had. With the attach
gated on it, the notice is just the else-branch.
**Motion stays on the main queue, and this is the argument for why.** GameController's
`handlerQueue` is a property of the CONTROLLER, not of an element, so moving motion off
main moves buttons, sticks, the touchpad and the escape chord with it. This class is
`@MainActor` throughout — eight `assumeIsolated` sites, the slot table, the gesture
timers — so that is a rewrite of the isolation model rather than a queue assignment, and
it would put the tvOS escape chord (the only controller way out of a stream there) on a
background queue. That is a real risk for a speculative gain. The comment says so at the
call site, and names the measurement to make first if it ever does bite: the host's
per-pad motion inter-arrival histogram already reports exactly this and would say whether
the delay is client-side or on the wire.
Gate: macOS `swift build` + the full suite (215 tests, 5 skipped, 0 failures) and the
iOS-triple typecheck green. No test pins the throttle removal or the capability gate:
both are properties of live `GCMotion` delivery, which this module cannot fake — there is
no injectable seam, and inventing one to assert "we called sendMotion twice" would test
the mock. They are argued at the call sites instead, in the same spirit as the parts of
`DsCapture` that are not unit-testable in their module either. On-glass verification is
owed with the two already outstanding on that rig.
G14, unblocked by the frame measurement in efb7f991 — the plan deliberately left this
one alone until the up axis was known, on the grounds that a confidently wrong constant
would be worse than an obviously wrong zero. It is known now.
A virtual DualSense, DualShock 4 or Steam Deck that had received no motion reported
acceleration `[0, 0, 0]`. That is not "no data": zero proper acceleration means free
fall, which is a definite claim about the physical world and one that is never true of
a controller sitting on a desk or held in someone's hands — both read 1 g up. Anything
that interprets the accelerometer gets a confident wrong answer rather than a boring
right one.
It is worst exactly where it is least visible. A pad with no gyro at all — an X-Box
controller forwarded as a DualSense, which is what "Automatic" does for anything not
Sony or Valve — never sends motion, so it sits on that neutral for the entire session,
telling every game that reads it that the controller is falling. `switch_proto` has
always done this correctly on its own up axis, which is what made the gap visible in the
first place.
Which axis, and why it took a measurement. The wire is a unit passthrough into the
virtual pad's report, so the wire's up axis is the pad's own, and on 2026-08-07 a real
DualSense read over raw HID put `+0.997 g` on report axis 1 at rest, in a frame pinned
the same session as (Right, Up, Backward). So `MOTION_NEUTRAL_ACCEL` is `[0, 10000, 0]`
— NOT the z-up the notes had assumed from `switch_proto`'s documentation, which is why
guessing would have shipped a backend confidently disagreeing with the hardware.
The constant lives in punktfunk-core beside the units it is expressed in, and every
backend derives from it rather than restating it. The Deck's neutral in particular goes
through `steam_remap::motion_wire_to_deck`, the same rescale a real sample takes, so the
neutral and the live path can never end up with two opinions about what 1 g is — its
`hid-steam` resolution stays in exactly one place. The DS4 needs no separate change: it
reuses `DsState`.
`switch_proto` is deliberately NOT touched, and the test says so. It is a different
device on a different driver, its up axis is its own, and nobody has measured its frame
— aligning it to the DualSense for consistency would be the same unmeasured guess this
commit exists to avoid, just in the other direction.
Non-vacuity proven both ways rather than assumed. Moving the up axis to slot 2 (the old
z-up assumption) fails on the wire constant itself, which is what makes the measurement
load-bearing rather than decorative; reverting both neutrals to `[0, 0, 0]` fails on the
DualSense assertion with the message naming the defect. Each backend is checked in ITS
OWN units, because hard-coding "1 g" three times is how the halves of a unit contract
drift apart.
Gate (Linux CI image): fmt, build, `clippy --locked --all-targets -D warnings` across
punktfunk-core / pf-inject / pf-client-core, and both test suites — green, with
`Running tests/motion_contract.rs` and the new case's own `... ok` line observed in the
log rather than inferred from a green exit (`cargo test` stops after the first failing
binary, so a green-looking run can mean the contract test never executed at all).
G16 step 1, and the second half of what 9e9bb9f4 started. That commit fixed the SIGN
of acceleration (Apple reports the gravity vector, pointing down; a pad reports proper
acceleration, pointing up). This fixes the FRAME, which is a separate defect and was
never going to show up as an inverted axis — it shows up as roll where the game reads
yaw.
The wire is a unit passthrough. `dualsense_proto::write_report` puts gyro[0..3] and
accel[0..3] straight into the virtual pad's report bytes 16.. and 22.., in order, with
no permutation — the same slots a real DualSense fills. So the frame the wire is
DEFINED in is the pad's own report frame, and forwarding GameController's x/y/z
unconverted was speaking a different language with the same vocabulary.
Both frames measured 2026-08-07 from ONE physical DualSense on one desk, read twice —
over raw HID and through GameController — so this is two readings of the same
controller in the same orientations rather than two documents:
DualSense report frame: (Right, Up, Backward) axis 0 pitch, 1 yaw, 2 roll
GameController frame: (Right, Forward, Up)
Right is already slot 0; Up is GC's z and moves to slot 1; slot 2 wants Backward, which
is GC's y negated. Hence (x, z, -y), applied to gyro AND acceleration because it is a
change of basis and both live in that basis.
Notable: the wire's documented naming was right all along — gyro[0]=pitch, [1]=yaw,
[2]=roll is exactly what the hardware does. And Android needs no remap at all: it
forwards the pad's own axis order un-remapped, which is correct. Its old reading was
purely the scale bug f6de620f fixed. Only Apple was converting nothing.
How the hardware frame was established, since a wrong frame here is invisible. Gravity
at rest put +0.997 g on axis 1. Yaw clockwise-from-above drove axis 1 negative (98% of
the rotation), pitch nose-down drove axis 0 negative (100%), roll right-side-down drove
axis 2 negative (95%) — plain right-hand rule, and (a0 x a1 = a2) confirms the triad is
right-handed. The accelerometer then corroborated the gyro's assignment independently:
under pitch-down axis 2 rose 0.160 -> +0.339 (nose down raises the back, so world-up
gains a Backward component) and under roll-right-down axis 0 went +0.021 -> -0.197,
while yaw left acceleration untouched. Two different physical quantities agreeing on
one triad.
Apple's frame took four attempts, and the failures are worth recording because each was
a different way to be confidently wrong:
- peak |w| over a window containing BOTH the tip-down and the return stroke can record
the return, with the opposite sign. Yaw (a continuous one-way spin) was unaffected;
pitch and roll were exactly the two that disagreed with everything else.
- reading `gravity + userAcceleration` when `hasGravityAndUserAcceleration` is FALSE
yields a constant (0,0,1) in every orientation. It looks like data. The tell is that
it never moves. The client's own else-branch on `m.acceleration` is the correct read
and is what the instrument now mirrors.
- `da/dt = -w x a` holds only for gravity, so testing it during vigorous waving — when
`m.acceleration` carries inseparable linear acceleration — fits nothing.
The frame that survived all of that: static poses, three of them, three repetitions
each. Nose-down moved axis 1 by -0.635 (so axis 1 is Forward), right-side-down moved
axis 0 by -0.686 (so axis 0 is Right), flat put +0.99 on axis 2 (Up). That conclusion
holds whether or not the acceleration negation is right, because negating flips the
measured vector and the physical direction it represents together.
Confidence, stated honestly. The accelerometer half is solid: nine pose measurements,
and mapping the flat pose through gives (+0.005, +0.992, +0.192) against the hardware's
own (+0.021, +0.997, +0.160) — all three components, including the small tilt term that
is what distinguishes this mapping from the five other permutations that also put
gravity on slot 1. That the gyro shares the frame unmodified rests on a weaker
measurement: a gravity-dominated consistency test that preferred (+x,+y,+z) by 1.22x,
which is a margin, not a landslide. It is corroborated by the yaw reading (the one
rotation measured without the return-stroke ambiguity) agreeing with right-hand rule in
that frame, and by the peak-vs-return mechanism explaining the two that did not. A
device-side confirmation is still owed and is listed below.
The tests carry the measurements, not just the conclusion. Resting gravity is asserted
against BOTH readings of that pose; each rotation is asserted to reach the slot the wire
reads it from; and two properties guard the shape rather than the numbers — that the
conversion is an isometry (a basis change may not stretch anything) and that it
preserves handedness. That last one matters most: a permutation with the wrong number of
sign flips is a REFLECTION, which looks plausible axis by axis and inverts every
rotation. Mutation-checked: dropping only the negation fails 6 assertions across 4 of
the 5 cases, the handedness test among them.
Owed, and not claimed done: on-glass re-verification through a real iOS device, together
with the two already owed on that rig (the 9e9bb9f4 sign fix and the Android
calibration read) — one pass covers all three. G14's DualSense neutral acceleration is
now unblocked by this measurement (1 g on slot 1, not the z-up the notes assumed) but is
deliberately left to its own change; and that constant must NOT be propagated to
switch_proto, which is a different device whose frame nobody has measured.
Gate: macOS `swift build` + the full suite (215 tests, 5 skipped, 0 failures) with the
five new cases observed in the run's own output, and the iOS-triple typecheck green.
Two conflicts, both unions of independent removals/fixes: main fixed the
same three install.rs SAFETY comments this branch fixed (main's phrasing
kept), and the runner provisioning drops BOTH env lines — main removed
PF_FFVK_VULKAN_INCLUDE (pf-ffvk is gone since the FFmpeg replacement),
this branch removed VBCABLE_DIR (the retirement).
memorysafety/rav1d#1497, filed with the one-line fix and a reproducer that
needs no capture — any AV1 stream with one temporal unit removed. Written down
where the setting is, because the next person to read `av1_settings` and
wonder whether the floor is still needed should be able to check rather than
re-derive it.
`Set-Content -Encoding UTF8` writes a UTF-8 BOM, and every Windows how-to
reaches for it, so `%APPDATA%\punktfunk\client-windows-settings.json` edited
from a shell arrives with `EF BB BF` in front of the `{`. serde_json rejects
that at byte 0 — correctly, JSON has no BOM — and
`.and_then(|s| serde_json::from_str(&s).ok())` turned the refusal into
`Default`. Every setting in the file, gone, with the file plainly correct on
screen and not one word anywhere about why.
Cost an hour on 08-07: a `codec: "av1"` edit was ignored and the client
negotiated HEVC. The obvious suspects — the negotiation, the caps, the host —
were all working exactly as designed.
So the mark is stripped, which is what every other JSON consumer on Windows
does. But the BOM is only the instance; the bug is the `.ok()`, which hides a
trailing comma, a truncated write and a hand-edit typo just as completely.
Those now cost one `warn!` naming the file and serde's own line and column. A
file that cannot be READ at all is reported too, and for the same reason: PowerShell's
`-Encoding Unicode` writes UTF-16LE, `read_to_string` rejects it as invalid
UTF-8, and that lands in exactly the same hole.
The RESULT is deliberately unchanged — `Default`, never an error. Nothing about
streaming may hinge on a settings file being readable, and refusing to start
because one is malformed would be a worse failure than the one being fixed. A
missing file stays silent, because that is just first run.
All three of this client's JSON stores share the loader, because all three had
the identical line: the settings file, the known-hosts store (where a BOM
silently unpairs every host) and the profiles catalog.
The software rung aborted the process — not the session, the process — the
first time a 4K AV1 stream lost a frame. Reproduced on .21 twice on 08-07,
`SIGABRT` a few hundred milliseconds after "first frame decoded".
It was never about 4K, and it was never our bitstream.
rav1d 1.1.0 kills the process on ANY decode error while it holds a single
frame context. `rav1d_submit_frame`'s `c.fc.len() == 1` branch calls
`rav1d_decode_frame` inline; that always finishes in
`rav1d_decode_frame_exit`, which does an unconditional
`mem::take(&mut f.frame_hdr)` (decode.rs:4873); and then, only if the decode
returned `Err`, the same branch re-enters a local `on_error` whose first act is
`f.frame_hdr.as_ref().unwrap()` (decode.rs:4997) — on the `None` the teardown
just left. The panic unwinds into `dav1d_send_data`, which is `extern "C"`, so
it is `panic_cannot_unwind` → `abort()`: no `catch_unwind` at our call site, no
rung demotion and no `NoSoftwareRung` refusal can catch it. The same code is in
upstream `main` today, and 1.1.0 is the newest release, so there is no version
to bump to.
4K was only where an error first HAPPENED. The CPU rung cannot keep up at
3840x2160 (35-39 fps against a 60 fps stream), so the receive backlog stopped
draining, `pump::data` flushed it and jumped to live, and the next AU
referenced frames nobody had decoded. libdav1d gives the identical verdict on
the identical capture — 13 frames, then "Invalid data found when processing
input" — and simply carries on. At 1080p the rung keeps up, nothing is ever
flushed, no AU is ever damaged, and the same code ran for years without
anybody seeing this.
So the fix is to stop asking rav1d for the configuration whose error path is
broken. `c.fc.len() > 1` never calls `rav1d_decode_frame` at all: it hands the
frame to `rav1d_task_frame_init` and errors come back through `cached_error` /
`task_thread.retval` as ordinary `EINVAL`s, which the pump already answers with
a keyframe request. Measured, against the captured 4K stream:
n_threads=8 max_frame_delay=1 -> n_fc=1 -> ABORT
n_threads=1 max_frame_delay=1 -> n_fc=1 -> ABORT
n_threads=1 max_frame_delay=2 -> n_fc=1 -> ABORT <- proves the rule
n_threads=8 max_frame_delay=2 -> n_fc=2 -> 13 pictures, EINVAL, survives
n_threads=8 max_frame_delay=0 -> n_fc=3 -> survives
The third row is why `n_threads` grows a floor of two as well as the delay:
`n_fc` is `min(max_frame_delay, n_threads)`, so one decode thread silently puts
the whole thing back on the aborting path. That row is also what rules out the
theory this investigation started with — pinning threads to 1 was the suspected
trigger, and it makes things WORSE, so the tile workers are innocent and the
single frame context is the entire defect.
Two frame contexts would normally cost a frame of latency, and this does not,
because `decode` now drains PAST the first `EAGAIN`. `rav1d_get_picture` only
reaches its blocking `drain_picture` on a call whose own `drain` flag is already
set, and that flag is set by the PREVIOUS `get_picture` and cleared by every
`send_data` that carried bytes — so the first `EAGAIN` after a send does not
mean "no picture for this AU", it means "ask again", and this AU's frame comes
out of the second call. Stopping at the first `None` is what a
single-frame-context reading of dav1d's API teaches, and it would have put the
pipeline two frames behind while looking perfectly healthy. Measured over 14
temporal units at `n_fc = 2`: stopping at the first `None` produces nothing at
all for units 0 and 1; draining past it produces one frame per unit from unit 0,
at 20-42 ms per unit against `n_fc = 1`'s 21-53 ms. Not a trade — same cadence,
slightly faster, because the tile workers overlap the drain.
`Av1Software::new` then asks rav1d itself, through `dav1d_get_frame_delay`,
what those settings actually bought, and refuses to open a decoder that would
run with one frame context. That is not a restatement of the arithmetic: it is
`get_num_threads`' own answer, so it stays right if rav1d's derivation changes.
It is there because the failure it guards is uniquely quiet — an edit that
reinstates `n_fc = 1` costs nothing at build time, nothing in the tests and
nothing on a clean link, and then kills the client the first time a frame
arrives damaged. Losing the rung is recoverable; `abort()` is not.
On glass, .21, 35-second sessions, `PUNKTFUNK_DECODER=software`:
4K60 AV1 before: SIGABRT on the second frame, every run
after: exit 0, 0 panics, 35-39 fps, 1204 frames, decode_failed=0,
and 13 decode errors recovered from across 17 backlog
flushes — the exact condition that used to abort, survived
thirteen times in one session
1080p AV1 after: 40 fps, decode p50 2.2 ms (2.1 ms before the change)
What this does NOT buy: rav1d has other `unwrap()`s, and because its whole
public surface is dav1d's `extern "C"` ABI — every internal `rav1d_*` entry
point is `pub(crate)` — no in-process guard can turn one of them into anything
but an abort. This removes the one we hit and can prove; it does not make the
CPU rung panic-proof, and the evidence table says so.
Reported upstream with a self-contained reproducer: the in-tree
`test-25fps.ivf.av1` vector with one temporal unit dropped aborts rav1d at
`n_fc = 1`, survives at `n_fc = 2`, and libdav1d decodes it with 145 error
reports and no crash.
A punktfunk client streaming HEVC from .21 (RTX 5070 Ti) refused every access
unit with "stream needs 17 DPB slots, device caps at 16", flushed, waited for an
IRAP, got a fresh IDR that needed 17 too, exhausted the decode ladder and
reconnected with HEVC excluded. On a build with no software HEVC decoder — there
is no permissively licensed one — that is not a slower path, it is losing the
codec.
The host was blameless. Reading the SPS it actually emitted: general_level_idc
153 (L5.1 High, which NVENC autoselects at hevcConfig.level = 0 because a
130 Mbps target does not fit L5.0's 100 Mbps ceiling) and
sps_max_dec_pic_buffering_minus1 = 5 — six pictures, RFI_DPB references plus the
current one. Six, at every resolution. That is already the minimum the encoder
can honestly declare, and the only host-side lever, the level, cannot be lowered
without signalling a bitrate the stream exceeds. There was nothing to fix there.
dpb_limit was reading equation A-2 instead. A-2 is a CEILING on what an SPS may
signal — 7.4.3.2.1 constrains sps_max_dec_pic_buffering_minus1 to
0..=MaxDpbSize-1 — not a statement of what a stream needs, and it branches on
picture size against the LEVEL's MaxLumaPs. At 1080p the coded 1920x1088 =
2 088 960 luma samples fall under MaxLumaPs(L5.1) >> 2 = 2 228 224, taking the
first branch for min(4 * MaxDpbPicBuf, 16) = 16. max(A-2, buffering) then
reported 16 where the stream had asked for 6, the backends added one slot for the
picture in flight, and 17 is one more than NVIDIA's maxDpbSlots.
A resolution sweep on the box drew A-2's branch table exactly, and it is the two
commonest streaming resolutions that lost the codec:
720p 1280x720 = 921 600 branch 1 -> 16 frames, 17 slots 82 refusals, HEVC dropped
1080p 1920x1088 = 2 088 960 branch 1 -> 16 frames, 17 slots 41 refusals, HEVC dropped
1440p 2560x1440 = 3 686 400 branch 2 -> 12 frames, 13 slots clean
4K 3840x2176 = 8 355 840 else -> 6 frames, 7 slots clean, decode 1.9 ms
One host, one level, one six-picture requirement. Only which branch the picture
size landed in decided whether HEVC worked. That is also why this hid for so
long: 4K was the resolution it was exercised at, and 4K is the one size that
falls through to the honest answer. H.264 escaped for an unrelated reason — its
own level-derived ceiling happened to land at 13 for 1080p L5.0 and 5 for 4K
L5.2 — but it is the same shape of derivation and would fail the same way if
NVENC ever picked a higher level for a smaller picture.
So dpb_limit now returns the stream's own sps_max_dec_pic_buffering_minus1 + 1,
capped at 16. That is not a workaround, it is what the number means: it is
exactly the bound C.5.2.2's fullness clause bumps against, and A.4.1 bounds the
total RPS entries by the same value, so `buffering` pictures hold `buffering - 1`
references plus the current one with nothing left over.
The max() that produced the 16 was written to be generous to malformed streams —
"storing their pictures beats erroring the AU" — but it never did that either.
Dpb::needs_bumping (C.5.2.2) already keys on the signalled buffering, not on
max_num_pics, so a stream referencing more pictures than it declared was ALREADY
being bumped below its own declared depth before every store. The widened limit
bought no tolerance at all; all it ever did was over-allocate hardware surfaces,
by ten pictures per session at 1080p, and on NVIDIA take HEVC away entirely.
The fix moves 720p and 1080p onto the pool shape 4K has been running in the field
all along (7 slots, 6 references), so it is not a new operating point — it is the
one already proven. max_active_references drops from 15 to 6, still above the 5
an RFI_DPB stream can name. The per-AU level gate in pf-vkdecode reads
plan.picture.level_idc directly, so dropping A-2 out of NegotiationInfo costs no
sensitivity to a mid-stream level change.
Two regression tests pin the arithmetic from both ends, because either end
drifting back reproduces this:
- h265: the field SPS synthesized byte for byte on the fields that matter must
plan 6 frames / 7 slots, all four resolutions must agree because the stream
does, and every depth the envelope gate admits must leave room for the picture
in flight. The one honest residue is pinned too and deliberately left
refusing: A.4 does let a conforming stream declare a full 16-picture DPB, and
17 slots genuinely do not fit 16, so that stream is still refused rather than
decoded with too few slots and silently corrupted references.
- pf-encode: RFI_DPB + 2 <= 16, guarding the producer end. RFI is a real
latency win and this does not cap it at today's value — there are nine slots
of headroom — it just stops it being raised past the point where clients can
no longer decode us at all.
Two of its notes became false the moment the host stopped truncating AV1.
native D3D11VA / AV1 said "NEVER decoded a frame on any hardware". It has
now decoded 4K60 on an RTX 3500 Ada — and the same run is why the note
matters: its warn line named the rung as unproven moments before it
failed 72 access units running with "reference picture N holds no DPB
slot". That was the host shipping half of every frame, not the rung, so
the M7 wiring was right all along.
It stays UNVERIFIED regardless. `verified` gates `native_rung_admitted` —
whether `auto` may pick this rung ahead of Vulkan Video — and one
25-second session with no frame-hash parity and no soak does not buy
that. Promoting it wants a deliberate gpu_parity-style run. The note now
says what is true instead of what is convenient.
software / AV1 said rav1d had "CPU unit tests only". rav1d has now run on
glass: 1080p AV1 decodes, and 4K ABORTS THE PROCESS. It takes an internal
error path and panics inside its own on_error (rav1d 1.1.0
decode.rs:4997, unwrap on a None frame header); the panic crosses the
extern "C" boundary in dav1d_send_data, so it is panic_cannot_unwind and
no rung demotion or NoSoftwareRung refusal can catch it. libdav1d decodes
the same 4K stream 715/715, so this is rav1d's own defect and is recorded
where the next person to reach that rung will see it.
G8's Android half, and the last of the three clients. Same failure as the other
two: a controller with a gyro, in a session whose virtual pad has no motion
plane, does nothing when tilted — silently, with no way from the couch to tell
that apart from a broken sensor. The fix is the Controller type setting, so the
notice names it.
Android read neither the requested nor the resolved backend, so this needed a
plumb. What it did NOT need was a third copy of the rule. `nativePadMotionReaches`
takes the kind a pad declared and answers off `pad_motion_reaches` in
punktfunk-core, where the argument and the tests already live. The rule is
subtler than it looks — the host builds each pad from its OWN declaration and
folds what it cannot build, so neither the declaration nor the session echo
answers it alone — and every way of getting it wrong is silent. A Kotlin
transcription would have been a third thing to keep in step with the host, which
is exactly how the SDL half got it wrong the first time.
Asked once per pad, at claim, in `openExternal` — where the pad's kind is already
being declared to the host — and the answer held for the pad's lifetime on the
`ExternalPad`. Not per sample: this runs at a DualSense's full report rate.
`hasGyro` gates only the NOTICE, and defaults to false. `DsCapture` passes true —
every pad it captures is a Sony one whose IMU is a headline feature, forwarded on
the rich plane. `Sc2Capture` keeps the default, because the Steam Controller 2's
motion rides inside the opaque passthrough report that `hidReport` carries, which
nothing here may second-guess: warning about motion for a pad that never calls
`motion()` would be a notice about a feature the player never lost. The
suppression itself is on `motion()` regardless, where it costs a dead pad nothing
and stops a live one paying to send samples the host will decode and discard.
The notice sits at the BOTTOM of the stream overlay, unlike the mic-chord
confirmation at the top. The two can coincide — a pad is claimed at roughly the
moment someone might be muting — and one landing on the other would cost the user
both. It holds 6 s rather than the mic chord's 1.6: that one confirms something
the user just did, this one explains something they did not, in a sentence they
have to read. Nulled at teardown beside `onExitArmed`/`onMicChord`, for the same
reason those are — a slot closing during release must not poke Compose state on
the way out.
Not covered by tests, and this is a limit of the module rather than a choice:
`GamepadRouter` needs Android plus a live JNI handle, there is no Robolectric
here, and the predicate it defers to is pure Rust that already has its table. So
the parts that carry the reasoning are argued in comments, as `DsCapture`'s
claim/teardown ordering already is. What IS mechanically verified is the piece
that a compiler cannot catch and a device would fail on: the JNI symbol
`Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches` is present and
global in the built arm64-v8a `.so`, so the `external fun` resolves rather than
throwing `UnsatisfiedLinkError` at the first pad.
Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest` (62 cases, 0 failed,
read out of the JUnit XML rather than inferred from a green build — unchanged
from this branch's previous count), `:app:compileDebugKotlin` and
`:app:testDebugUnitTest` (67 cases, 0 failed), with `:kit:cargoNdkRelease`
rebuilding the JNI crate clean across all three ABIs, plus `cargo fmt --check` on
it. On-glass verification is owed on the rig the earlier legs used, and is worth
doing as one pass with the two already owed there.
G8's Apple half — the UI hint 77797a9e left owed, plus the suppression, which on
this client is worth more than it was on the SDL one.
The failure being fixed is entirely silent. A controller with a gyro, in a session
whose virtual pad has no motion plane, simply does nothing when tilted: nothing
in the app says so, and from the couch a session that resolved an X-Box backend
is indistinguishable from a broken sensor. The fix is the Controller type setting,
so the hint has to name it — a badge that only said "motion unavailable" would
leave the player exactly as stuck.
Asked per pad, off what the slot declared, via the predicate punktfunk-core now
carries. `GamepadCapture` is the one client where this is naturally per pad
already: `openSlot` computes `manager.declaredKind(for:)` and puts it in
`slot.pref`, so the question is answered where the pad is opened rather than on
every sample. `GamepadType.motionReaches(declared:asked:resolved:)` is static and
pure so it can be tested without a live session; the connection's instance method
fills in the two halves it owns, and `requestedGamepad` is stored beside
`resolvedGamepad` for the same reason it exists in the Rust client — the echo is
only this pad's answer when the pad declared what we asked for.
Where Apple differs from the SDL client, and better: it never powers the IMU. The
existing code already declined to activate sensors when forwarding was off,
reasoning that with nothing to forward there is no reason to make the pad stream
gyro over Bluetooth and burn its battery — `closeSlot` is careful to power them
back down for exactly that reason. A host that built this pad a backend without a
motion plane is the same situation, so it takes the same branch. No per-sample
check, no handler attached, and a DualSense in an X-Box-class session stops paying
for a sensor nobody reads.
The hint fires only for a pad that really has a gyro (`motion.hasRotationRate`).
A gravity-only GCMotion — what an X-Box controller exposes — would otherwise
produce a notice about a feature the player never had. That is a narrower
condition than the capture path itself uses, deliberately: making the capture
gate agree is G13's job and its own change.
The badge sits in the bottom-centre stack with the muted-mic badge and the
start-of-stream banner, at every stats tier and with the overlay off, because
this is not a statistic. Unlike the mic badge it is not a control: the setting is
not reachable mid-stream on every platform and applies from the next session
anyway. So it states the fact, names the setting, and leaves after the banner's
same 6 s. Every platform including tvOS — a DualSense on an Apple TV is an
ordinary way to play, and is exactly the pad this happens to. The model owns the
expiry rather than the view, so a second pad's hint replaces the first cleanly
instead of stacking, and ending the session cancels a pending clear rather than
carrying a stale hint into the next stream.
Non-vacuity proven by mutation, not assumed: collapsing the predicate to
`resolved.hasMotion` fails 4 assertions, including the mixed-pad row that is the
whole reason it is not a session-level check. The table mirrors the Rust one row
for row — a client that disagrees with the host here either kills a working gyro
or streams ~250 Hz into a void, and both are silent.
Gate: macOS `swift build` + the FULL suite (210 tests, 5 skipped, 0 failures) with
the two new cases observed in the run's own output, and the iOS-triple typecheck
green (`arm64-apple-ios17.0`, iOS slices + hand-assembled xcframework per the
memory recipe) — the badge and the overlay it joins are on every platform, so the
macOS build alone would not have covered them. tvOS remains unverifiable from
this Mac; the badge deliberately reuses the neighbouring banner's shape rather
than introducing anything tvOS-specific.
The revert un-reverts, on measurement: with the per-direction stamp sets
(render = the pad-proven PCM16-device/float-mix stereo split, capture =
device-format only), micpitch reads 440 Hz in as 440 Hz out at exact
peak. The octave-low voice was the driver DEFAULT endpoints disagreeing
(stereo render vs mono capture), never a raw-crossing design. The user
called the wrong verdict — the pad program 4ch success was the
counter-evidence that reopened the case.
Every 4K AV1 frame this host encoded reached the wire truncated to its
first tile, and had since AV1 was wired up. Measured on .21 (RTX 5070 Ti,
4K60, split AUTO): each access unit carried a frame header declaring two
tile rows and a single Tile Group OBU with tg_start = tg_end = 0, so
libdav1d rejected 835 of 836 AUs with "Error parsing frame header".
NVIDIA's hardware decoder accepts the truncated stream, which is why
native Vulkan Video looked healthy at 60 fps while both conformant
software decoders — rav1d in-tree and libdav1d out-of-tree — refused
every frame and clients fell to a black screen.
The two halves of sub-frame readback are armed by different conditions.
build_init_params arms the WRITER (enableSubFrameWrite +
reportSliceOffsets) from subframe_on alone; the chunked READER
additionally requires slices >= 2, and resolve_slices returns 1 for AV1
unconditionally — before the PUNKTFUNK_NVENC_SLICES override is even
read, because AV1 partitions via tiles rather than slices. So an AV1
session asked the driver to publish its output tile by tile and then took
only the first tile with one blocking lock_bitstream.
resolve_split_subframe — the one arbitration point both direct-SDK
backends already call — now disarms sub-frame for AV1 and returns
split_mode untouched, so AV1 keeps every engine split encode gives it.
Arming the reader instead is not a drop-in alternative: poll_chunk cuts
at bitstreamSizeInBytes on the reasoning that "slices are contiguous
Annex-B", which AV1's OBUs are not.
With sub-frame disarmed and split still AUTO, the same session decodes
654/654 frames clean through libdav1d.
The test that pinned this as correct (av1_untouched, "both features are
legal together") is replaced by one that pins the disarm, and by one that
checks the reader's gate against the writer's — the comparison nothing
made. The Linux latch comment claiming the two "can't disagree" is
corrected; that claim is what made this invisible.
Live bisect on a fresh endpoint: the mix/host format keys are
RENDER-engine properties — stamped onto a capture endpoint they broke its
shared-mode graph (IsFormatSupported reported 2ch/48k OK while Initialize
failed 0x88890008 on a once-stamped fresh endpoint; unstamped it opened
fine, S3). The capture now gets ONLY the device-format key — the knob
mmsys.cpl itself writes — declaring the stereo the pins actually accept.
Supersedes the check 77797a9e shipped an hour ago. The suppression, the
log-once, and the "unknown must not suppress" rule all stand; the field it reads
does not.
77797a9e read `Welcome.gamepad` — the backend the host resolved for the SESSION
— and stopped sending motion when it had no motion plane. But the host does not
build pads from that. It builds each virtual device from that pad's own
`GamepadArrival` (`Pads::set_kind`) and falls back to the session default only
for a pad that never declares one, which is precisely why `declared_kind` exists
and why its doc comment says an explicit setting has to be re-declared per pad.
So the check had a false negative, and it is an ordinary living-room setup. Under
"Automatic" the Hello carries the ACTIVE pad's kind (`auto_pref`), so a couch
with an X-Box pad on slot 0 and a DualSense on slot 1 echoes Xbox360 — while the
host, reading pad 1's arrival, builds it a DualSense with a working motion plane.
The old check read the echo, saw no motion plane, and killed pad 1's gyro. That
is the exact failure 77797a9e's own commit message names as the worse of the two
("a false negative kills working motion"), introduced by the fix for the other
one.
The question is per pad, so the slot now carries what it declared, beside the
physical `pref` it already held. The two are deliberately separate fields
answering different questions: `pref` is the controller in the user's hands, which
is what the local feedback paths must keep reading, and `declared` is the one the
host is pretending to have.
Three facts decide the predicate, and they are written out in
`pad_motion_reaches` rather than at the call site because all three clients need
the same reasoning:
- the echo is not this pad's answer when the pad declared something else;
- the host FOLDS what it cannot build — a Switch Pro on Windows, any UHID backend
on a host whose /dev/uhid is unusable — and nothing client-side can predict it;
- but the echo IS one observed sample of that fold, for the kind the Hello asked
about, so it is authoritative for a pad that declared exactly that.
Hence: trust the echo when declared == asked, else fall back to the declaration.
That keeps both motivating cases — a generic pad under Automatic (declares X-Box
360, suppressed, the sweep's H5c) and an explicit Switch Pro folded to X-Box 360
by a Windows host (declared == asked, so the echo catches it, H5d) — where either
field alone gets one of them wrong. `requested_gamepad` is kept on the client
next to `resolved_gamepad` for this: the pair is what makes the echo usable per
pad, and a lone field would only tempt the next reader back into the session-level
question.
The residual gap is a pad whose declared kind differs from the session's AND gets
folded: we keep sending and the host keeps dropping. That is the direction to be
wrong in, and it is what the session-level check was worth in the first place —
wasted datagrams, not a dead gyro.
Non-vacuity proven both directions rather than assumed. Reverting to
`resolved.has_motion()` fails on the mixed-pad row; reverting to
`declared.has_motion()` (no echo at all) fails on the Switch-Pro-on-Windows row.
Each case in the table is a session someone can actually sit down to, and the
comment on each says which of the three inputs decides it.
Gate (Linux CI image, pf-lxcheck2): fmt, `build -p punktfunk-core`, `build -p
pf-client-core`, `clippy --locked --all-targets -D warnings`, and both test
suites — green, with the new case observed in the run's own `... ok` line rather
than inferred from a green gate, and pf-client-core's 163 unchanged.
Measured resolution of the 0x88890008 mystery: IsFormatSupported said the
capture accepts 2ch/48k shared while Initialize kept failing — because
the probe itself had switched to a MONO ask for frequency counting, and
this stack does not bridge channel counts on capture even under
autoconvert. Every unopenable-endpoint verdict after that switch was the
instrument, not the endpoint. Stereo ask restored; crossings counted on
channel 0.
Exclusive+shared IsFormatSupported across {1,2}ch x {16,32}bit x
{44.1,48,96}kHz on both minted mic pins. Interrogates the DRIVER,
bypassing every endpoint-store stamping question: what the pins truly
accept decides whether the mic leg has any coherent configuration, and
whether an exclusive-mode mono open is an escape hatch. (The pad program
made its own breakthrough with exactly this instrument on the sibling
SSS driver.)
The user challenged the format-locked-pins verdict, and the pad program
is the counter-evidence: it hit the SAME 0x88890008 unopenable-endpoint
signature and cured it with a COHERENT stamp set, after which the same
driver family served 4ch happily. This branch previous attempts were
contaminated twice over — a float device-format (the pad bisect proved
the split must be PCM16 device / float mix+host) and no
AudioEndpointBuilder restart (Restart-Service Audiosrv never touches its
dependency, so endpoint configs were never rebuilt). Both mic endpoints
now get one identical coherent stereo set; the octave-low hypothesis
shifts from "raw crossing by design" to "the two endpoint stores
disagreed (stereo render default vs mono capture default)".
ROOT CAUSE, from the reporter's device log:
16:25:49.093 mic capture: 48000 Hz, 1 ch <- tap installed, format fine
16:25:49.235 audio engines joined - voice processing active
... 13 s of session, no errors, and the 10 s silence verdict NEVER fires
The engine started clean and the tap was installed against a valid
format - so neither the format timing nor the encoder was the fault. The
tripwire fires after ten seconds of CAPTURED frames and never fired
across a 13-second session: the tap received nothing at all.
Because the capture side must be pulled, and only the render graph pulls
anything. On the combined engine the input node carried a tap and no
connection, so it was not in the graph and nobody drove it: the IO unit
came up (the recording indicator lit for a beat, then went out as the
input went idle) and not one buffer ever reached the tap. No error, no
failed start - a session that quietly sent no microphone.
The input now runs through a silent sink into the main mixer, which is
what Apple's own voice-processing sample does. outputVolume = 0 because
the mic must reach the graph and never the speaker. The split path never
needed this - a capture-only engine has the input node AS its graph - so
this broke exactly when the combined topology became the default.
Verified: swift build (macOS), swift build --triple arm64-apple-ios17.0,
swift test 208 passed. Awaiting the reporter's on-device confirmation.
Supersedes the parse gate in 26b0819f. The off-thread read, the claim token, the
teardown ordering and its bounded wait all stand — only what happens in the gap
changes.
26b0819f held every report back until the calibration read came home, so a pad
that stalled on EP0 could feel dead for up to the link's 250 ms timeout: no
buttons, no sticks, nothing. Reports are now forwarded immediately and their
motion scaled by the nominal calibration until the real one lands.
That gap is exactly the behaviour that shipped before f6de620f — acceleration
~18% short, gyro unscaled — for about a millisecond. Nobody can feel that. A
controller that ignores a button press for a quarter of a second is not in the
same category, and it is the only one of the two a user would ever report.
It is also the safer of the two conservatisms available here. The rejected third
option, forwarding motion as zeroes until the real numbers arrive, would have the
host read a still pad as being in free fall — a lie about the physical world
rather than an imprecision about it. The nominal constants are merely a slightly
wrong scale.
The token is more load-bearing under this, not less. With a gate, an unpublished
calibration meant "parse nothing"; now it means "scale nominally", so begin()
clearing the previous pad's value is the whole reason a re-claim falls back to
the nominal constants instead of silently inheriting factory numbers belonging to
a different unit — which are, in general, further off than nominal. The fallback
therefore lives in the hand-off itself (MotionCalHandoff.effective) rather than
as an elvis at the call site: restoring the gate now means changing the type's
API, not deleting three characters in onReport.
The tests moved with the contract. They assert the nominal calibration is what is
in effect during the gap, rather than merely that the slot is empty — an empty
slot is now compatible with either behaviour, so asserting on it would have let
a regression pass. Added the case the change exists for: the same raw report,
parsed either side of publication, forwards identical buttons and sticks while
its gyro and acceleration convert differently. Mutation-checked three ways —
dropping the nominal fallback fails all five cases, dropping begin's clear fails
the inheritance case, dropping the token check fails three.
Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest` and
`:app:compileDebugKotlin` green on a forced clean rerun, 62 cases across the
module, 0 failed, with the five hand-off cases read back out of the JUnit XML.
The on-glass re-verification f6de620f owes is still owed and unchanged.
Supersedes the synchronous calibration read f6de620f shipped an hour ago. The
ordering it protected is kept; the blocking it cost is not.
f6de620f read the pad's calibration inline in DsCapture.startUsb, which runs on
the main thread — the stream's setup path, and the USB-permission broadcast. The
read is a blocking EP0 control transfer: a pad that is there answers in about a
millisecond, but a pad that is stalling takes the link's whole 250 ms write
timeout, and either way the interface was waiting on a controller. That is the
wrong thread for it.
It now runs on its own daemon thread, one per claim, named pf-ds-cal — the same
shape HidUsbLink already uses for its reader rather than a second style. A
pathological stall now delays the pad's motion by a moment instead of freezing
the UI.
What kept the ordering honest before was "assign the calibration before `model`",
since `model` is what lets the link thread into the parse. That reasoning stands,
so the gate simply moved: MotionCalHandoff holds the claim's calibration, starts
null, and onReport parses nothing until it lands. No report is ever scaled by the
last pad's numbers — those are per unit — nor by the nominal fallback the real
read is about to replace. Dropping the first millisecond of a capture costs
nothing: the reports carry absolute state, so the next one says everything the
dropped one would have.
The calibration is what got deferred, not `model`, and that is deliberate.
Keeping `model` synchronous keeps isActive, the teardown writes, the feedback
sinks and the active-changed true/false pairing meaning exactly what they meant
yesterday — and, more to the point, it makes a late completion structurally
unable to resurrect a dead capture. A straggler can only ever publish a
calibration, and nothing is parsed while `model` is null.
Teardown, which is where this sort of change actually bites. Both stop() and the
unplug path end the claim before they close anything: ending burns the token, so
a read that lands afterwards publishes nothing and says so in the log. They then
wait, bounded at 500 ms and normally already over, for the read to let go of the
connection they are about to close — closing a descriptor with a transfer in
flight pulls it out from under the kernel, the same rule the pad-audio borrow
follows. It cannot deadlock: the reading thread blocks on the EP0 transfer and on
the hand-off's own monitor, never on anything a teardown holds. If a pad has
stopped answering entirely the wait elapses and teardown proceeds regardless,
which is the same exposure the feedback writes already carry and better than an
interface that never comes back.
Tested where it is testable. MotionCalHandoff is the piece that carries the
hazard and it is pure, so it has its own test: nothing is visible until the read
lands, a read that outlived its claim publishes nothing, a re-claim never
inherits the previous pad's calibration, and a doubled end still refuses every
outstanding token. Mutation-checked both ways — deleting the token check fails 3
of them, deleting begin's clear fails the fourth.
Not covered: DsCapture's own claim/teardown ordering is not unit-testable in this
module — there is no Robolectric, and the class builds a main-Looper Handler and
needs a UsbManager — so it is argued in comments rather than pinned. The on-glass
re-verification f6de620f owes is unchanged and still owed.
Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest` (61 cases across the
module, 0 failed) and `:app:compileDebugKotlin` green, with the four new cases
confirmed present in the JUnit XML rather than assumed from a green build.
Field report: mic uplink dead on iOS, iPadOS and macOS alike, while
Android on the same host works - so the host and the wire are fine.
Two defects in the combined (voice-processing) engine, which became the
default on all three Apple platforms a week ago and has never run on a
device - CI only runs swift test on macOS, and the loopback test counts
datagrams without decoding them.
- The tap read the input format before the engine was prepared. Enabling
voice processing swaps the engine's IO unit for the VPIO one and
renegotiates its formats; until prepare() the input node can still
report the pre-swap state, 0 Hz / 0 channels included, which
installMicTap correctly refuses as 'no usable input device'. Both
topologies now prepare first, so the chain is built against what the
voice processor actually emits.
- A mic chain that failed on the voice-processed engine took the whole
uplink down for the session: that arm fell back to playback ONLY. The
sibling failure a few lines above - the voice processor refusing to
engage at all - already falls back to the split path, which is a
working mic without echo cancellation. Both arms do that now. The mic
outranks the AEC.
Not reproduced locally (no Punktfunk entries in this Mac's log store,
and collecting the device's log needs root), so this is a strong
inference plus one proven logic defect rather than a confirmed fix. If
it persists, Console filtered to subsystem io.unom.punktfunk / category
audio names the stage: 'mic capture: N Hz' then 'audio engines joined'
then, 10 s in, either 'mic uplink OK - peak ...' or the SILENCE warning.
Follow-up worth doing separately: nothing reports whether the uplink
actually opened, so the HUD offers a Mute Microphone button over a
session sending nothing. Android gates that on a real micRunning signal.
Final pitch-probe verdict on the SSM driver pair: the render pin is
stereo-only, the capture pin mono-only (stamping either differently makes
the endpoint unopenable), and the crossing between them is a RAW byte
pass — so voice fed through the render endpoint reads back an octave low
and no format stamp can fix it. S3 peak-based PASS = false pass; per the
design doc revert clause the mic falls back to the name ladder (a virtual
cable), pending the user re-decision. The SPEAKERS substrate keeps tier-0
(no driver crossing — a plain engine loopback tap, measured clean).
minted_ids() publishes speakers only; the mic endpoints stay minted and
recorded (provisioned()) for the micpitch probe and a possible future
non-render transport, and their format stamps now pin each side to its
pin one true format — healing the endpoints this branch earlier
mis-stamped.
Second measurement round: the driver render pin is STEREO-ONLY — the
mono render stamp turned the endpoint unopenable (0x88890008 on every
open, the incoherent-stamp signature the pad program documented). Since
the crossing is raw, the coherent choice inverts: the CAPTURE side now
declares the stereo float stream that actually crosses (fixing the
octave-low voice), and the render has its stereo float default stamped
explicitly — pinning the pair AND healing any endpoint a previous build
left mono-stamped.
Measured with the new pitch probe: 440 Hz into the minted mic render came
back as 220 Hz off its capture side. The driver forwards the render
stream RAW into its mono capture, so a stereo-declared render (the
driver-default we inherited) turns every stereo frame into two mono
samples — half speed, octave down, exactly the field report. The mic
render now gets a coherent MONO 48 kHz format set stamped alongside its
name (PCM16 device format + float mix/host formats), making the engine
downmix before the driver crossing. The mic pump keeps pushing stereo;
shared-mode autoconvert handles the rest.
Field report through the minted microphone: voice plays back an octave
low. Peaks are pitch-blind — S3 passed while a potential half-rate link
hid in the numbers (288k samples fits both the honest and the half-speed
story). Every probe measurement now estimates the dominant frequency by
zero crossings over the signal span, and `audio-probe micpitch` runs the
decisive experiment against the LIVE minted pair: 440 Hz in, frequency
out — ~440 = pair innocent, ~220 = the stereo render stream is forwarded
raw into the mono capture.
Making the HUD concentric with the physical display corner had no upper
bound, so a modern phone (~62 pt of display radius) asked for a 48 pt
corner on a card whose lines sit 10 pt from the edge. A corner of radius
r pulls the edge inward by r - sqrt(r^2 - (r-y)^2) at distance y below
the top: at the first line that is ~19 pt, so the top and bottom lines
rendered INSIDE the arc.
Concentricity is only a virtue while the radius is small next to the
card. The radius is now capped at 28 (devices asking for less still get
a truly concentric corner) and the iOS content padding scales with it at
0.45*r, which leaves ~4.6 pt of arc against 12.6 pt of padding at the
cap. The card grows by under 3 pt a side; the compact pill is unchanged.
G14/G16 leg 3. This supersedes the nominal constant 0e40b374 shipped, which was
always labelled a stopgap.
Measured on glass 2026-08-07: a DualSense over USB into an Android phone,
streaming to a Linux host, flat and face up, arrived as |accel| = 0.811 g where
1.000 was owed. The parse forwarded the pad's raw i16s verbatim, and raw device
units are not wire units. 0e40b374 rescaled acceleration by the nominal
10000/8192 and deliberately left gyro alone, because a constant provably cannot
fix gyro: the same still average showed this unit's accel calibration is
near-identity (~1% off) while its gyro's emphatically is not — a near-identity
gyro calibration would imply 1024 LSB per deg/s, i.e. ±32 deg/s full scale, which
no controller has. That scale is per unit, and the only thing that knows it is
the pad.
So the client now asks. HidUsbLink grows a GET_REPORT path — EP0, the exact
mirror of the SET_REPORT it already had — and DsCapture reads the pad's IMU
calibration feature report ONCE, while claiming it: 0x05 / 41 B on a DualSense or
Edge, 0x02 / 37 B on a USB DualShock 4. DsDevice.MotionCal then applies
hid-playstation's own arithmetic per axis, which is the same math the host's
contract test (crates/pf-inject/tests/motion_contract.rs, SonyImuCalibration)
reads from the other end: gyro raw × speed_2x × 20 / (|plus−bias| + |minus−bias|),
accel (raw − (plus − range/2)) × 20000 / range. Long arithmetic, because the gyro
multiplier overflows an Int, and clamped, because both are >1 multipliers and a
full-scale flick would otherwise wrap the i16 into a motion in the opposite
direction. Reading the blob also removes acceleration's residual ~1% factory bias
that the nominal constant left behind.
Once at claim and never per report. EP0 is independent of the interrupt endpoints
so the read is safe alongside the reader thread, but a blocking control transfer
in the report path would wreck capture latency, and the calibration is fixed for
the life of the connection anyway. The capture logs the derived resolutions, which
is the discriminator for whether a blob was read at all: a real pad declares ≈16
LSB per deg/s, the fallback reads back as exactly 20.
A pad that refuses, answers short, or declares zeroes (a clone, a broken unit)
keeps today's behaviour per axis — nominal accel, gyro straight through. Nothing
here ever zeroes motion: slightly mis-scaled beats silent.
Not covered. The axis frame is still untouched: this leg puts gravity on Y where
the Apple leg put it on Z, so at least one client's frame is wrong, and settling it
needs the bare-metal Linux reference reading G16 step 1 calls for. Rescaling is
frame-independent, so it stands however that resolves — remapping is not, so it
stays out. Bluetooth's grouped plus/minus layout is not implemented either: this
path is USB-only by construction (Android exposes no raw path to a Classic pad),
and a half-used generalisation would be a latent bug rather than a feature.
Gate: `:kit:compileDebugKotlin` + `:kit:testDebugUnitTest` green, 16 DsDeviceTest
cases run 0 failed, and the five new ones were confirmed present in the JUnit XML
rather than merely compiled. Non-vacuity checked by mutation — perturbing the gyro
conversion fails 6 tests, including all four new ones that assert a number.
On-glass re-verification owed, on the rig that measured the defect (DualSense →
USB → phone → 192.168.1.21): at rest |a| = 1.00 g exactly via ~/gyroscope.py, and
a nominal 90 deg yaw integrating to ~90 deg via ~/integrate.py — the same 90 deg
that read ~62.7 deg before this change.
Three things the Intel Arc measurement showed were wrong or unhelpful in the refusal
path.
The message named NV12 whatever the stream was. A Main 10 session refused over P010
was told about NV12, which sends the reader to look up the wrong format's support.
Both variants now carry the format the driver's own entry reported.
A missing SAMPLED now says what it costs. "does not advertise usage SAMPLED" is
accurate and tells a field reporter nothing: the consequence is that no shader can
read this device's decoded pictures, so the zero-copy path cannot exist on it at all
— which is a different conversation from a device that is merely slower. The line
points at --probe-decode for the driver's own words.
And the probe's second opinion no longer claims to be one. Measured on both vendors,
vkGetPhysicalDeviceImageFormatProperties2 answers "creatable" for combinations the
video-format query rejects — on NVIDIA too, for SAMPLED alone, which is not a legal
video image usage at all. So it does not honour the chained profile list and must not
be read as permission; it is still printed, because otherwise everyone who reads a
refusal asks the question again, but it is labelled as not authority.
Also names the three video ENCODE usage bits, which NVIDIA advertises on decode
pictures and the probe was printing as "unrecognised 0xC000".
Structural, because tuning the transform values was treating a symptom.
A scroll transition derives its phase from the geometry of the view it
wraps, and the entrance was wrapping each card on the OUTSIDE - so it
moved the very thing the transition measures. Every card read as far
from centre for the whole travel, phase pinned at fully receded, and the
centred card only collapsed into its focused look as the entrance ended.
That collapse was the jump; shrinking the offset last round only made it
smaller.
The card builder now hands each caller its own CardEntrance and both the
launcher and the coverflow apply it BENEATH their .scrollTransition. The
transition measures a card that never moves and composes its scale and
rotation on top of the entrance's, so the two can no longer fight - and
the fuller travel is back (34 pt rise) now that the geometry constraint
that forced it down to 16 is gone.
The focused card jumping into its correct state at the end of the
entrance was the entrance's own geometry. The caller's .scrollTransition
reads the geometry of the view underneath the entrance's transforms, so
a card shoved 58 pt down and hinged on its leading edge spent the whole
travel reported as far from centre - phase pinned at fully receded - and
only collapsed to identity as the card came home. That collapse IS the
jump, and it explains why it looked timing-dependent rather than simply
broken.
Now the rotation is about the card's centre (it turns in place instead
of swinging sideways out of position) and the rise is 16 pt, inside the
strip's own vertical slack, so nothing the entrance does moves a card
away from where the scroll view thinks it is. The entrance also waits a
couple of frames for real layout - the GeometryReader's first pass can
report no width, so there is nothing to centre on yet - and the
transaction override from the previous round is gone: it was not the
cause, and nil-ing inherited animation could have made navigation
snappier than intended.
The Intel Arc refusal moved one step down the caps query and stopped again: the
coincide NV12 entry does not advertise SAMPLED. That sentence is punktfunk's, not
the driver's, and the last two times a conclusion was drawn from a sentence of ours
the conclusion was wrong.
So --probe-decode now prints the driver's own answers instead. For every profile the
client can negotiate (H.264 High, H.265 Main and Main 10, AV1 Main 8- and 10-bit) it
asks vkGetPhysicalDeviceVideoFormatPropertiesKHR in six usage combinations — the
three the image pools really create with, plus DPB|DST without sampling, SAMPLED
alone and DST alone, which are what localise a refusal to a half. Each answer is
printed as the driver gave it: format, usage and create flags named AND in hex with
unrecognised bits called out, image type, tiling. A failed query prints its VkResult
rather than vanishing into an empty list.
It goes through pf-vkdecode's own query rather than a copy of it, which meant
splitting query_formats into a physical-device form — the call never needed the
VkDevice the old signature demanded. VideoFormat gains imageType and imageTiling to
carry the whole record; VUID-VkImageCreateInfo-pNext-06811 compares both for
equality, so they were being assumed rather than read.
And because a driver that under-reports usage would be indistinguishable from one
that genuinely lacks it, the probe asks a second, independent question —
vkGetPhysicalDeviceImageFormatProperties2 over the same profile list — and prints it
only where the two disagree. A disagreement is the finding.
No behaviour change to any decode path: derivation reads the same fields it did.
Field-measured necessity, not cosmetics: unstamped, the minted instances
read 'Lautsprecher (2- Steam Streaming Microphone)' and even the box's
owner picked the wrong device out of the Sound settings zoo (as did the
S1 probe's name match before it). The provider now stamps device-desc +
device-name through the pad program's proven machinery — write_stamps/
stamps_served, extracted from the pad-only stamp functions — with the
same store-first/registry-fallback routes and settle/re-pass discipline.
Names only: a wider stamp set makes AudioEndpointBuilder re-mint the
endpoint under a new GUID (measured on pads). Stamping is best-effort
(SYSTEM ACL route); the wiring never depends on names — identity stays
the recorded id.
The strip entrance is one animated progress value now, not a Bool behind
per-card .animation modifiers. Those modifiers wrap the caller's card -
INCLUDING its .scrollTransition - so a delayed spring flipping while the
scroll view was still settling captured the transition's own per-frame
phase updates and stranded the centred card half-receded until the next
scroll re-drove it. That was the 'only navigating fixes it' report, and
the race with load speed was the same thing.
CardEntrance is now a ViewModifier + Animatable: it slices its own
window out of one master clock the carousel animates 0 -> 1, so every
transform is a pure function of an interpolated Double and no animation
modifier wraps a card at all. Benign failure mode too - progress
reaching 1 without animating leaves each card at exact identity rather
than stranded. The entrance also moved inside .frame(width:) so a scroll
target's geometry never depends on what its card is doing, and the
non-tvOS branch states its .id explicitly.
One leak remained after that: withAnimation sets its animation on the
whole TRANSACTION, so the scroll view's initial centring still inherited
the 1 s linear clock and the focused card only reached its correct look
as that clock ran out - arriving as a jump. The card subtree now clears
the inherited animation, so its phase lands per frame while the
entrance's own transforms (driven by animatableData, not by the
transaction) keep running.
G16 leg 2. A DualSense over USB to an Android phone, streaming to a Linux host,
flat and face up: |accel| = 0.811 g where 1.000 is owed. Magnitude is
frame-invariant, so this is unambiguous regardless of the separate axis question
below, and it came from a 27-second static average — no sampling error in it.
`DsDevice` said so plainly: "Gyro/accel stay in raw device units". It read the
i16s out of the pad's report and forwarded them verbatim. But raw device units
are not wire units — the wire is fixed at 10000 LSB/g and the pads' native
resolution is the 8192 that hid-playstation calls DS_ACC_RES_PER_G. 8192/10000 =
0.819 predicted against 0.811 measured. Acceleration is now rescaled on both the
DualSense and DualShock 4 parse paths, clamped because the multiplier is >1 and
a real near-full-scale slam would otherwise wrap the i16 into an impossible
acceleration in the opposite direction.
Two things deliberately NOT done.
Gyro is left alone. It is almost certainly low by the same mechanism, but it
cannot be corrected with a nominal constant the way acceleration can: the still
average shows this pad's accel calibration is near-identity (~1% off), while the
gyro's emphatically is not — a near-identity gyro calibration would imply
1024 LSB per deg/s, i.e. ±32 deg/s full scale, which no controller has. Fixing
gyro means reading the pad's calibration feature report and applying its own
numbers, which also removes acceleration's residual 1% bias. `HidUsbLink` can
SET_REPORT but has no GET_REPORT path yet, so that is a real change rather than
a constant, and it is owed.
I tried to pin the gyro factor by integrating the on-glass rotations instead: a
nominal 90 deg yaw integrated to ~88.5 deg through the Apple client (correct)
and ~62.7 deg through Android. Directionally consistent, but the readout samples
at 5 Hz and a ~1 s rotation is badly undersampled, so that ratio is not a
constant anyone should ship. Recorded, not used.
The axis frame is also left alone. This leg puts gravity on Y where the Apple
leg put it on Z, so at least one client's frame is wrong — but Android forwards
the pad's own axis order un-remapped, which makes its reading evidence about the
hardware rather than about us, and resolving it needs the bare-metal reference
reading G16 step 1 calls for. Every bare-metal Linux box was unreachable
(Deck down, HTPC down, .25 is another KVM guest). Rescaling does not touch axis
order, so this fix stands however that resolves.
Gate: `:kit:compileDebugKotlin` and `:kit:testDebugUnitTest` green, JNI libs
built clean at the API-28 floor across 3 ABIs. On-glass re-verification owed:
re-run the at-rest reading and expect 0.99-1.00 g.
Three defects behind an entrance that read as a card sliding up:
- The centred card never rotated. The stagger fans out from an anchor,
and the anchor was given side 0 = no rotation - but the anchor IS the
card the eye is on, so the single most visible card only rose. Side is
never 0 now; every card turns.
- The swing happened while the card was invisible. Opacity shared the
transform's spring, so the card spent its whole rotation at near-zero
alpha and only the last few degrees showed. The fade now runs on its
own 0.22 s curve (a second .animation governs only the modifiers above
it) while the transform springs over ~0.6 s. The travel is deeper too
- 0.74 scale, 64 degrees, 58 pt - and the rotation sign now matches the
coverflow's own recede, so a card unwinds INTO its resting angle
instead of swinging against it.
- It fired before the art existed. Cards swung in as grey placeholders
and filled with artwork afterwards. PosterImage reports when a cover
settles (art loaded, or candidates exhausted), the coverflow counts
the first few, and GamepadCarousel holds its entrance on a
contentReady gate - with a 700 ms backstop so a slow or artless
library still animates.
The strip entrance never ran in the library, for two reasons:
- The trigger was lost. Flipping the state inside onAppear puts the
change in the SAME transaction as the view's insertion, where SwiftUI
runs with animations disabled. The launcher got away with it; the
library's strip mounts late - only once the fetch lands - and lost
every time. The flip now defers one runloop turn, so it is an ordinary
animated state change.
- The art snapped in behind it. Covers hard-swapped from grey
placeholder to image, so even a working entrance was followed by a run
of cards popping to artwork after the strip had settled. PosterImage
cross-fades now (the touch grid inherits it).
And the entrance is 3D: a card starts turned away on the drum, small,
low and invisible, then swings flat, grows and rises on an overshooting
spring. Cards left of the anchor hinge on their trailing edge and cards
right of it on their leading one, so the strip FANS OPEN from the cursor
instead of sweeping past it - the same hinge-and-perspective language
the coverflow's own recede speaks, so arriving and scrolling read as one
object. Reduce Motion still drops every bit of travel.
Intel Arc never used Vulkan Video decode on Windows. The rung refused every
session with "driver advertises neither DPB_AND_OUTPUT_COINCIDE nor DISTINCT"
and fell back to D3D11VA — and that refusal was ours.
vkGetPhysicalDeviceVideoCapabilitiesKHR was called with the codec capability
struct chained BEFORE VkVideoDecodeCapabilitiesKHR (push_next prepends, so the
chain was caps -> h265_caps -> decode_caps). On Arc/Windows 101.8724 the driver
fills those two by POSITION, not by sType, and returned them SWAPPED. Measured,
on glass, both ways:
before: decode_flags_raw=12 max_level_idc=1
after: decode_flags_raw=1 max_level_idc=12
12 is STD_VIDEO_H265_LEVEL_IDC_6_2 and 1 is DPB_AND_OUTPUT_COINCIDE. We were
reading an H.265 level as a decode-capability bitmask; 12 contains neither 0x1
nor 0x2, so the check concluded the device had no DPB mode. It had one all along.
The base struct was fully populated throughout — 15 DPB slots, 8192x8192 max
extent — which is what gave the lie away: a driver that answers in that much
detail is not declining.
NVIDIA and RADV dispatch by sType and do not care about the order, which is
exactly why the fleet stayed green and this reached the field. Both orders are
spec-legal for us to write; only one survives a driver that assumes the
conventional one, and the conventional one — decode caps first, as every Vulkan
sample writes it — is now what all three codecs use.
⚠ This does NOT yet give the Arc Vulkan Video. It moves the refusal one step
down the same function: the device advertises only COINCIDE (no DISTINCT), and
its NV12 coincide entry does not advertise SAMPLED usage, which the zero-copy
presenter path needs. Whether that is a second bug of ours or a real Intel
constraint is not yet established, and this commit does not claim it either way.
Found because the user disbelieved my "Intel driver bug" conclusion. He was
right: I had reasoned from our own error message, which is the same circularity
the caps logging added in fb1a0a61/a183cac8 now exists to break.
Gates: fmt clean; clippy -D warnings; 187 pf-vkdecode tests. The GPU parity legs
that cover this code cannot run here (no GPU on the build host) — the evidence
is the on-glass A/B above.
Two more from the on-glass pass:
- The coverflow's store/source chip only showed its background on the
centred cover. Same mechanism as the tray blur: a card rides a
scrollTransition that composites it with opacity < 1 and a 3D
rotation, and a material cannot sample a backdrop through an offscreen
composite - so the frost stayed blank everywhere except the one card
sitting at exactly full opacity. The coverflow's chip is a flat wash
now (StoreBadge gains `solid`), which has no backdrop to sample and
is therefore simply always there. The touch grid keeps its material -
its cards carry no transform, so its frost samples fine.
- Host cards and library covers now arrive with the strip instead of
being there: each card rises out of a fade on a lightly overshooting
spring, delayed by its distance from the cursor, so the strip
assembles outward from where the eye already is. Implemented once in
GamepadCarousel, so the launcher and the coverflow inherit it
together. Transforms only - snapping, the callers' own
scrollTransition and the tvOS focus engine are untouched - and Reduce
Motion drops the travel for a plain unstaggered cross-fade.
G16, first result. A DualSense paired to an iPhone, streaming to a Linux host,
lying flat and face up: hid-playstation decoded z = −0.99 g where a DualSense
owes +1.00. Vector magnitude was 1.006 g, so the scale was already correct —
this is purely direction, and it was wrong for every accelerometer sample the
Apple client has ever sent.
The cause is a convention mismatch, not a sign typo. Apple reports acceleration
as the gravity VECTOR, which points down: a device face-up on a table reads
z = −1. An accelerometer physically measures proper acceleration, and at rest
that is the +1 g normal force pushing UP — which is what a DualSense's report,
and therefore our wire, carries. The two are exact negatives. Both branches were
affected, because `m.acceleration` follows the same Apple convention as the
gravity/userAcceleration split, so reading the "raw vector" was not an escape
from it.
`rotationRate` is a true angular rate and needs no flip. The same session
confirmed that independently: rotating the pad clockwise seen from above
produced a negative yaw, which is correct under the right-hand rule about an
up-pointing Z. That asymmetry — accel wrong, gyro right — is itself evidence for
this diagnosis rather than a blanket frame error, and it is why the fix is three
negations at one site instead of a remap.
The sweep predicted this ("Apple accel plausibly INVERTED — CoreMotion gravity
-1 g vs DS +1 g up at rest") but could not confirm it without hardware. It is
now measured, and the mechanism is confirmed in the code rather than inferred
from the number.
Method, for whoever repeats it: the readout is python-evdev on the host reading
the virtual pad's own motion node, dividing by the axis `resolution` the kernel
publishes, so it prints deg/s and g. That is downstream of the calibration blob
— the same layer a game reads — which is what makes a sign error visible to a
human at all.
Two things this does NOT establish. The host was a KVM guest, so the DualSense
could not be attached natively for a side-by-side reference reading; the test
stands on the DualSense convention being a fixed property of the hardware, which
is decisive for the at-rest sign but weaker for the gyro axis ORDER. And the fix
itself is unverified on glass: confirming it needs a rebuilt client on the
device, so someone should re-run the same at-rest reading and see +1.00.
Gate: `swiftc -parse` clean. A full typecheck needs the gitignored
PunktfunkCore.xcframework assembled first and has not been run.
The Arc returned decode_flags=0b1100 = 12 with a fully populated base struct (15
DPB slots, 8192x8192 max extent). Neither COINCIDE (0x1) nor DISTINCT (0x2) is
set, and 0x4|0x8 are not defined for that field at all — but 12 IS
STD_VIDEO_H265_LEVEL_IDC_6_2, and VkVideoDecodeCapabilitiesKHR and
VkVideoDecodeH265CapabilitiesKHR have identical layouts (sType, pNext, one u32).
So the suspicion is that we are reading H.265's maxLevelIdc where the decode
flags belong. Logging both settles it: if max_level_idc comes back as 1 or 2 the
two structs are crossed, and the refusal is ours rather than the driver's.
Nothing in the caps module logged anything, so when a device refused with
"advertises neither DPB_AND_OUTPUT_COINCIDE nor DISTINCT" there was no way to
separate two very different situations that present identically as a zero: the
driver filling the chain and genuinely declaring no DPB mode, versus our own
pNext chain never reaching VkVideoDecodeCapabilitiesKHR at all.
Printing the BASE VkVideoCapabilitiesKHR beside the decode flags is the
discriminator. A populated max_dpb_slots next to decode_flags: 0 means the
driver traversed the chain and answered; zeros across both mean the query never
landed and the refusal is ours, not the driver's.
Raised by the Intel Arc result on .221, where I concluded "driver bug" on the
strength of our own code's report — which is precisely the circular reasoning
this line exists to break.
Round-3 field findings: the pop-in had retreated to the X axis alone -
the one growth still left to safe-area resolution (the landscape side
insets), which settles a beat after insertion, outside any geometry
group and outside the view's own transaction. The scrim now reaches
full-bleed purely by layout: a fixed 80 pt negative-padding overhang on
the outer edge and both sides replaces ignoresSafeArea entirely, so
every axis is deterministic from the first frame (and orientation no
longer changes the gradient's proportions). The mask's strong region
moves to 0.65 to account for the overhang leading the gradient.
And the frost reads black now, not grey: an ink.shade(0.35) wash inside
the mask sinks the material's luminance lift toward the palette's shade
- black on a dark field, palette-honest on a pale one.
Two follow-ups from the second on-glass pass:
- The tray blur's pop-in survived the geometryGroup: the full-bleed
growth (negative padding + safe-area expansion) rode the push's
transaction, and safe-area resolution sits outside a geometry group.
The scrim now pins its own geometry out of any animation - the layer
fade/slide still carries it, only its SHAPE can never animate. The
bottom overshoot grows 32 -> 72 pt (the tray sits over scrolling rows
plus the detail line; the blur influence starts well above the legend
now) and the mask holds strength longer before dissolving.
- The selected tab pill is a Liquid Glass surface (accent-tinted
through consoleGlass, material fallback pre-26/tvOS) - the strip
wears the same material language as the rows below it. The
matched-geometry travel between pills is unchanged.
Six findings from the on-device pass over #91, all iOS-facing:
- The tray blurs no longer grow into place on a push: the screen layer
resolves its internal layout (safe-area trays, the scrims' full-bleed)
in a geometryGroup BEFORE the insertion animates.
- The option band is LINEAR now, not a ring. A ring showed the first
option waiting to the right of the last one - unreachable, since
left/right clamps - and on a 2-option ring the unselected item flipped
sides with every step (the 60/120 Hz row). Positions are fixed, the
ends are the ends, and A's wrap travels back across the list. Options
other than the facing one exist only while the drum is moving, so a
long label never sits under a resting neighbour as overlapping text.
- Toggles (and the pin rows) ride the band too: Off left of On,
matching the left-off/right-on step semantics.
- The close X is gone from settings, add-host and the library - a
gamepad UI exits with B. A chromeless cancel button keeps hardware
Esc and the macOS sheet working, and the library's loading/error/empty
states gain a zero-size B listener so a controller-only user is never
trapped where the coverflow (and its B) doesn't exist yet.
- The heading is a real heading: leading-aligned with the 24 pt content
inset, 24/34 pt (was 20/30), top margin 18/28 (was 10/18) - launcher,
settings, add-host and library alike. The launcher's hidden-mirror
chip trick died with the centred title that needed it.
Verified: swift build (macOS), swift build --triple arm64-apple-ios17.0,
swift test 208 passed / 0 failed.
Found on glass, and it cost a whole session to find. PUNKTFUNK_DECODER was read
untrimmed, so "native-vulkan " — ONE trailing space — matched no arm of
native_vulkan_gate, fell through to `auto`, and on an Intel box `auto` takes
d3d11va first. The operator's pin never ran and NOTHING said so. Read against a
log, that is indistinguishable from the rung being refused for a hardware
reason, which is precisely the ambiguity the rest of this module's logging was
just rewritten to remove.
The space is not exotic. A Windows .cmd produces it for free: `echo x>> file`
keeps the space before the redirect, so every line written that way carries one.
PUNKTFUNK_VK_ADAPTER already trimmed; this did not, and the inconsistency is what
made it invisible — the GPU override obeyed while the decoder override did not.
The rule now lives in one pure function, resolve_decoder_pref, called by BOTH
readers. decode_pinned_to_software had the identical untrimmed expression, and
its own doc comment says a second reading of the same two inputs is a second
place for them to drift — fixing one and not the other would have proved it
right. Whitespace-only counts as ABSENT rather than as a pin to "", because an
exported-but-empty variable means "no override" and "" is a value the gate
happens to accept.
Tested as a pure rule (no process environment), including the end-to-end leg
that matters: the trimmed pin reaches native_vulkan_gate and is admitted. Like
the create-array tests in dee97e89 its before-state is a compile error rather
than a failing assertion, because the function is new — what it guards going
forward is real, and an editor who drops the trim fails it.
Gates: fmt clean; clippy -D warnings over pf-client-core,
punktfunk-client-session and pf-presenter in the Linux container; 164
pf-client-core tests.
Working G14/G18 turned up two sweep findings that do not survive contact with
the code. Neither is implemented; one is now guarded.
The 2026-08-07 sweep read the Triton (Steam Controller 2) usbip endpoint's
`bInterval: 1` as 125 µs — an 8 kHz duplicate storm — and the plan's G14 says to
raise it to 4 "like the Deck". That reading assumes a high-speed device, where
bInterval is the 2^(n-1) × 125 µs exponent. Both Triton devices declare
`UsbSpeed::Full`, and on a full-speed device the field is a plain frame count in
milliseconds: 1 means 1 ms, which is the 1 kHz the existing comment claims.
Raising it to 4 would mean 4 ms — a 4× cut to the motion rate a passed-through
SC2 delivers, in the name of fixing a problem it doesn't have. The endpoint now
carries the reasoning so the next reader doesn't repeat it.
G18's first bullet ("bound/rate-cap the host's rich-input channel; motion is
unbounded") is stale rather than wrong — it was true of the tree the sweep read.
Current main already routes rich input, motion included, through a 1024-deep
`sync_channel` whose `offer()` helper `try_send`s and drops on full, ending the
loop only on Disconnected. That is the same bounded-queue pattern the mic plane
adopted for security-review S6. Nothing owed.
G14's remaining bullet — DS/Deck neutral accel should read 1 g on the up axis
instead of 0 g free-fall — is deliberately NOT done here. Which axis is up is
precisely what G16's on-glass session measures: `switch_proto` documents the
wire as z-up and its neutral ships +Z, but the Deck's kernel negates Z/RZ, so
guessing would leave one backend confidently disagreeing with another. A wrong
constant is worse than the current obviously-unset 0.
Gate: fmt, build, clippy --all-targets -D warnings, and the test suites — green.
Four reworks from the first palette-era on-glass review, all iOS-facing:
- Surfaces carry the palette now, not just the text on them: ConsoleGlass
washes every tier (Liquid Glass tint, pre-26 material, tvOS material)
with ink.glass — the same colour the desktop console fills its panels
with — and the close buttons move to an ink-aware consoleGlassBackground.
The pre-26 branch also gains the focus tint it had silently dropped.
Stray literals follow: ConnectOverlay text rides ink in the console
takeover, card shadows soften on pale fields, the focused keycap reads
onAccent. The online pip stays status-green on purpose.
- The header breathes: title top padding 4/10 -> 10/18 plus shared
header-spacing and title-bottom helpers mapped from the console shell's
rhythm, applied to the launcher, settings and add-host alike, with the
add-host close X re-anchored to the title row.
- Settings, Add Host and the Library present IN PLACE on iOS: one
persistent aurora whose calm is chased (the console's bg_mix), screens
as transparent layers with the console's 0.26 s ease-out-cubic push/pop,
an input drop for the transition, and the controller handed off through
isActive — no more opaque bottom-up covers, no backdrop teardown.
macOS keeps its sheets, tvOS its focus-engine covers.
- The settings select is a real band: choice rows mount GamepadOptionBand,
a spring-driven drum (Animatable body, ring-distance wrap, neighbours
gated by focus and flight) whose retargeting spring accumulates rapid
steps into one continuous spin. Reduce Motion falls back to a plain
crossfade; toggles keep the quiet 14 pt slip.
Verified: swift build (macOS), swift build --triple arm64-apple-ios17.0,
swift test 208 passed / 0 failed. On-glass QA still owed: palette sweep on
a pale palette, transition compositing over materials, drum feel on device.
G8 of the gyro program, SDL-client half.
The `Welcome` has always carried the backend the host actually RESOLVED, which
is not necessarily the one the client asked for — Auto lands on Xbox 360 for
anything not Sony/Valve/Xbox, and a Switch Pro on a Windows host folds to X360
too. No client read the field. So a player with an 8BitDo, or a Switch Pro on
Windows, got a controller whose gyro did nothing, with nothing anywhere saying
why: the client shipped ~250 Hz of Motion datagrams and the host parsed and
discarded every one.
`GamepadPref::has_motion()` answers whether a backend has a motion plane at all.
The SDL client checks it on the first gyro sample: it logs one line naming the
resolved backend and pointing at the fix (pick a DualSense-class controller
type), then stops sending. Once per slot, not per sample — this path runs at the
pad's sensor rate.
`Auto` deliberately answers true. It means "unknown" — an old host that omitted
the echo, which may well have resolved a DualSense — and suppressing motion on
unknown would silently break working gyro, a worse failure than sending
datagrams nobody reads. The predicate is an exhaustive match so a new backend
has to state its answer rather than inherit one, and a table test pins both
halves: a false negative kills working motion, a false positive keeps the void
open, and both are silent.
Owed: the plan wants this surfaced as a one-line UI hint, not just a log line.
Apple already stores `resolvedGamepad` and Android needs the plumb; neither is
done here, and both want their own gate.
Gate (Linux CI image): fmt, build, clippy --all-targets -D warnings, and the
test suites — green, with the new capability test observed running.
--probe-decode printed its DISPLAY position and called it the
PUNKTFUNK_VK_DEVICE value. It is not. pick_device resolves that variable against
the RAW vkEnumeratePhysicalDevices order (setup.rs, `devices.get(i)`) BEFORE any
ranking runs, while the probe sorts discrete-first for readability.
Those two orders disagree precisely on the hardware this flag exists to
diagnose. pick_device's own comment records why the ranking is there: "enumeration
order puts the iGPU FIRST on some hybrids (observed: Ryzen iGPU ahead of an RTX
dGPU)". So on a hybrid laptop the number the probe printed for the iGPU could
well be the number for the dGPU — a diagnostic handing out an actionable value
that selects the other GPU, which is worse than printing none.
Measured on the Arc + RTX 3500 Ada laptop, which is also where the first output
went out with the wrong claim in it: three adapters, and the same Arc iGPU
enumerated TWICE. So AdapterDecode now carries the raw enumeration index,
captured before the sort, and the printer uses it; the "default presenter"
marker stays on the first LISTED entry, because sorted-first is what pick_device
lands on when nothing overrides.
The duplicate is why the trailing hint names PUNKTFUNK_VK_ADAPTER as the safer
knob and admits its limit: two adapters sharing a marketing name cannot be told
apart by it, and a name match resolves to whichever enumerates first. The hint
also states the thing this whole output invites a reader to get wrong — that a
capable GPU in the list does not mean the decoder will use it, because Vulkan
Video decodes on the presenter's device and PUNKTFUNK_DECODER does not move the
presenter.
Gates: fmt clean; clippy -D warnings on punktfunk-client-session and pf-presenter.
First hardware run of --probe-decode, on the RTX 5070 Ti:
driver decode ops: H.264, H.265, AV1 (0xF)
Three names, four bits. 0xF is H.264|H.265|AV1|VP9 — bit 3 is
VK_VIDEO_CODEC_OPERATION_DECODE_VP9_BIT_KHR, a real decode operation this
client has no rung for, so the name table stopped short of it and the line
looked complete while silently dropping a codec the driver had advertised.
That is the exact failure this flag exists to prevent. The whole point of
--probe-decode is that a reader can trust the words to cover the number; a mask
with an unexplained bit asks them to trust it instead. VP9 is now named (marked
as having no punktfunk rung, because advertising it as decodable would be its
own lie), and any bit beyond the four we know prints as "unrecognised bits
0x…" rather than vanishing — so the next codec Khronos adds shows up as an
unknown rather than as nothing at all.
Gates: fmt clean; clippy -D warnings on punktfunk-client-session.
G6 + G15 of the gyro program.
G6 — the UMDF gamepad driver's input path. Its timer ran at 8 ms and completed
one pended READ_REPORT per tick, so a game could observe at most ~125 Hz while
clients stream motion at ~250 Hz: every other sample was overwritten in the slot
before anything read it, and the ones that survived carried up to 8 ms of extra
latency. For gyro, a dropped sample is not a dropped frame — it is rotation that
never reaches the game.
The timer now ticks at 2 ms (about a real DualShock 4's Bluetooth cadence). Only
the cheap half runs on every tick: read the input slot, complete one pended
read. The channel handshake and the health marks stay on their historical ~8 ms,
because they cost more, nothing wants them faster, and `driver_heartbeat`'s
documented "+1 per ~8 ms tick" is what the host reads as liveness.
The same slot is a single unqueued buffer that both sides touch without a lock,
so a driver read landing mid-copy handed the game a report that was half the
previous frame and half the next. For a button that is a one-tick glitch; for
motion it is a spike in angular velocity, which an integrator turns into aim
movement. `PadShm` gains an `input_gen` seqlock (v2.3, carved from reserved
space inside the v2 legacy region): the host takes it odd, fences, writes the 64
bytes, and stores it even; the driver samples it either side of its read and
retries once. The old code's own comment called this out as a known residual —
it is now closed rather than documented.
Version posture matches the ring's, with one simplification: no capability stamp
is needed, because an old host never writes the field and a constant 0 is
indistinguishable from "no write in flight", so a new driver against an old host
behaves exactly as it does today, and an old driver ignores the field entirely.
The Steam Deck write path had neither the seqlock nor even the trailing Release
its DualSense sibling carried; all three Windows backends now publish through
one `publish_input`.
G15 — motion-cadence observability. The host already computed the measurement a
"gyro feels floaty" report needs (client inter-arrival percentiles), but kept
ONE global accumulator, so two motion-capable pads in a session interleaved into
each other's gaps and produced a number describing neither. It also sat at
`debug` behind a `tracing::enabled!` check, so a field log arrived with nothing
in it and the only way to get the measurement was to ask for a re-run.
Now per-pad and always on, summarized at `info` when the session ends — the
moment a field report is being written. It costs one subtraction and one array
increment per sample: percentiles come from a fixed log2 histogram instead of a
growing sorted Vec, so there is no allocation, no per-window sort, and no way
for a client streaming as fast as the link allows to make the instrument
expensive. Percentiles are reported as bucket upper bounds (`_le`), which is a
factor-of-two answer to a question whose answers are orders of magnitude apart.
Gaps of 500 ms or more are counted as stalls rather than folded into the
percentiles — an interruption is not a cadence, and averaging it in would report
a healthy feed as a terrible one.
Gates. Windows CI runner .133, the drivers workspace on the real WDK: cargo
build, clippy -D warnings (which enforces the unsafe-audit lints), and fmt —
all green, against a source whose SHA-256 matches this commit's. Linux CI image:
fmt, build, clippy --all-targets -D warnings over pf-inject / punktfunk-core /
punktfunk-probe / pf-client-core / pf-driver-proto / punktfunk-host, and the
test suites including the 5 new motion-cadence tests — all green.
Not measured on glass. G6's stated gate is a sensor-rate reading (SDL
testcontroller or Steam's calibration screen) that matches the client's send
rate; that is still owed, and a driver change only a compile has seen deserves
it before anyone trusts the number.
The rows sat hard against the pinned title — the menu is the one form screen
with no subtitle, so the list started at the very top of the content band. It
now wears Add Host's explainer, which both says what the menu is for and is the
air that keeps the first row off the title. A pinned card explains what unpinning
does and does not touch, the same wording the Android dialog uses.
Caught by the screenshot pass, not by a test.
Observed in the first real session on the substrate: the loopback ran on
the minted 'Punktfunk Speakers' (silent on the host by construction), but
have_silent name-matches only the Streaming Microphone — so the capture
open logged 'desktop audio will also play on the host' (false) and
re-attempted the Steam-pair install it doesn't need. The minted sink is
recognized by id; its name honestly says Speakers, which the name rule
must keep refusing for FOREIGN instances.
Phase 1 of the gyro program (design/gyro-program.md, G1-G5) — the five
correctness fixes under it. Gyro aim integrates angular velocity over time, so
each of these is not a cosmetic wrongness: a wrong scale is every rotation being
the wrong size, a wrong clock is every rotation being integrated against a
fictional dt, and a stale sample is rotation that never happened.
G1 — the DualShock 4 calibration blob. A Sony pad does not assume a motion
scale, it reads one out of a fixed calibration feature report. Ours declared
0.5 LSB per °/s and 8192 LSB/g while the wire delivers 20 and 10000, so every
DS4-type session decoded gyro 40× too fast and acceleration 1.22× hot — since
the backend shipped. The blob now states the wire's own units (the DualSense
blob's numbers, deliberately: both pads consume the identical wire sample). Its
interleaved per-axis order is NOT a bug and stays: the virtual pad declares
BUS_USB, where interleaved is the correct layout; grouped is Bluetooth's.
The same blob lives a second time in the UMDF driver, which is a separate WDK
workspace that cannot depend on pf-inject — one wrong table in two files, where
fixing one reads as fixing it. Both are fixed, and the DS4 feature reports now
live in dualshock4_proto beside the DualSense's rather than in the Linux
backend, so there is one canonical copy to point at.
Field hosts keep the old blob until they update the host package.
G2 — the gate that would have caught it. Nothing pinned any backend's
declaration against the wire, so tests/motion_contract.rs now applies the
CONSUMER's arithmetic (the kernel's, and SDL's, which differ) to each backend
and asserts the result lands back on the wire constants — for the DualSense and
DS4 blobs, and for the Deck and Switch Pro rescales. It also parses the driver's
Rust source and re-derives the units from THAT, so the two copies cannot drift.
Verified non-vacuous both ways: re-introducing the old blob fails with "declares
a fractional 32/64 LSB per °/s", and reverting only the driver's copy fails with
"the UMDF driver's DS4_FEATURE_CALIBRATION has drifted from pf-inject's".
The wire units themselves move to punktfunk_core::input::gamepad, referenced by
the client's capture scale, the Deck/Switch rescales, and the probe — whose
at-rest vector said 16384 (a driver's number, not the wire's) and now says 1 g.
G3 — real sensor clocks. The DualSense advanced its sensor timestamp by +1 raw
unit per report (0.33 µs — a frozen clock) and the DS4 by a flat +188 (~1 ms)
regardless of the real 4-8 ms cadence. Anything integrating rate × dt off that
field got nonsense. All four backends now stamp elapsed monotonic time in their
own units via a shared SensorClock, anchored to the pad's first report so an
irregular publish loop cannot make it drift, and truncated to the field width —
which reproduces the wrap real hardware does.
G4 — motion is level-triggered and had no watchdog. merge_frame preserves the
last sample and the heartbeat re-emits it, so a feed that stops leaves the pad
rotating forever — and with G3's honest clock, at a dt that keeps growing.
Rumble and the pen plane each have an idle timeout; motion now has one too, at
100 ms. Angular velocity only: acceleration is kept, because gravity is
legitimately persistent and blanking it reads as free-fall. The SDL client
parks its gyro at zero when a slot closes, which is the case we can flush
rather than wait out. (The Apple half of this rides in PR #88.)
G5 — a pad returning inside the 300 ms replug grace keeps the same device and
skips the create path, so a different controller inherits the previous one's
touch contact and rotation — and a pad with no gyro never sends a sample to
correct it. sweep() now reports re-claims separately from drops, and the manager
clears the rich plane on one. Rich fields only: rumble and hidout dedup
deliberately survive a removal.
Gates (Linux, CI image): fmt, build, clippy --all-targets -D warnings over
pf-inject/punktfunk-core/punktfunk-probe/pf-client-core, and the test suites —
110 pf-inject unit + 6 contract + 29 pf-client-core gamepad, all green.
Not yet verified on glass; the on-glass sign/scale session is G16.
Field report from an Intel Arc + NVIDIA laptop: pinning the Vulkan rung on the Arc
iGPU silently produced D3D11VA, and there was no way to tell whether the build had
tried at all. That ambiguity was ours, in three places.
The "unavailable" log printed three of the FIVE conjuncts that gate Vulkan Video.
A device with 1.3, the features and a decode queue family — but no codec extension
— logged dev_is_13=true features_ok=true decode_family=true next to the word
"unavailable" and named nothing actionable. It now prints all five, plus which
base extensions are missing, which codec extensions are present, the decode
family's own advertised codec operations, and the device name and vendor. It also
no longer says "VAAPI/software" on Windows, where the rung below is D3D11VA.
The native-vulkan PIN refusal logged `video_decode` alone. On a device that
decodes something but not THIS codec, that reads as a contradiction: refused, yet
video_decode=true. It now carries the caps mask and the codec bit that was wanted,
so "your GPU can't" is distinguishable from "we asked for the wrong thing" — only
the second is our bug.
And `--probe-decode` is new: per-adapter Vulkan Video capability with no session,
no surface and no logical device. For each GPU it answers usable yes/no, the
driver's own decode ops, the extensions, and — when the answer is no — which
conjunct failed, in words. Separate from --list-adapters, which the desktop shells
parse line-by-line for their GPU picker and which therefore keeps printing bare
names.
The listing is ordered like pick_device (discrete first) and marks entry 0 as the
default presenter, because that ordering is very likely the reporter's actual
answer: pick_device ranks DISCRETE_GPU above INTEGRATED_GPU, Vulkan Video decodes
on the PRESENTER's device by design (that is what makes it zero-copy), and
PUNKTFUNK_DECODER does not move the presenter. So on a hybrid laptop, pinning the
decoder while the dGPU presents probes the wrong GPU entirely —
PUNKTFUNK_VK_DEVICE=<index> is the knob that moves it, and the index printed is
that value.
To keep the probe honest, VIDEO_BASE and VIDEO_CODECS moved to module scope and
the five-way AND became video_decode_gate(), called by both the probe and device
creation. A probe holding its own copy of the rule is one that eventually reports
a capability the session then refuses — which reads to everyone as a decoder bug
rather than a probe bug.
Gates: fmt clean; clippy -D warnings over punktfunk-client-session and
pf-presenter. The Linux container was unavailable (the host's disk filled and took
the docker daemon with it), so this ran on the macOS host target only — the
container leg is owed, and CI covers it on the PR.
Two gaps, both found on the shared Linux/Windows console UI.
**The settings tabs only moved for a gamepad.** They were bound to the shoulder
buttons and to PgUp/PgDn, and the legend spells PgUp/PgDn out only when NO pad is
attached — so with a controller plugged in a keyboard user had nothing to find,
and a mouse or a touchscreen could not change section at all.
The root cause was wider than the strip: `SkiaOverlay::handle_event` matched only
`KeyDown` and `TextInput`, so every mouse button, wheel and touch contact fell
past the console into the run loop, which routes pointer input exclusively at
`stream.capture` — `None` while you are browsing. Nothing in the console had ever
been clickable. Making just the pills answer would not have helped either: the
settings screen is opened with X from home, so a mouse could not reach it.
So the console gets a real pointer path:
- `Overlay::handle_pointer` carries mouse/touch in SWAPCHAIN PIXELS. The run loop
converts (it owns the window, hence the display scale, and mouse coordinates are
logical while fingers are normalised); the console then hit-tests the very rects
it drew last frame. Only DIRECT touch devices are offered — an indirect trackpad
already drives the mouse.
- Widgets act on the PRESS, not the release. The list and both carousels scroll the
focused item toward the centre, so what you pressed has slid out from under your
finger by the time it lifts; press-to-act has no such race and there is no drag
gesture to compete with.
- The hint bar became the pointer's button bar. It is already the console's only
on-screen statement of what the face buttons do, and a pointer has none — so its
Confirm/Back/Secondary/Tertiary pills are clickable on every screen, which is what
puts Settings and Library within reach of a mouse at all.
- Tab / Shift+Tab change section; PgUp/PgDn still do, and the keyboard legend now
reads "Tab".
- Right-click is Back everywhere, EXCEPT at the root: B there quits the launcher and
a right-click is far easier to fire by accident. Quitting stays explicit.
**Host cards had no menu.** Every other client hangs Wake / Copy link / Edit /
Forget off a host card; the console could add a host and connect to one, and that
was all — so a renamed machine or a fat-fingered address stayed wrong forever
unless you opened a desktop shell. UP on a saved tile now opens that host's menu,
the same gesture the Android console uses, on the one direction a horizontal
carousel leaves free.
- `ConsoleCmd::UpdateHost` edits the stored host IN PLACE. Removing and re-adding
would silently drop the fingerprint, the learned MAC, the pinned cards and the
profile binding — that is a rename, not a re-pair.
- `ConsoleCmd::ForgetHost` drops it; if it is still advertising it returns as a
discovered, unpaired row, which is the honest state.
- Forget arms on the first press and fires on the second. The other clients forget
outright; a console is driven by a thumbstick from across a room.
- A pinned profile card offers only Unpin. It is a shortcut, not a second host, and
offering to forget the host from it would blur exactly the distinction a pin draws.
- "Edit…" REPLACES the menu on the stack rather than stacking over it, so Back from
the editor doesn't land on a menu describing the host as it was before the edit.
Verified in the pf-lxcheck2 container (this crate compiles to nothing on macOS —
a bare `cargo check` there is vacuous): plain build and `clippy --all-targets`
clean under `-D warnings`, 72 tests pass. Seven are new, and cover the reported
bug directly — a press on a pill selects that tab, and each tab still keeps its
own cursor when a pointer is what switched it.
Measured on the target box: the pump wired 2 s before the provisioning
worker latched, took the cable as its write target, and the next wiring
pass would then have paired the default recording with the minted
microphone — which nothing writes into: dead mic-air until a pump reopen.
resolve_target now provisions synchronously (instant once latched; the
opt-out env is honoured), so the pump's held device and the plan's verdict
can never disagree.
An Audio wiring card (Windows hosts) below the status tiles: a readiness
badge (Ready / No microphone / No game audio / Not wired), the friendly
names carrying each role, and the degradation notes that were previously
visible only in the host log — mic withheld for game audio, the known-
degraded last resort, a narrowing endpoint. api/openapi.json regenerated
from the host build (AudioWiring + RuntimeStatus.audio); en+de messages.
RuntimeStatus gains an 'audio' object (Windows hosts): readiness
(full/audio_only/mic_only/none), the friendly names carrying each role,
and the three degradation flags (mic_withheld, last_resort, narrowing) —
the verdicts that previously lived only in tracing logs. Snapshot of the
last wiring pass (the mic pump wires at host start and on every reopen);
a status poll never triggers COM work or IPolicyConfig writes.
Opt-in "Gyro from this phone" (gyro_on_phone, off by default): this
device's IMU sources wire pad 0's motion while that pad is a controller
with no motion source of its own. On Android that gate is exact — the only
pads that forward motion are the capture links (USB DualSense / SC2,
claimed as ExternalPads), so the mirror stands down per sample whenever
GamepadRouter.padHasOwnMotion(0) says a capture link holds the index, and
sends nothing while pad 0 has no slot at all (motion never creates a host
pad). "Rumble on this phone"'s sibling, data flowing the other way: same
read-once-at-attach settings plumbing, same hardware-gated rows in the
touch and controller settings (a TV box has no gyroscope to mirror from).
DeviceGyro registers TYPE_GYROSCOPE + TYPE_ACCELEROMETER at ~200 Hz on a
dedicated HandlerThread with batching disabled (maxReportLatencyUs = 0 —
batching is poison for gyro aim), converts with the wire contract shared
with pf-client-core (rad/s → 20 LSB/°·s, m/s² → g → 10000 LSB/g; Android's
accelerometer already reads specific force, the DualSense report's own
convention), and rotates each sample from the natural-portrait sensor
frame into the controller frame by display rotation — a phone clipped
landscape yaws when the player yaws instead of rolling. The remap matrix
and unit constants are pinned by DeviceGyroTest.
A stand-down edge (capture link claims pad 0, or session teardown) sends
one zero-gyro sample so the host's virtual pad never keeps integrating an
angular velocity this device stopped producing — the gyro sweep's
stale-rotation latch, avoided by construction here.
Opt-in "Gyro from this device" (DefaultsKey.gyroFromDevice, off by default,
iOS only): while player 1's forwarded controller reports no rotation rate of
its own — no GCMotion, or the gravity-only motion an Xbox pad exposes — this
device's IMU sources pad 0's wire motion instead. The rumble-on-device
mirror's sibling, data flowing the other way: same session-scoped
UserDefaults read, same hardware-gated settings rows, same pad-0 rule.
DeviceGyro wraps CMDeviceMotion at the ~100 Hz CoreMotion ceiling on a
dedicated serial queue (not main — the controller path's main-queue delivery
is a known jitter source), converts with the shared GamepadWire constants,
and rotates each sample from the device's portrait frame into the controller
frame by interface orientation, so a phone clipped landscape yaws when the
player yaws instead of rolling. The remap matrix is derived and pinned by
DeviceGyroRemapTests.
GamepadCapture owns engage/stand-down (reconcile, suspend/resume, stop), and
suppresses pad 0's controller-motion forwarding while the mirror runs — two
writers on one pad's motion state would fight, and the accel-only stream
would stomp the mirror's gyro with zeros.
Also fixes the stale-motion latch from the gyro sweep on the controller
path: flush now parks motion at zero (keeping the last accel, so gravity
doesn't become free-fall), and the mirror's stop sends the same closing
zero. The host holds motion as state and re-emits it — a nonzero angular
velocity left behind read as endless rotation for as long as an overlay
(Control Center pull-down) kept the app inactive.
The capture-direction lookup built its endpoint id with the RENDER prefix
{0.0.0.00000000}., but WASAPI's enumeration returns capture ids as
{0.0.1.00000000}.{guid} — so the minted microphone's capture side never
string-matched the enumeration and the wiring plan paired no recording
device (audio-probe plan on the target box: mic_capture = '-'). Measured;
IMMDeviceEnumerator::GetDevice tolerated the wrong prefix, which is why
the S3 spike's direct open still passed.
A fresh CLI process has no startup worker to have finished, so the plan
devtest raced its own background provisioning thread and printed the name
ladder instead of tier-0. ensure_blocking() re-resolves existing marker
devnodes in milliseconds before the wiring pass runs.
The other half of the audio-substrate decision (spikes S2+S3 green, minted
endpoints landed in the previous commit): stop bundling a third-party
kernel driver the host no longer needs.
installer the VB-CABLE task, payload, silent-install run and the
donationware notice are gone; a suppressible notice tells
a Steam-less box that audio needs Steam INSTALLED (never
running) and that installing it later just works. A cable
from an older install is still deliberately not removed.
packer + CI -VbCableDir/VBCABLE_DIR, the staged-payload check and the
runner provisioning download are gone; SBOM drops the
redistributed-driver component.
winget the VB-Audio bundling-grant agreement becomes the honest
Steam requirement (surfaced on the unattended path where
no wizard is on screen).
docs windows-host/uninstall/security/echo say what actually
ships: no kernel-mode driver of our own, endpoints minted
from Valve's vendor-signed drivers, VB-CABLE mentioned
only as the historical fallback that keeps working.
host wording the mic-open guidance and module headers lead with Steam;
the NAME ladder itself is untouched — demoting 'cable
input' was considered and rejected (on a box where minting
transiently fails, the SSM would outrank an installed
cable, steal the silent sink, and make audio host-audible).
Reconciling with #85 (FFmpeg is gone from the client). Two of my claims
were true against the pre-merge tree and false against this one.
Note 4 said the desktop clients need no 4:4:4 decode probe "because every
rung can display full chroma — swscale converts for the software rung".
There is no swscale any more. The CPU floor is openh264 + rav1d, it is
4:2:0 8-bit by contract and has no HEVC at all, so it refuses a 4:4:4
stream rather than converting one. The client still advertises the bit
unprobed, which was the point of the original fix, but the honest reason
is different: full chroma is a hardware path (Vulkan RExt, NVIDIA today),
and what catches a box whose hardware 4:4:4 fails is note 2's codec
reconnect, not a downgraded picture. Note 13 repeated the same wrong
premise and now names both halves of its warning.
The C ABI is 17, not the 14 I read before the merge.
Textual side of the conflict: #85 rewrote the Codecs column and notes 1-3
of the same table while leaving note 4's stale 4:4:4 text alone. Theirs
kept in full; only the 4:4:4 column and note 4 are mine.
The audio-substrate program's Phase 2 (spikes S2+S3 measured green on the
target box): the host mints its OWN instances of Valve's streaming-audio
drivers and wires by IDENTITY instead of borrowing Steam's primaries —
minted.rs the provider: one devnode per role ('Punktfunk Speakers'
from SteamStreamingSpeakers.inf, 'Punktfunk Microphone'
from SteamStreamingMicrophone.inf), marker-matched across
restarts (PunktfunkAudioRole in Device Parameters — names
are NOT identity, a minted instance is name-identical to
the primaries), provisioned on a startup worker like pad
audio, retried with a 60 s cool-down from wiring passes,
defaults restored when a fresh endpoint grabs them.
wiring_plan MintedIds tier-0: the mic takes its minted device outright
(capture side paired by the provider's id — a name search
cannot tell it from the primary), the loopback prefers the
minted sink at the head of the silent tier, an operator
override still beats everything, a narrowing minted sink
demotes below real hardware, and stale ids fall back to
the ladder unchanged. Plus AudioReadiness — the
full/audio-only/mic-only/nothing classification, logged
with every plan change (§C4's seed).
audio-probe 'mint' runs the provider synchronously; 'plan' prints one
real wiring pass + readiness — the field-triage command.
Without Steam's drivers nothing changes: provisioning degrades to absent
ids and the plan keeps the name-based ladder (primaries → cable → real
hardware) exactly as before.
The Windows host job died in its clippy step: eight items in pf-encode's
split-encode policy (`SPLIT_AUTO`..`SPLIT_DISABLE`, `resolve_split_mode`,
`max_forced_split_mode`, `clamp_to_engines`) were reported as never used, and
`-D warnings` turns that into a build failure. Nothing about the encoder was
wrong — the items simply have no reader in one particular build of the crate,
and nothing was telling the compiler that.
`codec.rs` compiles on every platform, but the split policy only ever has a
caller on Linux (the libav NVENC path reads it unconditionally) or on Windows
with the `nvenc` feature (the direct-SDK backend). A featureless Windows build
of pf-encode has neither, so every item in the cluster is genuinely dead there.
Gate them on the union of their callers' cfgs, the way `forced_split_width`
next door already is.
The step lints pf-encode itself WITH `--features nvenc,amf-qsv,qsv`, where the
items are live, which is why this was invisible there; the failure came from the
next command in the same step, `clippy -p pf-vdisplay`, which pulls pf-encode in
as a plain default-features dependency. Same item-level `dead_code` trap this
crate has now hit five times.
Verified: default-features pf-encode reproduces all eight errors before the
change and none after (macOS default-features exercises the identical
"cluster has no caller" arm as featureless Windows — the two remaining errors
there, `vbv_frames_env` and a redundant closure call, are pre-existing and
macOS-only; both items have real Windows callers). Linux default-features and
Linux + nvenc `--all-targets` both stay clean, so the callers still see the
policy. `cargo fmt` clean.
The support matrix said the desktop clients' Full chroma switch "has no
effect today" and that only the Apple client asks for 4:4:4. Both stopped
being true in July: `clients/session/src/main.rs` advertises VIDEO_CAP_444
whenever the setting is on, deliberately with no client-side probe, because
every desktop decode rung can display full chroma — the Vulkan presenter
samples the 2-plane 4:4:4 pool formats and swscale converts for the software
rung. So Linux, Windows and Apple all ask; Android is the one that genuinely
doesn't implement it.
The other half was HDR. `9f72a3b6` gave the Windows IDD-push capturer a
packed 10-bit BT.2020 PQ RGB output, so NVENC encodes HEVC Main 4:4:4 10 and
the two compose — the matrix still said "4:4:4 and HDR together is refused",
and hdr.md still called PyroWave the only exception. Linux is the side that
keeps the trade: handshake.rs resolves the depth back to 8 for a 4:4:4
session, so full chroma wins and the stream is SDR.
Three cells move ❌ → ⚠️ rather than ✅ on purpose. The client half is
unconditional, but the host half is not: HEVC 4:4:4 means an NVIDIA host, or
PyroWave on any vendor. The notes say which, and point at the stats overlay's
`4:4:4→4:2:0` tag — this negotiation is the one that fails loudly.
Also: C ABI version 13 → 14; PyroWave's ≈8K 4:4:4 block-index ceiling now
has a note; and the roadmap no longer calls Intel 4:4:4 a hardware limit,
which the matrix and vaapi.rs both contradict — VCN can't, VAAPI hasn't.
Spot-checked and left alone as still accurate: the Linux client clipboard
stub, VAAPI declining 4:4:4, Android having no 4:4:4 at all, and the wire /
driver / gamepad-channel versions.
main moved 93 commits while this branch ran. Two conflicts, both where main's new
work sat next to M10's excision:
packaging/flatpak/io.unom.Punktfunk.yml — main added the vendored gamescope WSI
layer (the only route to HDR on a Deck) and, before it, a vulkan-headers module.
Took both: this branch predates them and deletes neither. But the headers module's
stated consumer was pf-ffvk's bindgen over FFmpeg's hwcontext_vulkan.h, and M10
deleted pf-ffvk — so it now reads as dead weight to the next person. It is not:
the WSI layer IS a Vulkan layer, compiles against those headers, and builds after
it, so module order is the dependency. Rewrote the rationale to say so, including
why dropping it would be expensive to discover — flatpak.yml has no pull_request:
trigger, so a manifest break reaches main invisibly and a tag then ships no Linux
flatpak. Also recorded that the native decoder needs nothing from there: pf-vkdecode
reaches Vulkan through ash, which is pure Rust bindings, no bindgen, no C headers.
crates/pf-console-ui/src/screens/settings.rs — main restructured the gamepad
settings into TABS, which removed the per-row section headers; this branch had left
Some("Video") untouched from the merge base and added the pre-M10 decoder migration
next to it. Git could not tell those apart. Took main's structure (no header, its
deliberate change) with this branch's migration layered on: a stored `vulkan`,
`vaapi` or `d3d11va` names no preset in the tabbed list and would render as "—",
then silently rewrite the user's preference on the next save.
Gates on the merged tree, Linux container: fmt clean; cargo check --workspace
--all-targets clean; clippy --workspace --all-targets -D warnings clean; tests
green across pf-vkdecode (187), pf-client-core (163), pf-console-ui (58) and
punktfunk-host (447 of 448 — the one failure is the pre-existing
gamestream::stream::tests::sender_delivers_batches, a UDP-loopback EINTR under
qemu that fails identically on a pristine HEAD).
The S1-S3 spikes from windows-audio-endpoints-and-vbcable.md as one
runnable devtest (no game, no client, ssh-drivable):
audio-probe ssm S3, the decision gate: mint a SECOND devnode of
Valve's Steam Streaming Microphone driver and
prove the pair end to end (tone into its render
endpoint must come back out of its capture
endpoint). Pass = a punktfunk-owned virtual mic
needs no VB-Cable wherever Steam is installed.
audio-probe sink S2: mint a Speakers instance, park the DEFAULT
playback on it, tone through the default device,
WASAPI-loopback the instance - the desktop-audio
capture path minus the game.
audio-probe sss-primary S1: the primary Speakers' known-silent loopback,
re-measured, with mix format + steam.exe state.
audio-probe cleanup remove every probe-minted devnode (marker value
in Device Parameters, never name-guessing).
pad_endpoint grows the first slice of the design's §C1 shared minting
surface: create_media_devnode(desc, hwid, mark), bind_driver(hwid, inf),
find_capture_endpoint_for_devnode — the pad provisioner now calls the
same functions. The probe restores whatever default devices the minting
disturbed before it exits.
install.rs (landed 2026-08-05 with the security-review remediation, while
the Windows CI runner was down) fails windows-host.yml's clippy gate:
#![deny(clippy::undocumented_unsafe_blocks)] wants the SAFETY comment on
the line preceding EACH unsafe block, and three blocks didn't have one —
two sat behind a comment anchored to the enclosing closure/neighbouring
statement, and EqualSid had none at all. Comments only; no behavior
change.
The wiring plan reserved the mic target unconditionally first, so on a box
without VB-Cable the mic took the Steam Streaming Microphone — the only
working client-only loopback sink — and desktop audio fell to the
known-silent Speakers last resort: a headless Steam-only host streamed
SILENCE (the 2026-08 field case), and the installer's 'optional (mic
passthrough)' wording never warned anyone.
The mic may now hold the Streaming Microphone only while the loopback still
gets a preferred (non-last-resort) pick without it — another silent sink or
real hardware. Otherwise the loopback takes the endpoint and the mic falls
to a lesser candidate or is honestly withheld (Wiring::mic_withheld), with
the open error naming the trade and the remedy. An operator
PUNKTFUNK_MIC_DEVICE override is exempt: an explicit choice may still
strand the loopback on the last resort.
Also: the Steam-pair auto-install latch is now once per INF-state instead
of once per process — an attempt made while Steam was absent re-arms when
its driver INFs later appear (files are invisible to the endpoint-set
fingerprint, so nothing else would ever retry), and a withheld mic skips
the pointless reinstall (the pair exists; the plan gave it to the loopback).
Two defects, found while tracing M8's codec-fallback reconnect and recorded
verbatim in d5e23146 as out of scope there.
A client retry re-sends Hello::launch verbatim, and the host launched
unconditionally. Steam and Epic URIs hide it — the launcher focuses the running
copy — but a gog:/custom: target really did start a SECOND COPY of the game. The
client cannot fix it by dropping the field: on Linux the per-session gamescope is
re-adopted through pf-vdisplay's display registry, whose reuse key includes the
launch command, so a retry without it orphans the running game.
And the retry minted a fresh launch_stamp, so procscan refused to adopt a game
started more than 2 s before it — the game was minutes old, so a reconnected
session had no game-exit detection for the rest of its life.
Both are now answered by a launch registry (launchreg.rs): one record per (client
fingerprint, library id), written at launch time and INDEPENDENT OF THE
TERMINATION POLICY. That independence is the point. The existing fingerprint-keyed
reclaim only exists under GameOnSessionEnd::Always — under the default Keep,
arm_grace is never called, so nothing was recorded at all in exactly the
configuration the defect was reported in.
The design correction that matters: at launch time the host knows NOTHING about
the game's processes — that is the premise of the whole lease design. So identity
flows BACKWARDS from the watcher, which publishes the concrete ProcRefs it
adopted, and the registry's liveness is Scanner::alive over that recorded set,
re-verified by (pid, start). Never a re-scan by spec: a later scan would find a
copy the player started since, and adopting that is what procscan's rule 1
forbids. The published set is never cleared on exit either — the last thing the
watcher saw is what makes a quit game read Gone rather than "no opinion", which
is how it becomes relaunchable at once instead of being suppressed for the window.
On rule 1: an adopting session inherits the older floor, so its own find() admits
what the ORIGINAL session's lease already admitted for its whole life. That is the
correct reading of "the same launch, continued" and not a new exposure — rule 1
forbids adopting processes that PREDATE the launch, and these postdate it.
The match rule is pure and total (covers()): liveness is authoritative where it
has an opinion, and only Unknown falls through to the tie-breakers — a live holder,
or a 90 s in-flight window for a re-dial while the launcher is still working. Gone
beats both, deliberately: a title that crashed on startup must relaunch at once.
Both race orders are handled and neither is relied on. Teardown-first takes the
Running arm; handshake-first (a fast re-dial on a half-open connection) takes the
holders>0 arm, and the old teardown then sees superseded() and does nothing —
without which, under Always, it would arm a grace the new session had already
passed its chance to reprieve, and the reaper would kill the new session's game.
Two tradeoffs taken deliberately: a custom: command with no detection hints stays
Unknown forever, so that reconnect trades game-exit detection for not
double-spawning; and IN_FLIGHT_WINDOW is a fixed 90 s rather than sharing
disconnect_grace_seconds, because the two have opposite failure costs — grace
being wrong leaves a game running, this being wrong silently swallows a launch the
player asked for.
Gates: fmt clean; clippy -p punktfunk-host --all-targets -D warnings green in the
Linux container; 418 passed, +9 exactly the new tests. One failure,
gamestream::stream::tests::sender_delivers_batches, is pre-existing and
environmental — a UDP-loopback EINTR under qemu at stream.rs:1697, outside every
hunk in this change (the last is at +448), and it fails identically on a pristine
HEAD. I reproduced both the failure and its location myself rather than taking it
on report.
⚠ OWED: the Windows leg is COMPILE-UNVERIFIED. cargo check --target
x86_64-pc-windows-msvc dies in ring's C build on macOS and xcheck.sh does not
cover punktfunk-host. The Windows edits are small restructures of existing
branches plus a bool assignment, reasoned through but seen by no compiler. Run it
on .133 before this merges.
I narrowed that exposure by inspection afterwards, and it is smaller than the
blanket warning suggests. The change presents exactly two things to a Windows
compiler that a Linux one did not already see. launchreg gates only alive_count
(lines 227/231), whose cfg(any(linux, windows)) arm calls
Scanner::system().alive(procs) — the identical call gamelease.rs:563 already makes
in code that compiles on Windows today. And the Windows launch arm at
native/stream.rs:1666 reads only ungated bindings the Linux arm type-checks thirty
lines below it (adopt_launch:1658, spawned_now:1663, launch_claim:1463) and calls
only the pre-existing library::launch_title. No new type, no new signature, no
Windows-only API.
That is an argument, not a compile. The run on .133 is still owed.
clippy's undocumented_unsafe_blocks (deny) flagged the three blocks that
81039581 introduced: the SAFETY comment sat outside the closure, so
IsValidSid/EqualSid inside it read as undocumented, and from_raw_parts
shared a comment that only covered the GetLengthSid line above it. Windows
host clippy is the only leg that lints this cfg(windows) code, red since.
The LAN-registry docker login only serves the Push step (Reconcile and
Tag-for-release authenticate via curl -u), but it ran unguarded — so a
hit=true leg landing on a host with a misconfigured docker daemon failed at
login with nothing to push (run 16044/16013 f44 leg). Gate it like Build/Push.
docker image prune -af --filter until=2h keyed on image CREATION time, so a
base image built days ago that merely had no container at that instant was
"aged" — including one a job had just pulled and not yet created. Measured
2026-08-07: three job failures, each coinciding with a prune tick to the
second ("No such image: …punktfunk-rust-ci:latest", every step cancelled),
plus a 4-7 GB re-pull of every idle base image within minutes.
The routine tick now retires only what this host actually accretes — per-SHA
app tags older than 2h (their creation time IS the local build time) — then
sweeps dangling layers, which cannot touch a tagged image. The blanket -a
prune survives only in the near-ENOSPC burst guard, where one re-pull beats
every concurrent job dying.
docker-reclaim.{sh,service,timer} are the hourly leak reclaimer that so far
lived hand-installed on home-runner-1 only; home-runner-2 went without it and
re-accumulated 176 leaked volumes (~60 GB) until jobs died of ENOSPC on
2026-08-06/07. Checked in so both hosts install the same files from here.
The AV1 use-after-free fix (cdd1f3ef) stabilised the wrong half. NVIDIA was
measured retaining pColorConfig, so StoredParamsAv1 boxed the colour and timing
blocks — but OwnedStdAv1SequenceHeader kept the Std struct ITSELF inline, so the
pStdSequenceHeader we handed vkCreateVideoSessionParametersKHR was a stack
address inside ensure_parameters, dead the moment it returned. The fix worked
because of WHICH pointer that driver happened to hold. A driver retaining the
outer one instead — no more of a spec violation than retaining pColorConfig was —
reproduces the original bug exactly: plausible pictures, wrong content, no error
and no counter moved.
The same shape was in the shipping codecs, one step further from evidence: the
H.264 and H.265 create paths pointed pStdSPSs/pStdPPSs/pStdVPSs at function-local
Vecs, and both Add paths handed over the wrapper's inline std field and then moved
the wrapper. Those are spec-legal — the object stores copies — and have never
misbehaved on the fleet. They are fixed anyway, because that is precisely what was
true of H.264/H.265 before the same class of bug was found in them, and a
correctness argument that reduces to which vendor we tested is not one.
So: the Std struct is boxed inside each owning wrapper (one level out from what
_color_backing already did), and the contiguous create-time arrays are now fields
of the stored parameters, assembled at their final address. Identical bytes at
identical offsets — only where they live changed.
The line drawn deliberately, in prose at session.rs:29: Std DATA is pinned; the
VkVideoSessionParametersCreateInfoKHR chain itself is not. Retention there would
be a different and far more extreme class of driver bug, and pinning it needs a
self-referential struct over lifetime-parameterised builders.
⚠ NOT hardware-verified. No GPU has run this — the fleet is unreachable and the
250/250 parity that proved this code bit-exact cannot be re-run. That is why the
change is constrained to address stability alone, and why it ships five CPU-only
tests instead: three capture the pointer handed to Vulkan, perform the real move,
and assert it survives — each verified FAILING first, with genuinely differing
addresses, not a tautology. Two more pin the create-array ownership; those fail
before the fix as compile errors rather than assertions, because the pre-fix bug
there is a dangling pointer and asserting on it is UB.
Also: caps.rs claimed the borrow checker pins a profile chain between wire() and
its last use. False at exactly one site — decoder.rs took a raw *const, ending the
borrow, leaving nothing but inspection to stop a future editor moving the chain
before create_query_pool. Correct today, guarded by prose, which is how the first
bug shipped. It is now compiler-enforced: the pointer write and the create call
live inside one helper that takes the profile by reference, so the borrow is held
across both by the signature. An audit cleared the chains otherwise — no entry
point we pass one to retains it.
Gates: fmt clean; clippy -D warnings over pf-vkdecode AND pf-client-core in the
Linux container (its only real consumer, which cannot build on macOS at all —
wol.rs uses deps its manifest gates to linux/windows, so workspace clippy has
never passed there and does not now); 187 lib tests green on Linux, up from 182.
The last coverage gap, and only worth building once S1 proved it possible: the
Windows backend drives NV_ENC_DEVICE_TYPE_DIRECTX, and an in-place splitEncodeMode
change had never been tested there. It works (071358cb), so the arbiter is now
ungated from Linux-only to the union of both direct-SDK backends and wired into
windows/nvenc.rs: the submit stamp, the feed hook on AU completion,
apply_split_mode, split_key, arm_split_arbiter, and set_send_spread_us.
Same gates as Linux, and they are correctness conditions rather than preferences:
opt-in while it earns trust, an operator PUNKTFUNK_SPLIT_ENCODE pin always wins,
a cached verdict short-circuits, >=2 engines, never H.264, and the sub-frame
trade is only entered when the host has actually reported a send spread to price
it with. The one Windows-specific difference is that `async_rt` is a real
possibility here (opt-in two-thread retrieve) and the arbiter refuses it, because
under pipelined retrieve the submit->AU span includes queue depth and the
comparison would be noise.
⚠ Two more instances of the same item-level dead_code trap, caught by the Windows
run and not by reasoning -- that is now 4 and 5:
- `clear_split_verdicts` is called only by the Linux on-hw test, so it is dead on
Windows; gated to `all(test, target_os = "linux")`.
- The arbiter methods first landed inside `impl Encoder` rather than the inherent
impl (the anchor I used, supports_chunked_poll, is a trait method), which the
compiler caught as "not a member of trait Encoder".
Verified .158 (RTX 4090 / Ada, driver 610.88, D3D11): clippy --features nvenc
--all-targets -D warnings clean, and 2 on-hardware NVENC tests green including S1
re-run with the arbitration code in place (engines=2 latched, DISABLE->TWO_FORCED
accepted, zero IDRs, reverse accepted). Verified .21: clippy clean with AND
without the nvenc feature, 65 unit tests, 25/25 NVENC on-hardware. fmt clean.
The plan's M10 checklist named "the about.toml carve-out that puts FFmpeg
outside the automated licence gate". There is no such stanza — I looked, on this
branch and on origin/main. The carve-out is structural, which is worse: cargo-about
walks the CARGO graph, so a native library reached through a permissively-licensed
-sys crate is invisible to it. ffmpeg-sys-next is WTFPL and passes the gate
cleanly while the LGPL libavcodec it link-imports is never harvested at all.
So about.toml's own claim to be "exactly the regression guard we want against a
copyleft dependency silently entering the linked set" was overstated: it did not
catch FFmpeg entering and would not catch the next one. The comment now says so,
and says where the LGPL obligations are actually discharged instead.
The one genuinely good piece of news is recorded too: since M10 the client links
no FFmpeg, so for every client artifact the crate graph and the linked set
coincide and the gate finally means what it appears to mean. The gap is the
host's alone.
Gate: cargo about generate about.hbs --fail — passes.
On a Mac with brew's opus installed, audiopus_sys found it via pkg-config and
statically linked it into the aarch64 slice — a lib built for the RUNNING
macOS (minos 26.0, tripping the script's own version guard) and existing only
for the host arch, so the x86_64 slice silently fell back to the vendored
build and the two slices shipped different libopus builds. Force the vendored
CMake build for every slice (OPUS_NO_PKG_CONFIG=1), with the CMake policy
floor modern CMake (>=4) needs to accept libopus's old cmake_minimum_required.
The shared JitterPolicy grew an adaptive target floor — clustered genuine
underruns raise the live target a step at a time up to max_target_ms, a long
quiet spell relaxes it back — and the three Rust rings all run it via
note_read. The Apple ring is the one hand-written mirror, and it mirrored the
shed half but not the growth half: its target was pinned at the 20 ms base
forever. On Wi-Fi that bunches arrivals (power-save is the classic; the field
MacBook report is the symptom), 20 ms is regularly shorter than one delivery
stall, so the ring re-primed through every stall for the whole session —
crackle that never got better, on exactly the client where a Moonlight with a
deeper buffer sounds fine on the same host and network.
The ring now carries the full mirror of note_read: 3 underruns inside a 5 s
window grow the target 10 ms (capped at COREAUDIO's 70), 30 s of quiet gives a
step back, and the write-side hard trim follows the grown target (including
the Rust policy's target+quantum guard, which the mirror also lacked). New
tests pin the mirror to the Rust suite's expectations — growth, relax, the
cap — plus the field scenario end to end: bunched 60 ms deliveries with every
fourth burst 30 ms late converge to a silence-free tail instead of crackling
forever.
A field report: game audio on a MacBook (M1) crackles over Wi-Fi against a host
that plays clean to other clients. The Apple client is the one client whose
Opus decode lives in core (punktfunk_connection_next_audio_pcm — AudioToolbox
has no multistream path), and that decoder only ever decoded packets that
ARRIVED. The Linux, Windows and Android decode loops all feed an
AudioGapTracker and synthesize libopus packet-loss concealment for every
packet the wire lost; the in-core path had the tracker sitting unused in the
same crate. So on Apple every lost 5 ms datagram — at ~200 packets/s over
Wi-Fi, a steady trickle — landed in the playout ring as a hard time-domain
gap: a click per loss, sustained crackle under real loss. The redundant-plane
recovery (0xD2) hides single losses when the host grants it, which is exactly
why the survivors are the burstier gaps that need concealing most.
The decode now runs through the same accounting as everyone else: concealed
frames land in front of the arriving frame in one contiguous buffer (the
embedder just writes it to its ring), a DTX marker advances the accounting
without being decoded, and the output buffer is pre-sized for a full
concealment run so the borrow-until-next-call pointer can never dangle.
Unit-tested against real libopus: gaps, duplicates, DTX-after-loss, and the
50 ms cap.
Everything the split-encode programme rests on had been proven only on
Linux/CUDA. The Windows backend drives NV_ENC_DEVICE_TYPE_DIRECTX, so none of it
transferred by assumption -- and if the driver refused an in-place split change
there, Windows arbitration would simply not be buildable.
RESULT on the RTX Windows box (RTX 4090 / AD102, driver 610.88, D3D11):
engines=2, latched by query_caps (WP1.1's probe, validated on Windows
hardware rather than inferred from Linux)
DISABLE -> TWO_FORCED via nvEncReconfigureEncoder, resetEncoder=0: ACCEPTED,
ZERO IDRs, and the reverse likewise.
So the foundation now holds across three platform x arch x driver combinations:
Linux/CUDA Blackwell 610.57.04, Linux/CUDA Ada 610.43.03, Windows/D3D11 Ada
610.88.
⭐ UNBLOCKS ALL FUTURE WINDOWS ON-HARDWARE TESTING. pf-encode's nvenc test
binaries were believed unlinkable on Windows ("NvEncodeAPICreateInstance
unresolved", recorded as pre-existing and worked around by only ever running
clippy there). They link fine given the SDK import library:
RUSTFLAGS='-L native=C:\Users\Public\nvenc -l nvencodeapi'
`-L` alone is not enough -- without a `-l` nothing pulls the archive in, which is
why the earlier attempt still failed. ⚠ This is TEST-BINARY-LOCAL and must stay
that way: production deliberately dlopens NVENC rather than link-loading it, and
an unconditional link-load is the known crash class on non-NVIDIA Windows hosts.
⚠ Box note: the RTX Windows box answers on .158, not the .173 in its memory
entry, and `Administrator@` there resets the connection right after
SSH2_MSG_SERVICE_ACCEPT in a way that reads like the host being down -- the
working login is "Enrico Bühler"@192.168.1.158.
windows-host.yml called FFMPEG_DIR "the same BtbN lgpl-shared x64 tree the
Windows CLIENT links against". Since M10 the client links no libav* at all and
windows.yml sets no FFMPEG_DIR, so the sentence pointed a reader at a link that
does not exist. The provisioning script still fetches the tree — for the host
alone — which is the part worth saying out loud, because the next person to read
it will wonder why a client-provisioning step still mentions FFmpeg.
The verification gap flagged in 01294e3a was real. `.133` came back up and the
WP4 commit failed Windows clippy: `forced_split_width` is used only by the libav
NVENC path (`enc/linux/mod.rs`), but it was added to `codec.rs`, which compiles
everywhere -- so it is dead code on Windows and `-D warnings` rejects it.
Third time this crate has hit the same item-level dead_code trap (see
`subframe_env_forced`, and the arbiter items in `nvenc_core`), and the third time
it was caught by actually running the Windows check rather than by reasoning
about it. The comment on the gate says so, since the pattern is clearly not
self-evident from the code.
Verified .21: clippy -D warnings clean both WITH and WITHOUT the nvenc feature,
65 unit tests. Verified .133: Windows clippy --features nvenc --all-targets
-D warnings clean, zero errors, zero dead_code. fmt clean.
cargo tree -p punktfunk-client-session finds no ffmpeg. The host still does,
which is the whole point: pf-encode keeps libavcodec unconditionally and no
host workflow, packaging script or licence file was touched.
Deleted: crates/pf-ffvk, video_vulkan.rs, video_vaapi.rs, video_libav.rs, the
libavcodec half of video_d3d11.rs, the av_log machinery, ffmpeg::codec::Id as
the decoder's vocabulary (the quic CODEC_* wire constants now serve, which is
why the evidence table was keyed on them), DecodedImage::VkFrame and ::Dmabuf,
the presenter's AVVkFrame lane, and the ffmpeg-fallback feature with
everything behind it. DrmFrameGuard collapses from an enum to a newtype, which
removes an unsafe impl Send. Roughly 25,000 lines.
Then the CI, packaging, licensing and docs work the plan's §6 lists: the
Windows workflows lose FFMPEG_DIR, PF_FFVK_VULKAN_INCLUDE and their PATH
prepend; the MSIX loses its DLL wildcard; the client .deb stops emitting libav
sonames on its own because depends come from dpkg-shlibdeps; arch, flatpak and
nix drop the dependency; and the README's "FFmpeg 7 or 8" contract narrows to
the host.
Three defects reached users' machines in the first cut, and none was in the
deletion itself.
All three desktop Settings UIs offer vulkan, vaapi and d3d11va as stored
decoder values, so those strings sit in shipped settings files today. Refusing
them by name — which is the correct rule for a stale pin — would have bricked
every upgraded client whose owner ever touched that dropdown. They now migrate
onto the native rung for the same hardware family, at decoder construction AND
at each dialog's lookup, because a legacy value that matches no preset
displays as "Automatic" and silently rewrites the user's preference on the
next save.
M9's evidence filter was deleted on the argument that with no libavcodec twin
below, barring an unproven rung removes hardware decode rather than moving
down one rung. That is true on Windows and false on Linux for Intel and every
unknown vendor id, where prefer_vulkan_first is false and the order is
native-vaapi → native-vk: a rung that has decoded nothing anywhere sitting
above one that is 250/250 on three drivers. Every Intel Linux desktop would
have moved from libavcodec VAAPI, shipping for years, onto pf-vaadec by
default — and a rung that constructs and then produces wrong pixels leaves
only by the error-streak demotion, which this codebase already documents as
not tripping on the B580's strobing. The filter is restored as a narrow, pure,
testable rule: an unproven rung yields to a proven one, and to nothing else.
Windows deliberately passes no rung below, because that vendor family is the
one with a measured wrong-pixel report against Vulkan decode, and trading no
evidence for evidence of corruption is the wrong direction.
And the notices still said FFmpeg was bundled. The root file is what both
desktop clients include_str! and what the MSIX ships, three lines under the
new card saying no FFmpeg is bundled; Apple's Acknowledgements said it too, on
iOS, tvOS and macOS. The generator now emits four per-client files scoped by
transitive closure — 0 FFmpeg mentions in each, verified — while the root file
keeps it for the host. That also ends the standing false attribution of
ffmpeg-next, GTK4, windows-rs and the NVENC SDK to an iPhone.
Windows has no reachable box, so it was compiled instead: a cross clippy at
-D warnings on x86_64 and aarch64-pc-windows-msvc with the C toolchain stubbed
so build scripts run without linking. That gate immediately caught an
include_str! path one directory too deep, which nothing else could have.
Gates: container clippy -D warnings, 160 tests, workspace check, both Windows
targets clean, client ffmpeg count 0 and host 2. The four decode crates are
untouched, so the hardware rungs' 250/250 stands.
⚠ Owed and unrun: no GPU has executed any of this milestone. M8's on-glass
software check, M7's D3D11 and VAAPI AV1 hardware legs, and M9's field bake
all still want hardware, and the bake window and criteria remain the user's.
The libav NVENC path carried its own inline copy of the split decision and had
already drifted from the direct-SDK selector: it hard-coded a 2-way split
regardless of engine count, and had no depth rule at all. That is the drift the
shared resolver was extracted to prevent, and the copy quietly reintroduced it.
Routing it through `resolve_split_mode` needed the policy to MOVE. `nvenc_core`
is gated on `feature = "nvenc"`, but the libav path is precisely the build where
that feature is OFF (`PUNKTFUNK_NVENC_DIRECT=0`, and the featureless packages --
the packaging gap this project has been bitten by before). So
resolve_split_mode / max_forced_split_mode / clamp_to_engines, plus a new
`forced_split_width`, now live in `codec.rs`, which is always compiled and
already owned SPLIT_FORCE_PIXEL_RATE.
That means the NV_ENC_SPLIT_ENCODE_MODE values had to be hand-written as plain
constants, since the SDK enum does not exist without the feature. They are
therefore pinned: `nvenc_split_constants_match_the_sdk` (feature-gated, the only
place both are visible at once) asserts all five against the real enum, so the
copies cannot rot.
⚠ Only the FORCED outcomes are actionable on the libav side -- libavcodec's
`split_encode_mode` AVOption is its own vocabulary and our DISABLE is the NVENC
enum's 15, which would be meaningless there. DISABLE/AUTO both map to "leave the
option unset", which is exactly today's behaviour (unset = the driver's auto).
`engines = 0` ("not probed") maps to 2-way, preserving what that site always did;
a 3-NVENC part gets the wider split only on the direct-SDK path, which is the one
that actually probes.
⚠⚠ VERIFICATION GAP: .133 went down mid-change (no ping), so the WINDOWS leg is
UNVERIFIED. This matters more than usual -- the Windows backend imported
resolve_split_mode from nvenc_core and that import had to move too, which a grep
caught rather than a compiler. Re-run before trusting it:
cargo clippy -p pf-encode --features nvenc --all-targets -- -D warnings
Verified .21: clippy -D warnings clean BOTH with and without the nvenc feature
(the featureless build is the whole point of the move) and with
nvenc,vulkan-encode; 65 unit tests incl. the new constant-parity test; 25/25
NVENC on-hardware; punktfunk-host clippy clean. fmt clean.
`ffmpeg-fallback` on pf-client-core, default off on the crate. With it off the
libavcodec rungs are not compiled, pf-ffvk leaves the dependency graph, and no
ladder or demotion arm names them; with it on each sits exactly where it sits
today, directly below its native twin. That is the switch which makes M10 a
deletion rather than a redesign.
The bake window and the regression criteria are the user's, per the plan, and
nothing here claims the M9 gate is met.
The hard part was not the feature, it was honesty. Two of the four native
rungs have never decoded a frame on any hardware — native VAAPI at all, and
native D3D11VA's AV1 leg — and making those the default would assert evidence
that does not exist. So admission is per rung and per codec: a pair with
hardware evidence joins `auto` always; a pair without it joins only when
nothing proven is left below it (a build with no FFmpeg twin, where the
alternative is not a proven rung but the CPU) or when the user asks with
PUNKTFUNK_NATIVE_FIRST=1. Pins bypass it, so a lab run can still reach any
rung.
The shipping default therefore changes in exactly three ways, all
evidence-backed: AV1 `auto` takes native Vulkan (250/250 bit-identical on an
RTX 5070 Ti), Windows H.264/H.265 `auto` takes native D3D11VA above its FFmpeg
twin (parity on two GPUs plus a 30-minute soak), and a failing Vulkan rung on
Windows demotes to native D3D11VA first. Everything unproven is byte-for-byte
as it was.
The evidence state is written where it cannot rot: a table in video.rs's
module docs, the same facts in code as `native_evidence()`, a test asserting
them in both feature states, and a per-session log line carrying the rung, the
codec, whether hardware has verified that pair and the evidence string — at
WARN when it has not. A support engineer reading a log can now tell proven
from assumed without asking anyone.
Termination needed a new guarantee. With the FFmpeg twins gone, two native
rungs in opposite per-vendor orders could hand a session back and forth
forever, so a rung once entered is never re-entered and the walk is monotone
to software. The never-delivered fall-through still works: with the feature on
it is unchanged, and with it off it is redundant, because the next candidate
already IS the rung below.
⚠ ffmpeg-next remains a hard dependency of pf-client-core, deliberately. What
is left off-feature is three type-level residues — the codec-id vocabulary,
the AVVkFrame guard that is pf-presenter's public import, and a pixel-format
in one signature — every one of them an M10 §6 line item. Deleting them here
would mean deleting the presenter's FFmpeg lane, 55 call sites, in a milestone
whose gates cannot run a GPU. No libavcodec decoder is opened in a default
build.
⚠ video_d3d11.rs was gated item by item rather than wholesale, and nothing in
this tree compiles it — it needs a Windows check before anyone trusts it.
Gates: both feature states, container clippy -D warnings and 158/159 tests,
workspace check. The four decode crates are untouched, so the hardware rungs'
250/250 stands.
The ladder's last rung no longer runs FFmpeg. H.264 decodes through openh264,
AV1 through rav1d, and HEVC is refused outright: no permissively licensed
software HEVC decoder exists, so an HEVC session that exhausts its hardware
rungs now tears down and re-dials advertising HEVC-less caps, and the host
picks H.264. The plan calls that a first-class path; it is one.
swscale is deleted, and with it the BT.601 default that its correction code
existed to undo. Colour on the H.264 lane now comes from the same
pf-bitstream planner every hardware rung submits from — openh264 reports no
VUI at all — and AV1's comes per-picture from the sequence header. One colour
source, one CSC: the old default is unrepresentable rather than merely fixed.
Frames reach the presenter as three tightly-packed planes through the planar
CSC pass, which had to be un-gated from the pyrowave feature and its device
probe, since the last rung must exist on devices that failed that probe.
rav1d rather than the dav1d crate, deliberately and against the plan's
literal wording: dav1d-sys is system-deps-only, so it would add a system
library and a .pc file to every client package — in the milestone family
whose excision checklist exists to delete exactly that. rav1d is the same
decoder, same licence, statically linked. The cost is honest: no-asm builds
on both decoders, and software throughput is still unmeasured.
The colour test is the milestone's exit criterion, so it is built to fail.
Three fixtures, and a mutation check: hardcoding the swscale default turns the
red bar to [255,24,0], and swapping Cb/Cr turns red to blue — a silent error
no metadata assertion could catch. Review then disproved the range half of it
numerically: with eight saturated bars, decoding the full-range fixture with
the wrong range gives max error ZERO, because a mismatch only pushes values
outside [0,1] where the shader clamps. A mid-tone was added; the wrong range
now costs 11, well past the tolerance. The exit criterion I set was
satisfiable by a test that proved nothing.
Two blocking defects, both emergent rather than local.
Software AV1 on a 10-bit stream never reached its typed refusal: rav1d is
built 8-bit-only and returns ENOPROTOOPT, which the send loop turned into a
generic error, so the pump's typed downcast missed and every AU failed
identically — a permanent freeze on precisely the shipping case, since AV1 is
advertised only where hardware AV1 exists and hardware AV1 plus HDR is Main
10. The shape is now read from the sequence header before any byte reaches
the decoder, exactly as the H.264 leg reads the active SPS.
And the new Reconnecting phase was the first state that is not streaming, not
connecting, and still holding a live stream — which opened all three guards
that had made a second launch impossible. Pressing A assigned over `stream`
where every other site shuts down first, and StreamState has no Drop, so the
old pump was detached: a second live session still submitting to a Vulkan
device that gets destroyed underneath it. Nothing about the reconnect was
wrong in isolation; the defect lived between a new state and three guards
nobody re-examined. Start is now defensive and the retry raises the
connecting modal, so the UI matches the state and B can cancel.
Also closed: retry_caps was computed, tested and never applied, so a shape
refusal could end a session reporting no codec available while a working
retry existed; the retry inherited force_software sticky-true, landing an
HEVC→H.264 fallback on software H.264 with working hardware H.264; it
re-dialled with a stale mode; the CPU present arm had no survivable-failure
handling where the pyrowave arm — same pass — has it; HEVC is no longer
advertised when the decoder is pinned to software; and the software rung now
feeds the recovery-point SEI it already had in hand to the re-anchor gate.
⚠ Two host-side gaps found while tracing, neither in scope here: Hello::launch
is NOT idempotent (gog:/custom: targets spawn a second copy on a retry; the
field is kept verbatim because dropping it orphans the gamescope display whose
reuse key includes the command), and a reconnected session can never adopt a
game predating its own launch stamp, so it has no game-exit detection.
⚠ OWED: the on-glass software run. ~200 lines of new Vulkan on a path that
only runs because the GPU already failed, and no driver has seen it. The
review's minimum check is sync validation enabled, a non-multiple-of-16 mode,
a mid-session resize and demotion, and both colour matrices.
Gates: container clippy -D warnings over four crates, 236 tests, workspace
check. pf-vkdecode and pf-bitstream are byte-for-byte untouched, so the
hardware rungs' 250/250 stands.
The libva AV1 layouts, the AuPlan conversion and the Linux rung's AV1 arm,
completing AV1 across all three hardware backends. Pin-only.
Layouts measured, not transcribed: the committed probe grew the AV1
structures and every size and offset it printed against libva 2.23.0 is a
compile-time assertion. Three that a hand-count gets wrong — the picture
buffer is align 8 because anchor_frames_list is a pointer, inserting seven
bytes of padding; seg_info and film_grain_info carry their own padding tails
inside the parent; and THREE of AV1's six bit-field unions are narrower than
a word (one uint8_t, two uint16_t), so a u32 packer over any of them writes
through its neighbour.
This is the fifth way this program has had to spell "which pictures does this
frame use", and it is unlike the other four: ref_frame_map is indexed by SLOT
and holds actual VASurfaceIDs rather than indices into anything, ref_frame_idx
is indexed by NAME and holds slots taken from the header — not from the
plan's refs, where a lost reference leaves a hole and a hole is not a slot —
global motion is picture-level, and there is no per-reference size field at
all. Established from va_dec_av1.h and libavcodec's vaapi_av1.c, and stated
in the module docs so the next reader does not re-derive it.
Review verified the whole happy path — every layout assertion re-measured,
every packer width and bit position, the reference convention, the
num_elements buffer shape — and found both defects on FAILURE paths, neither
reachable on the vendored vector.
A conversion refusal permanently desynced the ledger. The mutation block sat
after the tile walk, so any tile-shape refusal left the planner holding a
picture with no ledger slot — and the resulting UnresolvedReference fires
before that block too, so it never repaired. Every later access unit
hard-errored until a shown key frame: one lost packet costing a GOP. The
arm's own doc already warned that skipping conversion would desynchronise the
slot map; the refusal door did exactly what the skip door was written to
avoid. The block is hoisted, and a tile-shape refusal on an already-damaged
plan is now concealed rather than refused.
Fixing that exposed a sharper edge: the conversion can release a slot and
reassign it to the refused picture in one call, so the binding would still
hold the PREVIOUS picture's surface — a wrong reference rather than a missing
one, which nothing downstream could notice. The caller now clears the binding
unconditionally on the refusal path.
And a damaged frame's surface was never written yet was bound as a reference
and left in pending, so a later clean show_existing_frame would claim it with
damaged = false and ship uninitialised GPU memory to the presenter — on
several drivers another client's framebuffer. The justification quoted half
of va_dec_av1.h; its next sentence gives the remedy, which is to point the
problematic index at an alternative buffer. Damaged frames now submit as they
do on the other two arms, with live surfaces substituted for invalid entries
and reported as a bitmask — preferring a reference that really decoded over
the decode target, and keeping libavcodec's deliberate all-invalid map on a
shown key frame.
Film grain is refused rather than decoded wrong: libva wants two surfaces,
one ungrained for prediction and one grained for output, and libavcodec
allocates a second frame for exactly that. The gate now sits after the
mutation block so a grained frame costs itself rather than the GOP, and stays
per-AU rather than per-sequence because a stream that merely DECLARES the tool
decodes here perfectly.
⚠ Residual, flagged not fixed: a picture decoded from substituted references
can still be shown by a later show_existing_frame. It is decoded memory now
rather than uninitialised, and it is what the H.264/H.265 arms do, but
tracking "this was concealed" through to display needs new session state.
Gates: macOS fmt/clippy/125 tests/cargo-doc, container clippy -D warnings over
seven crates and 548 tests, workspace check. pf-bitstream's diff is
comment-only — verified — so the Vulkan rung's 250/250 stands untouched.
Nothing here has decoded a frame: no VAAPI hardware is reachable.
The AV1 arm of the native D3D11VA rung, parity-required because today's
FFmpeg d3d11va rung already decodes AV1 Profile 0 and the excision must not
silently drop it. Pin-only, as that rung is today.
decode() walks the temporal unit frame by frame; submit() splits into
decode_into and present, because AV1 decodes frames that are never shown. The
proven H.264/H.265 body is byte-for-byte unchanged — review diffed it against
HEAD mechanically and found only a rename plus one refusal arm — and the
VideoProcessorBlt hand-off is untouched. That mattered more than anything
else here: those two codecs are hardware-proven, .173 is powered off, and no
gate that runs could have caught a regression in them.
Every descriptor value comes from libavcodec's dxva2_av1.c read verbatim, not
from symmetry with the other codecs: three buffers and no qmatrix (AV1
transmits none), NumMBsInBuffer zero on all three, ConfigBitstreamRaw 1,
surface alignment 128, pool +8, and the session sized from the SEQUENCE
header's max frame size — sizing from the frame would rebuild the decoder and
drop every reference the first time a stream legally resized downward.
Two places where following the H.264/HEVC pattern would have been wrong.
libav pads the bitstream buffer and grows only its descriptor's DataSize,
never a tile's, because a tile's size is exact — charging padding to the last
record is corruption, not filler. And the committed tile records were one per
tile GROUP spanning the whole OBU, header and frame header included, where
libav emits one per TILE addressing the payload past its tile_size_minus_1;
the vendored vector is single-tile, so the old tests passed either way.
Review then found four more defects in the already-committed conversion, each
confirmed against libavcodec AND Chromium's D3D11 AV1 accelerator:
Tile widths and heights were the coded minus-1 where the field is a
superblock COUNT — every tile declared one superblock short, on every frame,
with a comment asserting the opposite of the truth.
StatusReportFeedbackNumber must be zero for AV1. Both reference
implementations disable it specifically for this codec — libav's note reads
"breaks decoding on some drivers (tested on NVIDIA 457.09)", Chromium's "it
crashes :|" — while both set it for H.264 and HEVC, which is why this rung's
proven codecs never showed it. It would likely have presented as a hang or a
rejected submission rather than bad pixels, sending the next session after
the tile records instead.
frame_refs[].Index is an index INTO RefFrameMapTextureIndex, not a surface
index; the neighbouring line already filled that map correctly. Measured:
1636 reference entries on the vendored vector where the two differ.
qm_y/u/v need the 0xFF "no matrix" sentinel — 0 is a valid matrix index, and
274 of 274 frames transmit no quantiser matrix, so every one was being
dequantized against matrix 0.
Also closed: the slot leak the Vulkan rung had already found and documented
(a frame refreshing no slot is never reported removed, so nine of them
exhaust the ledger); a tile-grid check that could not fire, replaced with
libav's own cols*rows guard; per-reference sizes now taken from the
reference's own header via RefState rather than the current frame's; and the
render size clamped against the decoded picture in both rungs, since AV1
permits a render size larger than the frame.
The parity leg was rewired through the real decode path — it previously
called the internals directly, so its hidden-frame assertion described the
harness's own counter rather than production withholding anything.
Gates: macOS fmt/clippy/383 tests, container clippy -D warnings over four
crates and 499 tests, and on Windows .133 (.173 is powered off) clean checks
plus 97 pf-dxvadec tests. All 8 Vulkan gpu_parity legs re-verified bit-exact
on the RTX 5070 Ti after the shared-code change.
No AV1 frame has been decoded through this rung anywhere: it needs .173 back.
The same use-after-free the AV1 rung was just fixed for, closed in the two
rungs that ship. session.rs and session_h265.rs handed their Std parameter
sets to vkCreateVideoSessionParametersKHR and dropped the backings when the
call returned; NVIDIA 610.57.04 was measured retaining such a pointer to
decode-record time, which is what made AV1 diverge on 250 of 250 frames.
Nothing was known to be broken here — both rungs are bit-exact on four
drivers — but that was luck rather than correctness: the freed blocks happen
to still hold the right bytes in that window. The native Vulkan rung sits in
the auto ladder above FFmpeg-Vulkan on shipping clients, so this was live
code, and its failure mode is silent wrong pixels rather than a crash.
StoredParams and StoredParamsH265 hold the parameters object together with
every wrapper it points at, so an object whose backing is gone cannot be
built. create_parameters_object takes the wrappers by value; the Add arm
adopts them only after a successful update, so a failed update drops what it
never stored; the Recreate arm replaces, destroys the old object, then drops
its backings, written explicitly so the ordering survives later edits. The
Add-vs-Recreate decision table and the VPS ledger are untouched — only
ownership moved.
params.rs still carried the refuted claim as a type-level contract, that
Vulkan "copies all parameter data before returning" and keeping the wrapper
alive across the call "is the whole obligation". Corrected to the measured
truth.
The tests are what stop this returning, and each was verified by sabotage:
inlining the H.264 PPS box fails at pps pScalingLists, inlining the H.265 SPS
DPB box fails at sps pDecPicBufMgr, and making either adopt drop instead of
store fails both session tests. Two lessons are recorded in them. Pointer
equality cannot be the assertion, because the Std struct carries pointers by
value and a stale one compares equal — the read-back is the discriminator, so
the tests clobber the dead stack first to make a dangling read deterministic
rather than lucky. And the first H.265 draft read six of eight pointers and
let the sabotage through, so it now reads every one with a labelled assert.
⚠ One site of this class remains, deliberately: the VkVideoProfileInfoKHR
chains, where wire()'s borrow dies with its enclosing block while the object
created from it lives on — three session creates, an image, a buffer, and a
query pool built from a raw pointer into a stack chain. It spans six modules
and all three codecs, and a profile is enums a driver resolves at create time
with no per-frame deref, so the risk is materially lower. It wants its own
pass with its own hardware verification.
Gates: macOS fmt/clippy/196 tests, container clippy -D warnings, pf-vkdecode
182/182 and pf-client-core 140/140. On the RTX 5070 Ti, all 8 gpu_parity legs
re-verified green after the change — H.264, H.265, Main 10 and AV1 all still
bit-identical to libavcodec.
250/250 frames bit-identical to libavcodec on NVIDIA 610.57.04, and all four
other parity legs (H.264, H.265, Main 10, both four-byte-prefix twins) still
green.
session_av1 built the sequence header, handed pStdSequenceHeader to
vkCreateVideoSessionParametersKHR, and dropped the backing the instant the
call returned — on the documented assumption that Vulkan copies parameter
data before returning. NVIDIA does not. It keeps the pointer and dereferences
pColorConfig when a decode is RECORDED. The freed block became our own next
allocation, whose bytes read back as mono_chrome = 1, and a monochrome frame
skips exactly loop_filter_level[2..3] (AV1 7.14).
That is the whole fingerprint two earlier rounds chased: luma bit-exact,
chroma off by small amounts, and rewriting the chroma levels in the bitstream
changing nothing — the driver read them correctly and then discarded them,
because it believed the stream had no chroma. StoredParamsAv1 now holds the
parameters object and its Std backing in one value, so an object whose
backing is gone is unrepresentable.
The road there is worth recording, because two well-evidenced conclusions
were wrong before this one was right. A software oracle reproduced the
divergence exactly by disabling chroma deblocking, and a GPU probe showed
chroma levels [8,12] and [63,63] producing byte-identical output — which
looked conclusive and was not. libavcodec's own Vulkan AV1 hwaccel is
bit-exact on this same driver, which proved the hardware fine and the defect
ours. ffmpeg never hits it: with VK_KHR_video_maintenance2 it uses inline
session parameters and never creates a parameters object at all.
The proof is direct rather than inferred: a throwaway Vulkan capture layer
dumped both submissions and every byte of our AV1 picture info already
matched libavcodec's, including the loop filter block; only the session
parameters layer differed. Watching the block's address showed correct bytes
at create and our next allocation at decode.
Ruled out on hardware, so nobody re-tests them: filmGrainSupport,
maxCodedExtent, maxDpbSlots/maxActiveReferences, VkVideoDecodeUsageInfoKHR,
the tile-start sentinel, the setup slot's SavedOrderHints, a NULL
pTimingInfo, and heap luck.
Two earlier fixes are confirmed against libavcodec's captured wire bytes and
kept: CDEF secondary strengths carry the coded value rather than the spec's
in-place fixup, and LoopRestorationSize is log2-based. The refuted
driver-ignores-chroma-levels claim is corrected everywhere it was written
down, and that probe test now passes and points at the lifetime of everything
a submission points at before blaming a vendor.
⚠ Adjacent and NOT fixed: session.rs and session_h265.rs drop their Std
backings the same way, and those sets carry embedded pointers too. Both are
measured bit-exact on four drivers, so nothing is known to be wrong — but the
contract now rests on a driver behaviour measured FALSE for AV1 on a shipping
driver. The SAFETY comments asserting it have been corrected; the structure
is deliberately untouched pending its own pass.
Gates: macOS fmt/clippy/336 tests, container clippy -D warnings, all green;
8/8 gpu_parity and 3/3 gpu_smoke legs verified on the RTX 5070 Ti.
WP0's real deliverable, and the hole every previous measurement in this
programme had. All prior timings ran against driver-zeroed buffers, so rate
control had nothing to code (~300 B/AU against an 833 KB quota) and only the
PIXEL-proportional half of the encode cost was ever exercised -- while the 4K60
HDR field report was a BITS/FRAME problem at 6.8 Mbit/frame.
Adds `pf_zerocopy::cuda::write_plane_from_host`, the exact mirror of the existing
read_plane_to_host. No new loader entry was needed: cuMemcpy2DAsync_v2 was
already in the table and CUDA_MEMCPY2D just needed the reverse memory types.
Linux-only by construction (pf-zerocopy's `imp` is cfg'd to linux).
⚠ Two harness mistakes found and fixed by looking at bytes/AU rather than
trusting the knob:
- Pure per-pixel noise is INCOMPRESSIBLE, so a low bitrate target does not
produce low bits/frame -- it OVERSHOOTS. At a nominal 50 Mbps the encoder
emitted 719 KB/AU against a 104 KB quota, and the three lowest rows of the
first sweep all sat at the same ~5.7 Mbit/frame. Sweeping nominal bitrate
measures nothing.
- So the sweep moves CONTENT DETAIL (block size) instead, and the x-axis is the
bits/frame the encoder ACTUALLY produced, never the one requested.
4K60 HEVC 8-bit, real content, single-engine vs forced-2:
bits/frame Ada 4090 Blackwell 5070 Ti
0.2-0.3 Mb 4567 -> 2381 1.92x 5549 -> 3552 1.56x
~1.1-1.2 Mb 5060 -> 2626 1.93x 5867 -> 4082 1.44x
~3.3 Mb 8478 -> 4455 1.90x 9286 -> 5862 1.58x
~9.6 Mb 16237 -> 8114 2.00x 16435 -> 9275 1.77x
RESULTS. (1) Encode time scales strongly with bits/frame -- 4.6 ms to 16.2 ms
across the range on Ada -- confirming the hypothesis' core claim. (2) There is NO
CROSSOVER: split wins at every point on both architectures (Ada ~1.9-2.0x and
notably flat, Blackwell 1.44-1.77x). So the arbitration's encode-side answer is
essentially always "split", which makes the sub-frame handicap the only decision
that actually matters -- exactly the part already built and unit-pinned.
(3) It corroborates the field capture: at ~6.8 Mbit/frame these curves put
single-engine 4K60 around 10-13 ms, and the field report was 10.3 ms on a 4090.
That reads as real ASIC time, not the retrieve-queue inflation it might have been.
⚠ Caveat the data itself shows: cost is NOT monotonic in bits/frame alone. The
1px row lands at the HIGHEST bits/frame yet encodes FASTER than the 4px row on
both boxes (Ada 10148 vs 16237 us) -- pure noise defeats motion estimation, which
gives up early, where semi-structured content makes it search hard. Content
structure is a real term, so "bits/frame" is a good axis but not a complete cost
model.
Verified .21: clippy -D warnings clean (pf-encode + pf-zerocopy), 64 unit tests,
25/25 NVENC on-hardware. Curves run on both Ada and Blackwell. fmt clean.
WP1.3, and the measurement that justifies it. `resolve_split_mode`'s 10-bit rule
sat ABOVE the pixel-rate arm and took no codec, so it (D1) vetoed 10-bit 4K120 --
the very case the pixel-rate arm exists for -- and (D2) applied an HEVC-Main10-on-
Ada result to AV1 10-bit, which has no such measurement. Both fixed: the
pixel-rate arm now comes first, and what remains is codec-scoped to HEVC and only
applies BELOW that bar, where a second engine buys nothing anyway.
The rule rested on one datapoint: 5120x1440@240 Main10 on Ada, forced-2 7.6 ms
vs 2.8 ms single-engine -- split 2.7x SLOWER. Dropping the short circuit flips
that exact configuration's behaviour, so it was re-measured on a 4090 (AD102,
driver 610.43.03), 400 Mbps, sub-frame pinned off, via a new mode-parameterizable
Main10 A/B test (PF_AB_MODE=WxHxFPS reproduces the original operating point).
Ada 4090 single forced-2 ratio
3840x2160@60 4483 us 2178 us 2.06x split WINS
5120x1440@240 3689 us 2813 us 1.31x split WINS <- the veto's origin
3840x2160@120 4148 us 2189 us 1.89x split WINS
Blackwell 5070 Ti
3840x2160@60 4216 us 2477 us 1.70x split WINS
5120x1440@240 4651 us 3894 us 1.19x split WINS
Split wins for Main10 at every mode on BOTH architectures, including the config
the veto came from. The original number does not reproduce.
⚠ Caveats, unchanged from the rest of this work: content is trivial (297-300 B/AU
against an 833 KB CBR quota -- zeroed VRAM), so this is the pixel-proportional
term and the bits/frame regime is still unmeasured; debug build; and the driver
differs from whenever the original was taken.
Also validated on Ada in the same session -- the whole spike set reproduces on a
SECOND architecture and an OLDER driver (610.43.03 vs 610.57.04): S1a in-place
split switch accepted with zero IDRs both directions; S1b takes effect
(|C-B|=12 vs |C-A|=1921, the cleanest run yet); S1c pair flip passes; D5 confirmed
(AUTO+sub-frame 4424 vs DISABLE 4409, 15 us apart -- and AUTO without sub-frame
2310 ~= TWO_FORCED 2314, so the arm stays); engines=2 with THREE_FORCED correctly
clamped to mode 2; arbitration converged with exactly 1 keyframe.
Verified: .21 clippy -D warnings clean + 64 unit tests; .133 Windows clippy
-D warnings clean (the resolver signature grew a `codec` param, so both backends
moved); Ada + Blackwell on-hardware as above. fmt clean.
The named next step after WP3's first increment. That increment deliberately
REFUSED to arbitrate HEVC-with-sub-frame -- the fleet default, and the reported
field case -- because engaging split there gives up sub-frame readback, whose
whole value is that the send overlaps the encode. An encoder measuring only
encode time would see split as ~2x faster, take it, and make end-to-end latency
worse while reporting a win. This supplies the missing number.
The real comparison is encode_1eng + send_of_last_slice against
encode_2eng + send_of_whole_AU, so the challenger owes roughly
spread x (slices-1)/slices. Split across the two sides that can each see half:
- Host: new `Encoder::set_send_spread_us` (defaulted, forwarded by
TrackedEncoder -- same trap class as set_wire_chunking, and unforwarded it
would fail SILENTLY IN THE SAFE DIRECTION, which is the hardest kind to
notice). The send thread is the only place a paced send is observed and the
encode loop the only place the encoder can be touched, so it goes over an
AtomicU32 like encoder_ceiling_kbps, EWMA-smoothed 3:1 per completed AU: one
content spike must not flip a verdict that then gets cached.
- Encoder: turns the raw spread into the handicap, because only it knows
`slices`. SplitArbiter::with_handicap charges it to the challenger before the
comparison. A unit test runs identical encode numbers with a cheap and an
expensive send and asserts the verdict REVERSES -- with an expensive send the
arm that looks twice as fast is a loss end to end, and the incumbent must
hold. That is precisely the regression an encode-only arbiter ships.
Gate now opens for HEVC+sub-frame only when a spread has actually been reported
(and slices >= 2); with no hint it still refuses, so behaviour is unchanged until
the host feeds it.
Two mechanics this needed:
- apply_split_mode became a PAIR flip (split + sub-frame), routed through
resolve_split_subframe and restoring from `subframe_opened_with` so a session
that never had sub-frame can never gain it. It also recomputes
`subframe_chunks`, which reconfigure_bitrate does NOT -- spike S1c's finding;
leave it stale and supports_chunked_poll keeps saying yes while numSlices never
advances, so poll_chunk busy-polls its whole budget every AU.
- The arbiter is now fed from BOTH completion points. A sub-frame session
finishes through poll_chunk, so the incumbent arm of an HEVC experiment would
otherwise never deliver a sample -- only the challenger, with sub-frame
dropped, comes through poll.
Verified .21: clippy -D warnings clean for pf-encode AND punktfunk-host with
nvenc, 63 unit tests (1 new), 23/23 NVENC on-hardware green. Verified .133:
Windows clippy -D warnings clean, zero dead_code. fmt clean.
The fix S1 unlocked. Rather than predict the right split mode at open — which
cannot work, because the decision depends on bits/frame and an Automatic client's
steady-state bitrate is unknown at open (ABR climbs in place afterwards) — the
encoder now measures both arms on the live session and keeps the winner. S1
proved nvEncReconfigureEncoder takes a changed splitEncodeMode with
resetEncoder=0, emits no IDR, and actually applies it, so the experiment is
invisible on the wire.
Deliberately measures instead of modelling: hard-coded per-arch constants are
exactly how the rule this replaces went wrong (one 5120x1440@240 Ada datapoint
generalised into a fleet-wide 10-bit veto). A measurement tracks driver updates
for free.
`SplitArbiter` (pure state machine, unit-tested without a GPU): measure incumbent
-> switch -> SETTLE -> measure challenger -> keep the winner, else switch back.
Verdicts cache per (gpu, codec, mode, depth, chroma) so later sessions open
straight into the winning arm; the key is CeilingKey minus split_mode, since the
split mode is the thing being decided.
⚠ SETTLE_FRAMES=16 is load-bearing, not padding: split-encode does not reach
steady state on the first frame (a FRESH TWO_FORCED session measured early-half
3280us vs late-half 1996), so judging an arm right after switching reads the
transient — intermittently, which would then be cached. A unit test feeds exactly
that transient and asserts the arbiter still sees the steady state.
Safety gates, all correctness conditions rather than preferences: opt-in
(PUNKTFUNK_NVENC_SPLIT_ARBITRATE=1) while it earns trust; an operator
PUNKTFUNK_SPLIT_ENCODE pin always wins; skip if a verdict is already cached; sync
depth-1 only (async_rt.is_none(), same gate chunked poll uses — under pipelined
retrieve the submit->AU span includes queue depth and the comparison is noise);
needs >=2 engines; never H.264.
⚠ And the one that bounds this increment: NO SUB-FRAME TRADE. For HEVC, forcing
split gives up sub-frame readback, which costs send/encode overlap the ENCODER
CANNOT SEE — it measures encode time only, so it would reliably prefer split and
silently make end-to-end latency worse. So arbitration runs only where nothing is
traded: sub-frame already off, or AV1 (both features legal). Pricing that trade
needs the host's send cost and is the next work package.
Challenger choice tests the question worth asking — anything not already the
widest forced split is challenged BY the widest ("are we leaving engines idle?").
The naive "challenge whatever we are not" spent the experiment re-proving that
splitting beats not-splitting, while parking the session on the slow arm to do
it, because 4K60 sits on the fallthrough AUTO.
⚠ Every new nvenc_core item is linux-gated: the arbiter is wired into the Linux
backend only for now and nvenc_core compiles on Windows too. Caught by the .133
check, not by reasoning — the first cut failed Windows clippy with 12 dead_code
errors, the exact item-level trap this file already carries a scar from.
Verified .21: clippy --features nvenc --all-targets -D warnings clean, 62 unit
tests (4 new arbiter tests), 23/23 NVENC on-hardware green including a new
end-to-end convergence test asserting ZERO extra IDRs and a cached verdict.
Verified .133: Windows clippy -D warnings clean, zero dead_code. fmt clean.
"unom" alone is not the legal entity name. Updated the copyright line in
all nine first-party license files -- the root MIT/Apache pair and the
hand-maintained copies under clients/apple, clients/decky,
packaging/windows/drivers and packaging/windows/pf-vkhdr-layer (there is
no script that syncs these, so each is edited directly).
The Linux and Windows clients' About screens pick this up automatically:
both `include_str!` the root LICENSE-MIT / LICENSE-APACHE at compile time.
Deliberately untouched:
- Third-party license texts (Geist OFL, FFmpeg, VB-CABLE, the vendored
pyrowave/Granite tree, KDE protocol XMLs, the os-icon licenses) -- those
are other parties' copyrights.
- Publisher/author/maintainer metadata, which is identity rather than
license text and is reported separately for a decision.
The docs site's page title read "punktfunk docs". Fixed that and swept the
rest of the tree for the same defect, capitalizing the brand wherever it is
shown to a human and leaving it lowercase where it is a technical identifier
(CLI/package names, `punktfunk://` scheme, PnP enumerator, TLS SNI, logcat
tag, config paths, CMS tenant id).
User-visible fixes:
- docs-site: page title -> "Punktfunk Docs"; API reference title, meta
description and the branded bar's aria-label; the BrandMark/Wordmark SVG
accessible names (the web console already had these capitalized -- the
docs site had drifted from it).
- Android: six strings of live UI copy -- the local-network permission
dialog (x2), the connect-screen error banner, and the no-controller
explainer.
- Apple: the "No Hosts" empty-state text and the fallback display name for
a host that advertises no instance name.
- Windows client: the `--discover` progress line.
- KWin fake-input: the application name passed to `authenticate()` (the
grant is cached per-exe, so the string is display-only).
- THIRD-PARTY-NOTICES: fixed in both generators (about.hbs and
gen-third-party-notices.py) and applied to the three checked-in outputs
so they match what a regeneration now produces.
Every changed line differs from the original only by letter case, so line
lengths are unchanged and no formatter width rule is affected.
`cargo fmt --all --check` passes.
A parity and smoke harness for AV1, mirroring the H.264 and H.265 legs that
proved those rungs bit-identical to libavcodec on four drivers before either
ran on glass. This was the milestone's largest test gap: the adversarial
review found four blocking defects in the AV1 conversion — flags unset on
274 frames of 274, a units error in LoopRestorationSize, per-reference info
describing the wrong picture, film-grain fields left zero — and every one of
them would have shown on frame 1 of a parity run, while clippy and 164 green
unit tests said nothing at all.
The golden is 250 per-frame SHA-256s in DISPLAY order, not 274. The vector
carries 274 coded frames in 250 temporal units; the 24 extras are hidden
ALTREFs, decoded and referenced but never shown, and the rung delivers what
dpb.outputs names. The count is re-derived from the planner rather than
assumed.
Cross-checked between ffmpeg 8.1.1 on macOS arm64 and 8.0.1 on Linux x86_64,
whose raw outputs are byte-identical — and then against a third party neither
build knows about: the vendored vector ships upstream's own per-frame MD5s,
and re-running those reproduces all 250. The golden agrees with a decode
nobody in this program performed. I reproduced both independently before
committing.
8-bit NV12, traced from the sequence header rather than presumed
(seq_profile 0, high_bitdepth 0, mono_chrome 0), so the P010 scar does not
apply here — and the header says which check to make if a Main 10 golden is
ever added. film_grain_params_present is 0, which is load-bearing: grain
synthesis is part of the Vulkan decode profile, so this golden is only
comparable against a grain-less profile key.
Anti-vacuity is the point of the exercise, so it is structural. The golden
guard asserts the exact count, that every line is a bare digest, and that all
entries are DISTINCT — 250 copies of one digest would let a decoder frozen on
a single frame pass parity. The parity body asserts the golden set and the
access-unit count before it touches hardware, so an IVF reader returning
nothing cannot become "0 frames compared, pass". The agent verified the
guards fire by mutating the golden three ways.
assert_bit_identical now names the FIRST divergent frame, which is what
localises a defect; that improves all six legs, not just AV1.
AV1 has no four-byte-start-code twin, deliberately: OBUs are
length-delimited, so there is no prefix for a driver to mis-skip. Documented
where a reader would otherwise see an omission.
Nothing here has run on a GPU. The harness exists precisely so the four
review defects can be answered by measurement instead of argument.
Two separate things had to be wrong for this, and both were.
The frame's own policy locked it out. Plugin UIs moved to their own origin so a
plugin cannot act as the logged-in operator, and the plugin origin names the
console as the only page allowed to frame it. It built that name from the
incoming request — but Nitro hands the app a synthetic request with no TLS
socket, so an HTTPS console reads back as `http:`. The header said
`frame-ancestors http://host:47992` while the operator was on
`https://host:47992`, and the browser refused the frame outright
(ERR_BLOCKED_BY_RESPONSE). Nothing on screen said so; the reason was only in
devtools. The scheme now comes from the listener's own TLS state, stamped at
bind time, with x-forwarded-proto winning when something in front terminated
TLS for us — the one case where the browser's scheme is not ours.
And the port was shut. 47993 was added to the firewall definitions, but an
already-open firewall does not pick a new port up: ufw expands an app profile
into rules when you allow it and keeps those, so editing the profile on upgrade
changes nothing, and firewalld needs a reload. Every upgraded Linux host kept a
47992-only rule, silently. The packages now notice on upgrade and print the one
command that fixes it, without touching the running firewall. The NixOS module
and the container image never learned the port at all; both now open it.
Also: the console no longer mounts the frame while it is still checking whether
that origin is reachable. A firewalled port drops rather than refuses, so the
check hangs for the browser's whole connect timeout, and mounting meanwhile is
exactly the empty panel with no explanation. The card that follows now names
both causes it can be — an untrusted certificate for that port, or a closed one
— because from a browser the two are indistinguishable.
The rule is now a pure function with tests, since its failure mode is a
well-formed header that only a browser rejects.
Verified on glass against home-worker-5 (.21) and its ROM Manager plugin: the
frame was refused before, renders the plugin's UI after.
Last change's docs concluded "AUTO never splits, retire the arm" from the
sub-frame-ON measurement alone. Measured the missing leg before implementing it,
and the conclusion was wrong.
On .21 at 4K, plain AUTO (env unset, the resolver's fallthrough):
sub-frame ON -> 5023/5157 us/frame ~= DISABLE 4979/5000 (does NOT split)
sub-frame OFF -> 2401/2352 us/frame ~= TWO_FORCED 2319/2378 (DOES split)
So AUTO is CONDITIONAL, not dead. Retiring it would have silently cost every
sub-frame-off session its second engine -- a regression introduced while
"cleaning up" an arm that looked inert. Split and sub-frame are mutually
unsupported for HEVC, so the driver resolves AUTO to no-split only in that
combination.
Fix is disclosure, not removal:
- resolve_split_subframe debug-logs the inert HEVC + AUTO + sub-frame case, which
is the fleet default shape: "split_mode=AUTO" has meant "no split" for every
default session and nothing said so. Deliberately NOT rewritten to DISABLE --
the mode we pass is what the driver was actually given, and the ceiling-cache
key must keep describing that.
- New unit test `auto_survives_the_arbitration_in_both_subframe_states` pins the
contract so the arm cannot be simplified away later.
- The resolver doc now records both measured legs instead of "AUTO is dead".
Also in this change:
- WP1.6: `resolve_subframe`'s doc said "Windows passes `false`". Stale since the
2026-07-31 .173 A/B flipped Windows to caps-gated default-on. It mattered:
it made the AUTO-plus-sub-frame dead combination look Linux-only when it is
fleet-wide.
- Windows session-ready log parity: split_mode + engines + subframe. The Windows
line had no split_mode at all, so a Windows field report could not answer "did
this session actually split?" -- the question that started this whole thread.
Verified: fmt clean; .21 clippy -p pf-encode --features nvenc --all-targets
-D warnings clean, 58 unit tests (1 new), 22/22 NVENC on-hardware tests green;
.133 Windows clippy --features nvenc --all-targets -D warnings clean (15m cold,
zero errors or warnings) -- the Windows backend is cfg'd out on both macOS and
the Linux box, so that leg needed a real Windows host.
The third codec arm in video_vk_native, AV1 admitted to native_codec and to
native_vulkan_gate by pin only. It stays out of `auto` on the same rule M5's
D3D11VA rung follows: `auto` admission is earned with hardware evidence, and
this has decoded nothing on a device.
is_integrity_warning_av1 did not exist, so the client could not have
concealed AV1 damage at all. Added, exhaustive, no wildcard: all three AV1
warnings really are damage, because AV1 has no spec-legal-but-noisy signal
to mis-classify — no reorder envelope to announce, no MMCO to rebase — and
the exhaustive match is what stops a future variant defaulting to clean.
The blocking defect review found was two safety mechanisms cancelling each
other. After a failure the decoder skipped to the next key frame answering
Ok(None), and because AV1's planner has no flush its store kept planning
cleanly, so those AUs carried no warnings and the client read them as proof
the rung works — clearing the demotion streak and resetting its clock on
every one. The streak could then never reach the threshold, which made the
never-delivered fall-through to FFmpeg-Vulkan unreachable, which is the
documented backstop for exactly three things: a level above maxLevelIdc, a
sequence header disagreeing with the Welcome, and film grain. Film grain is
the probe's own admitted assumption, so a grain stream would have frozen the
screen for the session while DecodeHealth reported run 0 — recovered.
AV1 now answers the wait with an error, as H.264 and H.265 already do
through AwaitingIdr, so all three codecs are indistinguishable to the
demotion machinery. That matters more than the extra precision of a third
state: only the H.26x paths have hardware evidence, and they are proven WITH
that behaviour.
The obvious form of that fix would have wedged the decoder. A key frame can
sit behind a skipped frame inside the same temporal unit — the vendored
vector has 24 two-frame units — so erroring out of the per-plan loop would
never reach it and the wait would never end. Skips are therefore counted per
frame and the error raised only when the whole unit was skipped, with the
metadata-only unit staying a clean Ok(None).
Also closed: a refused temporal unit left an already-decoded frame in the
ready queue, which shipped on the next AU as a clean success — putting a
picture from a refused AU on screen, clearing the streak again, and latching
delivered so the fall-through was disabled for good. The error arm now
drains and releases unshown.
MAX_DELIVERABLE is derived rather than picked: HOLD_HEADROOM minus the
pipeline's own hold, pinned to pf-vkdecode's constant so a hardcoded depth
fails the build. At the previous 8 the queue plus the presenter's 4-7 stood
against a headroom of 8, so it capped memory without preventing the
exhaustion it named, and a frame waiting 8 AUs burned 16 of the 17 query
slots — where a re-armed slot reads as Failed and becomes a fabricated
driver-corruption verdict in the very counter the Ally X signal lives in.
The trim now runs after this AU's frame is taken, or at the derived depth it
would drop a two-output unit's first frame and invert display order inside
one AU.
Its justification was also wrong: the claim that a temporal unit may carry a
show_existing_frame alongside a shown frame is disproved by this repo's own
golden — 250 units, 250 shown, zero show_existing. The bound is kept as
defence in depth against a non-conformant or multi-operating-point stream,
and now says so.
Gates: macOS fmt/clippy/392 tests, container clippy -D warnings over six
crates, 851 tests, workspace check. No hardware: the rung is pin-only and
has still never decoded a frame on a device.
WP1.1 plus the engine-count fix. `resolve_split_mode` forced TWO_FORCED at high
pixel rate regardless of hardware, so a 3-NVENC part (GB202, AD102 workstation)
left a third of its encode silicon idle, and a 1-NVENC part paid a wasted session
open to discover it could not split.
Probes NV_ENC_CAPS_NUM_ENCODER_ENGINES in both direct-SDK backends' query_caps
(the cap is `= 49` in both linux_sys and windows_sys of the vendored SDK 0.4.0 --
the caps enum is cfg-selected per-OS, so that was checked) and latches it on a
backend field. NOT on EncoderCaps: nine backends construct that struct as
exhaustive literals, so a new field would be a 9-site change of which 7 are
unrelated codecs passing a meaningless value, and the only consumer is the
resolver.
New `max_forced_split_mode(engines)`: 1 -> DISABLE, 2 -> TWO, 3 -> THREE, and
>3 -> AUTO_FORCED, because NV_ENC_SPLIT_ENCODE_MODE cannot NAME more than three
(NVENCAPI 12.1; values 4..14 are unallocated, so a future API may extend it) and
AUTO_FORCED = "split, driver picks how many" is measurably a real split (2.01x vs
disabled on .21). 0 = unprobed keeps the historical two-engine assumption.
⚠ WHY THE CLAMP EXISTS, measured on .21 (RTX 5070 Ti, 2 NVENC, 4K HEVC):
requesting THREE_FORCED was HONOURED -- session opened in mode 3 -- and ran at
2303 us/frame, identical to TWO_FORCED's 2308. The driver does not reject an
over-ask; it silently encodes narrower. So the rejection fallback cannot find the
ceiling and PUNKTFUNK_SPLIT_ENCODE=3 on a 2-engine card would have logged a
3-way split over a 2-way encode. Operator overrides are now clamped with a warn.
The ordering trap is covered by a test: on a >3-engine part hw_max is
AUTO_FORCED (1), which is not "narrower than" TWO_FORCED (2) despite comparing
smaller, so a naive min() would collapse a legitimate 3-way request to AUTO.
Also adds `engines` and `subframe` to the Linux session-ready log: split_mode
alone is ambiguous between "used both engines" and "left a third idle", and
since the driver honours an over-wide request the mode cannot be read without
the ceiling it was chosen from. This is the line a field report needs.
--- and a correction to S1b, in the same change ---
Re-running S1b afterwards flipped its verdict to "the driver appears to have
IGNORED the in-place split change", contradicting the isolated runs that produced
the |C-B|=34 figure already written into the design docs. Investigated rather
than re-rolled.
The switched leg was landing MIDWAY between the arms (~3600 us against A~5050,
B~2300) and the nearest-neighbour verdict flipped on noise. Cause: split-encode
does not reach steady state on the first frame -- a FRESH TWO_FORCED session
shows it too (early-half 3280 us vs late-half 1996 in one run), so it is split
warmup generally, not something specific to reconfiguring in place. A single
median over the whole window cannot see that. The test now reports early-half vs
late-half and gives a switched leg SETTLE=16 frames before its window opens,
every leg the same length. With that, 4/4 runs agree: the switched leg reaches
~2030 us against a fresh-split ~2000 and a single-engine ~4900.
⚠ S1b's CONCLUSION stands (the switch does take effect) but the evidence behind
the committed number did not reproduce; the docs are corrected rather than left
implying a cleaner result than the harness could support.
⚠⚠ This is a WP3 REQUIREMENT, not just a test fix: a live-session arbitration
that switches arms and immediately measures will misjudge the arm it just chose,
because the encoder needs ~16 frames to settle. The settle window has to be part
of the arbitration, and it is now a measured number rather than a guess.
Verified on .21: clippy --features nvenc --all-targets -D warnings clean,
57 unit tests (3 new), all 23 NVENC on-hardware tests green, fmt clean. The 3
failing on-hw tests in a full --ignored run are VAAPI (no AMD/Intel GPU on that
box -- their own ignore reason says so), pre-existing and unrelated.
S1c `nvenc_cuda_split_subframe_pair_reconfigure`: the leg S1a/S1b excluded. Both
pinned sub-frame OFF to isolate the split variable, but a real HEVC arbitration
cannot -- split and sub-frame are mutually unsupported there, so engaging split
means flipping enableSubFrameWrite in the same breath, a second init param and
the one the reconfigure path deliberately pins. RESULT on .21: the PAIR moves in
place, accepted, ZERO IDRs, both directions.
It also pins the invariant that makes this safe to build on: `subframe_chunks` is
latched ONLY in the init path (~line 1625) and is NOT recomputed by
reconfigure_bitrate, so a caller flipping sub-frame in place must clear it too or
supports_chunked_poll keeps reporting true and poll_chunk busy-polls its whole
budget every AU against a numSlices that never advances. The test performs the
correct sequence and asserts the state stays coherent, so WP3 has a worked
example rather than a warning.
`nvenc_cuda_auto_split_with_subframe`: the D5 confirm -- the one claim in the
design's defect list that was only ever inferred. The driver reports no "mode I
actually chose", so it is settled by timing, at 4K where the gap is ~2x.
RESULT: AUTO (env unset) + sub-frame 4904 us/frame, DISABLE + sub-frame 5062,
TWO_FORCED without sub-frame 3464. AUTO sits 158 us from DISABLE and 1440 from
TWO_FORCED ⇒ D5 CONFIRMED: plain AUTO does not split while sub-frame is on, so
the resolver's AUTO fallthrough reads as "let the driver decide" and means
"never split".
⚠ TRAP, hit on this test's first run and now documented in it: the env knob
CANNOT express plain AUTO. `0` is DISABLE and `1` is AUTO_FORCED, and
resolve_split_subframe counts AUTO_FORCED as forced, so passing `1` silently
disarms sub-frame and measures a different configuration entirely -- which
produced a spurious "D5 REFUTED". Plain AUTO is only reachable as the resolver's
fallthrough with the env unset. The leg now asserts sub-frame resolved TRUE, so
the test can no longer answer the wrong question quietly.
Verified on .21: clippy --features nvenc --all-targets -D warnings clean, all 4
spikes green, the normal 54-test suite unaffected, cargo fmt --all --check clean.
Android was the one app that could open a punktfunk:// link but never hand
one out, so every Android link had to be typed by hand — and the host's
stable record id, which is the part that keeps a link working after the box
changes address, isn't shown anywhere in the UI to type.
Both homes now offer Copy link: the touch grid's card overflow menu, and the
controller home's host options (Up on a tile). A pinned card copies its own
profile with it, matching Linux and Apple; a host card copies none and so
keeps honouring the host's binding, exactly like tapping it does.
The URL is the shared self-emitted form (DeepLinks.forHost), already covered
by the cross-language vector tests, so the three emitters stay in step.
Android 13+ draws its own clipboard confirmation and we add nothing on top of
it; below that we say so ourselves, as a toast in the console home, which
renders neither banner.
caps_av1 / session_av1 / decoder_av1, over the CPU half already committed,
sharing the picture pool, bitstream ring, op ring, DPB settling and frame
delivery with H.264 and H.265 rather than forking them. AV1 session
parameters carry exactly one sequence header — no PPS, no VPS — so the
parameters ledger is two-state: current, or recreate.
The GPU plumbing came through review clean. The damage was all in the
conversion committed two rounds ago, which nothing tested against a
reference, and none of it would have failed a gate: clippy was clean, the
tests were green, and the rung would have decoded its own conformance vector
wrong on essentially every frame on AMD, silently.
Four blocking defects, each measured on the vendored vector rather than
argued:
Nine StdVideoDecodeAV1PictureInfo flags were never set. Four change
reconstruction — allow_screen_content_tools on 274 frames of 274,
allow_warped_motion on 273, is_filter_switchable on 172, force_integer_mv on
1 — and RADV reads three of them directly. The block already set
allow_intrabc, which is only codeable when screen-content tools are on, so
it contradicted itself.
LoopRestorationSize sent the pixel size where the field is log2(size) - 5.
cros-codecs stores 64/128/256; RADV names its destination
log2_restoration_size_minus5 and reads 1/2/3. Nothing truncates, nothing
errors, and every frame with loop restoration reconstructs against a
nonsense unit size.
Per-reference Std info answered questions about the wrong picture: every
reference carried the CURRENT frame's type, and RefFrameSignBias was never
set at all. Sign bias is what tells a decoder a reference lies in the
future, and this vector is the hidden-ALTREF one, so all-zero meant every
reference was treated as past. Fixed at the source: pf-bitstream now records
a RefState when a picture is stored — its own frame type, sign-bias mask,
saved order hints — and carries it on the slot, so all three backends get
answers about the reference rather than about the frame reading it.
Film grain's six chroma-scaling fields were zero, which defeats the profile
machinery that exists to refuse devices unable to synthesise grain.
The reference-name compaction is fixed in the PLANNER, once. AuPlan::refs is
now name-indexed with holes preserved, so a lost reference can no longer
renumber every later AV1 reference name — a class that was live in both
conversions and armed for the VAAPI rung that does not exist yet. The DXVA
twin had a second name-versus-slot confusion: it read global motion by DPB
slot from an array the spec indexes by reference name, and slot 0's matrix
is all-zero rather than identity, so 273 references were given a zero warp.
Also closed: pTileOffsets/pTileSizes were sized to tileCount while RADV
reads AV1_MAX_NUM_TILES entries unconditionally — a 4-byte allocation read a
kilobyte deep — now fixed 256-entry arrays with zeroed tails. And the test
guarding the lost-reference refusal re-implemented the predicate inline, so
deleting the guard left it green; both now call one named function.
The bitstream layout now matches libavcodec: raw tile payloads only,
frameHeaderOffset 0. The review established the spec-literal layout was NOT
wrong — AV1 has no start-code scanning, so the 3-versus-4-byte and
slices-only scars do not transfer, and no driver in the fleet reads
frameHeaderOffset — but matching the validated reference deletes code,
uploads 5835 fewer bytes over the vector, and removes the untested-driver
tail.
Upstream, and the third of its kind: the vendored parser writes
ref_frame_sign_bias[i] in the same loop body where it writes
order_hints[LAST_FRAME + i], so its array is shifted one down and index 7 is
never written. Corrected in RefState::of with the shift documented, the
vendored tree untouched, and pinned by a test that recomputes the bias from
order_hints through the parser's own get_relative_dist.
Gates: macOS fmt/clippy/tests, container clippy -D warnings over six crates,
845 tests, workspace check. No hardware: nothing here has reached a driver.
Two on-hardware spikes answering the gate on the split-encode engagement
program (design/nvenc-split-encode-engagement-implementation-plan.md).
S1a `nvenc_cuda_split_reconfigure_in_place`: can splitEncodeMode change via
nvEncReconfigureEncoder with resetEncoder=0, without an IDR? Our "reconfigure
must present the SAME init params as the open" rule (windows/nvenc.rs:620) is
our own invariant and had never been tested against a driver. It reports rather
than asserts the verdict -- both outcomes are legitimate findings -- and only
asserts what would invalidate the measurement (session live, engines >= 2, the
arms actually differ). Sub-frame is pinned off so the driver can't reject for
the wrong reason (HEVC forced-split and sub-frame are mutually unsupported).
S1b `nvenc_cuda_split_reconfigure_takes_effect`: the other half -- a driver that
accepts the parameter and quietly ignores it looks identical to one that honours
it. Three legs at 4K (fresh DISABLE / fresh TWO_FORCED / DISABLE->TWO in place);
if C tracks B and not A, the switch is real.
RESULT on .21 (RTX 5070 Ti, GB203 Blackwell, driver 610.57.04):
NV_ENC_CAPS_NUM_ENCODER_ENGINES = 2
S1a: accepted, ZERO IDRs, both directions.
S1b: A fresh DISABLE 5054 us/frame, B fresh TWO_FORCED 2453,
C switched in place 2419 -- |C-B|=34 vs |C-A|=2635. It takes effect,
and split is a clean ~2x at 4K.
Two limits, both recorded in the test docs rather than the commit only. The
frames come out at 427 B/AU against an 833 KB CBR quota: the driver hands back
zeroed VRAM, so the rotated buffers are identical and rate control skip-codes
everything. So this measures the PIXEL-proportional half of the cost only --
the bits/frame regime the field case lives in is untested here, and the test
prints an explicit INCONCLUSIVE-on-content line when it detects that. And this
is Blackwell 8-bit; the Ada Main10 question is untouched.
Verified on .21: clippy -p pf-encode --features nvenc --all-targets -D warnings
clean, both spikes green, cargo fmt --all --check clean.
Found while running the lutris plugin's own release gate against a live host on
.21: `parity --compare` reported `1 missing` — the plugin produced NO entry for
the one game Lutris had, while the host's built-in scanner produced it fine.
`scan` agreed: "present: 0 games". `detect` still said "present", because detect
only stats the file.
`openReadOnly` builds a `file:<path>?immutable=1` URI — the right idea, since
`immutable=1` is what makes this a pure read that cannot lock a running
launcher's database or spawn WAL sidecars next to it. But it opened that name
with `{ readonly: true }`, and the options object does NOT enable SQLite's URI
filename parsing. Without SQLITE_OPEN_URI the name is taken literally, no such
file exists, and the open throws:
SQLiteError: unable to open database file
Every path here then degrades that to silence by design: `openReadOnly` returns
`undefined` for "this launcher isn't installed", `withReadOnlyDb` passes the
`undefined` through, and callers write `withReadOnlyDb(...) ?? []`. So a total,
permanent failure to read ANY database was indistinguishable from an empty
library. The lutris plugin reported 0 games on every Linux box, always.
It is platform-split, which is why it survived review and local runs: macOS
links Apple's system SQLite, which is built with URI filenames enabled, so the
same call succeeds there. Linux uses Bun's bundled SQLite, which is not. MEASURED
both ways on bun 1.3.14.
Fixed by passing the flags explicitly — SQLITE_OPEN_READONLY | SQLITE_OPEN_URI.
(`{ readonly: true, uri: true }` is not a supported option shape; also measured.)
And the second half, which is why nothing caught it: `parity --compare` sets
`process.exitCode = 1` on a mismatch and then returns NORMALLY — a red parity is
a finished comparison, not a crashed command. `runPluginCli` then assigned
`process.exitCode = 0` unconditionally after the effect resolved, overwriting it.
So the one verb both plugin READMEs document as the release gate — "exits
non-zero on any difference", "do not publish a version whose parity run is red" —
always exited 0, and any scripted use of it passed. Now `??= 0`, so a code a
command set deliberately survives.
Tests: the sqlite helper had NO coverage at all, which is the whole reason a
total failure shipped looking like an empty library. Added five cases against a
REAL database file — reads rows back, handles a path needing URI escaping,
withReadOnlyDb round-trips, an absent file is `undefined` not a throw, and a bad
query still degrades to []. Verified they actually catch it: against the shipped
code on Linux, 4 of the 5 fail; with the fix, 21/21 in that file and 61/61 across
the suite, typecheck and build clean.
End to end on .21 with the fix: lutris `scan` goes 0 -> 1 games and
`parity --compare` reports "parity OK — 1 entries identical". A deliberately
doctored baseline now exits 1 instead of 0.
0.3.0 -> 0.3.1.
The nix gate landed in fb707b49 on `nixos/nix:latest`, and it has never run a
single step. That image carries nix and essentially nothing else — including no
`/bin/sleep` — and Gitea's act_runner starts every job container with
`entrypoint=["/bin/sleep", "10800"]`:
failed to create shim task: OCI runtime create failed: runc create failed:
unable to start container process: exec: "/bin/sleep":
stat /bin/sleep: no such file or directory
The expensive part is how it reports: with the container dead, every step is
marked `cancelled` rather than `failed`, which reads exactly like a run that was
superseded by a newer push. Run 15907 looked skipped, not broken.
Switch to `node:22-bookworm` and install Nix in a step:
* full Debian, so the entrypoint exists and coreutils are present;
* a real node, so `actions/checkout` works with no pre-checkout install dance
(the reason flatpak.yml's fedora job installs node before its checkout);
* audit.yml already pulls this image on this fleet, so it is known to resolve.
Nix comes from the Determinate installer with `--init none` — the container mode:
no systemd, no daemon. That distribution is also what the hand-verification Nix
box (.21) runs, so CI and it stay on the same Nix.
MEASURED in a real `node:22-bookworm` container rather than assumed, since the
last version of this file shipped on an untested assumption and cost a red run:
* `/bin/sleep` present — the entrypoint failure is gone;
* the installer completes and `nix --version` runs from the absolute path;
* `nix build` of a trivial derivation SUCCEEDS, `nix store ping` reports
`Store URL: local, Trusted: 1`, and flakes are enabled.
That last one is the trap this change also pins. `--init none` runs no daemon,
but the installer still writes a profile script exporting `NIX_REMOTE=daemon`;
anything sourcing it (any `-l` login shell) then dies on "cannot connect to
socket at '/nix/var/nix/daemon-socket/socket'". It is why the installer's own
self-test fails, harmlessly, in the middle of this step's log. The steps here
never source that profile — they invoke `$NIX` by absolute path — but `NIX_REMOTE`
is now pinned empty at job level so a later step cannot reintroduce it.
Also records `df -h` before the build: this fleet ran a runner out of disk today
(ci.yml's `web` job died with "no space left on device" mid-`bun install`), a Nix
build is the heaviest thing that would run here, and a future failure should be
attributable at a glance rather than guessed.
Still unproven, and only the first green run can settle it: whether `nix flake
check` evaluates this flake cleanly under CI, and whether the runner has the disk
to build punktfunk-web. Both now produce a real diagnostic instead of a container
that never started.
The standing open item M7 was meant to close. `decodable_codecs` answered the
AV1 bit from `ffmpeg::decoder::find(AV1)`, which says yes on every build that
links libdav1d — a software decoder. So the client told the host "send me
AV1" on machines that would then decode a 4K stream on the CPU, and codec
negotiation happens once at Welcome, so there is nothing to fall back to
afterwards. A promise the client cannot keep is worse than not making it.
`av1_hardware_decodable` answers from device facts only: the presenter's
Vulkan device listing DECODE_AV1 among its decode queue family's codec
operations, or — on Windows — the D3D11 import path, which is the same gate
the D3D11VA rung sits behind and that rung decodes AV1 Profile 0 today.
VAAPI is deliberately not consulted: asking libva costs opening a display,
and this is called too early and too often for that. The Vulkan bit covers
the Mesa devices where VAAPI AV1 exists in practice, and a machine with
VAAPI AV1 but no Vulkan AV1 loses the advertisement, not a working path.
The test pins what the gate must not accept: a device that decodes H.264 and
H.265 but lists no AV1 operation, and a device whose caps word claims AV1
while it has no decode queue at all.
Gates: macOS fmt/clippy, container clippy -D warnings over six crates, 805
tests, workspace check.
One AuPlan into DXVA_PicParams_AV1, over the layouts the SDK header measured.
AV1 on DXVA needs TWO reference arrays that mean different things at once,
and this program has now written down four spellings of the same question.
`frame_refs[7]` is indexed by reference NAME and each entry carries a
SURFACE index — where Vulkan's `referenceNameSlotIndices` carries a SLOT —
plus that reference's own global motion. `RefFrameMapTextureIndex[8]` is
indexed by SLOT and states the whole reference store, which is what
`RefFrameList` is for the other codecs and why a long-term reference no
frame names still has to appear in it.
The test asserts that difference is exercised rather than assumed: it fails
if the run never saw the store hold a picture the frame did not name, which
is precisely the distinction the Ally X class of bug lives in.
Three transpositions that would each have been silent:
Global motion is signalled per reference SLOT in the frame header and stored
per reference NAME in DXVA, so the conversion reads by one and writes by the
other. Carrying the Vulkan shape across would leave every warped reference
at identity.
CDEF strengths pack two fields to a byte, primary in the low six bits and
secondary in the top two, where the AV1 syntax keeps parallel arrays.
DXVA wants log2 of the loop-restoration unit size; the parser records the
size. And the superres denominator is the real one here — SUPERRES_NUM when
superres is off — where Vulkan's `coded_denom` is the denominator less nine.
Film grain rides only where the sequence enables it and the frame applies
it, its scaling points transposed into [value, scaling] pairs, and an
over-count refused rather than truncated: fewer points than the stream
declared is different grain, not less of it.
Gates: macOS fmt/clippy/349 tests, container clippy -D warnings over six
crates, 804 tests, workspace check.
A launcher entry (design D4) has no "the game exited" moment to detect, but the lease was
still deciding its lifetime from whatever happened to be true at launch — and the two
outcomes disagree:
launcher NOT already running the spawned child stays alive -> `Child` lease
-> quitting the launcher ends the session
launcher ALREADY running the command forwards to the live instance and exits
inside SHIM_WINDOW, with no detect signals behind it
-> `Untracked` -> the session persists
Same tile, two lifetimes, chosen by something the user cannot see. Steam is the case that
settles which one is right: Big Picture is a *mode* of an already-running Steam client, not
a process — and on a Deck or SteamOS host Steam is always running — so no process signal can
express "the launcher's window closed". Heroic has the same shape for a different reason
(single-instance Electron: a second invocation forwards and exits).
So a launcher entry is now `LeaseKind::Untracked` unconditionally. The check sits AHEAD of
`nested`/`child`/`spec`, and that ordering is the fix rather than an implementation detail:
a launcher the host just started leaves a live child behind, and tracking that child is
precisely the inconsistency being removed.
`Untracked` already meant the right things downstream, so nothing else had to change: no
watcher thread, `terminate()` is a no-op ("nothing to end" — closing a session must not kill
the user's Steam), and no `GameExited` event, so a client does not bounce back to its library
when the launcher closes.
Threaded as `LaunchTarget::launcher` -> `LeaseRequest::launcher` from the entry's `role`.
Three call sites, one of which was the actual trap: the GameStream path does not build its
lease from a `LaunchTarget` at all, it goes through a `GsApp` intermediate that silently
dropped the field. An operator-typed `apps.json` command has no library entry behind it and
is never a launcher tile, so it passes `false` explicitly.
The test pins BOTH cases from the table above, plus the same request without the flag still
being `Matched` — so the assertions are the flag's doing and not an artifact of the fixture.
Gates: punktfunk-host 439 passed / 0 failed on .21 (+1).
Not covered here: nothing publishes launcher tiles until the plugins are released, so there
was no live exposure to fix — this is the semantics being made deliberate before the first
tile is ever clicked.
The blocker on this rung was never the code — it was that `dxva.rs` is the
most safety-critical file in the backend and nothing in it is type-checked
against Windows, so M5's layouts only became trustworthy once a libavcodec
byte capture had verified them field by field. AV1 turns out not to need
that capture: `DXVA_PicParams_AV1` ships in the Windows SDK's OWN `dxva.h`
(10.0.26100.0 and 10.0.28000.0 on .173), which is the declaration the driver
was compiled against and therefore outranks any mirror.
So `layout-probe-av1.c` is committed beside pf-vaadec's probe, compiled with
MSVC against that header on .173, and every number below came out of it:
DXVA_PicParams_AV1 is 912 bytes with alignment 1, PicEntry 36, Tile 16, and
each offset is a compile-time assertion. The nested blocks are asserted
through their own types too, so a wrong internal layout cannot hide behind a
right outer one. Every assertion passed on the first build, which is the
result worth having: the transcription and the compiler agree.
The bit-field words are measured, not assumed. C bit-field allocation order
is ABI-defined rather than standardised, so the probe sets one member at a
time and prints the word, and the tests check each packer against what MSVC
produced — tx_mode at bits 22-23, reference_frame_update at 26, film grain's
sixteen-bit word with matrix_coeff_is_identity at 12, and so on.
Two places AV1 puts things where the other two codecs would not, both now
written down where a conversion will read them:
Global motion is per REFERENCE, inside DXVA_PicEntry_AV1 — where Vulkan
hangs one global-motion block off the picture info. A conversion carrying
the Vulkan shape across would leave every warped reference at identity.
CDEF strengths are packed two-to-a-byte, primary in the low six bits and
secondary in the top two, where both the AV1 syntax and Vulkan's Std block
keep parallel arrays.
A zeroed block names NO reference: 0 is a valid surface index, so a
memset-style default would quietly point every unused reference at surface
0, which decodes, and decodes wrong.
Gates: macOS fmt/clippy/347 tests, container clippy -D warnings over six
crates, 802 tests, workspace check.
The sequence header and the picture info, converted for
VK_KHR_video_decode_av1. Same shape as the H.264 and H.265 conversions, and
the same ownership contract: boxed backing beside the Std struct that points
at it, movable wrapper, no mutation, not Clone.
AV1 puts almost the whole frame header in the PICTURE info rather than in a
parameter set, so StdVideoDecodeAV1PictureInfo carries eight pointers to
per-frame blocks — tile info, quantisation, segmentation, loop filter, CDEF,
loop restoration, global motion, film grain — and the tile info carries four
more arrays of its own. Session parameters, by contrast, hold exactly one
sequence header. That asymmetry is why params_av1 is the small module here
and pic_av1 the large one.
The plan now carries the parsed frame header whole. The client needs a
digest — size, depth, colour, keyframe — but a backend needs nearly all of
the header, so AuPlan carries it the way its H.264 and H.265 siblings carry
their activated parameter sets: a backend builds from exactly what was
parsed, never by re-reading the access unit.
referenceNameSlotIndices holds DPB SLOT indices, not positions in the
reference list, and that is the HEVC RPS defect's exact shape in a narrower
place. Measured rather than argued: over the vendored vector the two
readings disagree 566 times across 274 frames, and the test fails if they
ever stop disagreeing, because then it would no longer be able to tell the
conventions apart.
Two places where transcription would have been wrong, both caught by the
types and then by asking the spec:
The parser's film-grain point arrays are 16 entries where the Std ones are
14 (luma) and 10 (chroma) — the spec's own maxima. The counts are validated
against the Std capacity and the copy is bounded by them; a stream declaring
more is refused, because a decoder handed fewer scaling points than the
stream declared synthesises different grain.
`coded_denom` is the superres denominator less SUPERRES_DENOM_MIN and only
meaningful where superres is in use, and `UsesLr` is derived — no frame
header codes it — from whether any plane's restoration type is not NONE.
Film grain rides only where the sequence enables it AND the frame applies
it, with the apply_grain flag set from whether a block is attached, so the
flag and the pointer cannot disagree.
Gates: macOS fmt/clippy/345 tests, container clippy -D warnings over six
crates, 800 tests, workspace check.
The third planner in this crate, and the foundation every AV1 rung will
consume. Same contract as its H.264 and H.265 siblings: an access unit in, a
plan out, with the vendored cros-codecs parser reading the bitstream and this
module owning the reference ledger, the output bookkeeping and the
concealment posture.
AV1's reference model is simpler than H.264's and entirely explicit — eight
numbered slots, `ref_frame_idx` naming what a frame reads and
`refresh_frame_flags` naming what it writes — so the planner is bookkeeping
rather than derivation, and a frame naming an empty slot is a lost reference
with no spec process that might legitimately have emptied it.
Two things measurement changed, both before a line of backend code depends on
them.
`plan_au` returns a VECTOR. An AV1 temporal unit may carry several frames,
and the vendored vector does: 250 units, 274 frames, 24 units carrying two.
Measured, those 24 extras are not `show_existing_frame` (there are none in
this vector) but HIDDEN frames — decoded, never displayed, referenced later.
A planner that took the last header in each unit would have decoded 250
frames and silently dropped 24 REFERENCES, and the damage would have
surfaced as missing-reference concealment on frames that were never damaged.
A picture is not removed until its LAST slot goes. One picture routinely
occupies several slots at once — a key frame refreshes all eight — so a slot
being overwritten does not mean its picture is gone. Reporting it removed
would free a surface under a live reference, which is precisely the shape
this program exists to catch. Tested directly, and asserted to report once
rather than once per slot.
What this does not cover is written down rather than left to be assumed: the
vector uses `show_existing_frame` zero times, so the display-only path and
its key-frame slot reset are exercised by no test here, and the test asserts
that count is zero so the day it changes the claim gets revisited.
Per-backend conversions are deliberately absent. Vulkan, DXVA and libva
disagree about what a reference list indexes — the disagreement that made
HEVC unplayable on every driver — so each belongs beside its siblings in
pf-vkdecode / pf-dxvadec / pf-vaadec, where its own convention is written
down and tested.
Gates: macOS fmt/clippy/344 tests, container clippy -D warnings over six
crates, 799 tests, workspace check.
The native VAAPI decoder now runs end to end: pf-vaadec's plans go into
libva's buffers, the surface comes back as DRM-PRIME dmabufs, and the
presenter imports them exactly as it does the FFmpeg rung's. Pin-only —
`PUNKTFUNK_DECODER=native-vaapi` — for the reason M5's D3D11VA rung was:
`auto` admission is earned with hardware parity and a soak, and this rung
has decoded nothing yet.
libva is dlopen'd rather than linked, so the pf-lxcheck2 container compiles
and clippies the whole thing without libva-dev, and a machine without a
VAAPI runtime gets a clean refusal instead of a packaging dependency.
The surface pool is not the slot map. `SlotMap::assign` hands out the lowest
free slot, and a slot freed by an access unit's own removals is free by the
time that unit's picture takes it — measured at 225 of the vendored vector's
250 access units. A surface bound by slot index would therefore decode, on
nine frames in ten, into the surface still holding the picture on screen. So
`plan_to_va` now takes the decode target as a parameter, bound by the caller
at activation time the way pf-vkdecode binds a pool image, and a surface is
free only when no live picture is bound to it, no output is owed for it, and
no consumer holds it.
Measured rather than transcribed, as everywhere else here: layout-probe.c
grew the export descriptor (312 bytes, objects[4]/layers[4]), the buffer-type
enumerators — VASliceParameterBufferType is 4 and VASliceDataBufferType is 5,
not the 3 and 4 that counting off the header suggests — and the config,
attribute and generic-value layouts. All pinned as compile-time assertions,
which is how the 12-byte VAGenericValue in the first draft was caught: the C
union holds a pointer, so it is 8-aligned and 16 bytes.
The plane walk lives in pf-vaadec, pure and unit-tested on macOS, because it
is the one structure the DRIVER writes and we read: SEPARATE_LAYERS returns
NV12 as two layers, and taking layers[0] is the green screen this project has
already paid for. It also refuses what it cannot express rather than guessing
— a bogus object count, a plane naming an object that is not there, objects
disagreeing on tiling.
Own DecodedImage variant, same payload type. The physical hand-off is
identical to the FFmpeg rung's, so the presenter keeps ONE arm and one
demotion streak; the variant exists so the compiler asks which rung decoded
wherever that matters. Both D3D11VA rungs share a variant and `1573a987` had
to fix the consequence afterwards — a "native" soak that could silently have
been an FFmpeg soak. Here the four uncovered matches were compile errors.
Buffers are destroyed by us, not by vaEndPicture: va.h is explicit that the
user must call vaDestroyBuffer, and the libva 0.x behaviour is long gone.
Leaking two per picture at 60 fps exhausts the driver's store in minutes.
pf-vaadec's presenter headroom was 4, written against no consumer. The Vulkan
rung had already measured the client pipeline at four to seven held frames;
it is 8 now, pinned to that crate's constant so a re-measurement moves both.
Gates: macOS fmt/clippy/341 tests/cargo doc, and in the container clippy
-D warnings over six crates, 795 tests, workspace check.
Hardware legs are still owed — no AMD/Mesa or Intel box was reachable.
The first pass built each palette by rotating ONE colour field's hue, and it
showed: every option was a single tone at several brightnesses, which reads flat
next to any real gradient. A palette is now an ordered ramp of DISTINCT hues.
The 4×4 mesh samples that ramp along the diagonal with a fixed per-cell offset
table, so neighbouring cells land on different parts of it and the colours pool
and swirl instead of banding; the control points' existing drift then moves the
pools around. Violet keeps its explicit sixteen colours, so the default is
untouched.
Twelve of them now, dark first then pale: Violet, Nebula, Abyss, Ember, Moss,
Graphite, then Holo, Sunset, Bloom, Dawn, Mint, Opal. Holo and Sunset are
straight takes on the two reference gradients — foil and poster.
`every_palette_is_multi_tone` measures the hue spread across all sixteen cells
and fails under 45° (20° for Graphite and Opal, which are meant to be
restrained). It caught Ember at 35°, all reds and oranges — the very flatness
this rework exists to remove — and Graphite at 3° despite a comment claiming it
drifted cool to warm. Both were rebuilt until the numbers matched the prose.
The UI follows the palette now, rather than wearing brand violet over whatever
happens to be behind it. Each palette carries an accent and a light flag, and an
Ink derived from those (foreground, accent, on-accent, glass, scrim and its
strength) is published to the whole tree — a thread-local in the console, an
environment value on Apple, a CompositionLocal on Android. Pale palettes flip
the ink: dark text on white frost, with the materials, tray scrims and every
wash that sits under text following suit.
Three things only the renders could have told us:
- Additive blending blows out over a pale ground. Android's blobs and Apple's
legacy field composite with Plus/plusLighter, which over near-white
saturates every blob to white — Holo rendered as a grey wash. Pale palettes
blend normally.
- A white scrim at the dark field's strength BLEACHES the gradient. Mixing
toward black at 0.4 reads as depth; toward white at 0.4 destroys the chroma
it is drawn over. The scrim now carries a per-palette strength.
- White glass over a bright field has far less separating it from its backdrop
than dark glass over a dark one, and needed more body.
Verified: console build + clippy -D warnings + 173 tests, Apple build + 200
tests + an iOS-triple typecheck, Android compile + 62 tests, and eyeball passes
on real renders of both the vivid and the pale ends (console CPU rasters; a new
Roborazzi light-palette scene, which is what exposed the blend-mode bug).
The xbox scanner resolves each package's PackageFamilyName by enumerating
%ProgramData%\Microsoft\Windows\AppRepository\Packages. Probed on .173 (design doc S1),
that directory is:
NT AUTHORITY\LocalService UnauthorizedAccessException <- the plugin runner
LocalSystem count=348 <- the host service
So an extracted xbox plugin cannot build an AUMID, and `aumid` — which takes a complete
`<PFN>!<AppId>` — is unpublishable by one.
The design doc offered two fallbacks: entries without `aumid` (they would list but not
launch), or a one-shot elevated PFN resolve at install time. This is neither. The plugin
sends `<Identity>!<AppId>`, both read straight out of MicrosoftGame.config — which IS
readable de-privileged — and the host resolves the publisher hash at LAUNCH time, where it
already owns command construction.
That keeps D1 intact (the plugin supplies a validated value, the host builds the command),
it is the same shape as the `playnite` kind, and resolving late means a package update that
changes the publisher hash cannot leave a stale unlaunchable tile behind — which the
install-time cache would have.
`xbox_pfn` therefore moves from the scan path to the launch path rather than being deleted,
and its doc records the privilege asymmetry so the next reader doesn't re-derive it.
The charset guard is factored out (`aumid_part` / `valid_aumid`) and shared with the `aumid`
kind. On `aumid` it was belt-and-braces — every value was host-derived. On `xbox` it is
load-bearing: the Identity arrives over the wire from a plugin, and it is interpolated into
a `shell:AppsFolder\…` argument. Validated inbound at reconcile too, so a malformed value is
a 400 the plugin author can act on rather than a tile that fails on click.
Gates: punktfunk-host 438 passed / 0 failed on .21, and 55/0 for the library tests on .173
(where these arms actually compile). Both counts +1 for the new guard test.
Moving the bun packages to bun2nix (1db8f763) removed the aggregate deps hash
that used to go stale, but not the second, quieter way a generated file rots.
bun2nix is a pure function of bun.lock, and `bun.nix` is committed alongside it
— but it regenerates ONLY from a local `bun install` that runs lifecycle
scripts. It does not regenerate under `bun install --ignore-scripts`, which is
what every bun install in CI uses, and it cannot regenerate on a merge or
rebase, where git carries someone else's lockfile change past a `bun.nix`
generated before it and reports no conflict.
That is not theoretical. web/bun.nix went onto main holding brace-expansion@5.0.7
(plus two nested entries the `^5.0.8` override had already collapsed) while
web/bun.lock said 5.0.8: the override landed in ec9aa415, the bun2nix branch had
generated bun.nix off the pre-override lock, and the merge kept both. The Nix
build fetches node_modules strictly from bun.nix, so the derivation's offline
`bun install` was asked for a tarball its store cache did not contain and
`nix build .#punktfunk-web` failed. It stayed broken for 553 commits
(2026-07-27 -> 2026-08-05) and was closed by accident, when an unrelated
advisory bump in b79d90b4 happened to rerun a real `bun install`.
Nothing caught it because nothing could: the string "nix" appeared in exactly
one workflow file, and only in a comment about bun2nix breaking a Windows step.
* scripts/ci/check-bun-nix.sh regenerates each bun.nix from its committed
bun.lock and diffs, with `--fix` to rewrite them. It reads which packages to
check out of packages.nix's own `bunNix = src + ...` lines rather than a
hardcoded list, so a third bun package is covered the day it is added, and
an empty list is a hard error — a gate that checks nothing passes exactly
like a clean tree. It also enforces the bun2nix version pin that flake.nix
and the README only stated: bun.nix has no schema stability across releases,
so the flake input ref and both package.json devDependencies must agree, and
generation always uses the pinned version rather than a floating `bunx`.
* ci.yml gains a `bun-nix` job running it. Deliberately unfiltered and not in
nix.yml: it needs no Nix, takes under a minute, and the whole point is that
this drift arrives through commits that look unrelated to Nix.
* nix.yml is new and is the first CI that evaluates the flake at all —
`nix flake check --no-build` over every output, then a real build of
punktfunk-web + punktfunk-scripting, which are the two derivations whose
inputs churn and the only ones that do not compile Rust. That leg is the
end-to-end proof the drift gate cannot give: it catches a tarball the
registry no longer serves, or the codegen going quietly message-less. It is
path-filtered, has a workflow_dispatch opt-in for the hour-long Rust
packages, and keeps `pull_request` — flatpak.yml shipped push-only and let
manifest breakage reach main invisibly for weeks.
The script is POSIX sh (shellcheck-clean, exercised under dash, which is the
CI container's /bin/sh) and avoids process substitution, the parse-time failure
that silently disabled ci.yml's shader SPIR-V gate for 35 commits.
The H.265 twin of plan_to_va, and with it pf-vaadec covers both codecs end to
end from an AuPlan to the buffers a vaRenderPicture call carries. What remains
for the rung is the Linux-only plumbing.
HEVC differs from H.264 in four ways that each had to be got right rather than
assumed, and they are why this is a separate module instead of a parameter:
ReferenceFrames is 15 entries, not 16.
The reference sets are FLAGS, not arrays. There is no RefPicSetStCurrBefore
here: membership is ORed into each DPB entry's own flags. Vulkan wants slot
indices in identically named arrays, DXVA wants list positions in them, and
VAAPI wants neither — three spellings of one idea, and confusing the first two
is what made HEVC unplayable on every driver.
The per-slice lists are INDICES into ReferenceFrames, not pictures and not
surfaces. So the DPB array is built first and every list entry resolved
through it; a picture a slice names that is not in the marked DPB is a refusal
rather than something to paper over, because there is nothing to fall back to.
The offset is in BYTES. slice_data() is byte-aligned by byte_alignment(), so
header_bit_size / 8 is exact — and a header that is not a whole number of
bytes is an error rather than a rounded offset, which would decode garbage
from the first inter picture.
Two conversions that are NOT copies, and would have been silently wrong as
copies: libva takes the derived ChromaOffsetLX (equation 7-56) where the
parser stores the coded delta, so putting the delta there would tint every
weighted-predicted block; and only 32x32 matrixIds 0 and 3 exist, where the
parser keeps six slots. The IQ matrix is Optional and gated on
scaling_list_enabled_flag for the reason review round 13 found on the DXVA
side — a driver MUST apply what it is handed, so a table of parser defaults
dequantises every residual to zero.
The weight table is only filled where 7.3.6.1 says one is coded, and the
chroma denominator is clamped into a legal shift so a malformed stream cannot
panic a decode thread.
Tests walk both HEVC vectors — the 250-frame 8-bit one and the 50-frame
Main 10 one, so a depth field wired to a constant would show — asserting per
slice that the start code was trimmed, the byte offset is inside the slice,
and every used list index points at a DPB entry that is actually valid. Per
picture it asserts that exactly the three current sets carry RPS flags and
nothing else does, and the walk fails if it never saw an RPS flag or a
reference at all, so it cannot pass vacuously.
The HEVC twin of pf-vaadec's H.264 buffer layouts, measured the same way: the
committed probe extended to cover va_dec_hevc.h, every size and offset read
off real libva 2.23.0 headers and pinned as const assertions —
VAPictureHEVC 28, VAPictureParameterBufferHEVC 604,
VASliceParameterBufferHEVC 264, VAIQMatrixBufferHEVC 1016 — and every
bit-field position read back out of a real header rather than counted by eye.
The finding worth carrying: HEVC's reference plumbing is a THIRD convention,
and this program has now been bitten by confusing two of them.
Vulkan takes DPB SLOT indices in RefPicSetStCurrBefore/After/LtCurr.
Writing reference-list positions there is what made HEVC unplayable on every
driver until it was root-caused.
DXVA takes positions into RefPicList[] in identically named arrays.
VAAPI takes neither. It marks set membership as FLAGS on the DPB entries
themselves — VA_PICTURE_HEVC_RPS_ST_CURR_BEFORE / _AFTER / _LT_CURR — and
its per-slice RefPicList[2][15] holds INDICES INTO ReferenceFrames, not
pictures and not surfaces.
Three spellings of one idea, identical names on two of them, and different
referents on all three. The conversion will say which it is writing, every
time, and the docs now hold all three side by side.
Two more asymmetries with the H.264 side, recorded where they will be read:
ReferenceFrames is 15 entries here, not 16; and the offset is
slice_data_byte_offset — BYTES, where H.264 wants bits — over the same
definition. slice_data() is byte-aligned by byte_alignment(), so the parser's
header_bit_size / 8 is exact rather than rounded, which the conversion will
assert rather than assume.
Tests cover the probe's measured bit patterns plus a disjointness sweep over
every field of pic_fields and slice_parsing_fields — two probe vectors per
word would not catch a shift typo that overlapped two neighbours, and these
words are 20 and 14 fields wide.
`windows/pyrowave.rs` raised the process's WDDM scheduling class to HIGH itself, once per
process, at every session open. `pf-frame::dxgi::auto_priority_gate` already owns that policy
for the whole process and runs from `create_device` — the call the Windows capture path always
makes before any PyroWave texture exists. Two owners of one process-wide setting.
The audit filed this as "downgrades REALTIME to HIGH", which undersells it. pf-frame's default
`auto` mode starts at HIGH and then UPGRADES to REALTIME once it has established that is safe —
HAGS off, or HAGS on with VRAM headroom — and leaves a monitor running that drops back when VRAM
tightens, because REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC hang. Opening
a PyroWave session after that upgrade stamped HIGH back over the class AND orphaned the
monitor's decision, losing the ceiling-raise on exactly the GPU-saturated workload PyroWave
exists to survive: it encodes on the shader cores a game saturates, where the measured spike is
~2 ms to 15-18 ms.
Removed rather than reconciled. `PyroWaveEncoder::open` takes no device, so there was nothing
session-specific to preserve, and the surviving owner is strictly better informed — it knows the
adapter, HAGS state and VRAM headroom, none of which this call site had.
The duplicated knob goes with it: `PUNKTFUNK_GPU_PRIORITY` is retired in favour of
`PUNKTFUNK_GPU_PRIORITY_CLASS` (`off|normal|high|realtime|auto`, default `auto`), which is a
superset — the removed knob could not express the auto gate at all. No other reference to it
exists in the tree.
Verified on .173: clippy -D warnings at nvenc,amf-qsv,qsv (host + pf-encode --all-targets),
amf-qsv without qsv, qsv alone, no-features, cargo test --features qsv (34 passed), rustfmt —
7 legs green. Windows-only file, so the Linux legs do not compile it.
Streaming a KDE desktop showed two cursors: the one the user was moving, and a
second one sitting underneath it that never moved. It was not KDE's — KWin 6.7.3
in cursor-as-metadata mode calls `setRenderCursor(false)` on every recorded buffer
and hands the cursor item to an exclusive `ItemTreeView`, so `shouldRenderItem()`
skips it and no pointer is ever painted into that stream. It was ours.
Both clients declared the render model as `captured && desktop`, so ANY released
pointer handed compositing back to the host. But releasing does not remove the
local cursor — it restores the ordinary window arrow over the video. The host then
blends its own pointer in underneath, and since a released client forwards no
motion, nothing drives it: it stays frozen wherever the host pointer was last left.
Caught live on the host with the render-model diag:
cursor diag: client_draws=false blended=true live=Some((-1, 622, true))
x = -1 — parked on the streamed output's left edge, unchanged sample after sample,
while the user moved their own cursor around freely. Engaging capture flipped it to
`client_draws=true blended=false` and the duplicate vanished, which is why it only
looked "stuck when not dragging": dragging means engaged, and engaged was the one
state that behaved.
The host may composite ONLY while the client holds a grabbed, hidden pointer — the
capture model, engaged — which is the single state with no local cursor on screen.
Released now counts as "the client draws it": the host stops compositing and keeps
forwarding shape/state over the channel (the forwarder ticks on this side of the
flip), so re-engaging is seamless and the client's cached shape stays warm.
`ffmpeg-next` is pulled with default features, so `ffmpeg-sys-next`'s build script
pkg-config-probes codec/device/filter/format/util/resampling/scaling and panics on
the first one missing. The spec named three.
RPM Fusion's `ffmpeg-devel` ships all seven in one package, which hid it. On a host
where those three instead resolve to Fedora's split `libav*-free-devel` packages,
`dnf builddep` installs exactly three and the build dies in a build script:
The system library `libavfilter` required by crate `ffmpeg-sys-next` was not found.
`cargo clippy -p punktfunk-client-windows -- -D warnings` fails on main with the
pinned 1.96.0 toolchain: `ticks % 5 == 0` trips `manual_is_multiple_of`. Pre-existing
and not from this branch — found while gating the launcher work on .173, because
neither macOS nor Linux ever compiles this crate.
Clippy's own suggestion, applied verbatim.
`local_art_bytes_is_confined_and_image_only` and `posix_local_art_is_classified_and_proxied`
built their `file://` values as `format!("file://{path}")`. On Windows that yields
`file://C:\covers\cover.png`, whose authority is `C:` — a UNC reference, not a local
file — so the read half failed on the box and the host suite was red there.
The parser is right and the tests were wrong: `@punktfunk/plugin-kit/library`'s `fileUrl`
emits `file:///C:/covers/cover.png` (three slashes, forward separators) and
`file_url_to_path` documents exactly that. A shared `file_url` helper now builds the value
the way the kit does, so both tests exercise the real plugin contract on both platforms
rather than a shape no plugin ever sends.
Found while gating the Playnite launch kinds on .173 — Linux CI never compiles these arms,
so the failure had gone unnoticed. Test-only: no product code changes.
The Playnite plugin emits `kind: "command"` for every game (a `start "" "playnite://…"`
shell line). The 2026-08-05 review made `command` operator-only, and `privileged_field`
refuses a PROVIDER reconcile carrying one — so on this branch the published
`@punktfunk/plugin-playnite@0.3.0` cannot publish anything at all. Not a launcher tile:
not one game. That is a regression against a shipped plugin, and it is the same hole
`launcher_ui` was created to close, one kind further along.
Two kinds, both host-owned so D1 holds — the plugin supplies a validated VALUE and
never a command line:
playnite valued by the game's GUID; resolves to
explorer.exe "playnite://playnite/start/<guid>", the same
protocol-via-a-concrete-EXE shape the `epic` kind uses. GUID-validated
on the way in (so a bad value is a 400 the plugin author can act on)
and again at launch.
launcher_ui now accepts "playnite" on Windows, resolving to
Playnite.FullscreenApp.exe with Playnite's own install dir as the
working directory.
Fullscreen, not Desktop, is the whole point of a couch tile — and it is also why this
one cannot ride the URI the games use: probed on .173, Playnite's registered
`playnite://` handler is bound to Playnite.DesktopApp.exe, so no URI opens fullscreen
mode. The exe is spawned directly, with the install dir read from Playnite's own
uninstall entry (HKCU, then HKLM for a machine-wide install), falling back to
%LOCALAPPDATA%\Playnite.
`valid_launcher_ui("playnite")` is answered by RESOLUTION rather than by a static list:
a host without Playnite installed refuses the entry instead of publishing a tile that
does nothing when a user clicks it. That is the same instinct that left Epic, GOG
Galaxy and the Xbox app off the list — each still needs its own verified activation,
and a guess would ship exactly that dead tile.
Gates: punktfunk-host 436 passed / 0 failed on .21 (the Linux arms), and the Windows
arms compiled and their library tests run on .173.
The host has been able to describe a launcher entry since M2 — `role: "launcher"`,
the `steam_ui` and `launcher_ui` kinds — and the web console has grouped them into
their own rail since M4. No other client ever looked. `pf-client-core` decoded
`role` into an `is_launcher()` helper with zero call sites, and the shared console
model dropped the field entirely on its way to the renderer.
So a launcher tile arrived everywhere else as an ordinary game with no cover art:
indistinguishable from a title whose poster failed to load, sorted into the middle
of the alphabet, and captioned "Play".
One contract, implemented in each client's own idiom:
* launchers never interleave with titles — they lead, and each group keeps the
host's title order
* grid surfaces get a labelled section; a coverflow keeps its single carousel and
names the group the cursor is in, changing as it crosses the boundary. A second
focus rail would mean a new up/down nav model in three renderers for two or
three tiles
* an art-less launcher gets an accent face naming its launcher, not a title
monogram on the neutral one — "opens Steam", not "a cover that didn't load"
* anything that is not `"launcher"` is a game, and a host that omits the field
renders exactly as before (design D4's intended degradation)
* launching is unchanged: the client sends an id, the host resolves the recipe
The grouping is enforced once per client stack rather than per screen. In the
console UI it is an invariant of `LibraryShared::set_games`, so the cursor
arithmetic, the art pump and every future consumer inherit it; on Apple and Android
it is applied where the library is fetched/parsed.
Fixed in passing: the Apple and Android store badges were hard-coded
`isCustom ? "Custom" : "Steam"`, so every Lutris, GOG, Heroic, Epic and Xbox title
was labelled "Steam". Both now carry the same store table the Rust clients use.
The CLI's `--library` gains a fourth column (`game`/`launcher`), appended rather
than folded into an existing one so anything reading the first three is untouched.
Gates: punktfunk-host 436 passed / 0 failed and pf-console-ui 49 passed / 0 failed
on .21 (three new tests), workspace clippy -D warnings and cargo fmt --check clean
there; `swift build` of the full PunktfunkClient and `:app:compileDebugKotlin` clean
on macOS; `cargo check` + `clippy -D warnings` for the Windows client on .173.
Still unproven on hardware: no launcher tile has been clicked on a real host — that
needs the plugins published, which needs this branch's base merged first.
The stream watchdog polled a bare "has the session ended" boolean, so it
had exactly one thing it could say and said it every time: "Connection
lost — the host may be asleep. Wake it to reconnect." That ran when the
player quit their game, when an operator ended the session from the
console, and when they pressed Back themselves — telling them to go wake
a host that was never asleep.
It now reads the end reason. Only a connection that actually died gets
that line, a host-side failure gets its own, and the three deliberate
endings say nothing at all: leaving the stream is already the feedback,
and a toast on top of it is just noise.
A game launched from a library also returns to that library instead of
host selection, which needs the intent hoisted out of the console shell:
the stream replaces that shell in the composition, discarding the
`remember`s holding its screen and host, so by the time the session ends
there is nothing left to navigate back with. The parent holds it across
the gap and the shell consumes it on the way in. The touch UI has no
library — only the console shell does — so there it is the toast fix
alone.
The desktop clients turned every host-side close into "Host ended the
session", and a reason string means "abnormal" to everything downstream:
the GTK and Windows shells raised a banner, the console overlay drew a
status strip. Quitting a game you launched yourself produced all of
that. Now only a host error or a lost connection carries a message; the
deliberate endings return the silence those shells already give a clean
exit, which is also what puts the console back in its library with
nothing in the way.
The Apple client gains the same distinction. It had one line for every
ending — "Session ended by <host>." — which is fine for an operator
stopping the session and wrong for a link that died, so each now says
what happened. A game exiting stays silent and returns to the library it
was launched from.
Both read the reason while the connection is still up, because tearing
it down is what makes it unreadable, and both fall back to their previous
wording when there is no verdict — an older core, or a close that raced
the read — rather than inventing a new one for a case they cannot see.
A session ending was a single bit. A player quitting their game, an
operator ending the session from the console, a stop the client itself
asked for, a host crashing and a Wi-Fi drop all arrived as the same
"closed" — so every client had to write one message covering all of
them, and every client picked an error. That is how quitting your own
game came to be reported as trouble on all three.
The information was already there and thrown away: the host closes with
APP_EXITED when a launched game exits, with 0 when it ends the session
cleanly and 1 when it fails, and a link that simply dies never closes at
all. The connection watcher now classifies that into a
PunktfunkEndReason — local, game exited, host ended, host error, lost —
and latches it before the shutdown flag, since the two are read by
different threads and the reason must never arrive second.
Exposed as punktfunk_connection_end_reason. This replaces the
game-exited flag added a moment ago rather than joining it: that
question is one row of this table, and it was never released. Still
additive to any embedder that ignores it, and the host sends the same
bytes either way, so the wire is untouched.
`is_normal()` is the question nearly every caller actually has, so both
the Rust and C surfaces answer it directly rather than making each
client re-derive which of five values are worth alarming a user about.
The second half of pf-vaadec: picture parameters, inverse-quantization
matrices and one slice-parameter record per slice, over the same transaction
discipline pf-dxvadec uses — validate, resolve references against the
PRE-removal slot map, then apply removals and assign the setup slot last. A
half-applied DPB update is the shape of a corrupt reference, so nothing
mutates until every fallible step has passed.
Three things VAAPI wants that neither other backend does, all of which the
existing plan already carries:
A bit offset. slice_data_bit_offset is where slice_data() begins, counted from
and including the NAL header byte with emulation-prevention bytes removed —
DXVA takes a byte offset, Vulkan takes nothing. It costs no new parsing: the
vendored parser records exactly that as SliceHeader::header_bit_size, because
cros-codecs' own production backend is VAAPI.
The slice data without its start code, since that offset is relative to the
NAL header byte. SlicePlan::data is start-code-inclusive and the prefix is
three OR four bytes — the host emits four on every access unit — so it is
measured per slice rather than assumed. Assuming it is the defect that made
HEVC unplayable on every driver.
The per-slice reference lists. DXVA's short-format slice control expresses no
lists at all; VAAPI wants RefPicList0/1 in 8.2.4.2 order, which is what the
plan's derived lists already are.
And the distinction that cost M5 a defect, now written down in a third place:
reference_frames is documented "in DPB", the same statement DXVA's
RefFrameList makes and the opposite of Vulkan's pReferenceSlots. It is filled
from the marked-DPB snapshot; the per-slice lists come from the slice's own.
Getting that backwards loses a long-term reference no slice happens to name.
Weight tables follow 7.3.3's presence rule rather than being copied
unconditionally: flagged only where the PPS actually enables explicit
weighting for that slice type and list. Flagging them otherwise hands the
driver defaults as though the stream had coded them. The vendored
PredWeightTable stores luma_offset_l0 as [i8; 32] but luma_offset_l1 as
[i16; 32] — an upstream inconsistency, not a semantic one — so the narrow side
widens.
Envelope refusals are errors, never silent narrowings: slice groups, separate
colour planes, a capacity mismatch, a reference holding no slot, lists past
their array bounds, a slice range outside its access unit.
Tests: 15. The one that matters walks all 250 access units of the vendored
conformance vector through H264Planner and this conversion, asserting per
slice that the range lies inside its access unit, that the declared size
matches it, that the start code really was trimmed, and that the header
neither is zero bits nor outruns the slice — plus that reference_frames
carries exactly as many valid entries as the marked DPB and every entry past
it is invalidated. It also asserts it saw a multi-slice picture and a
non-empty reference set, so a splitter bug cannot make it vacuous. Gates:
rustfmt, clippy, cargo doc with no unresolved links, and the container's
clippy -D warnings, tests and workspace check.
Quit a game you launched from a host's library and the stream ended with
"Session ended by <host>." on the host-selection screen — an error
report for something you had just done on purpose, and several taps away
from starting the next title.
The host has always said what happened: it closes the connection with
APP_EXITED when the game it launched for a session exits, and that
code's own documentation describes this feature. Nothing ever read it —
a search across every client found zero consumers. (It also could not
reach anyone until the previous commit, since the close only happens
once the lease declares the game gone.)
The core now records the reason as it observes the close, latched before
the shutdown flag because different threads watch the two, and exposes
it as punktfunk_connection_game_exited. Purely additive: a client that
never asks behaves exactly as before, the host sends identical bytes,
and the wire version is untouched — ABI 17.
The Apple client asks while the connection is still up, then treats a
game exit as the normal finish it is: no error banner, and if the
session began as a library launch it reopens that library so the next
title is one tap away. Any other ending — a stop, the host going away,
network loss — is unchanged. The other clients keep their existing
end-of-session behaviour; the call is there when they want it.
Pressing Escape mid-stream on an iPad handed the mouse back to iPadOS:
the captured cursor was swapped for the system one and the game stopped
receiving relative motion, so aiming died until you clicked back in.
Two previous attempts treated that release as unavoidable and built
recovery around it — a re-lock burst, then a click that re-asks. Both
came back from the field unchanged, because both fought the release
after it had already happened, inside the cooldown the platform applies
straight after its own "let me out" gesture.
The release was never unavoidable. This app had no UIKit key handling at
all: every key arrives on the GameController path, which is a parallel
HID feed that does not consume the UIKit event, and the only thing that
ever became first responder was the video view, and only to summon the
soft keyboard. So every hardware Escape reached UIKit unclaimed — and an
unclaimed key press is precisely what lets the system apply its own
default for that key. Apps that read a hardware keyboard the ordinary
way consume the event as a side effect and never see this.
So claim it. The stream controller becomes first responder while capture
is engaged and takes Escape in pressesBegan/pressesEnded, passing every
other press to super untouched. Escape still reaches the host on the
GameController path, so in-game menus open exactly as before; only the
system's own interpretation is suppressed. Scoped to captured input, so
Escape keeps dismissing sheets and leaving full screen whenever the
stream doesn't own the keyboard, and the deliberate ways out are
untouched — Cmd-Escape and Ctrl-Opt-Shift-Q are read off the same
GameController path and clear capture themselves.
The recovery path stays as a backstop and is retimed to match what was
measured: the old burst spent its entire budget within ~0.6 s of the
drop, i.e. wholly inside the cooldown, where the answer can only be no.
Retries now continue at 1.2 s and 2.4 s, and quietly — they don't hide
the cursor or mute pointer motion the way the burst does, so a longer
recovery costs nothing when it fails.
When a launched game's processes are all gone, the watcher asks one last
out-of-band question before ending the session: does the launcher still
think the game is up? On Windows that reads Steam's per-app `Running`
registry flag. It was only ever meant to be a tie-breaker for a scan that
momentarily can't see the game — a launcher re-execing, an engine
relaunching itself into a new pid.
It had no bound. Honouring the flag reset the confirm window every pass,
so a flag Steam left set — it does that whenever it doesn't cleanly
observe the exit: it crashed, it was closed first, the game re-parented —
pinned the lease in `running` for the life of the host. The console kept
showing the game, `session_on_game_exit` never fired, and the only way to
get the stream back was a manual "End". Reported from the field on
Windows 0.24.0. `steam_running_hint` also believes the FIRST hive that
says so, so a stale flag in any loaded profile was enough.
The absence timer now keeps running instead of being reset, and that is
what bounds it: past `VETO_LIMIT` (30 s) with nothing of the game on the
box, the launcher's opinion is stale rather than early and the session
ends anyway, logged at WARN so it is visible. Ending a moment early is
the cheaper failure — the stream drops while the game lives, the user
reconnects, and nothing is ever killed. Ending never was the bug.
The rule is now a pure `exit_confirmed(gone_for, hint_running)` with a
test. The watch loop polls a live process table and can't be unit-tested,
which is exactly how an unbounded veto shipped unnoticed.
The companion to the Vulkan ten-bit leg, over the same vector and the same
P010 goldens — one golden file serves both rungs because a D3D11 P010 surface
and Vulkan's 3PACK16 family hold the ten bits in the same place.
This is the rung where the gap mattered most. D3D11VA exposes no per-picture
status query at all, so its HDR evidence was a session that built a Main10
decoder and streamed without complaint — which is precisely what a Main10
stream decoding to garbage would also produce. Now there is a number.
It exercises geometry the eight-bit legs cannot reach: P010 samples are two
bytes, so a row is width * 2 rather than width, and HEVC's 128-line granule
pads a 240-line picture to a 256-line surface — so the chroma plane starts a
long way from where the display height alone would put it. Getting either
wrong is the smeared-rows failure this project has already paid for once, and
it would have looked like a decoder fault.
The run body now takes the stream format and the expected access-unit count
rather than assuming the eight-bit envelope and 250 frames.
A CPU guard pins the vector at ten bits — 4:2:0, both depths minus8 == 2,
320x240, 50 access units. A regenerated eight-bit vector would otherwise turn
this into a second run of the eight-bit path under a ten-bit name, passing,
because its goldens would have been regenerated with it.
Hardware: HEVC Main 10 50/50 bit-identical on the RTX 4090 and on the AMD
Radeon iGPU, alongside the unchanged eight-bit legs at 250/250 on both. With
the Vulkan leg's two drivers that is four independent drivers across two
rungs for the ten-bit path, where yesterday there were none.
Picking the device's native mode on a phone hands the host the panel's own
aspect ratio, so the aspect-fit presenter fills every pixel — including the ones
behind the sensor housing and under the four rounded corners. That is why the
corners look cut off at max resolution while 1080p has always been fine: a 16:9
mode on a 20:9 phone pillarboxes, and those black bars land exactly on the
unsafe regions.
So the fix is entirely a sizing one — no layout change, no input change. Ask the
host for a mode narrowed by the unsafe inset and the existing aspect-fit centres
it inside the safe region; pointer mapping follows for free, because both
clients derive the picture rect from the live host mode rather than assuming
full-bleed.
Apple: `SafeDisplay` (PunktfunkShared, pure + unit-tested) and a "This device
(safe area)" row beside the native one, using Moonlight's formula — full native
height, width less the left+right safe insets. The stream is always landscape
but the settings screen may be portrait, where the same housing is reported on
`top` and the horizontal insets read zero; the portrait top inset stands in,
gated so an iPad's status bar never fabricates an inset.
Android: the same shape via `SafeArea` + a `SAFE_AREA_MODE` sentinel resolved at
connect like the existing `0`=native one. The cutout insets get the same
portrait fallback, and the rounded corners are added on top — Android does not
count them as cutout, and a full-height picture needs exactly the corner radius
of horizontal clearance.
Both even-floor and clamp, since `validate_dimensions` rejects odd dimensions
and an inset subtraction lands odd about half the time. Where a display has
neither cutout nor rounded corners the safe mode equals the native one, which on
Apple lets the existing dedup drop the duplicate row.
Every golden set in this program was eight-bit. So the strongest thing anyone
could say about ten-bit decode was that a Main10 session BUILDS and streams
clean — which is not the same claim, and is exactly the shape of claim this
program has been burned by. A Main10 stream decoding to garbage logs just as
cleanly: HEVC Main10 on D3D11VA has no per-picture status query at all, and on
the Vulkan side the devices that matter report queryResultStatusSupport=false.
The HDR legs were measuring that the pipe ran, not that the pixels were right.
So: a Main10 vector and its goldens, and a ten-bit leg that runs them.
The vector is 50 frames of 320x240 HEVC Main 10 4:2:0 from libx265 — 48 KB,
generated by a command recorded in the golden file's header along with
everything else needed to regenerate it. The goldens come from libavcodec's
software decoder and were cross-checked between two independent builds on two
architectures (ffmpeg 8.1.1 Homebrew/macOS-arm64 and 8.0.1 Ubuntu/x86_64),
which agreed on all 50.
The goldens are P010, NOT yuv420p10le, and that distinction is the whole
reason this could have quietly gone wrong: P010 puts the ten bits in the HIGH
bits of each little-endian 16-bit word with the low six zeroed, which is what
a D3D11 P010 surface and Vulkan's G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16
both contain. Hashing LSB-aligned samples against MSB-aligned ones would fail
on every frame on every driver, for a reason that has nothing to do with
decoding. One golden file therefore serves both native rungs.
The readback is now depth-aware. Its only eight-bit assumption was the second
region's buffer_offset, which is a BYTE offset where the extents around it are
TEXELS — that plus the buffer size are the whole change, because
bufferRowLength = 0 already packs rows at the copy extent. The expected pool
format moved onto the readback so the sizing and the per-frame assertion come
from one source; a readback sized for eight bits that then accepted a ten-bit
frame would hash half a picture and blame the decoder.
A CPU guard asserts the vector really is ten-bit — 4:2:0, both depths
minus8 == 2, 320x240, 50 access units, 50 planned outputs. Without it a
regenerated eight-bit vector would turn the ten-bit leg into a second run of
the eight-bit path wearing a ten-bit name, and it would PASS, because its
goldens would have been regenerated alongside it. That guard is not ignored,
so it runs on macOS and in the container rather than only on the fleet.
Hardware: HEVC Main 10 50/50 bit-identical on NVIDIA 610.43.03 (Linux) and on
the Steam Deck's RADV/VanGogh — first run on both, which also confirms the
P010/3PACK16 layout match rather than assuming it. The four eight-bit legs are
unchanged and still green on both boxes.
A field report from an iPad: the host is not found on first run, and
restarting the client finds it. Pull-to-refresh appeared to do nothing.
Both were real. The Apple client's discovery had three ways to go
permanently deaf, each needing an app relaunch to clear:
- A failed resolve was never retried. `browseResultsChangedHandler`
only fires when the result SET changes, and a host whose resolve
failed is still in the set — so nothing ever re-offered it.
- A stuck resolve never ended. `NWConnection` has no timeout, so the
throwaway UDP flow used to resolve an address could sit in
`.preparing`/`.waiting` forever, and a service with a connection in
flight was skipped.
- `NWBrowser` parking in `.waiting` was ignored (only `.failed`
re-armed). On iOS that is where the local-network privacy prompt
lands on first launch after install: the browse starts, the system
asks, and the browser waits. Granting does not revive that browser —
only a new one sees the grant. That is the reported first-run bug.
HostDiscovery now runs a 1 Hz sweep that times out stuck resolves,
retries failed ones on a 1→30 s backoff, and re-arms a browser that
stopped working; the advert's TXT is re-read on every browse report, so
a host that re-keys or flips its pairing policy is followed. Returning
to the foreground re-arms the browse (iOS/tvOS: `onAppear` does not
fire across background/foreground, and a suspended browse stays dead).
Pull-to-refresh did nothing because there was no `.refreshable` in the
client at all. Added, plus the explicit control the report asked for:
a toolbar Refresh on iOS/macOS, an action-row button on tvOS, a Rescan
tile in the gamepad launcher, Scan Again on the empty state, a
header-bar button in the GTK client, a hosts-page button on Windows,
and Scan again on Android. Decky already had one.
The desktop/Android browses needed a rescan trigger to make those
buttons mean anything: mdns-sd re-queries on a doubling backoff capped
at ONE HOUR, so a long-lived browse is effectively passive and a host
that appears later can stay invisible. `discovery::Rescan` forces a
fresh query; the wake-and-wait loops use it too, so a host that just
booted is noticed in seconds rather than at the next backoff tick.
Also fixed, found on the way: clients/windows/src/discovery.rs is a
second copy of the browse that d0fa8bd3 ("pin mDNS discovery to IPv4 on
every client") missed. It took an arbitrary first address, so when a
host's OS responder answered AAAA the Windows GUI rendered a card that
failed on every click. It also never noticed a dropped receiver, leaking
a thread and a :5353 socket per wake-and-wait.
Gates: Apple macOS + iOS (arm64-apple-ios17.0, proven non-vacuous) build
clean, 195 tests pass incl. a new one asserting a rescan re-finds a
still-advertising host. On .21: fmt, clippy --all-targets -D warnings
and build clean for pf-client-core + client-linux + client-session,
117 tests pass. Android :kit: and :app: compileDebugKotlin clean.
The Windows client is UNGATED — its CI runner was unreachable.
The VAAPI rung's crate, in the shape the other two native rungs established:
everything that can be a pure decision or a pure conversion lives in a
cross-platform crate the ordinary gates run, and only the parts that genuinely
need a device stay behind a platform cfg. This lands the first half of that —
the buffer layouts and the decoder-creation decisions — with the conversion to
follow.
Route: minimal FFI rather than cros-libva. The plan of record permits either
("cros-libva (or minimal FFI)"), and hand-declaring keeps the crate building
and testing on macOS and in the Linux container, which is the property that
made pf-dxvadec's defects findable on a laptop instead of on a box.
The layouts are not eyeballed. A C probe compiled against real libva 2.23.0
headers printed sizeof/alignof/offsetof for every field and set individual
bit-fields to read the resulting word back; those numbers are pinned as const
assertions, so a transcription slip is a compile error rather than a driver
reading the wrong byte. The probe is committed beside them, with the command
that runs it, because evidence that cannot be re-run is a claim.
What the probe settled that a reader would otherwise get wrong: VAPictureH264
is 36 bytes and is embedded 81 times across the two buffers, so its size is
load-bearing for every later offset; the three DEPRECATED FMO fields still
occupy bytes 624..628, and dropping them would shift everything after; and C
bit-fields allocate from the least significant bit on this ABI — proven, since
that is ABI-defined rather than standardised.
Groundwork for the conversion, established here so the next work package
starts from facts:
slice_data_bit_offset needs no new parsing. VAAPI is the only backend that
wants a bit position — DXVA takes a byte offset, Vulkan takes none — and the
vendored parser already records exactly it as SliceHeader::header_bit_size,
computed as (nalu.size - epb) * 8 - bits_left: from and including the NAL
header byte, emulation-prevention bytes removed. That is the field's
definition verbatim, and it is there because cros-codecs' own production
backend is VAAPI.
The slice data buffer starts at the NAL header byte, so the start code is
skipped — SlicePlan::data is start-code-inclusive and the prefix is three OR
four bytes, the host emitting four on 100% of access units.
reference_frames is the marked DPB, the same statement DXVA's RefFrameList
makes, so it comes from the dpb_refs snapshot; Vulkan's pReferenceSlots is the
opposite and takes the access unit's own set. All three conventions now have a
written home, which is the distinction that cost M5 a defect.
Unlike DXVA short-format, VAAPI wants the per-slice reference lists and the
full prediction weight tables inline — hence the 3128-byte slice record. One
wrinkle recorded rather than left to be discovered: the vendored
PredWeightTable stores luma_offset_l0 as [i8; 32] but luma_offset_l1 as
[i16; 32], and libva wants i16 for both.
Profile selection resolves H.264 to High for every 8-bit 4:2:0 stream instead
of reading profile_idc, because High is a superset for the tools our hosts
emit and picking Main for a stream that turns out to use 8x8 transforms is a
mid-stream failure where picking High is not. 4:4:4 and 10-bit H.264 are
refused rather than narrowed to an 8-bit profile — that class of silent
narrowing decodes to garbage instead of failing.
11 tests: the probe's bit patterns, a disjointness check per bit-field word
(two probe vectors alone would not catch a shift typo that overlapped two
fields), and the envelope refusals. Gates: rustfmt, clippy, cargo doc with no
unresolved links, and the Linux container's clippy -D warnings, tests and
workspace check.
The console settings were one 30-row scroll, which on a Deck meant thumbing past
Video and Audio to reach the pad settings. They are now split across sections —
Stream · Video · Audio · Controller · Interface · Profiles, plus Input on the
desktop console, which alone carries the touch/mouse rows. L1/R1 walks them,
each section remembers where its cursor was, and the names are the same word on
every client so a setting is where you looked for it last.
Shoulders are not the only route, because a D-pad remote hasn't got any: on
Android, Up from the first row moves onto the strip (left/right walks sections
there, A drops back in), and on tvOS the pills are focusable, so the focus
engine handles it — a Siri Remote has no extended gamepad profile and never
reaches the input poll at all. The desktop console needs neither; PageUp and
PageDown already map to the same events.
New "Background" row, six palettes: Violet (the brand default), Tide, Forest,
Ember, Rose, Graphite. A palette is a hue rotation plus a saturation scale over
the ONE colour field each client already draws, so every palette inherits its
structure and Violet is the identity transform — existing installs see exactly
what they see today. The maths is ported three times (Rust/Swift/Kotlin) under
one shared `ui_palette` key, with the same assertions pinned in each language.
It is presentation only, so it is a device preference and never part of a
profile.
The form screens no longer have a backdrop of their own. Settings, add-host and
pair used to sit on a still gradient; they now wear the same living field at a
calm mix — pools dimmed onto the palette's own corner colour, vignette halved so
rows that run to the edges don't get crushed. On the desktop console that
collapsed the old aurora-over-static crossfade into one shader pass with a
chased uniform. Motion speed is identical in both modes on purpose: changing it
would make the field jump mid-transition. Nothing in the gamepad UI is backed by
a static image now, and Reduce Motion (Apple) / "remove animations" (Android)
still freeze it.
Also: the settings screen had no raster coverage at all — the eyeball dump is
`#[ignore]`d — so a new test draws every tab, and the Android screenshot set
gains a console-settings scene. Both earned their keep immediately: the renders
showed the extra hint pushing "Done" off a 360 dp phone (the legend scrolls now,
and the Section cell only appears where shoulders exist) and the form backdrop
crushing its own edges.
The `stats:` line's decode-path tag is derived from the DecodedImage variant,
and both D3D11VA rungs deliver DecodedImage::D3d11 — they share the hand-off
ring on purpose. So a native-d3d11va session and an FFmpeg-d3d11va session
emitted a byte-identical tag, and nothing downstream could tell them apart.
The native Vulkan rung never had this: it carries its own variant, hence its
own `native-vulkan` tag.
That is not cosmetic, and it was found the only way it could be — by running
the rung on glass and having to grep the log to prove which one had answered.
A native pin that fails to initialise falls through to the FFmpeg rung by
design; the line it then emits is exactly the line the native rung would have
emitted. M5's owed soak and M9's vendor-matrix bake both rest on attributing
a session to a rung, and until now the machine-readable half of that evidence
could not do it. This project has already shipped one measurement that could
not tell "clean" from "unmeasured"; this is the same shape.
D3d11Frame now records which rung wrote the surface, keyed off the pin
constant itself rather than a second field the two rungs could set
inconsistently — the native rung passes DECODER_PIN into the hand-off and
nothing else does.
The stats line stays additive for every shipping session: the only value that
changes belongs to a rung that is pin-only and deliberately absent from the
automatic ladder, and the Windows shell passes the line through opaquely
rather than matching on the tag.
Verified on glass on .173, both directions: pinned native-d3d11va gives 85
windows tagged `native-d3d11va` and 0 plain, pinned d3d11va gives 64 plain and
0 native, zero errors either way. Gates: clippy -D warnings on Windows, the
Linux container's clippy/tests/workspace check, rustfmt.
The native D3D11VA rung had no pixel evidence at all. Its DXVA bytes were
checked against libavcodec's own captured bytes, and its Intel bring-up proved
the driver accepts the submission — but nothing had ever compared what came
out. This is that comparison, against the same goldens and the same reference
the Vulkan rung was held to: libavcodec's SOFTWARE decode, which is ground
truth rather than a peer implementation, so the two rungs' verdicts are now
directly comparable numbers.
It reads back the DECODE surface, before the VideoProcessorBlt, so what is
hashed is the half this rung is responsible for; the hand-off is the shared,
field-proven half and is deliberately not in the measurement.
Finding, recorded rather than papered over: this rung presents in DECODE
order. It never consults AuPlan::dpb.outputs — submit blits setup_slot and
returns. The native Vulkan rung keeps a display-order queue for exactly that
reason, and libavcodec's D3D11VA rung reorders internally, so this rung
differs from both. It cannot bite on punktfunk streams, which are zero-reorder
and carry no B pictures, but that is a convention of our hosts rather than a
structural guarantee, and a stream that did reorder would present out of order
with nothing to say so.
Both vendored vectors DO reorder — the H.265 one's first B picture at AU 3 is
what localised the RPS slot defect — so a harness hashing in decode order
would report a permutation against display-order goldens and read like a
decoder fault. Instead each decoded surface is hashed against the PicId the
planner gave it and the hashes are emitted in the planner's own output order.
The reordering is the test's, done by the planner the rung already trusts, and
`both_vendored_vectors_really_do_reorder` asserts the reason so the docs
cannot go stale silently.
The crop reads the chroma plane at RowPitch * texture height, not display
height: the decode pool is aligned to the codec's granule and is taller than
the picture. That is the 1088-row smear this project has already paid for.
Two CPU guards run in ordinary CI. This file needs its own Annex-B splitter
(pf-client-core does not depend on the vendored parser), and a splitter that
disagreed with pf-bitstream's would fail on hardware as a frame-count mismatch
that reads like a decoder defect; instead it fails on CPU, saying so.
PF_DXVA_ADAPTER pins a GPU by description substring and every run prints the
adapters it saw — .173 enumerates its AMD iGPU alongside the 4090, and which
one answered is a fact worth printing rather than inferring.
Hardware: H.264 and H.265 both 250/250 bit-identical on NVIDIA GeForce RTX
4090 and on the AMD Radeon iGPU, Windows. Gates: clippy -D warnings and the
lib tests on Windows, the Linux container's clippy/tests/workspace check, and
rustfmt.
Both vendored vectors carry three-byte Annex-B start codes throughout. The
real host emits four-byte ones on 100% of access units in both codecs —
1514/1514 H.264 and 1133/1133 HEVC, measured off the M0 NVENC corpus through
the capture hook's own .idx offsets. So every parity verdict this program has
recorded was taken on a prefix form that never ships, and the one form that
does ship was exercised by nothing.
That gap is not hypothetical. Submitting four-byte start codes to
vkCmdDecodeVideoKHR unchanged is exactly what made HEVC unplayable on every
driver tested: drivers are validated on the three-byte form, and a fixed
+3 + 2 skip into a four-byte-prefixed slice reads a nonsense pps_id — the
115 and 119 both NVIDIAs printed. H.264 was never safe here by structure,
only by its vendored encoder's convention, which is why the cure lives in the
shared ring layer and why this coverage is generic over both codecs.
Each codec's parity body now takes its access units as a parameter and runs
twice: once over the vector as it sits, once over the same vector rewritten
to four-byte prefixes. Prefix width carries no information, so both runs must
reproduce the same goldens — sharing one body is what makes that an equality
rather than two assertions that can drift.
The rewrite copies nalu.data[nalu.offset..], the same nal_size bytes the
parser hands the planner, so trailing_zero_8bits are dropped exactly where
the production parser drops them: the only difference between the two streams
is the width of every prefix.
Two CPU guards keep the new legs from passing vacuously, which is the failure
mode they are most exposed to — a rewrite that quietly returned its input
would make them trivially green and nothing on the fleet would notice. They
assert the original really does carry three-byte prefixes, that the rewritten
stream carries none, that the NAL count is preserved exactly, and that the
planner still yields 250 pictures.
Hardware: all four legs 250/250 bit-identical to libavcodec on two
independent driver stacks — AMD VanGogh on RADV/Mesa 26.0-devel (the Steam
Deck) and NVIDIA 610.43.03 on Linux. NVIDIA is the family that rejected the
four-byte form outright, so it is the meaningful witness for this regression.
wrong slice offsets
Two independent defects, both in this crate. pf-bitstream is untouched — its
HEVC plans were sound all along, which the D3D11VA rung proves by rendering
correctly from the same AuPlans.
The corrupter: StdVideoDecodeH265PictureInfo's RefPicSetStCurrBefore,
StCurrAfter and LtCurr carry DPB SLOT indices. We wrote positions in the
reference list. libavcodec's Vulkan HEVC hwaccel — the implementation every
driver is validated against — writes the index into its own DPB array and
passes that same value as slotIndex, while packing pReferenceSlots densely
over the used entries; the two numberings are provably different there, and
the RPS arrays follow the slot.
The two readings coincide on a freshly anchored stream, because the references
then occupy slots 0..n in reference-list order. They first diverge at the
vendored vector's first B picture, AU 3, where refs are slots 0, 2, 1 — so we
named slots 1 and 2 where the picture wanted 2 and 1, and every later access
unit inherited the error through its own references. That is why this shipped
and why review could not see it: correct for the opening pictures, wrong from
the first reordering onward.
It also accounts for the measurements exactly. Display order maps to decode
order as display 0 from AU 0, display 3 from AU 1, display 2 from AU 2,
display 1 from AU 3 — so the three frames that matched on AMD are precisely
the three access units where positions and slots agree, and 250 - 3 = 247 is
the divergence count that was measured. From AU 6 the named slots stop being
merely wrong and become unbindable by that operation, which is where NVIDIA
stopped reporting a verdict at all.
The diagnostic: every slice offset must point at a THREE-byte start code.
libavcodec discards the stream's prefix and writes 00 00 01, so that is the
only pattern drivers are validated on, and pf-dxvadec's packer already
normalised for exactly this reason and said so in its docs. This path uploaded
the prefix verbatim. 249 of 250 HEVC slice segments in the vendored vector
carry a four-byte prefix; all 500 H.264 slices carry three.
That derives the driver's own complaint bit for bit. A decoder reaching the
slice header by a fixed skip lands on the NAL header's second byte, reads
first_slice_segment_in_pic_flag as 0, and then takes six bits of the real
slice header as the tail of a long ue(v): 0xd0 gives 115, 0xe0 gives 119. A
P-slice header and a B-slice header — which is why exactly two bogus pps_id
values ever appeared.
⚠ H.264 was NOT protected structurally, only by its encoder's convention, and
the real host does not share that convention: every one of 1514 H.264 access
units and 1133 HEVC access units captured from an NVENC host prefixes its
slices with FOUR bytes. The vendored H.264 vector is therefore not
representative of what ships, and its bit-exactness was passing on a prefix
form the field never sends. The normalisation lives in the shared ring layer
and covers both codecs for that reason.
rebased_offsets is replaced by pack_slices, which trims the leading zero byte
and computes the offsets from the trimmed lengths in one call, so the bytes and
the offsets cannot drift apart; upload and the CPU test go through the same
pack_into.
Hardware, after the fix — H.264 AND H.265 both 250/250 bit-identical to
libavcodec, all four smoke legs green:
NVIDIA RTX 4090 610.88 Windows coincide
AMD Adrenalin 25.10.30.02 Windows distinct
NVIDIA RTX 5070 Ti 610.43.03 Linux coincide
On glass on the 4090 against a real NVENC host, 2800x1260 HEVC through the
auto ladder: 73 one-second windows all native-vulkan, fps avg 59.3 of 60,
decode 1.1 ms, e2e 4.5 ms p50, and ZERO driver-reported status failures where
the same session before the fix logged 1489 in 181 seconds and had dragged ABR
down to a 5 Mb/s target. No refusals, demotions, PlanWarnings, concealment,
DEVICE_LOSTs or panics.
Both defects now have CPU tests that were confirmed FAILING before the fix:
one walks every access unit of both vendored vectors and asserts each declared
offset opens on a three-byte start code, its own NAL header and a
first_slice_segment_in_pic_flag consistent with the segment index; the other
resolves every RPS entry by slot and asserts that 247 access units disagree
with the positional reading, so it cannot go vacuous on a stream where the two
happen to agree.
M3 was recorded as code complete. Its exit criteria named the HEVC gpu_smoke
and gpu_parity legs, and the goldens for them were committed — 250 per-frame
NV12 hashes, cross-checked between two independent FFmpeg builds, with a
header saying they are "consumed the same way by the HEVC parity test". No
such test existed. Both GPU files were H.264 only, with zero references to
h265, so nothing had ever decoded a single HEVC frame through this crate on
hardware.
They exist now, and the first run answered. On AMD Adrenalin 25.10.30.02
(distinct mode, queryResultStatusSupport=false) 247 of 250 frames diverge
from libavcodec, and that device's smoke leg PASSES — because smoke only
reads the driver's verdict and that driver reports none. That is the Ally X
class, reproduced in-house on demand: output that is wrong everywhere the
picture is looked at and clean everywhere the decoder is asked. Both NVIDIA
drivers reject the stream outright and name the cause themselves,
"Invalid PPS/SPS id in slice header (pps_id=119 / 115)" — the identical two
values, and the smoke leg dies at the identical AU 9, on a 4090 under 610.88
on Windows and on an RTX 5070 Ti under 610.43.03 on Linux. Same wrong values,
same access unit, two GPU generations, two operating systems: deterministic,
and therefore ours rather than any driver's.
It is not an ordering fault. Five of the divergent hashes appear nowhere in
the 250 goldens, so the pixels are wrong rather than correct-but-reordered.
Parity dies at frame 1 while smoke dies at AU 9 only because smoke holds four
frames before it looks; the first inter-predicted picture is already corrupt.
The legs are committed ahead of the fix deliberately. They are the regression
test for the defect, they are #[ignore]d so no CI leg changes colour, and the
evidence above is worth recording in the order it was obtained.
Adding a third and fourth copy of ~150 lines of unsafe Vulkan bring-up was
not acceptable, so it moved to tests/common. The two behavioural differences
between the callers are now named parameters rather than accidents: the parity
legs read back on a graphics queue and require one, while the smoke legs
accept a decode-only device and fall back to the decode family — which also
decides whether pool images are EXCLUSIVE or CONCURRENT, so it is load-bearing
rather than cosmetic. H.264 came through the refactor unchanged, verified two
ways: argument-by-argument against the previous file, and on hardware, still
250/250 bit-identical on NVIDIA Windows, AMD Windows and now NVIDIA Linux.
The loader is deliberately leaked at teardown. ash::Entry owns the Arc<Library>,
so dropping it unloads the Vulkan loader with every ICD and implicit layer;
harmless while each binary held one GPU leg, but each now holds two, and the
second would re-open a loader the first had torn down.
Three guards run without a GPU, because everything above is #[ignore]d: the
golden file's count and digest shape, the HEVC access-unit split agreeing with
what the CPU planner emits (with iraps == 1 pinning "no CRA anywhere", so a
re-synced vector that opens with one fails here rather than as a frame-count
mismatch on the fleet), the vector staying Main 4:2:0 8-bit since both legs
hard-code that probe, and a refusal to run the smoke legs with
PF_VKD_TEST_READBACK set, which would quietly grow the pool a usage flag
production never carries.
The native D3D11VA rung was committed with zero hardware evidence and, more
to the point, zero evidence of any kind: every claim it made about the DXVA
structures rested on reading the specification and reading libavcodec, and
reading is exactly the method that produced the four defects the last review
found. Three of those lived where a smoke test cannot look — in the buffer
descriptors — so a passing session would have proved nothing about them.
So this compares our submission against libavcodec's, byte for byte, on the
same access units of the same two vendored vectors. The reference side comes
from an FFmpeg n8.1 cross-built for Windows with the DXVA paths instrumented
(the recipe is in the harness docs); the comparison covers picture parameters,
quantisation matrices AND the buffer descriptors, 250 AUs per codec:
H.264 / HEVC picture parameters 250 AUs, no undocumented divergence
H.264 / HEVC quantisation matrices 250 AUs, no undocumented divergence
H.264 / HEVC buffer descriptors 250 AUs, no undocumented divergence
It found a real defect immediately. The DXVA short slice record is TEN bytes
— dxva.h packs these bitstream-layout structs to a byte — and this crate
declared it `repr(C)`, which pads {u32,u32,u16} to twelve. libavcodec's own
descriptors say so twice over: 20 bytes of slice control for a two-slice
H.264 picture, 10 for a one-slice HEVC one. Record 0 survives either way
(its fields sit at 0/4/8 regardless), so the mistake is invisible on a
single-slice stream and displaces every later record by two bytes on a
multi-slice one — which punktfunk hosts emit. Both records are now
`repr(C, packed)`, and the HEVC slice-control test grew a second record
because one record is the shape that hid this.
The audit that followed matters more than the fix. Per-field offset asserts
cannot see TAIL padding, which is what this was, so all six hand-declared
structs now also assert that their size equals the last field's offset plus
that field's own size. Under that rule the slice records were the only place
packed and natural alignment disagree — 1040, 232, 224 and 1000 were right
all along, and now provably rather than luckily. The module docs claimed
`repr(C)` "reproduces MSVC's default packing exactly for that shape"; that
was a guess wearing a proof's clothes, and it is gone.
Two differences are documented rather than fixed, each with the argument for
why it is inert. libavcodec seeds prev_poc_msb = 1 << 16 at every IDR, so its
POCs are the specification's plus 65536 uniformly; every use a driver makes
of those fields is a difference, and references match on FrameNumList, so the
harness compares POCs relative to that constant and requires it to hold on
every AU rather than importing a magic number into a derivation the Vulkan
rung shares. And HEVC's loop_filter_across_tiles_enabled_flag is inferred 1
by 7.4.3.3.1 when the PPS codes no tiles while libav leaves it 0, with tiles
disabled either way. Both ride a channel that always prints, and both are
guarded by tests that synthesise the differences an allowance must NOT
absorb — a documented divergence that swallows a real defect would be worse
than no harness at all.
Everything checkable without a capture is now a non-ignored test: the buffer
set and order per codec, NumMBsInBuffer's codec asymmetry (mb_width*mb_height
on H.264's bitstream and slice-control buffers, zero everywhere for HEVC),
the three 7.4.5 scaling-list cases, contiguous slice records tiling DataSize,
the 128-byte padding charged to the last record and no other. That is the
part which would have caught the last round's defects with no hardware at all.
The Windows fallback rung, and auto's first choice on Intel, now has a
native implementation driven by pf-bitstream's plans instead of
libavcodec. New crate pf-dxvadec holds everything that can be a pure
function — the DXVA structure layouts, both codec conversions, bitstream
packing, config selection — deliberately CROSS-PLATFORM, because a
cfg(windows) module is verified by a remote cargo check and nothing
else, and this milestone's riskiest code is exactly the part no local
test can see. Only the FFI lives in video_d3d11_native.rs.
windows-rs does not generate dxva.h at the pinned rev, so the DXVA
structures are hand-declared: compile-time assertions on every struct
size AND every field offset, packed bitfield words as plain integers
with named builders and the bit positions written beside the C
declaration, and a const zeroed() per struct so construction needs no
unsafe at all. The crate's only unsafe is a sealed byte view over those
PODs. Review round 13 checked all seven layouts field by field in
declaration order — sizes, widths, array lengths, the PicEntry
index/flag packing, and every named bit's position and width.
The decode pool reproduces libavcodec's rather than inventing one:
ONE texture with ArraySize = pool size, BIND_DECODER and nothing else,
MiscFlags 0, aligned 16 for H.264 and 128 for HEVC. That is deliberate.
This rung's predecessor records that a hand-built pool which validated
on NVIDIA was rejected by Intel at the first SubmitDecoderBuffers — and
Intel is the vendor this rung exists for. The VideoProcessorBlt into
shareable RGBA is untouched: importing a multiplanar NV12 D3D11 texture
into Vulkan device-losts on NVIDIA, so that hand-off is load-bearing
field-proven code. It was extracted into a shared HandoffRing so both
rungs fill one implementation; the review diffed the blit statement by
statement, including the keyed-mutex pairing.
Review round 13's four defects are fixed. The blocking one: the HEVC
quantisation matrix was submitted unconditionally, and the vendored
parser leaves it ALL ZEROS unless the stream codes one — unlike FFmpeg,
which seeds the spec defaults. On a stream saying 'use the default
matrices' the driver is obliged to apply what it is handed, so every
residual would dequantise to zero and the picture would drift to flat
prediction. It is now gated on scaling_list_enabled_flag exactly as
libav gates it, with the Table 7-5/7-6 defaults supplied when enabled
but uncoded.
Second: NumMBsInBuffer was 0 where libav's H.264 path sets
mb_width * mb_height. This module's whole method is verbatim
reproduction on precisely the call that once failed for Intel, so an
omitted descriptor field is the same class of bug as the pool.
Third, and the one to watch on hardware: RefFrameList carried the
frame's reference set rather than the pictures marked used for
reference. Vulkan defines pReferenceSlots as the slots this operation
uses, so a subset is correct there; DXVA defines RefFrameList as a
statement about the DPB. The list DERIVATION survives a subset — which
is exactly why a smoke test would have passed — but a long-term
reference held across frames that none of them name would vanish and
reappear, and a driver keeping per-reference state is entitled to
discard it in between. That is the Ally X symptom shape. pf-bitstream
now exposes a per-AU DPB snapshot for both codecs and the converters
build the array from it, frame references first, marked tail appended.
121 of the 250 vendored AUs carry a marked picture the frame never
names, so this is exercised, not theoretical.
Fourth: the session identity omitted bit depth and chroma, while the
Windows host flips an HDR desktop to PQ in-band with a new SPS — a
depth change at unchanged size would have decoded 10-bit samples into
an NV12 pool. Identity now derives from the SPS per AU and rebuilds.
Wired PIN-ONLY (PUNKTFUNK_DECODER=native-d3d11va), absent from every
auto arm. Nothing has decoded a frame yet, and M2's discipline was that
auto admission comes only after hardware parity. A runtime streak
demotes to the FFmpeg D3D11VA rung first, then software.
Also scaffolded: a byte-diff harness against libavcodec's own DXVA
picture parameters, with the FFmpeg patch and capture recipe in its
docs. Nothing here is checked against libav's actual bytes the way M3
was checked against its pixels, and that is the cheap way to buy the
confidence before hardware.
Gates: fmt clean; container clippy -D warnings zero across
pf-client-core + pf-presenter + pf-vkdecode + pf-dxvadec +
punktfunk-core; tests 73/131/63/129/354 green; cargo check --workspace
clean; Windows cargo check and clippy -D warnings clean on .173.
This program exists because a field corruption was architecturally
undetectable through FFmpeg: no decode-status read, no corrupt-frame
flag, errors only as scraped log lines, and no recovery-point signal so
intra-refresh healing was invisible. The native decoder has all of those.
M4 is where they stop being internal.
DecodeHealth counts, per session and without allocating per frame, what
the three answers actually are: damaged (the stream arrived incomplete),
refused (the rung would not decode it at all) and driver-failed (the
hardware says it could not decode what arrived), plus the current and
worst concealment run — the figures that separate one bad AU from a
stream that never came back. They ride the stats line additively, so an
FFmpeg session and a healthy native session emit byte-identical output
to today. The status-query capability is reported too: without it a
clean report cannot be told from an unmeasured one, which is the whole
nb_queries=0 lesson.
The headline is local recovery. Until now the pump could only learn that
intra-refresh healing finished from wire flags the host sends; absent
those it froze until the 500 ms backstop forced an IDR. The parsed
recovery-point SEI now feeds the re-anchor gate directly, so a session
lifts on the picture that is actually clean. Wire semantics are
untouched for every client that never calls it.
Detection now asks for recovery instead of erroring — an integrity
warning ticking the error streak would demote the native rung on exactly
the lossy links it exists to diagnose, where an FFmpeg rung conceals
silently and keeps its job.
Review round 12 found that trade had removed the escape hatch entirely.
Concealment returning Ok(None) reset the demotion streak, and worse: the
driver-verdict ledger is only populated when a frame ships, so under
continuous concealment no verdict was ever read and the erroring arm
could not fire at all. A host framing regression of the 0.23.0
slice-wire class — which does not self-heal, and which a keyframe does
not clear — would have frozen indefinitely with no demotion and a clean
integrity line, where before it demoted to FFmpeg-Vulkan and showed a
picture. Now only an answer that proves the rung works clears the
streak: a shipped frame, or a clean no-frame. Concealment neither ticks
nor clears, so a lossy link still cannot demote a healthy rung while a
driver failure interleaved with concealment reaches the threshold again.
Two more honesty defects from the same round. A rung refusing every AU
reported no integrity line at all — the founding failure mode, wearing
the shape of a clean bill of health; refusals are now counted. And
driver-failed could be non-zero on a device that cannot produce driver
verdicts, because a degraded timeline read looked the same as one; the
attribution is now withheld inside the counter rather than at call
sites, so the self-contradictory line is unrepresentable.
Local recovery also no longer trusts any recovery-point SEI: only one
whose target advances past an outstanding wave counts as a new wave, so
an encoder re-announcing the current wave with a decreasing count — legal,
and what x264 intra-refresh does — cannot lift the freeze early onto a
partially stale picture. Frames buffered across an arm are dropped by
decode order for the same reason.
Fault injection is a first-class tool now (PUNKTFUNK_AU_FAULT, inert
unless set, env read once). Its test replays the vendored vectors
through the real planners and asserts a negative the plan assumed away:
truncation and bit flips are PROVABLY invisible to the parser — Annex-B
carries no NALU length, so a cut slice is just a shorter slice and a
flipped payload byte is syntactically perfect. Only dropped AUs are
parser-detectable; the rest need the driver verdict, which is why the
status query matters. The H.265 leg found a second: three of that
vector's faulted AUs are sub-layer non-reference pictures, so dropping
them damages nothing and silence is correct — the test asserts both
verdicts and guards that neither half goes vacuous.
Per-frame decode latency was deliberately NOT built. Polling answers
only 'complete by now', and the pump polls once per AU, so every sample
would quantise up by as much as a frame interval — 8.3 ms at 120 Hz
against decodes of 0.1-2 ms. Sampling faster needs a spin or a second
thread on a decoder that is deliberately not Sync. A blocking per-frame
wait is the field scar that once capped a stream at 51 fps. The honest
sampled stat stands.
Also fixed, pre-existing: the re-anchor gate re-armed on every damaged
AU, so sustained damage permanently zeroed the mark count — meaning the
wire's two-mark rule could never complete on exactly the lossy links it
was written for.
Field note recorded while wiring this: intra_refresh_recovery is set by
exactly one encoder backend (Linux libav-NVENC under
PUNKTFUNK_INTRA_REFRESH). AMF and QSV run a wave with no wire mark, and
AMF emits no recovery-point SEI either, so AMD/Windows intra-refresh
sessions still have no clean recovery point by either route.
Gates: fmt clean; container clippy -D warnings zero across
pf-client-core + pf-presenter + pf-vkdecode + punktfunk-core; tests
69/131/129/354/41 plus 5 fault-detection green; cargo check --workspace
clean.
The last piece of M3 WP-2 — VkH265Decoder was built and hardware-gated
but nothing drove it. video_vk_native.rs holds a two-arm codec enum and
forwards to it; the ledger, release tokens, status-query settling and
timeline waits are byte-for-byte what they were, since they were always
codec-agnostic over one DecodedVkFrame contract. The forwarders are
written out per arm rather than macro'd so the unchanged H.264 arm is
visible to a reviewer.
The picture's own format now reaches the presenter, which picks bit
depth and MSB packing from it instead of assuming the H.264 envelope.
That incidentally fixes a live bug on the SHIPPING FFmpeg-Vulkan path:
it derived ten-bit-ness by comparing against the 10-bit 4:2:0 format
alone, so a 10-bit two-plane 4:4:4 surface — which its own format table
accepts, and which NVIDIA reports for HEVC RExt — got 8-bit range and
transfer maths. Reachable today with Full chroma plus 10-bit: decoded
correctly, displayed wrong.
Review round 11 caught a regression this WP would otherwise have
shipped. pf-vkdecode refuses a stream whose (chroma, depth) pair has no
picture format on the device, but the session is built lazily from the
first SPS, so the refusal arrived AFTER construction — past the point
where a native init failure falls through to FFmpeg-Vulkan. It burned
the error streak instead and demoted to VAAPI/D3D11VA, which on
NVIDIA/Linux means software. Turning on Full chroma on any non-NVIDIA
GPU was enough: a 4K HEVC session that ran on FFmpeg-Vulkan before this
branch would have landed on software decode.
Both halves are fixed. The negotiated chroma and bit depth — already at
the call site, the PyroWave arm four lines up uses them — are threaded
into the backend, which probes the same caps path ensure_state would
run, so the whole class refuses at CONSTRUCTION where the fall-through
already exists. For the legs no negotiation can carry (a level above
maxLevelIdc, an SPS that disagrees with the Welcome) the decoder latches
'never delivered a frame' and routes that first streak to FFmpeg-Vulkan
rather than down the hardware ladder. H.264 is deliberately not probed:
its envelope is fixed, so a probe would only add a profile guess on the
bit-exact path; it gets the latch as its backstop.
Two more from the round. Planner warnings are typed again rather than
Debug strings — pf-vkdecode simply lacked the h265 re-export its h264
twin already had — which restores the H.264 log rendering exactly and
unblocks M4, whose job is counting concealment by kind. And concealment
is now the integrity set only: NonZeroReorder is documented spec-legal
and fully planned, but the client treated every warning as damage, so
the opening IDR and every ABR renegotiation's IDR were released unshown
and re-anchored — a visible hitch on a healthy stream.
Also: a raw-format newtype so a neighbouring i32 field cannot be passed
to the colour maths, the presenter's depth table now pinned against
pf-vkdecode's actual output vocabulary rather than the FFmpeg lane's,
a per-format warn latch, and four stale docs.
Gates: fmt clean; container clippy -D warnings zero across
pf-client-core + pf-presenter + pf-vkdecode; tests 69/125/108/40 green;
cargo check --workspace clean.
M3 WP-2 complete. caps_h265.rs builds the profile the stream actually
needs (profile idc + chroma + bit depths, all three stated on every
Vulkan object) and resolves its picture format — Main to NV12, Main 10
to P010, RExt 4:4:4 to the two-plane 4:4:4 formats — validating it
against the format list of every role the chosen arrangement creates
images in. A Main 10 stream on an 8-bit-only device is refused BEFORE a
session exists, never narrowed: decoding 10-bit into an 8-bit surface is
the silent-wrongness class this crate exists to refuse. session_h265.rs
adds the three-array parameters ledger; decoder_h265.rs adds
VkH265Decoder, mirroring VkH264Decoder method-for-method so the client
wiring is a two-arm dispatch away.
H.264 and H.265 now SHARE the machinery instead of duplicating it:
derive_arrangement (one coincide/distinct/layered decision table),
ring::rebased_offsets (the slices-only rebase — non-VCL NALUs in the
decode range hang VCN firmware), session::bind_session_memory, and a
parameterised build_frame. A DecodeProfile enum replaces the bare
profile idc that images.rs and ring.rs used to take: both codecs' idc
types are c_uint, so handing an H.265 idc to the H.264 path COMPILED
SILENTLY and built a mismatched profile chain. That is now
unrepresentable.
The VPS leg is the ledger's real work. The vendored parser attaches a
VPS to an SPS only when it saw the NALU, and clients join live streams,
so VpsSource is Parsed-or-FromSps and is stored BY VALUE: re-activating
a VPS-less SPS is Current (no churn), but the real VPS arriving under
the same id is a content change and RECREATES onto it, because Vulkan
cannot replace a stored parameter set.
Review round 10 (adversarial) confirmed the hardware-proven H.264 path
is NOT regressed — derive_arrangement's check order and error identity
are byte-for-byte the original, build_frame's call sites still pass the
granularity-aligned extent (the 1088-row scar stays shut), and
rebased_offsets reproduces the deleted inline loop for every input while
moving the sum to u64 so overflow errors instead of wrapping. Also
verified: the refs-order contract on every path, the RESULT_STATUS caps
gate (each of reset/begin/end individually gated, no pool created when
unsupported — recording one on RADV hangs its VCN), pNext lifetimes, and
that no panic is reachable on stream input.
Its 10 findings are fixed. The two that mattered:
- A failed decode stranded a DPB slot. Once plan_to_vk_h265 had mutated
the slot map, five later failure paths returned without restoring it,
so planner and slot map both believed a picture was resident while no
image held it — and every later AU referencing it failed, where H.264
soft-degrades and keeps delivering. Fail-closed is kept (substituting
a reference silently is the corruption-hiding this program exists to
end) but made RECOVERABLE: a latch flushes the planner to AwaitingIdr
and resets the bindings on the next decode, which composes with the
client already requesting a keyframe on every decode error. The fix
deliberately covers pre-mutation failures too — those strand the
picture the other way round and wedge identically.
- DecodedVkFrame carried no picture format, so a Main 10 frame would
decode correctly and be rendered with 8-bit transfer/range math. It
now carries one, stamped from the pool so it is truthful for both
decoders by construction. The presenter comment says depth 8 is
because only H.264 is WIRED, not a decoder limit.
Plus: bind_session_memory freed allocations before the session that may
hold them was destroyed (an ordering regression from the extraction,
with a SAFETY comment asserting the opposite) — the bind-stage exit now
hands them back so Drop destroys first; max_level_idc is codec-tagged
rather than an H.264 type carrying H.265 code points; and the decode
family's videoCodecOperations is now checked, turning 'create an H.265
session on a device without the extension' from UB into a clean ladder
demote.
Deferred by design: no HEVC gpu_smoke/gpu_parity yet (its goldens are
already in tests/data/test-25fps-h265.nv12.sha256), and no codec
dispatch in the client — both later legs.
Gates: fmt clean; mac clippy zero warnings, pf-vkdecode 106 +
pf-bitstream 69 green; container clippy -D warnings zero for
pf-client-core + pf-presenter + pf-vkdecode, tests 69/121/106 green.
HARDWARE (.173, after the refactor — review saying the proven path is
safe is not the GPU saying it): gpu_parity '250 frames bit-identical to
libavcodec software decode' on BOTH the NVIDIA 4090 (610.88, coincide
mode) and the AMD iGPU (Adrenalin 25.10.30.02, distinct mode), gpu_smoke
green on both. Two independent drivers, both DPB modes, still bit-exact.
The smoke trace also shows the new videoCodecOperations capture reading
DECODE_H264 | DECODE_H265 | DECODE_AV1 off the real decode family.
M0's capture hook has been in since 119ec0dd with nothing consuming its
output. corpus_replay.rs is that consumer: point PF_CORPUS at an
au-<stamp>.<codec> capture and every AU walks back through the H.264 or
H.265 planner, asserting no errors and no warnings — a clean capture of
a healthy session must plan whole. Ignored by default (captures are
hundreds of MB and live outside the repo).
It earns its keep immediately. Captured on .173 against the live host
(NVENC, 2800x1260, ~30 s each, client-side codec pin only — no host
config touched):
h265 1133/1133 AUs planned, 0 errors, 0 warnings
h264 1514/1514 AUs planned, 0 errors, 0 warnings
The HEVC number is the point: it is the FIRST validation of the WP-1
h265 planner against real host output rather than the vendored
conformance vectors, and it lands before the client's HEVC rung exists
to produce on-glass evidence.
Two real-capture facts the harness had to learn, both from this run:
ending a capture means killing the client, so the final .idx line is
routinely half-written and the final AU's bytes may not all have landed.
Both are tolerated at the TAIL only — a malformed line anywhere else, or
a gap the data cannot cover mid-file, still fails loudly rather than
silently replaying a subset.
Also adds tests/data/test-25fps-h265.nv12.sha256: 250 per-frame NV12
hashes of the vendored HEVC vector from libavcodec's software decoder,
cross-checked frame-for-frame between two independent FFmpeg builds
(8.0.1 in pf-lxcheck2, 8.1.1 from Homebrew) — the sibling of the H.264
goldens, ready for WP-2's parity leg.
Gates: fmt clean; pf-bitstream clippy clean, 69 tests green (the replay
stays ignored in normal runs).
The flatpak has not built since 35ba64ca. Every push to main fails at "Build the
flatpak", before a single build command runs:
cp: cannot overwrite non-directory
'.../build/gamescope-wsi-layer-1/subprojects/vkroots/.git'
with directory '.../git/https_github.com_Joshua-Ashton_vkroots.git'
Error: module gamescope-wsi-layer: Child process exited with code 1
vkroots was declared twice. flatpak-builder clones git sources WITH SUBMODULES by
default, and `subprojects/vkroots` is a real gamescope submodule — `git ls-tree
8c676c39 subprojects/` shows it as mode 160000 at 5106d8a0, which is byte-for-byte
the commit the explicit source pinned. So the submodule checkout already produced
the right tree and left `subprojects/vkroots/.git` as a gitlink FILE; the second,
redundant source then tried to copy the bare mirror onto that path as a DIRECTORY,
and cp refused. Source extraction died there — `buildsystem: simple` and the
hand-applied glm/stb patch_directory copies were never reached, so neither is at
fault.
Removing the redundant source is therefore a no-op on the resulting tree: the
submodule supplies that exact rev. glm and stb are NOT submodules — `subprojects/
glm.wrap` and `stb.wrap` are plain blobs at that rev — so nothing else populates
them and their explicit sources have to stay. That asymmetry is the whole trap,
and it is now written down in the manifest next to the sources, along with the
disable-submodules escape hatch for anyone who later needs to pin a subproject
away from the gamescope rev.
Why this reached main: flatpak.yml has no `pull_request:` trigger — only `push` on
main with path filters, `tags: ['v*']`, and workflow_dispatch. PR #64's checks were
green because the flatpak was never built on the PR; run 15775 was the first time
this module had ever been built in CI. Adding a PR trigger (or a manifest lint) is
the durable follow-up, deliberately not bundled here.
This blocks the release, not just main. flatpak.yml runs on `tags: ['v*']`, and the
failing step gates the bundle export, the generic-registry publish, the OSTree push
to flatpak.unom.io and the release-asset attach — all of which stay skipped. A
v0.25.0 tag cut today would ship with NO Linux/Steam Deck flatpak at all, on the
release whose headline Linux change is Deck HDR working out of the box.
NOT VALIDATED LOCALLY: this cannot be built on macOS. The reasoning is confirmed
against the upstream tree (the ls-tree above) but the green run is still owed —
dispatch flatpak.yml on this branch before merging.
main moved from 8983ec04 to 35ba64ca while this branch sat open, taking the
release from 98 commits to 135. Merged in and folded the new work into the notes.
The largest addition is a new `## Before you update` section, because this batch
carries changes that need the reader to DO something and they were not going to
survive being buried in a Fixed bullet:
* Linux users of the virtual Steam Deck pad must `usermod -aG punktfunk` and
log back in, or it stops attaching — the capability moved off the `input`
group (which every gamepad guide tells you to join) onto its own, because it
can emulate arbitrary USB hardware.
* Plugin UIs moved to their own origin on PORT+1, so a self-signed console
needs the new port trusted once, and custom firewalls/proxies need it opened.
* Saving a custom launch command re-confirms the console password, and add-ons
may no longer set launch/pre-launch commands at all — a real break for any
third-party add-on that populated them.
* A fresh install now runs the plugin runner by default (upgrades untouched).
* The Deck setup script used to leave the generated console password
world-readable, so rotating it is worth a sentence.
The library-sources work is written as GROUNDWORK, deliberately. All six built-in
scanners still ship, still on by default, and nothing is removed — and none of
the replacement add-ons are published yet, so the migration banner only appears
as they arrive. Promising a user they can move Steam to an add-on today would be
the v0.22.3 mistake again: notes describing a build nobody is getting.
Two other honesty items. The Android HUD entry says outright that the stream did
not get faster and the headline number only got smaller because it stopped
counting the compositor's wait — otherwise every reader takes it for a speed-up.
The Windows non-C: settings entry says plainly that nothing is recoverable,
because the writes never reached disk, so there is no orphaned copy to restore
and the reader has to re-enter their preferences once.
`56adb470` (pad-audio WASAPI module path) is deliberately NOT a user-facing Fixed
entry: verified it is not an ancestor of v0.24.0, so it repairs a Windows build
break in code that has never shipped. It folds into the pad-audio feature. Same
for `19f637ea`, which is CI-only.
Under the hood gained the origin-isolation mechanism, the allowlist authorization
gate that fails the build on an unclassified route, store claims and the v2
library.json shape, the registry auth work, the config-writer fallback, send
pacing, and the vendored Deck WSI layer. The unverified list grew too: the origin
split has not been in a real browser, the packaging default-on changes have had
no installer run, and no launcher tile has ever been clicked.
Re-verified after the merge, all green: lock diff versions-only 32/32 against
origin/main, `cargo metadata --locked` resolves (35 members), `cargo fmt
--all --check` clean in both workspaces, doc lazy-continuation scanner 0 hits
over 521 files, notes body 0 internal-vocabulary hits above `## Under the hood`,
Play notes still 494/500 by android.yml's own gate logic. Wire 2, C ABI 16, and
the capability bytes are all unchanged from the bump commit — host_caps still has
exactly one free bit (0x80).
Play's "What's new" is left as it stands: at 494/500 there is no room, and the
only Android-facing additions here (the stats-overlay measurement change and a
certificate-strictness fix) are both worth less to a phone user than any line
already in it.
PROVENANCE deviations #6 and #7 carried 'report upstream' — done:
- chromeos/cros-codecs#99: h264 PictureData display_resolution
double-counts the left/top crop and underflow-panics on
parser-valid crop offsets (answers their open #81).
- chromeos/cros-codecs#100: h265 parse_slice_header index-OOB panic
when num_long_term_sps + num_long_term_pics > 16 — a hostile-input
panic on the LTR path (an instance of their #78).
Both reports offer the downstream patch for the AOSP tree.
M3 WP-2, first half. params_h265.rs: VPS/SPS/PPS -> StdVideoH265* with
owned pointer-backing (the params.rs contract), scaling lists incl. the
32x32 two-matrix quirk and +8 DC convention, short-term RPS re-encoded
from the parser's RESOLVED DeltaPoc arrays back into delta_poc_sX_minus1
syntax under monotonicity checks, fallback_vps_from_sps for streams
whose VPS NALU was lost. pic_h265.rs: plan_to_vk_h265 — h265 AuPlan ->
StdVideoDecodeH265PictureInfo + per-reference infos; the binding set is
the union of the three current RPS sets with the Std index arrays
indexing into refs (0xFF unused; the GPU half must lay pReferenceSlots
out in refs order); NumDeltaPocsOfRefRpsIdx from the predicted-from
candidate; transactional SlotMap lifecycle identical to pic.rs. SlotMap
reused unmodified — HEVC's ceiling equals H.264's 16+1.
Envelope fails closed: Main/Main10/MainStill/RExt only, 4:2:0-8/10 +
4:4:4 only (separate_colour_plane_flag rejected — ChromaArrayType 0 in
disguise), SCC palette predictors out, >64 ST RPS sets / >16 per side /
>32 LT SPS candidates out, checked narrowing on every narrower Std
field. No panics on untrusted input.
Review round 9 (adversarial): RPS re-encode math, Std field-by-field
conformance, transactionality and slot ceiling verified clean; 6
findings fixed pre-commit. Headline (BLOCKING): long_term_ref_pics_
present_flag=1 with num=0 left pLongTermRefPicsSps NULL — the header
demands a valid pointer whenever the flag is set, and flag=1/num=0 is
exactly the punktfunk LTR/RFI recovery stream shape; the all-zero
backing now rides whenever the flag is set. Also: the slice_offsets doc
in BOTH pic modules claimed submit-as-planned while decoder.rs packs
slices-only and rebases (non-VCL NALUs in the decode range hang VCN
firmware) — reworded so the HEVC GPU half cannot implement the hang; a
concealment-produced ST/LT duplicate now ORs the long-term flag across
occurrences; NumDeltaPocs clamps became a typed error; dead
UnmappableLevelIdc variant dropped.
Deferred to the GPU half: HEVC caps/profile chain, session parameters
(VPS leg in the ledger), P010/4:4:4 pool selection, recording, and the
pReferenceSlots-in-refs-order contract consumption.
Gates: fmt clean; mac pf-vkdecode 80 + pf-bitstream 69 green, clippy
clean; container clippy -D warnings zero (pf-client-core, pf-presenter,
pf-vkdecode) + tests green (69/121/80).
HDR on a Deck needed a manual second step nobody took:
flatpak install --user flathub org.freedesktop.Platform.VulkanLayer.gamescope//25.08
documented only in a comment in this file. Build the layer ourselves
instead, so a plain `flatpak install` is all it takes.
The layer is genuinely required, not legacy. Measured on SteamOS 3.8.16
(gamescope 3.16.23.4): the gamescope-0 socket advertises
gamescope_swapchain_factory_v2 but NOT wp_color_manager_v1, with HDR both
off and on — so Mesa's Wayland WSI has no colour-management protocol to
negotiate HDR10 through, and this layer is the only thing that can append
the ST.2084 surface formats. Removing the extension gives zero
[Gamescope WSI] lines and hdr10_format=None.
Vendored rather than declared via add-extensions autodownload: the
extension is 94 MB of whole-gamescope for one 4 MB .so, its layer JSON
hardcodes a /usr library_path that an app-scoped extension mounted under
/app would not satisfy, and it would make flathub a hard install-time
dependency of an app we self-host on flatpak.unom.io.
enable_gamescope=false skips subdir('src') and every compositor
dependency, so only protocol/ and layer/ build. buildsystem is simple
rather than meson because glm and stb ship no meson.build of their own -
the wraps' patch_directory supplies it, and without that copy configure
dies with "Subproject exists but has no meson.build file".
meson generates the layer JSON from prefix+libdir, so it self-writes
library_path=/app/lib/... into /app/share/vulkan/implicit_layer.d, which
XDG_DATA_DIRS already covers. VK_ADD_IMPLICIT_LAYER_PATH is therefore
dropped - keeping it would also risk double-loading two same-named layers
for anyone who still has the flathub extension installed.
Pinned to the same gamescope rev as packaging/gamescope/PKGBUILD so the
client's layer and the host's punktfunk-gamescope come from one tree.
Verified on a Deck OLED: builds offline (--wrap-mode=nodownload) in
org.gnome.Sdk//50, and the resulting .so drives the Deck's system
gamescope to "hdr formats exposed to client: true" with
hdr10_format=Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT).
Still user-side, and not fixable in packaging: gamescope's hdr_enabled
convar (Steam's HDR display setting) must be on.
Closes M2. The 2026-08-05 ladder decision: WP-D shut with bit-exact
parity vs libavcodec (250/250 AUs on RADV, AMD-proprietary and NVIDIA)
and a 92-minute clean soak, and the program's goal is dropping FFmpeg
from the client — so on H.264 sessions where caps pass, auto now tries
pf-vkdecode FIRST, exactly where the ladder would reach FFmpeg-Vulkan.
No bake period.
native_vulkan_gate widens from by-name-only to the auto family
(auto/""/hardware); the vendor-first rungs are untouched (Linux
Intel/unknown still VAAPI-first, Windows Intel/unknown still
D3D11VA-first — NVIDIA and ALL AMD go native first). A native INIT
failure or caps refusal logs and falls through to FFmpeg-Vulkan, so
admission can't cost a session its decoder at start; runtime error
streaks ride the existing demotion machinery unchanged (past
FFmpeg-Vulkan to VAAPI/D3D11VA/software — a native→FFmpeg-Vulkan
runtime rung is deliberately absent, FFmpeg is on its way out).
PUNKTFUNK_DECODER=native-vulkan stays as the explicit pin; vulkan
keeps naming the FFmpeg backend specifically. A native_tried guard
keeps a failed pin init from re-attempting construction in auto.
Review round 8 (adversarial): no blocking code defect — no demote
bounce-back (Decoder::new is session-start-only; demotion mutates in
place), no double attempt, no cfg imbalance. 5 findings fixed: two doc
overclaims ("nothing regresses" now scoped to init; the ladder
enumerations no longer claim desktop-AMD Linux is VAAPI-first —
prefer_vulkan_first is vendor-wide), stale opt-in claims in Cargo.toml,
stale user-facing ladder text (console-ui row, trust.rs decoder field,
session README incl. the env-knob list), and the gate test now pins the
H264 codec-op bit to the literal 0x1 so a typo'd constant can't make
native silently never engage.
Gates: fmt clean; container clippy -D warnings zero for pf-client-core +
pf-presenter + pf-vkdecode; container tests green (pf-client-core lib +
pf-vkdecode + pf-bitstream); pf-console-ui check clean; mac
pf-vkdecode/pf-bitstream/cros-codecs 167 tests green.
On-glass sanity CLOSED 2026-08-05 ~22:10 UTC on .173 (4090, coincide
mode), decoder=auto and NO env var: the ladder picked native on its
own ("pf-vkdecode auto rung" log line), 525/526 stats windows on
native-vulkan over ~8m46s / 31550 frames, fps 0/59.4/61 with 6
windows <55 incl. startup zeros, bad-signature grep over the whole
log EMPTY, zero TDR events, host service Running after teardown.
The gamescope WSI layer decides whether to engage from one signal:
isRunningUnderGamescope() reads $GAMESCOPE_WAYLAND_DISPLAY and nothing
else. flatpak does not forward host env into the sandbox, so it arrived
unset and the layer's CreateInstance early-returned before creating a
GamescopeInstance — no gamescope surface, so the HDR10/ST.2084 formats
were never appended and the surface stayed SDR.
The layer still loads and still logs its generic bits in that state, so
it reads as working. It is not: the three settings already here (layer
search path, ENABLE_GAMESCOPE_WSI, the socket bind) all sit downstream
of this gate and buy nothing without it.
Measured on a Deck OLED (Galileo, SteamOS 3.8.16), client --browse,
reading "swapchain config":
unset -> no [Gamescope WSI] Surface state block, None
set, hdr_enabled=0 -> server hdr output enabled: false, None
set, hdr_enabled=1 -> hdr formats exposed to client: true,
Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT)
Matches the field report of "HDR->SDR" in the stats overlay on a
correct HDR host. DXVK_HDR was ruled out by measurement. The remaining
gate (gamescope's hdr_enabled convar = Steam's HDR display setting) is
a user-side step, not a packaging one.
The panel captioned most rows with an IP address. The saved records were
the source: `hosts add` falls back to the address when the pairing path
knew nothing better, so `name` is literally "192.168.1.21" — and
`mergeHosts` took `s.name || s.addr` unconditionally. The fallback only
ever fired for an EMPTY name, so a name that was already a copy of the
address sailed through as if it were meaningful, and the row printed the
address twice: once as its title, once as its subtitle.
The friendly name was in hand the whole time. The row is built by joining
the saved record to the live advert, and that advert carries the host's
actual hostname — the join was already trusted for address, port, online
and OS, and only the name was read from the saved side alone.
So treat a name equal to the record's own address as the placeholder it is
and yield to the advert. A real saved name still wins, even when stale: it
may be one the user chose, and an advert must never silently overwrite it.
The comparison is against the SAVED address, so a host that moved DHCP
lease still recognises its old address as a placeholder rather than
mistaking it for a chosen name.
Checked against the Deck that reported this, over its actual store and
browse: three online rows turn into home-worker-5, ENRICOS-DESKTOP and
steamdeck, the four offline ones keep their address (nothing is
advertising a better name for them yet), and a user-chosen name survives a
conflicting advert.
`shortcutStillExists()` extracted the store method before calling it:
const get = appStore?.GetAppOverviewByAppID;
return get(appId) != null;
`GetAppOverviewByAppID` reads the store's own state (`this.m_mapApps`), so
the unbound call throws on the lost `this` — and the function's own
`catch { return true }` swallowed it. The guard therefore returned "still
exists" for EVERY appId. Not a stale-data bug: it never once answered no.
Everything downstream of it was consequently inert. A dangling appId — the
documented hazard this guard exists to catch, since the id outlives the
shortcut in Steam's CEF localStorage across a plugin reinstall — was never
dropped, so `ensureGamepadUiShortcut` always took the reuse branch and
`SetShortcut*`'d a dead id (silent no-ops). The visible library entry never
came back, `recreateShortcuts` reported success having done nothing (its
toast only checks for a non-null appId, and the dead one is non-null), and
"Open Punktfunk" ran `RunGame` on the dead id — Steam answers that with
"Game configuration unavailable".
Call it as a method so `this` survives, and guard the global with `typeof`
first: `appStore` is Steam-injected, and a bare reference to a missing one
is a ReferenceError that optional chaining does not prevent — which would
have landed in the same catch.
Verified against the live Deck that hit this: evaluated both versions over
its actual appIds, and where the old guard says alive/alive, the fixed one
says alive for the live stream shortcut and dead for the dangling UI id —
so the stale key now drops and the entry is recreated on the next mount.
H265Planner mirrors the H.264 layer's contract exactly: plan_au -> AuPlan
{ picture, slices with ref lists by stable PicId, DpbUpdate, warnings },
same concealment posture (warnings never abort, in-place reference
substitution preserving ref_idx positions, outputs survive failed AUs,
flush gates on AwaitingIdr, any IRAP resumes). Ported logic: RPS 8.3.2
(short-term AND long-term incl. PocLsbLt/MSB-cycle - the hosts' RFI
recovery rides long-term refs), ref lists 8.3.3/8.3.4, DPB C.5.2.2/C.5.2.3
via the vendored dpb; POC 8.3.1 from the vendored PictureData. Written
fresh: the plan surface, AU walk, envelope gates (multilayer, interlaced,
SCC self-reference, DPB>16, conf-window overflow - checked at EVERY
activation, not just parse), HEVC recovery-point SEI (prefix NALU 39,
se(v) recovery_poc_cnt), VUI colour with E.3.1 inference, and a test-only
HEVC bitstream synthesizer (upstream has none).
Upstream deviations worth naming (all in-code with spec anchors): the
empty-RPS inter slice cannot infinite-loop (upstream bug); RASL behind a
joined CRA refuses BEFORE any state change (PlanError::RaslSkipped - the
WP-2 wiring must map it to skip, not reanchor; module docs carry the
contract note); MaxPicOrderCntLsb reads from the ACTIVATING SPS (upstream
latches at parse - a latent multi-SPS bug); C.5.2.2's exemption is
picture 0 of the BITSTREAM (EobNut), never first-after-EOS.
Vendored parser gained PROVENANCE deviation 7 (report upstream): hostile
slice headers with num_long_term_sps+num_long_term_pics > 16 indexed out
of bounds of SliceHeader's [_;16] arrays - a production panic on exactly
the long-term-reference path, now a parse error.
Port review round 7: 10 findings (3 blocking: the vendor panic, an
EOS-boundary output interleave, an envelope bypass through PPS-only SPS
rebind reaching wrapping crop arithmetic) - 9 fixed with a regression
test each, 1 documented as the WP-2 contract note. Known follow-up: the
h264 AU-tail truncation detector shares h265's dead-arm shape (its arm
also cuts reserved NALU types, so the fix is not identical - deferred).
Tests: 29 h265 planner + 2 HEVC SEI + full test-25fps.h265/bear/bbb clip
walks with real invariants (every stored id output exactly once,
ascending POC per IRAP period). Gates: fmt clean; clippy -D warnings zero
(mac + pf-lxcheck2 incl. pf-client-core/pf-presenter); tests 45+69 mac,
69+121+53 container.
The label Decky shows for an installed plugin is plugin.json "name", which
we had set to the lowercase directory name — so the one place every user
sees the plugin listed was the one place it was off-brand, while the panel
header (titleView) already read "Punktfunk".
The two were conflated because the name looked load-bearing: the zip's
top-level dir becomes ~/homebrew/plugins/<dir>, and the scripts derived
that dir FROM plugin.json "name". They are in fact independent — Decky
extracts the zip as-is and locates an installed plugin by MATCHING
plugin.json "name", never by folder name (that is how a plugin can live in
DeckWebBrowser/ and list itself as "Web Browser").
So brand-case the label and pin the on-disk dir to the literal `punktfunk`
in package.sh/deploy.sh/CI instead of deriving it. Pinning is the part that
matters: had the dir followed the label, this rename would have installed a
second `Punktfunk/` folder beside the existing `punktfunk/` and the plugin
would have shown up twice.
The self-update call passes the name Decky uninstalls before extracting, so
it moves to "Punktfunk" with it. The upgrade INTO this build still passes
"punktfunk" (the installed build's own value), which matches that build's
plugin.json — so the old folder is removed and the new zip lands in the
same lowercase dir either way. Decky's per-plugin settings dir is unused
(all state lives in ~/.config/punktfunk), so nothing is stranded.
Reported from the field (2026-08-05): a fresh Windows 11 box with a data
partition, "New apps will save to: D:", and the client installed there. It
launches, finds hosts and streams — but no setting and no profile survives a
restart. Reinstalling to C: fixes it completely. The reporter's read was "it's
in read-only mode", and that is almost exactly right.
The one clue that localises it: the client creates its mTLS identity with a
plain `fs::write` on first run and hard-exits if that fails. Their app started,
so ordinary file creation in the config directory works. Only the config stores
were being lost — and those are the three files that go through `write_atomic`,
which writes a sibling temp and renames it over the target.
The rename is what breaks. The client ships as a full-trust MSIX package, so
its `%APPDATA%` writes are redirected into the package container. When the
package lives on a secondary drive, Windows keeps that redirected state on the
package's own volume: `C:\Users\<u>\AppData\Local\Packages\<pfn>\` stays a real
directory on C:, but its children (LocalCache, RoamingState, …) are junctions to
`D:\WpSystem\<SID>\…`. Both sides of our rename still spell `C:\Users\…`, so
nothing looks unusual, but they can resolve across that junction boundary — and
`std::fs::rename` is `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` and *not*
`MOVEFILE_COPY_ALLOWED`, so a cross-volume move fails outright rather than
degrading to a copy. Creating files still works, which is why everything else
about the install looks healthy.
So the fix is not to make the rename work — it is to stop treating it as the
only way to persist. `write_atomic` now falls back to writing the target in
place when the atomic route fails. That is the same operation the identity files
already use, and those demonstrably round-trip on the affected installs, so the
fallback lands on a path we know resolves. It trades crash-atomicity for exactly
the writes that would otherwise be lost, and nowhere else: temp+rename stays the
normal route everywhere it works.
Writing into a redirected location cannot desync from reading it — Microsoft
documents one private-location-first resolution order for both, so whichever
layer a write lands in is the layer the next read finds. The fallback verifies
anyway, by reading the bytes straight back: a write that reports success and
disappears is precisely the bug being fixed, so this path does not get to claim
success on an `Ok(())` alone. It costs nothing normally — it only runs on an
install that has already shown it does something unusual.
Two things this uncovered on the way:
The temp file was a single shared `<name>.json.tmp`, but these stores have five
whole-file writers (WinUI shell, session, console UI, CLI, Decky). Two saving at
once collide on it — on Windows the second write hits a sharing violation, and
worse, one process can rename the other's half-written bytes over the target.
The scratch path now carries the pid.
And none of this was visible to anyone. Every save on this page is
fire-and-forget by design (a failed settings write must never take a stream
down), so ~15 call sites discard the error and the UI cheerfully shows the
toggle you just moved. The reporter had no log file to send either, because
"Open log folder" was handing out a phantom path — a separate bug, already fixed
in f3c0ee47 but not in the 0.24.0 they were running. `store_health` records the
last persistence failure centrally, and Settings shows an error bar naming the
path when the store is refusing writes, so a client that cannot save says so
instead of pretending.
`update.rs` had hand-rolled the same temp+rename inline, so it neither cleaned
up its temp on a failed rename nor picks up the fallback; it now goes through
the one writer. The update floor silently never rising is how a declined update
comes back forever.
Deliberately NOT done: disabling MSIX AppData virtualization in the manifest
(`desktop6:FileSystemWriteVirtualization`). It would stop the redirection at the
source, but every existing packaged install's settings, profiles and pairings
live inside the container today — turning it off points the client at an empty
real `%APPDATA%` and silently resets all of them. That needs a migration, not a
manifest flag.
Also considered and not taken: resolving the destination directory with
`GetFinalPathNameByHandleW` and creating the temp inside the resolved path, to
keep atomicity. It does not reliably close this hole — when the target file
exists only in the unvirtualized layer while its directory resolves to the
private one, the rename still straddles the boundary — and it would rest on
canonicalisation behaving through the redirection, which we have never verified
on a packaged run.
Verified on the RTX box (.173, Windows 11 26200), which is the platform that
actually has these rename semantics: `cargo fmt --all --check`, the full
`pf-client-core` lib suite (109 passed), and clippy `-D warnings --all-targets`
on both `pf-client-core` and `punktfunk-client-windows` — all clean. Also green
under linux/amd64 (116 passed). Three new tests: the pid-scoped scratch path,
the fallback actually persisting and reading back when the atomic route is
blocked, and a genuinely unwritable store surfacing its error instead of
swallowing it.
The mechanism above is established from documentation and third-party reports,
not from a reproduction on a second-drive install — that box does not exist
here. The fix does not depend on the diagnosis being exactly right: it repairs
any install where the rename fails but a direct write succeeds.
The Android HUD headlined `capture→displayed` with SurfaceFlinger's latch
and scanout inside it — pipeline depth no client can pace under. The usual
Android streaming overlays stop measuring at decode-complete, so users
comparing overlays read our honesty as latency: on a 60 Hz panel that floor
alone clears 30 ms, more than everything those overlays display put together.
Exclude it, the way the Apple clients have since the presentation rebuild
(8a40e467): shave the measured floor off the shown display and end-to-end at
every tier, and name what came off in Detailed as `os present +N excluded
(display pipeline minimum)`. The equation still tiles the headline, because
the `display` term is shaved by the same amount.
The floor is the `latch` p50 we already measure (release→OnFrameRendered),
not a modelled 2/refresh: it moves with the panel rate, tunnelled playback
and the vendor's low-latency mode, and it exists on every render path (the
release stamp is parked on all three), so it does not depend on the timeline
presenter being active. Unmeasured reads 0.0 and nothing is shaved — we
exclude only what we actually measured. With the floor out, the `display`
term is already just `pace`, so the `(pace + latch)` split now renders only
on a window where no latch sample paired, and the hardcoded 2-refresh
Apple-equivalence twin is gone with it.
Raw numbers are untouched in the 1 Hz `pf.present` logcat line, so HUD-off
A/Bs and cross-session comparisons still read unshaved values.
The round-4 residuals, closed after the WP-D hardware verdict:
- VUI colour plumbing (the one silent-wrong): the picture's ACTIVE SPS's
colour signalling (H.273 code points + range, with E.2.1's 'unspecified'
inference where the VUI is silent — the vendored parser's defaults ARE the
inferred values, verified) rides PicturePlan -> DecodedVkFrame ->
NativeVkFrame per frame, never latched: the Windows host switches an HDR
desktop to PQ/BT.2020 IN-BAND while the Welcome still says SDR. Before
this, the native path would have painted PQ washed out, silently.
- Native decode-latency stat: the deliberately-deferred NativeVk arm of the
pump's sampled once-per-stats-window decode measurement now feeds - the
frame's (semaphore, semaphore_value) is the decode-done signal, resolved
through the shipped ledger before a bounded, pure-measurement
vkWaitSemaphores (VkH264Decoder::wait_decoded).
- The renegotiation-teardown window is settled as NO HOLE: rebuild_state now
documents the full safety argument (graveyarded pools stay intact under
presenter holds, tokens route strictly by generation, session objects die
only post-drain with the generation gate INSIDE read_status), and the two
backend comments that wrongly claimed stale pools were 'gone' are fixed.
- VK_KHR_unified_image_layouts stays deferred (fleet drivers lack it).
Adversarial review round 6: 3 minor findings (2 doc fixes applied; the
SPS-replaced-without-PPS-resend divergence stays a documented envelope
assumption - hosts re-send both at every keyframe, and a hardening
PlanWarning could cost real frames on a false positive).
Gates: fmt clean; clippy -D warnings zero (mac + pf-lxcheck2 container,
incl. pf-client-core/pf-presenter); tests 45+30+53 mac, 30+121+53 container.
Design D4 promised entries that open the LAUNCHER — Steam Big Picture, Heroic,
Lutris — and the plumbing for it landed in M2/M4: the `role` field, the
`steam_ui` kind, the console's Launchers rail. But nothing could flow through it
for anything except Steam.
D4 said the other launchers would ride the `command` kind. The 2026-08-05 review
then made `launch.kind = "command"` operator-only (it is handed to a shell), so a
plugin publishing one is refused with a 403. The two changes are individually
right and jointly leave a hole: `steam_ui` was the only launcher kind a plugin
could publish, so a Heroic or Lutris tile was unreachable.
New `launcher_ui` kind, valued by store id. One kind rather than one per store
because every launcher except Steam has exactly a single UI to open; Steam keeps
its own kind because it genuinely has two. D1 is preserved — the plugin names a
launcher, the host builds the command, and no shell string crosses the wire:
heroic -> the same native-or-Flatpak resolution the `heroic` game kind uses,
minus --no-gui and minus the URI, so the window itself opens
lutris -> bare `lutris`, which opens the window (the URI form is `lutris_id`)
Platform-gated to what this host can actually resolve, and validated INBOUND: a
value naming a launcher this OS cannot open is a 400 the plugin author can act
on, not a tile that silently does nothing when a user clicks it. Windows
launchers (Epic, GOG Galaxy, Xbox app) are deliberately absent — each needs its
own verified activation and a guess would ship exactly that dead tile.
Also closes a WP4.3 item I under-delivered and did not flag: the console's
add/edit form had no way to mark an entry as a launcher, so even hand-adding one
was impossible. It now has the checkbox — and `formFrom` round-trips it, without
which editing a launcher entry would silently demote it to a game, which is the
precise bug that file's own comment warns about.
Gates on .21: punktfunk-host 435 passed / 0 failed (two new), workspace clippy
-D warnings clean, cargo fmt --all --check clean, OpenAPI drift green. Console:
orval + paraglide regen, tsc clean, check-i18n at 604 messages for en + de.
Still unproven on hardware: no launcher tile has been clicked on a real host.
The steam plugin (the first to emit one) is not built yet.
WP-D parity A/B. gpu_parity (ignored) decodes the conformance vector,
reads every frame back through the presenter's exact contract (wait,
layout round-trip, signal-back, release), crops at the copy so pitch
can never leak, and compares SHA-256s in display order against goldens
from ffmpeg software decode — cross-checked bit-identical between
ffmpeg 8.0.1 (linux) and 8.1.1 (macOS), so the reference is the spec,
not one build. PF_VKD_TEST_READBACK=1 is the one test-only hook (ORs
TRANSFER_SRC into pool usage; production pools stay zero-copy-tight).
Fleet verdict: 250/250 frames bit-identical to libavcodec on RADV
(Mesa 26.0.3, distinct), AMD proprietary Windows (25.10.30.02,
distinct) and NVIDIA Windows (610.88, coincide) — H.264 decode is
exactly specified, and the native path meets the spec on every driver
and both DPB arrangements.
Session-memory and image allocation now honor each binding's
memoryTypeBits with DEVICE_LOCAL preferred, not required: NVIDIA 610.88
legally places a video-session binding in host-visible-only memory and
the hard requirement refused the whole device. The bitstream ring keeps
its hard HOST_VISIBLE|COHERENT need. Smoke test gains
PF_VKD_SMOKE_VENDOR device pinning + attribution and a final-state
print (DPB mode now observed, not inferred).
On-glass matrix after this fix (.173, vendor-pinned): NVIDIA 4090
PASSES in COINCIDE mode — the first end-to-end run of the RESULT_STATUS
query path, ~44 per-frame driver verdicts on the recording pattern that
hangs RADV's VCN — and Adrenalin re-passes in distinct mode unchanged.
With RADV's distinct pass, both DPB arrangements and three of four
desktop drivers are now hardware-validated; Intel remains a clean caps
refusal (no SAMPLED on decode outputs — its rung stays D3D11VA).
Gates: fmt clean, clippy -D warnings zero, 45+27+53 green both
platforms.
PR #58 hardened the art proxy in the same three files this branch rewrote, and
the two changes pull in opposite directions: #58 narrowed what the host will read
from disk, while WP1.2 widened what counts as a local art path so an extracted
scanner's covers can be served at all. Resolved so the widening goes through the
gate rather than beside it.
Kept from #58, unchanged: art_path_is_confined (UNC refusal, canonicalize-or-
refuse, config-dir exclusion, roots check), the image-extension whitelist,
sniff_image_type, validate_art_paths as write-time validation, the AuthLane
privileged-field check on every entry in a reconcile payload, and the launch
redaction in GET /library.
Three reconciliations:
* `local_art_bytes` converts a `file://` value to a path BEFORE calling
art_path_is_servable, so the confinement check and the read see the same
path. Ordering is the point: percent-decoding happens before
canonicalization, so a `%2e%2e` escape cannot hide from the traversal check.
Pinned by a test.
* `art_roots()` gains $HOME on POSIX. This is the one that would have bitten
silently: the list was empty on non-Windows, which was correct while
is_local_art_path was Windows-shaped (Playnite is Windows-only, so nothing on
a POSIX host was ever classified as local art and the confinement had nothing
to confine). Once WP1.2 classifies POSIX paths as local, an empty root list
is not "secure by default" — it serves NO plugin art on Linux, which is every
cover the lutris and steam plugins emit. $HOME is the exact analogue of the
Windows users base #58 already ships, and covers Steam's librarycache and
grid overrides, Lutris's coverart/banners (both copies), Heroic's caches and
all the Flatpak variants. It is not the load-bearing control: a value still
needs an image extension, must canonicalize to a real regular file inside a
root and outside the config dir, and must CONTAIN image bytes.
* The two tests that both wanted to mutate PUNKTFUNK_LIBRARY_ART_ROOTS became
one. Cargo runs tests as parallel threads of a single process, so two tests
setting the same env var race. The `file://` and confinement assertions moved
into #58's existing confined test; what remains of the WP1.2 test is the
pure classification/rewrite half, which touches neither env nor filesystem.
Also: `steam_ui` was missing from the list of host-resolved launch kinds in
privileged_field's doc comment and in the 403 a plugin sees. Prose only — the
check is a denylist (prep, launch.kind = "command"), so steam_ui was never
actually refused — but a plugin author reading that error would have concluded
otherwise.
Gates on .21: punktfunk-host 433 passed / 0 failed (including #58's H-2 tests and
the new file:// ones), full workspace tests clean, workspace clippy -D warnings
clean, cargo fmt --all --check clean, OpenAPI drift test green.
Second half of security-review-2026-08-05 H-6. The infra half (unom/infra,
runners/ci-core/) split the LAN registry in two: :5010 serves GET/HEAD only and
refuses everything else with 405, :5011 demands basic auth on every request
including the /v2/ ping. Both fronts sit on one store, and a registry keys by
repository name rather than by the host:port the client used, so an image
pushed to :5011 is the identical image every consumer pulls from :5010.
So: builds tag the write port, a docker login precedes the push, and the
release-tag manifest PUTs authenticate. Consumers are untouched — every
`container:` in every other workflow still pulls anonymously from :5010, and
ci/rust-ci-arm64cross.Dockerfile's `FROM 192.168.1.58:5010/...` still resolves.
Not doing the digest pinning the review asked for, deliberately, and the header
says why at length. Once pushes are authenticated, the people who can overwrite
a tag are exactly the people who can push to main and edit a pinned digest in
this file — a pin defends against nobody it did not already trust, and costs a
two-commit dance on every ci/ change (~3x a month) during which consumers run a
builder image predating the change they are testing.
What does close the residual gap is making :latest a checked function of the
tree. reconcile-latest.sh asserts on every run that :latest and :ck-$KEY are the
same digest, re-points it when they are not, and warns loudly. An out-of-band
overwrite is caught on the next push to main with no churn, and it fixes a
pre-existing bug on the side: reverting ci/ used to leave :latest on the newer
build forever, because the older key is a cache hit and nothing re-pointed it.
Repair rather than fail, because a legitimate revert must not red-line main.
Verified against the live registry from a runner host with the real docker
client: unauthenticated push denied, push to :5010 refused 405, authenticated
push to :5011 accepted, that same image pulled back anonymously from :5010.
reconcile-latest.sh exercised over all three cases (diverged -> repaired,
already equal -> no-op, missing key -> exit 1). All seven builder images are
consistent with their content keys today, so the new step is a silent no-op on
its first real run.
`check_entry_fields` returned `Result<(), Response>`, which trips
`clippy::result_large_err` under CI's `-D warnings`: an axum `Response` is 128
bytes and it was riding in the `Err` variant.
`Option<Response>` is the shape this always wanted. There is no error value to
propagate here — the "error" IS the response the handler sends back — so `None`
means "the payload may proceed" and `Some(r)` is the refusal to return. The call
sites read the same, one word different.
Caught by CI, not by me: I ran `cargo check` and not `cargo clippy -D warnings`.
WP-D leg 1 (.25 RADV, distinct mode) root causes, both real:
1. Output starvation: the fixed 4-deep ring lost to a stream that keeps
max_dpb_frames+1 = 8 pictures pending. Zero-copy fix (user
requirement, no copies): one picture pool of required_slots +
HOLD_HEADROOM(8) images decoupled from DPB slots — a re-activated
slot binds a fresh free image, so a delivered picture is never a
decode target; the WP-B pin layer became dead and is deleted.
Per-image timeline semaphores carry the AVVkFrame contract: decode
signals value+1, the presenter waits and signals back, later decodes
wait the image's latest value — layout traffic ordered against
reference reads with no copy anywhere.
2. RESULT_STATUS queries HANG RADV's VCN firmware (ring timeout,
DEVICE_LOST): queryResultStatusSupport=false on the decode family.
Queries are now caps-gated; without them poll/wait degrade to
timeline-completion verdicts (FFmpeg parity — and the likely reason
upstream never wired nb_queries). The Ally-X-class detection runs
where drivers advertise the query; .173 probes NVIDIA/Windows-AMD.
Also: slice-only bitstream feeding (the field-proven consumer shape),
graveyarded pool retirement keyed by release tokens + generation,
decode-current-AU-before-status attribution, take_ready drained,
H264-bit gating, teardown short-circuit on disconnected channel.
On-glass: 48 AUs green on .25 holding 4 frames like the real client.
Gates: fmt clean, container clippy -D warnings zero, 27+121+52 green
both platforms.
Writing a real scanner against the kit before six repos get cut from it, rather
than after. It is the lutris pilot (M5/WP5.1) — the smallest of the six and the
one that exercises the POSIX local-art path end to end.
It earned its keep immediately: withReadOnlyDb / openReadOnly were never exported
from the parsers barrel, so the single most distinctive thing the lutris plugin
needs was unreachable from @punktfunk/plugin-kit/library. Nothing caught that,
because nothing had consumed the public surface yet.
It also caught a vacuous green in this package: tsconfig's include was
["src","test"], so anything under examples/ type-checked as a no-op. `examples`
is now in the check scope; tsconfig.build.json still narrows to src and
package.json still ships only dist + README, so nothing new is published (verified
against the built dist).
The example carries two deliberate departures from the Rust original, both
documented inline: art is emitted as file:// URLs instead of inlined data: URLs
(the host proxies the bytes, so the payload stays small — inlining covers is what
blew the 2 MB body limit at 49 titles during the playnite work, and is exactly
why the POSIX art path exists), and the untrusted-slug guard is carried over
verbatim, since the slug comes from Lutris's own database and is interpolated
into a path the host will later be asked to serve.
What it demonstrates, which is the reason one-repo-per-plugin is safe: everything
below `scan` is store-specific parsing, and everything else — store claim, sync
engine, launcher entries, __config, console registration, and the CLI verbs
including the parity gate — comes from defineLibraryPlugin.
plugin-kit: tsc clean (now including examples), 56 tests pass, build clean.
One plugin = one repo, matching the house pattern (playnite, rom-manager and
virtualhere are already each their own repo with their own biome/bunfig/tsconfig
/CI). The implementation plan's WP5.0 had proposed a single workspace repo for
all six library scanners; this is the piece that makes the split cost nothing.
Everything the six scanners share is already published rather than adjacent: the
parsers and defineLibraryPlugin live in @punktfunk/plugin-kit/library, so repo
boundaries are irrelevant to them. Fixtures are not shared in practice either —
the Rust scanners build theirs inline in code, there are no fixture files, and
the one genuinely cross-plugin builder (binary shortcuts.vdf) is already in this
package's own tests. A pga.db fixture is useless to the epic plugin.
The parity harness was the exception: generic across all six, and parked in the
shared repo the plan assumed. It moves here.
What it is: the acceptance gate for an extracted scanner. Ported unit tests pin
the PARSERS; they do not prove the plugin reproduces the scanner it replaces. A
plugin that parses perfectly and emits steam:440.0 instead of steam:440 breaks
every Moonlight pin on the host and no parser test notices.
punktfunk-plugin-steam parity --snapshot before.json # host on its built-in
punktfunk-plugin-steam parity --compare before.json # offline; exits non-zero
--compare runs the plugin's own scan rather than requiring it to be installed
first, so a mismatch is visible before anything is published and the run is
repeatable while you fix it.
Three judgement calls in the diff, each pinned by a test:
* art is compared by PRESENCE, not value. The representation legitimately
changes on extraction (a host-relative proxy path or inlined data: URL
becomes a file:// path or a CDN URL), so comparing values would fail every
run for no reason. Losing an art kind fails; gaining one does not.
* launcher entries (role: "launcher") are reported separately instead of as
unexpected extras — the built-in scanner had no concept of them, so they can
never be in a baseline. An ORDINARY title the scanner never had still fails,
which is what catches a bad tool filter.
* absent and empty are the same thing in metadata: the host omits empty lists
and nulls, so a plugin sending genres: [] has not changed anything.
plugin-kit: tsc clean, 56 tests pass (10 new).
M2 WP-C. video_vk_native.rs adapts the presenter's VulkanDecodeDevice to
pf-vkdecode (queue lock shared only when the families actually collide —
the one case the 2026-07-09 DEVICE_LOST race proved matters), and the
presenter consumes DecodedImage::NativeVk on its own device: no handle
import, no AVVkFrame co-authoring — wait the timeline, barrier to
sampled, existing crop-aware CSC, barrier back, release after the fence.
Frame lifetime is a token: presented, retired, displaced or dropped
mid-demotion, the guard's drop sends it exactly once; the backend
releases the decoder slot only after the status query resolves, so a
recycled slot can never report a false Failed. Driver-reported decode
failures and plan warnings ride the existing streak/reanchor machinery —
the Ally X corruption class is now a visible error, not a silent frame.
Opt-in only until WP-D's on-glass parity verdict; H.264 sessions only;
failures demote to the existing ladder. Known WP-D items recorded in
code: coincide-mode cross-queue reference overlap, renegotiation
teardown window, VUI colour plumbing.
Gates: fmt clean; container clippy -D warnings zero for pf-client-core +
pf-presenter + pf-vkdecode; 121+53+27 tests green.
Closes H-3 of the 2026-08-05 review, the last of its six highs. A plugin's
interface was reverse-proxied onto the console's own origin and framed with
`allow-same-origin`, so plugin JS ran as first-party code on that origin: one
`fetch('/api/**', {credentials:'same-origin'})` and the BFF attached the
operator's ADMIN bearer. That reached everything `plugin_may_access` withholds
— arm pairing, read the host PIN, approve a device, read `/hooks`. The "open
in new tab" link was the same escalation with no iframe involved at all.
The fix is not a sandbox attribute, and it is worth writing down why, because
the obvious change is the one that does not work. Dropping `allow-same-origin`
gives the frame an OPAQUE origin; its subresource requests are then cross-site;
the `SameSite=Lax` session cookie stops being sent; every plugin asset 302s to
/login and the frame is blank. Nothing about the new-tab link is helped either.
So the origin moves instead. A second listener on its own port (default
PORT + 1) serves plugin UIs and nothing else:
different ORIGIN — scheme+host+PORT — so the same-origin policy separates the
plugin from the console: it cannot read the console's DOM,
its cross-origin fetch of /api/** is unreadable (no CORS)
and cannot mutate (Sec-Fetch-Site sees same-site).
same SITE — cookie scope ignores the port and SameSite is computed on
the site, so the session cookie still reaches the plugin
listener and plugin pages keep working.
Enforcement is two refusals and both are load-bearing: the console origin
refuses /plugin-ui/**, and the plugin origin refuses everything ELSE — above
all /api/**, which would otherwise hand the admin bearer right back to plugin
JS that is now same-origin with that listener. Both are unconditional: if the
plugin port cannot be bound, plugin UIs are DISABLED and the console says so,
rather than falling back to the arrangement this exists to remove.
Two consequences that would otherwise bite in the field:
The port has to be open. Done for the Windows netsh rule, the firewalld
service and the ufw profile.
A browser stores a self-signed-certificate exception per ORIGIN, including
the port — and a certificate interstitial can never be shown inside an
iframe, so the frame would just sit blank with nothing on screen explaining
why. A `no-cors` probe distinguishes it (a TLS failure rejects; any HTTP
answer, even 401, resolves) and the console renders a card linking the
operator to open the port once in a real tab.
Also here: the health probe moved server-side to the console origin (it used
to rely on being same-origin with the plugin), the postMessage listener now
verifies `event.origin` — a real check rather than a tautology — and
plugin-kit's `postMessage(..., "*")` is documented as load-bearing, since
narrowing it to `location.origin` would now target the plugin's own origin and
silently drop every message.
Verified against a running console with a fake mgmt API and a fake plugin:
console /plugin-ui/** → 404; plugin-origin /api/v1/hooks, /, /login,
/_auth/logout → 404; plugin page loads 200 through its own origin;
unauthenticated plugin origin → 401 (not a redirect to a /login it does not
serve); a forged x-pf-listener header changes nothing on either listener; the
plugin's own Clear-Site-Data / Access-Control-Allow-Origin / Set-Cookie are
dropped by the proxy allowlist; the plugin origin's CSP names the console as
its only frame-ancestors source; and with the port squatted, ui-config reports
`unavailable`, the console still refuses /plugin-ui/**, and the console itself
keeps working.
Still wants on-glass confirmation in a real browser — the cookie and framing
behaviour is reasoned from spec, not observed.
cargo fmt --all --check clean; cargo check -p punktfunk-host --all-targets
green on Windows; web console builds and typechecks.
Acts on the 2026-08-05 host security review. 36 of its 38 findings; the two
exceptions are recorded below and in the review doc.
The review's headline is that `plugin_may_access` was the one authorization
gate in the system that was allow-by-default — a hand-maintained denylist of
route prefixes, where every sibling gate is deny-by-default. Its own doc
comment names the two capabilities it exists to withhold, and both were
reachable one route over, because ~1450 commits of new routes were added and
the list was never one of the things anyone remembered to update.
So the gate is now an allowlist, and a test walks the live route table and
fails the build for any route that has not been deliberately classified for
both non-admin lanes. That test is the actual fix: it is what stops the next
route from arriving pre-authorized.
Route reachability and field authority turned out to be different questions.
A provider plugin has to be able to reconcile its own library entries — that
is what a scanner plugin IS — but `prep` and a `command` launch inside that
payload are handed to `/bin/sh -c` as the host user, and every execution site
documents them as operator-typed. Requests now carry the lane that authorized
them, and those two fields are refused to everyone but the operator's own
token.
The art proxy read any absolute path off disk in the host process, which on
Windows is LocalSystem, from a path the plugin lane could write and then read
back — so it yielded `mgmt-token`, which is full admin. It now serves only
real images (extension AND magic bytes, so a renamed secret fails), only from
inside an allowed root, only after canonicalization, and never over UNC; and
a path it would refuse to serve can no longer be persisted in the first place.
On Windows, the config-dir hardening was skipped exactly when it was needed —
it ran only in the branch that CREATES host.env, so the case it was written
for (a local user pre-created the directory and planted one) was the one case
it never ran in. It is now unconditional and first, an existing host.env is
re-owned, and the inheritable OWNER RIGHTS ACE that kept an attacker's files
theirs after the directory was re-owned is gone. The identity and token
readers were hardening the directory only on the path that GENERATED a new
secret, so a planted cert/key or token was adopted verbatim and permanently;
they harden before the first read now.
`ensure_admin_only_source` is implemented. The 2026-07-05 audit recorded it as
FIXED and it was in no commit in this repository's history — the local EoP it
described was live, and it is the payload half of the config-dir chain above.
Also: the three input planes are bounded and lossy like the mic plane on the
same loop already was; Android's library client no longer accepts any
publicly-trusted certificate for the pinned host; the usbip vhci nodes get
their own group instead of riding on `input`, which every packaging scriptlet
tells users to join; a registry URL can no longer inject a TOML table into
bunfig.toml; the pairing cooldown is charged before the arming state is read,
so armed/disarmed is no longer a free oracle; and the whole Low tier, of which
the two worth naming are a clipboard MIME NUL that panicked the host on one
control message, and an unauthenticated global logout that let any LAN peer
sign the operator out on a loop.
NOT fixed, deliberately:
H-3 (plugin UIs framed allow-same-origin). Dropping allow-same-origin does
not work: the document's origin goes opaque, its subresource requests are
then cross-site, the SameSite=Lax session cookie is not sent, and every
plugin asset 302s to /login. The "open in new tab" link is the same
escalation with no iframe at all, so the sandbox attribute is not where this
gets fixed either. It needs a second listener — a distinct origin that is
still the same site — which changes the console's deploy model and wants
on-glass validation. The mechanism and the dead end are written down at the
iframe.
H-6 registry authentication, whose other half lives in unom/infra. The
in-repo halves are done: workflow_dispatch inputs no longer interpolate into
run: blocks (one of them in the step holding UPDATE_MANIFEST_KEY), and the
syft installer is pinned to its tag instead of main. Digest pinning is left
until the registry is authenticated, because a tag — content-keyed or not —
can simply be overwritten while anonymous pushes are accepted.
M-5 is half done: the oracle is closed, but binding the arming window needs
the console to learn the fingerprint first, which is a knock-then-bind flow
rather than an edit.
Verified: cargo fmt --all --check clean; cargo check --all-targets green on
Linux and on Windows (confirmed non-vacuous — a planted type error in
windows/install.rs fails the build); scripts/xcheck.sh windows check green;
cargo test -p punktfunk-host --bins 416 passed, the single failure being
gamestream::stream::tests::sender_delivers_batches, the known qemu-environmental
UDP-loopback flake that fails identically on clean main in the same container;
cargo test -p pf-clipboard 13 passed; web console typechecks.
M2 WP-B. VkVideoSessionKHR lifecycle with drain-before-destroy on
parameters recreation, DPB pools in both coincide and distinct modes
(caps-derived, usage/flags validated against the driver's format
properties), an aligned bitstream ring, vkCmdDecodeVideoKHR recording
with one-shot RESET re-armed on failed submits, timeline-semaphore
completion, and the per-op RESULT_STATUS query ring — the signal
FFmpeg's hwaccel never reads and the reason this program exists.
Frame lifetime is two-phase by construction: release_frame pins a
delivered frame's slot against reuse, closing the coincide-mode
overwrite the adversarial review round proved (a full DPB handed a
just-returned frame's image back as the same call's decode target).
Nine review findings fixed pre-commit; a counterfactual test pins the
collision. Generation-stamped frames, memory-type misses as errors,
granularity-aligned extents, level gate.
AuPlan now carries its activated SPS/PPS (Rc) so backends never
re-parse. GPU smoke test (ignored) decodes 48 AUs past DPB-full with
releases — the fleet runs it in WP-D.
Gates: fmt clean, clippy -D warnings zero, 45+27+53 tests green on
macOS and the linux/amd64 container.
M2 WP-A (design/client-native-decode.md §3.2). AuPlan -> StdVideo
parameter sets (owned pointer backings), per-AU decode info with slice
start-code offsets, and a PicId->slot map that never evicts on its own.
Deliberate rejections over silent claims: FMO, separate colour planes,
DPBs deeper than 16 frames (unbounded VUI ue(v)) all fail closed.
pf-bitstream API grew what the review proved necessary: RefPic carries
the true top/bottom field order counts (a single poc fabricated
BottomFieldOrderCnt whenever the PPS signals pic-order deltas), the
>16-frame DPB envelope gate, and an MMCO5 rebase warning.
Adversarial review round two: 8 findings fixed pre-commit, including
transactional slot mutation (an error path could permanently desync
the map) and count/pointer coherence on type-1 POC offsets.
Gates: fmt clean, clippy -D warnings zero, 45+26+21 tests green on
macOS and the linux/amd64 container.
Adapted from cros-codecs decoder/stateless/h264.rs (POC 8.2.1, ref lists
8.2.4 incl. modification, sliding-window + MMCO/LTR marking 8.2.5,
frame_num-gap handling 8.2.5.2), minus the fd-coupled backend trait.
H264Planner::plan_au maps one wire AU to picture params, per-slice byte
ranges + ref lists keyed by stable PicIds, and a DPB update; recovery-
point SEI parsing is new code (upstream reads no SEI payloads).
Concealment posture, deliberately different from upstream's aborts:
frame_num gaps, failed RPLM/MMCO and mis-split AUs degrade to warnings
the session turns into recovery asks, gap placeholders substitute
in-place so ref_idx mapping never shifts, and DPB outputs queued during
a failed AU survive to the next plan.
An adversarial review round fixed 11 findings before this commit;
one was an upstream cros-codecs bug our conformance-window test exposed
(display_resolution double-subtracts the crop offset and underflow-
panics — PROVENANCE deviation #6, worth reporting upstream).
Gates: fmt clean, clippy -D warnings zero, 45+24 tests green on macOS
and the linux/amd64 container.
#![forbid(unsafe_code)] on both crates. Upstream's codec module was one
production unsafe away: build_ref_pic_lists turned DPB borrows into
indices via pointer offset_from — same pointer-identity mapping now
expressed as position(ptr::eq) over the <=16-entry DPB (PROVENANCE #5).
Three test-only mem::zeroed() asserts became Default::default(), an
identical value for the all-integer PredWeightTable.
Honest coverage note: build_ref_pic_lists has no callers inside the
vendored subset (its consumer was the non-vendored stateless layer), so
the rewrite is equivalence-by-construction until the DecodePlan layer
exercises it against goldens.
M1 of design/client-native-decode.md. The vendored snapshot (AOSP mirror
main @ 5ff6d693ffae, BSD-3, PROVENANCE.md) is the codec module only —
H.264/H.265/AV1/VP9 parsers, DPBs and their test vectors, which now run
as 45 conformance tests in our CI. pf-bitstream sits where upstream's
Linux-only decoder::stateless half would and starts with vendor-pinning
smoke tests: a re-sync that shifts parser behavior trips in-tree, not in
a decode session.
Fixture-corpus enabler for the native-decode program (M0,
design/client-native-decode.md): every AU exactly as the pump hands it
to decode_frame — the raw concatenation plus a sidecar .idx carrying
the AU boundaries and wire flags a byte stream cannot. Best-effort by
design: any I/O error disables the capture, never the stream.
WP6.1 of design/library-scanner-plugins-implementation-plan.md.
The library is a flagship surface and cannot depend on an opt-in subsystem
(design D9, closing G9): once the scanners are plugins, a host whose runner is
off comes up with an empty library and no obvious reason why. The security
posture for on-by-default was already built and shipped — LocalService on
Windows, a sandboxed systemd --user unit on Linux, the scoped plugin-token lane.
Windows (.iss): the PunktfunkScripting task is registered ENABLED and started on
a FRESH install, and left to the existing restore path on an upgrade. The
distinction is a new TaskExists probe taken before StopBunRuntimes disables
anything — TaskEnabled alone cannot tell a fresh install from an operator who
deliberately turned the runner off, and defaulting to "on" would silently switch
it back on for them.
deb/rpm: `systemctl --global enable` from the postinst/%post, guarded to first
install only so an upgrade never undoes a mask. `--global` because a maintainer
script has no user session to act on, and it is the only mechanism that makes a
--user unit on-by-default for everyone.
sysext: RPM scriptlets never run from a sysext image, so the enablement symlink
is baked in directly (/usr/lib/systemd/user/default.target.wants/). Without it
the runner would ship present-but-off on exactly the platform where an operator
is least likely to go looking for it.
Opt-out throughout is `systemctl --user mask punktfunk-scripting` — `mask`, not
`disable`, since a plain disable cannot remove a symlink under /etc or /usr. The
unit comment, both package descriptions, and the docs-site plugins page all say
so; the page also gains the Windows equivalent.
Not gated on hardware: none of this is verifiable from a Mac. The .iss change
needs an installer run (fresh + upgrade, and an upgrade with the task
deliberately disabled), and the deb/rpm/sysext changes need a package build.
M4 of design/library-scanner-plugins-implementation-plan.md, plus WP6.2.
WP4.1 — SourceToggles and ProvidersCard merge into Library/Sources.tsx. They
were two cards because they were two different things: scanners were compiled
into the host, plugins were an afterthought. After the extraction they are the
same thing — the host reports ONE list of sources whose ids match whether they
came from a built-in scanner or the plugin replacing it — so one surface is both
simpler and the only honest presentation. Each row carries its toggle, a
running/stopped badge for plugin sources, an entry count, filter, settings and
an uninstall that offers to remove the games too. An "Add a source" rail lists
uncatalogued library plugins with a "Detected" badge; `detected` is deliberately
tri-state, so only a POSITIVE probe badges — an entry with no probes for this
platform is unknown, and calling that "not installed" would be a lie.
The settings drawer (SourceSettings.tsx) renders a generic form from the
plugin's own JSON Schema over GET/PUT /__config, through the existing
session-gated /plugin-ui/<id>/ proxy — zero new host surface, and the browser
never learns the plugin's port or secret. It flattens allOf branches (effect
nests a checked schema's annotations there, so a form reading only the top level
silently loses every title and default) and falls back to a JSON editor when any
field is a shape it cannot express — partial rendering would be worse than none,
because a field missing from the form is a setting the operator cannot change.
WP4.2 — uiPlugins() now excludes category "library", which covers both the
sidebar and the mobile overflow since they share the selector. The
/plugins/$pluginId/$ route still resolves, so existing deep links keep working;
library plugins are just not advertised.
WP4.3 — LibraryGrid groups role:"launcher" entries into a rail above the grid,
and the empty state points at the sources surface rather than leaving a bare
grid (after extraction, "no games" is the expected first-run state).
WP6.2 — a migration banner offering one install per still-built-in scanner whose
plugin is catalogued. One button per scanner, never a single "migrate
everything" and never a silent auto-install: installing code stays an explicit
operator act, and per-scanner is what makes it safe to repeat (the claim
suppresses the built-in idempotently, so a half-finished migration is a valid
state).
WP4.4 — i18n en+de (kept under the existing "Game sources" label rather than
minting a third "Plugins"), Storybook stories for the sources card in three
states, the launcher rail and the banner. Gates: orval regen, tsc clean, vite
build clean, check-i18n green at 595 messages for both locales.
Still owed: the browser click-through (the store's Tabs-theme bug shipped
through green types and lint), and an AppShell nav story — that one needs the
plugins query mocked, which does not exist in this Storybook setup yet.
M3 of design/library-scanner-plugins-implementation-plan.md. Target shape: a
first-party scanner plugin is its parsers plus a scan function.
WP3.1 — a parsers module under the new ./library subpath, porting what the six
in-host scanners hand-rolled: text VDF/ACF, the BINARY shortcuts.vdf KeyValues
walker with its CRC-32 appid derivation and the 64-bit rungameid composition,
read-only SQLite (bun:sqlite, immutable=1 so a scan can never take a lock or
spawn WAL sidecars next to a launcher's live database), a reg.exe wrapper,
capped readers, the path-confinement join that keeps a crafted goggame-*.info
from pointing a launch at an arbitrary program, Steam root/library discovery,
art location helpers, and a fetch helper carrying the host's no-redirect
anti-SSRF posture. Every parser is total: a missing launcher or a truncated file
degrades to "no titles", never to a throw.
Two deliberate departures from the Rust originals, both about the Windows
runner's account: steam root discovery now also reads HKLM Valve\Steam
InstallPath (a non-default install dir was previously uncovered), and the
registry wrapper refuses HKCU outright — as LocalService that is not the
operator's hive, so reading it would silently look like "not installed".
WP3.2 — GET/PUT /__config on the kit's UI server, so a plugin with settings does
not ship an SPA (closes G8). GET answers {schema, value}: the derived JSON Schema
and the raw operator-authored config. PUT validates by decoding and only then
persists RAW, so defaults are never baked into the file. The handler is split out
as makeConfigHandler and driven directly in tests.
WP3.3 — defineLibraryPlugin wires SyncEngine (poll + fs-watch + debounce), the
store-claiming reconcile, launcher entries appended to every sync, a UI server
serving only __config under category "library" (which keeps six installed
scanners out of the console nav), and the standard detect/scan/uninstall CLI
verbs. It warns ONCE when a pre-M2 host silently ignores the store claim — that
degradation is otherwise invisible except as duplicated titles.
M0/S2 is recorded here as a committed fixture rather than prose. Two findings the
original spike missed because deriving a schema does not exercise it:
withDecodingDefaultKey takes an Effect, not a thunk — a thunk type-checks, derives
fine, and dies at decode time; and a checked schema (Schema.Int) nests its
annotations under allOf, so a form must merge those branches. Both are pinned.
plugin-kit: version 0.3.0, tsc clean, 46 tests pass (16 ported parser tests, 10
config/derivation). Publishing (WP3.4) is deferred — it needs a tag and a push.
M2 of design/library-scanner-plugins-implementation-plan.md. Everything a
library scanner plugin needs is now expressible over the API; all additive.
WP2.1/2.2 — store claims (D2). library.json gains a v2 shape ({entries, claims})
that loads the v1 bare array unchanged and is written on the first mutation.
PUT /library/provider/{p}?store=<s> claims a store for a provider: its entries
then surface with deterministic <store>:<external_id> ids and the store's own
badge instead of opaque custom:<id> ones. That identity is the whole point —
entry ids, GameStream FNV app ids, client art caches and Moonlight pins all
survive a title moving from an in-host scanner to a plugin. One provider per
store (409 otherwise); DELETE releases; an empty reconcile does NOT (a store can
legitimately have zero titles). While a claim is held, all_games() skips the
matching built-in scanner, so the two never double-list during the bridge.
WP2.3 — DetectHint gains steam_appid and env_marker, the two store-derived
signals the host used to read for itself. Without them a steam plugin's lease
tracking would drop from reaper-exact to dir-prefix, and Heroic-under-Proton
would lose the only signal that works. Malformed markers are dropped, not
honoured — this feeds a path that can end processes.
WP2.4/2.5 — role: game|launcher on the entry shapes (serde-default, skipped when
default), and a steam_ui launch kind valued bigpicture|desktop that opens the
Steam client itself. Validated inbound as well as at launch.
WP2.6 — GET/PUT /library/scanners generalizes to SOURCES: built-in scanners
minus claimed ones, plus claimed stores, plus any provider with entries. The
same library-scanners.json disabled-set backs all of them and the ids match by
construction, so a user's disabled state carries over verbatim through the whole
migration. A disabled plugin source has its entries filtered at read time,
exactly like a disabled scanner.
WP2.7/2.8 — plugin registration gains a category field (the console keeps
library plugins out of the nav); index entries gain categories and per-platform
detect probes, evaluated existence-only into CatalogEntry.detected so the host
never re-grows per-store knowledge. Index SCHEMA stays 1 — additive.
WP2.9 — OpenAPI + SDK regenerated on Linux; kit wire widened (LaunchSpec.kind is
now a plain string documented against the host's vocabulary — closes G3), and
ProviderClient.reconcile takes an optional store and returns the host's echoed
entries so a caller can detect a pre-M2 host silently ignoring the claim.
Also fixes a bug the S3 spike turned up: is_steam_launch gated on a steam:// URI,
so a steam_ui launcher entry would have skipped BOTH gamescope's --steam mode and
the B1 single-instance free — on a box autologged into game mode, the nested
second Steam would see the first and exit, crashing the spawn. It now tests the
first token.
Gates on .21: workspace tests green (punktfunk-host 425 passed), workspace
clippy -D warnings clean, cargo fmt --all --check clean, OpenAPI drift test
green. plugin-kit: tsc clean, 20 tests pass.
M1 of design/library-scanner-plugins-implementation-plan.md — behavior-frozen
groundwork for lifting the six scanners out into plugins.
WP1.1: heroic_command/heroic_launch_prefix, epic_launch_uri, gog_spawn,
valid_steam_appid and shortcut_gameid move into library/launch.rs with their
unit tests. The scanner modules beside it now do enumeration only, so they can
be deleted wholesale later without taking launch logic with them (D1).
WP1.2: is_local_art_path accepts file:// (the plugin contract) and POSIX
absolute paths, excluding the two /-leading shapes the host itself emits (its
own /api/ proxy path and protocol-relative CDN URLs). local_art_bytes
percent-decodes and converts a file:// value first. The art proxy and
fetch_box_art resolve ANY id against library.json before the legacy steam:
branch, so a plugin's entries serve art without the host knowing its store.
No API change; no user-visible change.
A minor bump: 98 commits since v0.24.0. The headline is DualSense pad audio
(PR #23) — a wired DualSense playing a game's voice-coil haptics and its own
speaker, streamed from the host, on Android and the desktop session client
against a Windows host with Steam's driver present. Behind it: the haptics
sweep's twelve milestones closing more than twenty controller faults across
every client and both hosts; the audio quality/latency work (256 kbps stereo,
the Steam Streaming Microphone endpoint root cause, and the de-jitter ratchet
that left audio permanently behind the picture); and MTU resilience plus
mid-session shard renegotiation, which turns the silent all-black stream on a
sub-1330-byte path into a diagnosed warning that heals itself. Plus the Decky
plugin reduced to a launcher, system-button routing with hold-Select, gamepad-UI
profiles on all three UIs, `discover`/`launch --request-access` in the CLI, and
the Sunshine false-conflict and crashed-host display-restore fixes.
The canary base is already 0.25 — scripts/ci/pf-version.sh derives it as one
minor ahead of the latest stable tag — so this is the version canary has been
publishing against all along.
Wire protocol stays at 2: every addition this cycle is optional or
capability-gated (an optional trailing max_shard_payload on Hello, the 0x08/0x09
renegotiation pair, the 0xD1 pad-audio plane, the 0xD2 redundant desktop-audio
plane, MAX_DATAGRAM_BYTES 2048 -> 9216). C ABI moves 14 -> 16 in two steps: 15
retroactively declares the floor that guarantees the rumble policy engine's C
surface (which shipped while the constant still read 7), and 16 adds the
pad-audio surface and mirrors its two capability bits. Four new capability bits
land in the client/host bytes (audio redundancy 0x04/0x20, pad audio 0x08/0x40);
the video-caps byte was NOT touched and stays full from 0.23.0, so the standing
"next video cap needs a second byte and an ABI bump" note still holds. host_caps
is now down to its last free bit (0x80). Virtual-display driver protocol 6 and
the Windows gamepad channel 3 are untouched — pf-driver-proto is byte-for-byte
identical to v0.24.0. The generated header is in sync (ABI 16, both cap mirrors).
Breaking for C embedders: 149 unprefixed macros are now PUNKTFUNK_-prefixed
(139 #defines renamed in the checked-in header). Mechanical to fix, and it
cannot break silently — the old spellings cease to exist, so it is always an
undeclared-identifier error rather than the wrong value a colliding #define
used to produce.
Lock touched for the 32 workspace members only, via `cargo update --workspace`:
diff against origin/main is versions-only, 32 insertions and 32 deletions. Unlike
the last cut there is no third-party crate sitting on the outgoing version to
trip the count — `wasapi` is at 0.23.0 and was never a candidate. `cargo metadata
--locked` resolves (35 members; fec-rs, pf-driver-proto and usbip-sim keep their
own versions by design). `cargo fmt --all --check` clean in both the main and
packaging/windows/drivers workspaces. Doc lazy-continuation scanner: 0 hits over
521 files — that regex is the exact defect that made the first v0.23.0 tag go red
on Windows clippy, and no Windows leg runs on a main push, so main being green
proves nothing about the tag fan-out.
api/openapi.json is deliberately left at 0.23.0: it tracks API edits and lags,
as in every prior cut. It is now two releases behind and worth a look.
Notes at docs/releases/v0.25.0.md, per docs/releases/README.md — authored with
the bump so CI's ensure_release seeds the release body at tag creation. Body
voice checked programmatically: 0 internal-vocabulary hits above `## Under the
hood`. Play's "What's new" at docs/releases/whatsnew/v0.25.0.txt (494/500 chars),
verified by running android.yml's gate logic verbatim against it, including the
byte-identical-to-another-release check.
2026-08-05 00:05:40 +02:00
1141 changed files with 233154 additions and 31026 deletions
| tr -d '\r'| sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest: //p'||true
}
key_digest=$(digest_of "$KEY")
latest_digest=$(digest_of latest)
if[ -z "$key_digest"];then
echo"::error::$IMAGE:$KEY has no manifest — the build or push above did not land"
exit1
fi
if["$key_digest"="$latest_digest"];then
echo"$IMAGE:latest == :$KEY ($key_digest)"
exit0
fi
echo"::warning::$IMAGE:latest did not match its content key :$KEY — re-pointing it. If ci/ was not just reverted, someone overwrote this tag out of band: check the registry access log on home-ci-core."
echo "embedded Developer ID profile: $PROFILE_SRC"
else
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
# tags to `punktfunk` — separate repos, so neither channel can shadow the other.
tags:['v*']
# REBUILDING A PUBLISHED RELEASE, because on a rolling distro the ground moves under one.
# Arch went FFmpeg 8 -> 9 (every libav soname +1) four minutes before v0.25.0 was tagged, so
# the release's punktfunk-host was linked in a builder image that still had 8 and shipped
# `libavcodec.so=62-64`. No up-to-date Arch box can satisfy that — and pacman prepares the
# whole transaction at once, so it did not merely block our package, it blocked those users'
# entire `pacman -Syu`. The repair is a rebuild of the SAME upstream version at a HIGHER
# pkgrel; nothing else reaches a box that already has the broken build recorded in its db.
# The workflow file at the tag can never carry inputs added after it was tagged, so dispatch
# this from `main`: it checks the tag's SOURCE out, publishes to the STABLE repo, and
# replaces the release-page assets. Same lever for any future "the distro moved" rebuild.
workflow_dispatch:
inputs:
release_tag:
description:'Rebuild this published release (e.g. v0.25.0) into the stable `punktfunk` repo. Empty = ordinary canary build of the dispatched ref.'
required:false
default:''
pkgrel:
description:'pkgrel for that rebuild — MUST be above the published one (2, 3, …); a same-pkgrel republish is invisible to pacman. Ignored without release_tag.'
| python3 -c "import json,sys;k=set(sys.argv[1].split());k|={n+'.sha256' for n in k};print('\n'.join('%s %s'%(a['id'],a['name']) for a in json.load(sys.stdin) if a.get('name','').endswith(('.pkg.tar.zst','.pkg.tar.zst.sha256')) and a['name'] not in k))" "$KEEP" \
[ "$ok" = 1 ] || echo "::warning::miri (pf-driver-proto @ MSVC layout) did not pass — non-blocking, but this is the layout oracle for every frame and IOCTL; see design/rust-safety-programme.md §7"
# fec-rs dispatches its GF(2^8) multiply through RUNTIME `is_x86_feature_detected!`. Under
# Miri that detection reports the COMPILE-TIME target features, so WITHOUT these RUSTFLAGS
# the step silently interprets the scalar fallback and is worthless. Verified both ways on
# 192.168.1.25: bare, `avx2=false ssse3=false`; with the flags, `avx2=true ssse3=true` and
# `_mm256_shuffle_epi8` genuinely executes under the interpreter. GFNI stays false either
# way — Miri does not implement it — so the gfni branch is simply not covered here.
#
# ⚠ x86_64 ONLY, and it must stay that way. A RUSTFLAGS env var OVERRIDES config rustflags
# ENTIRELY — it does not merge. That used to cost the aarch64 `--cfg aes_armv8` /
# `--cfg polyval_armv8` decrypt flags; the aes 0.9 / polyval 0.7 bump retired those cfgs
# (see the tombstone in .cargo/config.toml), so there is nothing left for an override to
# drop here. Keep the pin anyway: these target-features are meaningless off x86_64.
# Narrow selection is mandatory, not an optimisation: see the punktfunk-core note above.
- name:miri — punktfunk-core fec::gf8, taking the real AVX2/SSSE3 branches
[ "$ok" = 1 ] || echo "::warning::c-abi-asan did not pass — non-blocking on day one; see design/rust-safety-programme.md §6.1. An LSAN report here means the abi.rs into_raw/from_raw contract broke."
echo "embedded Developer ID profile: $PROFILE_SRC"
else
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
# A release must not be able to make a claim its own CI silently dropped — v0.26.0's notes
# said the patched gamescope was dnf-installable while both Fedora bases had skipped it on a
# `::warning::` (missing libstdc++-static, which the -static-libstdc++ link needs).
#
# ⚠ LAST step on purpose, matching deb.yml: failing at the build step instead would skip the
# sysext image, the feed publish AND the attach above, withholding the punktfunk RPMs and
# .raw images that built perfectly well. Everything good ships first; the job goes red after.
- name:A stable tag must ship the gamescope RPM
if:startsWith(gitea.ref, 'refs/tags/v')
run:|
shopt -s nullglob
built=(dist/punktfunk-gamescope-*.rpm)
keep=()
for r in "${built[@]}"; do
case "$r" in *debuginfo*|*debugsource*) continue;; esac
keep+=("$r")
done
if [ ${#keep[@]} -eq 0 ]; then
echo "::error::no punktfunk-gamescope RPM was built for f${{ matrix.fedver }} — a stable tag must not ship without it (the release notes and docs-site say it is installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
"description":"Removes the client's certificate from the pairing store. Caveat: the nvhttp TLS layer\ndoes not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed\nclient cert — a planned hardening step), so until that lands this removes the client\nfrom the listing without severing its ability to reconnect.",
"description":"Removes the client's certificate from the pairing store (persisted — the removal survives a\nhost restart). Revocation is complete: a LIVE GameStream session owned by this certificate is\nended (the client gets the standard TERMINATION+disconnect), and removing the last pairing\nalso closes the ENet control port (UDP 47999), which is only bound while at least one pairing\nexists. The nvhttp TLS layer stillcompletes a handshake with any well-formed client cert BY\nDESIGN (authorization is per-request via the paired-fingerprint check) — an unpaired client\nthat reconnects is rejected at every post-pair endpoint.",
"operationId":"unpairClient",
"parameters":[
{
@@ -997,7 +997,7 @@
"library"
],
"summary":"List the game library",
"description":"Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).",
"description":"Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
"operationId":"getLibrary",
"parameters":[
{
@@ -1021,13 +1021,13 @@
],
"responses":{
"200":{
"description":"Unified library across all stores",
"description":"Unified library across all stores (the operator's lane also gets hidden entries, flagged)",
"content":{
"application/json":{
"schema":{
"type":"array",
"items":{
"$ref":"#/components/schemas/GameEntry"
"$ref":"#/components/schemas/OperatorGameEntry"
}
}
}
@@ -1052,7 +1052,7 @@
"library"
],
"summary":"Fetch one cover-art image for a library entry",
"description":"Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what theuser's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which casethis 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.",
"description":"Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact —it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URLconvention second\n(newer titles' CDN assets can live at a per-asset-hash path the hostcan't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
"operationId":"getLibraryArt",
"parameters":[
{
@@ -1301,13 +1301,86 @@
}
}
},
"/api/v1/library/hidden/{id}":{
"put":{
"tags":[
"library"
],
"summary":"Hide or un-hide one library title",
"description":"Curation, not access control: a hidden title disappears from every play surface — the console\ngrid on a client, native clients, the GameStream app list, and launch resolution — while nothing\nis deleted and un-hiding restores it immediately. The operator's own console still lists it\n(flagged `hidden`) so it can be brought back.\n\nKeyed by the entry's stable `<store>:<external_id>` id, which survives re-scans and reconciles by\nconstruction (D2). The id is **not** validated against the current library on purpose: a title\ncan be legitimately absent at this moment (launcher closed, plugin mid-sync, drive unmounted),\nand refusing the operator's choice in that window would be worse than storing an id that\ncurrently matches nothing. Emits `library.changed` (source = the store) only on a real change.",
"operationId":"setLibraryEntryHidden",
"parameters":[
{
"name":"id",
"in":"path",
"description":"The library entry id (e.g. `steam:70`)",
"required":true,
"schema":{
"type":"string"
}
}
],
"requestBody":{
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/HiddenToggle"
}
}
},
"required":true
},
"responses":{
"200":{
"description":"Stored; the entry's visibility after the call",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/HiddenState"
}
}
}
},
"400":{
"description":"Empty entry id",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"401":{
"description":"Missing or invalid bearer token",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"500":{
"description":"Could not persist the settings",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/library/provider/{provider}":{
"put":{
"tags":[
"library"
],
"summary":"Replace a provider's library entries (declarative reconcile)",
"description":"Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.",
"description":"Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
"operationId":"reconcileProviderEntries",
"parameters":[
{
@@ -1318,6 +1391,15 @@
"schema":{
"type":"string"
}
},
{
"name":"store",
"in":"query",
"description":"Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)",
"required":false,
"schema":{
"type":"string"
}
}
],
"requestBody":{
@@ -1348,7 +1430,7 @@
}
},
"400":{
"description":"Invalid provider id or payload",
"description":"Invalid provider id, store id, or payload",
"content":{
"application/json":{
"schema":{
@@ -1367,6 +1449,16 @@
}
}
},
"409":{
"description":"That store is already claimed by another provider",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"500":{
"description":"Could not persist the catalog",
"content":{
@@ -4026,6 +4118,51 @@
}
}
},
"AudioWiring":{
"type":"object",
"description":"The Windows host's audio wiring verdict — which endpoint carries each role. The names are\nthe endpoints' friendly names as the Sound settings show them (on current hosts the minted\n\"Punktfunk\" instances of Steam's streaming drivers).",
"required":[
"readiness",
"mic_withheld",
"last_resort"
],
"properties":{
"last_resort":{
"type":"boolean",
"description":"The loopback is the known-degraded last resort — desktop audio may be silent until the\nendpoint set changes."
},
"loopback":{
"type":[
"string",
"null"
],
"description":"Friendly name of the desktop-audio loopback source; absent = desktop audio unavailable."
},
"mic":{
"type":[
"string",
"null"
],
"description":"Friendly name of the virtual-mic write target; absent = mic passthrough unavailable."
},
"mic_withheld":{
"type":"boolean",
"description":"The mic was WITHHELD so game audio could keep the only working sink — mic passthrough\nneeds Steam installed (the host mints its own microphone) or a virtual cable."
},
"narrowing":{
"type":[
"string",
"null"
],
"description":"Why the chosen loopback endpoint NARROWS the desktop mix (rate/channels), when it does."
},
"readiness":{
"type":"string",
"description":"`full` | `audio_only` | `mic_only` | `none` — whether desktop audio and mic passthrough\neach have an endpoint at all.",
"example":"full"
}
}
},
"AvailableCompositor":{
"type":"object",
"description":"A compositor backend the host can drive a virtual output on, and whether it's usable now.",
@@ -4159,7 +4296,8 @@
"tier",
"platforms",
"compatible",
"update_available"
"update_available",
"categories"
],
"properties":{
"author":{
@@ -4172,6 +4310,13 @@
],
"description":"A revocation covering the catalogued version — do not offer this without shouting."
},
"categories":{
"type":"array",
"items":{
"type":"string"
},
"description":"What kind of plugin this is — the console filters Browse by these, and the Game sources\nsurface's \"Add a source\" rail shows exactly the `library` ones (design D5/D6)."
},
"compatible":{
"type":"boolean",
"description":"Can this host install it?"
@@ -4179,6 +4324,13 @@
"description":{
"type":"string"
},
"detected":{
"type":[
"boolean",
"null"
],
"description":"Whether the launcher this plugin scans looks **installed on this host** (design D8), from the\nindex's own existence probes. `null` = the entry declares no probes for this platform, which\nthe console renders as \"unknown\" rather than \"not installed\"."
},
"homepage":{
"type":[
"string",
@@ -4337,6 +4489,13 @@
],
"description":"The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
},
"icon":{
"type":[
"string",
"null"
],
"description":"Which brand mark a client should draw for this entry — see [`GameEntry::icon`]. A token\n(`steam`, `heroic`), never bytes and never a URL."
},
"id":{
"type":"string",
"description":"Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
@@ -4365,6 +4524,17 @@
],
"description":"The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
},
"role":{
"$ref":"#/components/schemas/GameRole",
"description":"Whether this entry is a game or the launcher itself — see [`GameRole`]."
},
"store":{
"type":[
"string",
"null"
],
"description":"The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten."
},
"title":{
"type":"string"
}
@@ -4392,6 +4562,13 @@
"$ref":"#/components/schemas/DetectHint",
"description":"How to recognize this title's process — see [`CustomEntry::detect`]."
},
"icon":{
"type":[
"string",
"null"
],
"description":"Which brand mark to draw — see [`GameEntry::icon`]. Hand-settable for the same reason `role`\nis: an operator's own \"Steam\" tile should be able to look like one."
},
"launch":{
"oneOf":[
{
@@ -4409,6 +4586,10 @@
},
"description":"Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"role":{
"$ref":"#/components/schemas/GameRole",
"description":"Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)."
},
"title":{
"type":"string"
}
@@ -4467,6 +4648,17 @@
"type":"object",
"description":"What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.",
"properties":{
"env_marker":{
"oneOf":[
{
"type":"null"
},
{
"$ref":"#/components/schemas/EnvMarker",
"description":"A launcher-stamped environment marker (D3) — see [`EnvMarker`]."
}
]
},
"exe":{
"type":[
"string",
@@ -4487,6 +4679,15 @@
"null"
],
"description":"The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]."
},
"steam_appid":{
"type":[
"integer",
"null"
],
"format":"int32",
"description":"The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**\nsignal that exists — Steam wraps every launch, native or Proton, in\n`reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a\nsteam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.",
"minimum":0
}
}
},
@@ -4552,6 +4753,10 @@
"type":"boolean",
"description":"EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
},
"edid_lock":{
"type":"boolean",
"description":"**EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the\nsoftware equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at\nthe first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its\nlive-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks\non its next start. Targets the standby-sink stall class at its SOURCE: with emulation\npinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD\ndriver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like\n`game_session`); `#[serde(default)]` = off."
},
"game_session":{
"$ref":"#/components/schemas/GameSession",
"description":"How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
@@ -4587,7 +4792,7 @@
"version":{
"type":"integer",
"format":"int32",
"description":"Schema version (currently 1) — lets a future field addition migrate rather than reject.",
"description":"Schema version (currently 1) — lets a future field addition migrate rather than reject. Read\nat load time ([`DisplayPolicyStore::load_from`] warns when a file claims a version this host\ndoes not know, then reads it best-effort) and pinned back to the current version on write.",
"minimum":0
}
}
@@ -4656,7 +4861,7 @@
},
"EffectivePolicy":{
"type":"object",
"description":"The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].",
"description":"The six resolved fields after preset expansion — what the lifecycle/registry and the policy call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].\n\n**Every field is required on the wire, deliberately.** Unlike [`DisplayPolicy`] — which is only\never a *file* — this shape is also the `fields` member of [`CustomPresetInput`], i.e. the request\nbody of `POST /display/presets` and `PUT /display/presets/{id}`, and a *response* member three\ntimes over (`DisplaySettingsState.effective`, `PresetInfo.fields`, `CustomPreset.fields`).\n`#[serde(default)]` here would (a) turn `{\"name\":\"Kiosk\",\"fields\":{}}` — or any camelCase typo —\nfrom a serde rejection into a 201 storing a preset that expands to six axes nobody chose, and\n(b) make all six OPTIONAL in the generated OpenAPI schema, so every codegen'd client has to\nnull-check them. The *persisted* catalog's tolerance for an entry written before an axis existed\nis bought where it belongs, on the read path only: see [`StoredEffectivePolicy`].",
"required":[
"keep_alive",
"topology",
@@ -4715,6 +4920,27 @@
}
}
},
"EnvMarker":{
"type":"object",
"description":"An environment variable a launcher stamps onto the game's process, identifying it.\n\nSerializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin\nthat knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to\nbe able to say so, since after extraction the host no longer reads that launcher's files itself.",
"required":[
"key"
],
"properties":{
"key":{
"type":"string",
"description":"The variable name (e.g. `HEROIC_GAME_ID`).",
"example":"HEROIC_APP_NAME"
},
"value":{
"type":[
"string",
"null"
],
"description":"The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time."
}
}
},
"EventKind":{
"oneOf":[
{
@@ -5142,6 +5368,14 @@
"art":{
"$ref":"#/components/schemas/Artwork"
},
"icon":{
"type":[
"string",
"null"
],
"description":"Which brand mark to draw for this entry, as a **token** — `steam`, `heroic`, `playnite` —\nnever image bytes and never a URL. See [`is_icon_token`].\n\nIt exists for launcher tiles, which by design ship no cover art: a launcher's own icon is\nsquare, every client cover-crops a 2:3 poster, and the crop turns a mark into a strip — so\nuntil now those tiles were the launcher's name on a flat accent face. The token lets a client\ndraw the real mark from art it already ships, at whatever size its tile happens to be.\n\nA token rather than art on the wire because the host's art proxy serves *raster* bytes only\n([`art::local_art_bytes`] sniffs the container and refuses anything else, SVG very much\nincluded — it is script-capable XML and the console renders art in a browser). Sending the\nname of a mark instead of the mark keeps that refusal intact, keeps the glyph vector at every\ntile size, and lets it take the tile's ink.\n\nOrdinary titles may carry one too — nothing here is launcher-specific — but nothing sets it\nfor them: a game has real cover art, which is strictly better than a brand mark.",
"example":"steam"
},
"id":{
"type":"string",
"description":"Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
@@ -5165,6 +5399,10 @@
],
"description":"The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
},
"role":{
"$ref":"#/components/schemas/GameRole",
"description":"Whether this entry is a game or the launcher itself — see [`GameRole`]."
},
"store":{
"type":"string",
"description":"Which store surfaced it: `\"steam\"` or `\"custom\"`.",
@@ -5296,6 +5534,14 @@
}
}
},
"GameRole":{
"type":"string",
"description":"What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big\nPicture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,\nleases and lists exactly like a game (design D4), and clients that don't know the field render it\nas a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged\nfor every entry that doesn't opt in.",
"enum":[
"game",
"launcher"
]
},
"GameSession":{
"type":"string",
"description":"How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
"description":"Request body for `setLibraryEntryHidden`.",
"required":[
"hidden"
],
"properties":{
"hidden":{
"type":"boolean",
"description":"Whether this title should be hidden from every play surface."
}
}
},
"HookEntry":{
"type":"object",
"description":"One hook: fire `run` and/or `webhook` when an event matching `on` (+ `filter`) occurs.",
@@ -5642,7 +5919,7 @@
},
"Identity":{
"type":"string",
"description":"Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.",
"description":"Stable display identity, so desktop environments persist per-display config (KDE scaling). The\nslot this resolves to is carried per backend: the Windows EDID serial + IddCx connector index,\nKWin's per-slot output name, and the host-persisted Mutter scale map.",
"enum":[
"shared",
"per-client",
@@ -5859,14 +6136,14 @@
"seconds":{
"type":"integer",
"format":"int32",
"description":"Linger window in seconds.",
"description":"Linger window in seconds, clamped to `0..=86400` on write (see\n[`DisplayPolicy::sanitized`]): a window longer than a day is `forever` by any honest\nreading, and `u32` seconds is ~136 years — a deadline the reaper would never reach and a\nnonsense `expires_in_ms` in `/display/state`.",
"minimum":0
}
}
},
{
"type":"object",
"description":"Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\n**Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.",
"description":"Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\nHonored end-to-end: the registry resolves it to `Release::Pin`, so the display survives every\ndisconnect — free it with `POST /display/release` (which force-releases `Pinned` exactly like\na `Lingering` display). This is what the `gaming-rig` preset selects.",
"required":[
"mode"
],
@@ -5910,6 +6187,7 @@
},
"positions":{
"type":"object",
"description":"Keys are the **canonical decimal** identity-slot id (`\"1\"`..`\"15\"`) — the exact string\n`arrange` looks a member up by. [`DisplayPolicy::sanitized`] re-canonicalizes them on write\n(`\"01\"` → `\"1\"`) and drops anything that is not a slot id, because a key that never matches is\na pin the operator can see in the console and in `GET /display/settings` while every session\nsilently auto-rows past it.",
"additionalProperties":{
"$ref":"#/components/schemas/Position"
},
@@ -5921,7 +6199,7 @@
},
"LayoutMode":{
"type":"string",
"description":"How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.",
"description":"How group members are arranged in the desktop coordinate space, resolved by `layout::arrange` —\nwhich both the `/display/state` readout and (on Linux, KWin only) the per-backend position apply\nconsume, so the answer is computed in exactly one place.",
"enum":[
"auto-row",
"manual"
@@ -6081,7 +6359,7 @@
},
"ModeConflict":{
"type":"string",
"description":"Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.",
"description":"Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Enforced by [`super::admission`] before the Welcome is sent, so a `reject` is a\nclean handshake error rather than a half-built session.",
"enum":[
"separate",
"steal",
@@ -6192,6 +6470,23 @@
}
}
},
"OperatorGameEntry":{
"allOf":[
{
"$ref":"#/components/schemas/GameEntry"
},
{
"type":"object",
"properties":{
"hidden":{
"type":"boolean",
"description":"The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only\ngrows for entries that actually are hidden."
}
}
}
],
"description":"A library entry plus the operator's own view of it — today, whether they hid it.\n\nA separate type rather than a field on [`GameEntry`] for two reasons. It keeps the visibility\nanswer out of the providers entirely: a store parser has no opinion on what the operator hid, and\nadding `hidden: false` to all eight construction sites would imply it does. More importantly it\nmakes the lane rule a TYPE guarantee instead of a discipline — `GET /library` answers\n`Vec<GameEntry>` on every lane but the operator's, so a hidden entry cannot leak to a paired\nclient by someone forgetting a filter; there is no field there to leak.\n\n`flatten` keeps the wire shape identical to a plain entry with one extra key, so the console\nparses one model either way."
"description":"What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`\nbecause it describes the plugin, not its surface. The console knows one value today —\n`library` — which it filters **out of the nav**: six installed scanner plugins would otherwise\nflood the sidebar, and their real entry point is the Game sources surface (design D5). A\nlibrary plugin that genuinely wants its own page (rom-manager, which is much more than a\nscanner) simply omits the category."
},
"title":{
"type":"string",
"description":"Human-readable title for the console nav entry (1–64 chars; control chars stripped)."
@@ -6366,6 +6668,13 @@
"title"
],
"properties":{
"category":{
"type":[
"string",
"null"
],
"description":"The plugin's kind — see [`PluginRegistration::category`]."
},
"id":{
"type":"string"
},
@@ -6587,6 +6896,13 @@
"type":"string",
"description":"The provider's stable id for this title (the reconcile diff key)."
},
"icon":{
"type":[
"string",
"null"
],
"description":"Which brand mark to draw — see [`GameEntry::icon`]. This is the field a library plugin sets\non its `launchers(cfg)` tiles, and the whole reason the token exists."
},
"launch":{
"oneOf":[
{
@@ -6604,6 +6920,10 @@
},
"description":"Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"role":{
"$ref":"#/components/schemas/GameRole",
"description":"Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`."
},
"title":{
"type":"string"
}
@@ -6685,6 +7005,17 @@
"description":"Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.",
"minimum":0
},
"audio":{
"oneOf":[
{
"type":"null"
},
{
"$ref":"#/components/schemas/AudioWiring",
"description":"The audio wiring verdict (Windows hosts; absent on other platforms and before the first\nwiring pass). Present even while idle — the wiring exists for the host's lifetime."
}
]
},
"audio_streaming":{
"type":"boolean",
"description":"True while the audio stream thread is running."
@@ -6780,26 +7111,46 @@
},
"ScannerInfo":{
"type":"object",
"description":"One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a togglefor. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
"description":"One **game source** on this host, with its enable state — the unit theconsole renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible.",
"required":[
"id",
"label",
"enabled"
"enabled",
"origin"
],
"properties":{
"enabled":{
"type":"boolean",
"description":"Whether this host runs the scanner (default true)."
"description":"Whether this host runs the source (default true)."
},
"entries":{
"type":[
"integer",
"null"
],
"description":"How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle.",
"minimum":0
},
"id":{
"type":"string",
"description":"Stable scanner id — the same string the scanner's entries carry in their `store` field.",
"description":"Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin.",
"example":"steam"
},
"label":{
"type":"string",
"description":"Human-facing name for the console toggle.",
"example":"Steam"
},
"origin":{
"$ref":"#/components/schemas/SourceOrigin",
"description":"Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
},
"provider":{
"type":[
"string",
"null"
],
"description":"The provider id backing a `plugin` source — absent for a built-in scanner."
}
}
},
@@ -6962,6 +7313,14 @@
}
}
},
"SourceOrigin":{
"type":"string",
"description":"Where a [`ScannerInfo`] comes from.",
"enum":[
"builtin",
"plugin"
]
},
"SourceView":{
"type":"object",
"description":"A configured catalog source and how its last refresh went.",
# host, client, worker and tray on every arch.yml run. Wired via cargo-config-mold.toml
# below. It does NOT affect the gamescope companion leg — that is meson + its own linker,
# and its `-static-libstdc++` link is untouched.
mold \
&& pacman -Scc --noconfirm
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored
@@ -40,3 +69,16 @@ ARG SCCACHE_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'\
&& sccache --version
# CARGO_HOME is declared here only so this image agrees with what arch.yml already sets at job
# level (and so `cargo` finds the config below when the image is used by hand). The workflow still
# passes CARGO_HOME explicitly across the `sudo -u builder env …` boundary, which strips ambient
# env — that is why the C/C++ sccache wiring has to be re-exported there by name while THIS file,
# being a file, crosses the boundary for free.
ENVCARGO_HOME=/usr/local/cargo
RUN mkdir -p /usr/local/cargo && chmod -R a+w /usr/local/cargo
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
# punktfunk-client link deps (GTK4 shell + SDL3 gamepads)
gtk4-devel libadwaita-devel SDL3-devel \
# pf-ffvk bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>
vulkan-headers \
# No vulkan-headers: nothing in the workspace compiles against the system Vulkan headers
# (pyrowave-sys bindgens its own vendored copy; host and client both reach Vulkan through
# ash, which dlopens the loader), and packaging/rpm/punktfunk.spec BuildRequires none.
# rpm.yml's HDR gamescope leg needs them and pulls them with `dnf builddep gamescope`.
&& dnf clean all
# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
@@ -74,3 +82,8 @@ ARG SCCACHE_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'\
&& sccache --version
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
#
# STAYING ON 8.0 THROUGH THE 2026-08-08 FFmpeg-9 BUMP IS DELIBERATE. `ffmpeg-next` moved to 9, but a
# crate major is a CEILING (ffmpeg-sys-next 9 spans libavcodec 56..63), so an 8.0 tree still compiles
# — and this .deb is the one package with NO exposure to the soname break that motivated the bump: it
# BUNDLES these libs into /usr/lib/punktfunk-host behind an rpath and strips the libav* sonames from
# its Depends, so nothing the user's apt does can move them underneath it. Bumping this tag would
# re-qualify the encode stack for every Ubuntu user and buy none of them anything, so it is its own
# change — and it drags NVHDR_TAG and the soname assertion below along with it.
ARGFFMPEG_TAG=n8.0
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
@@ -91,3 +102,10 @@ ARG SCCACHE_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'\
&& sccache --version
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
# ⚠ This does NOT touch the from-source FFmpeg built above: that is a plain ./configure && make in
# an earlier layer, linked by GNU ld exactly as before. Only cargo's links move to mold.
# pf-ffvk (bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>)
libvulkan-dev \
# No libvulkan-dev: nothing in the workspace compiles or links against Vulkan (pyrowave-sys
# bindgens its own vendored headers, and both host and client reach Vulkan through ash, which
# dlopens the loader), so neither the build nor deb.yml's dpkg-shlibdeps ever asks for it.
&& rm -rf /var/lib/apt/lists/*
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
@@ -58,3 +64,12 @@ ARG SCCACHE_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'\
&& sccache --version
# Link x86_64 with mold (see the file's own header for the rustflags-precedence traps).
#
# The assertion is the point: an image carrying the flag but NOT the linker would fail every cargo
# invocation in every consuming job, which is a catastrophic way to find out that a base image
# renamed the package. `mold --version` fails the docker build instead, so nothing is pushed and
# `:latest` keeps pointing at the previous working image — consumers never see it.
Surface.ROTATION_90->floatArrayOf(-y,x,z)// top left: right = bottom, up = +x
Surface.ROTATION_270->floatArrayOf(y,-x,z)// top right: right = top, up = −x
Surface.ROTATION_180->floatArrayOf(-x,-y,z)
else->floatArrayOf(x,y,z)
}
}
}
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.