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).
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.
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.
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).
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.
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
328 changed files with 29269 additions and 2693 deletions
# `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.'
required:false
default:'2'
env:
REGISTRY:git.unom.io
@@ -94,7 +113,52 @@ jobs:
}
bun --version
# THE BUILDER'S FFmpeg IS PART OF THE PACKAGE CONTRACT, not merely a build detail.
# packaging/arch/PKGBUILD binds punktfunk-host to the exact libav sonames it linked
# (`libavcodec.so=63-64` …), so a builder one FFmpeg major behind Arch emits a package
# that NOBODY can install — and takes the user's whole `pacman -Syu` down with it, since
# pacman prepares the transaction as a unit. That is exactly how v0.25.0 shipped: PR #108
# re-keyed this image for FFmpeg 9, the release tag fired four minutes later, and the job
# still got the FFmpeg-8 `:latest`. The image is a cache and is allowed to lag — but never
# on this one axis. So heal it in-job and shout, instead of building a dead package.
# (Runs BEFORE checkout: a stale image should be repaired before anything depends on it.)
- name:FFmpeg soname parity with today's Arch (heals a stale builder image)
run:|
export LC_ALL=C # `Provides` is a localized field name
# Piped (never a TTY here) pacman prints each field on ONE line, unwrapped.
| 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" \
volume/routing bytes, change-only and value-deduped. Older clients drop it as an unknown kind.
- **Arrival flags** — bits 8 (haptics) and 9 (speaker), sent only toward a `HOST_CAP_PAD_AUDIO` host.
- **Adaptive-trigger effects are length-bounded** on encode and decode against one shared constant;
the header emits `uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]` in place of a literal `11` (same value,
so the struct layout is byte-identical). A zero-length effect body is now rejected rather than
decoding as an empty — that is, a *release* — effect.
- Out-of-range pad indices are dropped before **either** rumble consumer sees them. The reorder gate
bounds-checked and the legacy queue did not, so an embedder draining it could be handed an index it
would use to subscript its own array. The client also clamps the host's rumble lease receive-side
at 5 s, where the ceiling had been sender-side only.
### Host environment variables
| Variable | Default | Notes |
|---|---|---|
| `PUNKTFUNK_AUDIO_QUALITY` | `high` | `low`/`standard`/`high`; `high` = stereo 256 kbps. `standard` reproduces the pre-0.25 encoder exactly for an A/B. A typo warns once rather than silently downgrading. |
| `PUNKTFUNK_AUDIO_REDUNDANCY` | unset = automatic | on when the client supports it and the budget allows |
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | platform default | art-serving roots; POSIX now defaults to `$HOME` |
| `PUNKTFUNK_DECODER` | client | **values changed**: `native-vulkan` · `native-vaapi` (Linux) · `native-d3d11va` (Windows) · `software`. Legacy `vulkan`/`vaapi`/`d3d11va` still accepted and migrated. Now **trimmed** — a trailing space used to fall through to `auto` silently. |
"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"
}
}
}
@@ -1301,6 +1301,79 @@
}
}
},
"/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":[
@@ -4045,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.",
"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.",
@@ -6294,6 +6443,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":"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."
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.