Commit Graph
2834 Commits
Author SHA1 Message Date
enricobuehler 79ee308a7a build(rpm): declare all seven FFmpeg pkg-config modules, not three
`ffmpeg-next` is pulled with default features, so `ffmpeg-sys-next`'s build script
pkg-config-probes codec/device/filter/format/util/resampling/scaling and panics on
the first one missing. The spec named three.

RPM Fusion's `ffmpeg-devel` ships all seven in one package, which hid it. On a host
where those three instead resolve to Fedora's split `libav*-free-devel` packages,
`dnf builddep` installs exactly three and the build dies in a build script:

    The system library `libavfilter` required by crate `ffmpeg-sys-next` was not found.
2026-08-06 15:03:05 +02:00
enricobuehler 58ee74cb58 fix(clients/windows): clippy's manual_is_multiple_of on the rescan tick
android / android (pull_request) Successful in 6m8s
ci / web (pull_request) Successful in 4m50s
ci / docs-site (pull_request) Successful in 1m14s
ci / rust-arm64 (pull_request) Successful in 5m50s
apple / swift (pull_request) Successful in 1m32s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 12m37s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m18s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
`cargo clippy -p punktfunk-client-windows -- -D warnings` fails on main with the
pinned 1.96.0 toolchain: `ticks % 5 == 0` trips `manual_is_multiple_of`. Pre-existing
and not from this branch — found while gating the launcher work on .173, because
neither macOS nor Linux ever compiles this crate.

Clippy's own suggestion, applied verbatim.
2026-08-06 14:50:18 +02:00
enricobuehler 6ae2ea6708 Merge remote-tracking branch 'origin/main' into worktree-library-clients 2026-08-06 14:45:36 +02:00
enricobuehler 00d4026054 Merge pull request 'Worktree field kleisty triage' (#69) from worktree-field-kleisty-triage into main
arch / build-publish (push) Failing after 40s
apple / swift (push) Successful in 1m26s
ci / web (push) Successful in 1m10s
ci / docs-site (push) Successful in 2m30s
deb / build-publish (push) Successful in 3m43s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
deb / build-publish-client-arm64 (push) Successful in 2m23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 6m51s
docker / builders-arm64cross (push) Failing after 25s
docker / deploy-docs (push) Failing after 1m57s
release / apple (push) Successful in 9m17s
deb / build-publish-host (push) Successful in 7m58s
android / android (push) Successful in 12m19s
ci / rust (push) Successful in 12m1s
flatpak / build-publish (push) Successful in 9m40s
apple / screenshots (push) Successful in 5m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 15m55s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m50s
windows-host / package (push) Canceled after 2m58s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 1s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reviewed-on: #69
2026-08-06 12:41:28 +00:00
enricobuehler dd20a17edb test(host/library): the art tests build a file:// URL Windows can read
`local_art_bytes_is_confined_and_image_only` and `posix_local_art_is_classified_and_proxied`
built their `file://` values as `format!("file://{path}")`. On Windows that yields
`file://C:\covers\cover.png`, whose authority is `C:` — a UNC reference, not a local
file — so the read half failed on the box and the host suite was red there.

The parser is right and the tests were wrong: `@punktfunk/plugin-kit/library`'s `fileUrl`
emits `file:///C:/covers/cover.png` (three slashes, forward separators) and
`file_url_to_path` documents exactly that. A shared `file_url` helper now builds the value
the way the kit does, so both tests exercise the real plugin contract on both platforms
rather than a shape no plugin ever sends.

Found while gating the Playnite launch kinds on .173 — Linux CI never compiles these arms,
so the failure had gone unnoticed. Test-only: no product code changes.
2026-08-06 14:36:04 +02:00
enricobuehler 8ff2c2e1c6 feat(host/library): Playnite can publish again, and gets a fullscreen tile
The Playnite plugin emits `kind: "command"` for every game (a `start "" "playnite://…"`
shell line). The 2026-08-05 review made `command` operator-only, and `privileged_field`
refuses a PROVIDER reconcile carrying one — so on this branch the published
`@punktfunk/plugin-playnite@0.3.0` cannot publish anything at all. Not a launcher tile:
not one game. That is a regression against a shipped plugin, and it is the same hole
`launcher_ui` was created to close, one kind further along.

Two kinds, both host-owned so D1 holds — the plugin supplies a validated VALUE and
never a command line:

  playnite     valued by the game's GUID; resolves to
               explorer.exe "playnite://playnite/start/<guid>", the same
               protocol-via-a-concrete-EXE shape the `epic` kind uses. GUID-validated
               on the way in (so a bad value is a 400 the plugin author can act on)
               and again at launch.

  launcher_ui  now accepts "playnite" on Windows, resolving to
               Playnite.FullscreenApp.exe with Playnite's own install dir as the
               working directory.

Fullscreen, not Desktop, is the whole point of a couch tile — and it is also why this
one cannot ride the URI the games use: probed on .173, Playnite's registered
`playnite://` handler is bound to Playnite.DesktopApp.exe, so no URI opens fullscreen
mode. The exe is spawned directly, with the install dir read from Playnite's own
uninstall entry (HKCU, then HKLM for a machine-wide install), falling back to
%LOCALAPPDATA%\Playnite.

`valid_launcher_ui("playnite")` is answered by RESOLUTION rather than by a static list:
a host without Playnite installed refuses the entry instead of publishing a tile that
does nothing when a user clicks it. That is the same instinct that left Epic, GOG
Galaxy and the Xbox app off the list — each still needs its own verified activation,
and a guess would ship exactly that dead tile.

Gates: punktfunk-host 436 passed / 0 failed on .21 (the Linux arms), and the Windows
arms compiled and their library tests run on .173.
2026-08-06 14:35:48 +02:00
enricobuehler 883c317872 feat(clients/library): a launcher tile looks like one, on every client
The host has been able to describe a launcher entry since M2 — `role: "launcher"`,
the `steam_ui` and `launcher_ui` kinds — and the web console has grouped them into
their own rail since M4. No other client ever looked. `pf-client-core` decoded
`role` into an `is_launcher()` helper with zero call sites, and the shared console
model dropped the field entirely on its way to the renderer.

So a launcher tile arrived everywhere else as an ordinary game with no cover art:
indistinguishable from a title whose poster failed to load, sorted into the middle
of the alphabet, and captioned "Play".

One contract, implemented in each client's own idiom:

  * launchers never interleave with titles — they lead, and each group keeps the
    host's title order
  * grid surfaces get a labelled section; a coverflow keeps its single carousel and
    names the group the cursor is in, changing as it crosses the boundary. A second
    focus rail would mean a new up/down nav model in three renderers for two or
    three tiles
  * an art-less launcher gets an accent face naming its launcher, not a title
    monogram on the neutral one — "opens Steam", not "a cover that didn't load"
  * anything that is not `"launcher"` is a game, and a host that omits the field
    renders exactly as before (design D4's intended degradation)
  * launching is unchanged: the client sends an id, the host resolves the recipe

The grouping is enforced once per client stack rather than per screen. In the
console UI it is an invariant of `LibraryShared::set_games`, so the cursor
arithmetic, the art pump and every future consumer inherit it; on Apple and Android
it is applied where the library is fetched/parsed.

Fixed in passing: the Apple and Android store badges were hard-coded
`isCustom ? "Custom" : "Steam"`, so every Lutris, GOG, Heroic, Epic and Xbox title
was labelled "Steam". Both now carry the same store table the Rust clients use.

The CLI's `--library` gains a fourth column (`game`/`launcher`), appended rather
than folded into an existing one so anything reading the first three is untouched.

Gates: punktfunk-host 436 passed / 0 failed and pf-console-ui 49 passed / 0 failed
on .21 (three new tests), workspace clippy -D warnings and cargo fmt --check clean
there; `swift build` of the full PunktfunkClient and `:app:compileDebugKotlin` clean
on macOS; `cargo check` + `clippy -D warnings` for the Windows client on .173.

Still unproven on hardware: no launcher tile has been clicked on a real host — that
needs the plugins published, which needs this branch's base merged first.
2026-08-06 14:35:24 +02:00
enricobuehler 72777119fd fix(client/android): stop reporting every disconnect as a lost connection
ci / web (pull_request) Successful in 1m6s
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m50s
android / android (pull_request) Successful in 3m43s
ci / rust-arm64 (pull_request) Successful in 4m26s
ci / rust (pull_request) Successful in 7m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 12m38s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 8m23s
The stream watchdog polled a bare "has the session ended" boolean, so it
had exactly one thing it could say and said it every time: "Connection
lost — the host may be asleep. Wake it to reconnect." That ran when the
player quit their game, when an operator ended the session from the
console, and when they pressed Back themselves — telling them to go wake
a host that was never asleep.

It now reads the end reason. Only a connection that actually died gets
that line, a host-side failure gets its own, and the three deliberate
endings say nothing at all: leaving the stream is already the feedback,
and a toast on top of it is just noise.

A game launched from a library also returns to that library instead of
host selection, which needs the intent hoisted out of the console shell:
the stream replaces that shell in the composition, discarding the
`remember`s holding its screen and host, so by the time the session ends
there is nothing left to navigate back with. The parent holds it across
the gap and the shell consumes it on the way in. The touch UI has no
library — only the console shell does — so there it is the toast fix
alone.
2026-08-06 14:30:59 +02:00
enricobuehler 81b4f76c4d fix(client): a session ending on purpose stops reading as a failure
The desktop clients turned every host-side close into "Host ended the
session", and a reason string means "abnormal" to everything downstream:
the GTK and Windows shells raised a banner, the console overlay drew a
status strip. Quitting a game you launched yourself produced all of
that. Now only a host error or a lost connection carries a message; the
deliberate endings return the silence those shells already give a clean
exit, which is also what puts the console back in its library with
nothing in the way.

The Apple client gains the same distinction. It had one line for every
ending — "Session ended by <host>." — which is fine for an operator
stopping the session and wrong for a link that died, so each now says
what happened. A game exiting stays silent and returns to the library it
was launched from.

Both read the reason while the connection is still up, because tearing
it down is what makes it unreadable, and both fall back to their previous
wording when there is no verdict — an older core, or a close that raced
the read — rather than inventing a new one for a case they cannot see.
2026-08-06 14:30:46 +02:00
enricobuehler ec44496285 feat(client): tell clients WHY a session ended, not just that it did
A session ending was a single bit. A player quitting their game, an
operator ending the session from the console, a stop the client itself
asked for, a host crashing and a Wi-Fi drop all arrived as the same
"closed" — so every client had to write one message covering all of
them, and every client picked an error. That is how quitting your own
game came to be reported as trouble on all three.

The information was already there and thrown away: the host closes with
APP_EXITED when a launched game exits, with 0 when it ends the session
cleanly and 1 when it fails, and a link that simply dies never closes at
all. The connection watcher now classifies that into a
PunktfunkEndReason — local, game exited, host ended, host error, lost —
and latches it before the shutdown flag, since the two are read by
different threads and the reason must never arrive second.

Exposed as punktfunk_connection_end_reason. This replaces the
game-exited flag added a moment ago rather than joining it: that
question is one row of this table, and it was never released. Still
additive to any embedder that ignores it, and the host sends the same
bytes either way, so the wire is untouched.

`is_normal()` is the question nearly every caller actually has, so both
the Rust and C surfaces answer it directly rather than making each
client re-derive which of five values are worth alarming a user about.
2026-08-06 14:30:33 +02:00
enricobuehler 7a31f1089e feat(client): M6's conversion half — one AuPlan into libva's buffers
The second half of pf-vaadec: picture parameters, inverse-quantization
matrices and one slice-parameter record per slice, over the same transaction
discipline pf-dxvadec uses — validate, resolve references against the
PRE-removal slot map, then apply removals and assign the setup slot last. A
half-applied DPB update is the shape of a corrupt reference, so nothing
mutates until every fallible step has passed.

Three things VAAPI wants that neither other backend does, all of which the
existing plan already carries:

A bit offset. slice_data_bit_offset is where slice_data() begins, counted from
and including the NAL header byte with emulation-prevention bytes removed —
DXVA takes a byte offset, Vulkan takes nothing. It costs no new parsing: the
vendored parser records exactly that as SliceHeader::header_bit_size, because
cros-codecs' own production backend is VAAPI.

The slice data without its start code, since that offset is relative to the
NAL header byte. SlicePlan::data is start-code-inclusive and the prefix is
three OR four bytes — the host emits four on every access unit — so it is
measured per slice rather than assumed. Assuming it is the defect that made
HEVC unplayable on every driver.

The per-slice reference lists. DXVA's short-format slice control expresses no
lists at all; VAAPI wants RefPicList0/1 in 8.2.4.2 order, which is what the
plan's derived lists already are.

And the distinction that cost M5 a defect, now written down in a third place:
reference_frames is documented "in DPB", the same statement DXVA's
RefFrameList makes and the opposite of Vulkan's pReferenceSlots. It is filled
from the marked-DPB snapshot; the per-slice lists come from the slice's own.
Getting that backwards loses a long-term reference no slice happens to name.

Weight tables follow 7.3.3's presence rule rather than being copied
unconditionally: flagged only where the PPS actually enables explicit
weighting for that slice type and list. Flagging them otherwise hands the
driver defaults as though the stream had coded them. The vendored
PredWeightTable stores luma_offset_l0 as [i8; 32] but luma_offset_l1 as
[i16; 32] — an upstream inconsistency, not a semantic one — so the narrow side
widens.

Envelope refusals are errors, never silent narrowings: slice groups, separate
colour planes, a capacity mismatch, a reference holding no slot, lists past
their array bounds, a slice range outside its access unit.

Tests: 15. The one that matters walks all 250 access units of the vendored
conformance vector through H264Planner and this conversion, asserting per
slice that the range lies inside its access unit, that the declared size
matches it, that the start code really was trimmed, and that the header
neither is zero bits nor outruns the slice — plus that reference_frames
carries exactly as many valid entries as the marked DPB and every entry past
it is invalidated. It also asserts it saw a multi-slice picture and a
non-empty reference set, so a splitter bug cannot make it vacuous. Gates:
rustfmt, clippy, cargo doc with no unresolved links, and the container's
clippy -D warnings, tests and workspace check.
2026-08-06 14:22:33 +02:00
enricobuehler d74639de70 Merge pull request 'A safe-area resolution that keeps the picture out of the notch' (#68) from worktree-launchers-safearea-exclusions into main
apple / swift (push) Successful in 1m28s
ci / rust-arm64 (push) Successful in 1m53s
android / android (push) Successful in 5m48s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
ci / web (push) Successful in 1m4s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / docs-site (push) Successful in 1m12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 34s
ci / rust (push) Failing after 9m39s
release / apple (push) Successful in 9m11s
apple / screenshots (push) Successful in 5m42s
Reviewed-on: #68
2026-08-06 11:59:31 +00:00
enricobuehler d4dd5f7a3d feat(client): a game exiting takes you back to its library
Quit a game you launched from a host's library and the stream ended with
"Session ended by <host>." on the host-selection screen — an error
report for something you had just done on purpose, and several taps away
from starting the next title.

The host has always said what happened: it closes the connection with
APP_EXITED when the game it launched for a session exits, and that
code's own documentation describes this feature. Nothing ever read it —
a search across every client found zero consumers. (It also could not
reach anyone until the previous commit, since the close only happens
once the lease declares the game gone.)

The core now records the reason as it observes the close, latched before
the shutdown flag because different threads watch the two, and exposes
it as punktfunk_connection_game_exited. Purely additive: a client that
never asks behaves exactly as before, the host sends identical bytes,
and the wire version is untouched — ABI 17.

The Apple client asks while the connection is still up, then treats a
game exit as the normal finish it is: no error banner, and if the
session began as a library launch it reopens that library so the next
title is one tap away. Any other ending — a stop, the host going away,
network loss — is unchanged. The other clients keep their existing
end-of-session behaviour; the call is there when they want it.
2026-08-06 13:58:57 +02:00
enricobuehler ea762b849d fix(client/ios): Escape stays in the game instead of freeing the pointer
Pressing Escape mid-stream on an iPad handed the mouse back to iPadOS:
the captured cursor was swapped for the system one and the game stopped
receiving relative motion, so aiming died until you clicked back in.
Two previous attempts treated that release as unavoidable and built
recovery around it — a re-lock burst, then a click that re-asks. Both
came back from the field unchanged, because both fought the release
after it had already happened, inside the cooldown the platform applies
straight after its own "let me out" gesture.

The release was never unavoidable. This app had no UIKit key handling at
all: every key arrives on the GameController path, which is a parallel
HID feed that does not consume the UIKit event, and the only thing that
ever became first responder was the video view, and only to summon the
soft keyboard. So every hardware Escape reached UIKit unclaimed — and an
unclaimed key press is precisely what lets the system apply its own
default for that key. Apps that read a hardware keyboard the ordinary
way consume the event as a side effect and never see this.

So claim it. The stream controller becomes first responder while capture
is engaged and takes Escape in pressesBegan/pressesEnded, passing every
other press to super untouched. Escape still reaches the host on the
GameController path, so in-game menus open exactly as before; only the
system's own interpretation is suppressed. Scoped to captured input, so
Escape keeps dismissing sheets and leaving full screen whenever the
stream doesn't own the keyboard, and the deliberate ways out are
untouched — Cmd-Escape and Ctrl-Opt-Shift-Q are read off the same
GameController path and clear capture themselves.

The recovery path stays as a backstop and is retimed to match what was
measured: the old burst spent its entire budget within ~0.6 s of the
drop, i.e. wholly inside the cooldown, where the answer can only be no.
Retries now continue at 1.2 s and 2.4 s, and quietly — they don't hide
the cursor or mute pointer motion the way the burst does, so a longer
recovery costs nothing when it fails.
2026-08-06 13:58:41 +02:00
enricobuehler 76e8bd1b98 fix(host/gamelease): a game that exited stops counting as running
When a launched game's processes are all gone, the watcher asks one last
out-of-band question before ending the session: does the launcher still
think the game is up? On Windows that reads Steam's per-app `Running`
registry flag. It was only ever meant to be a tie-breaker for a scan that
momentarily can't see the game — a launcher re-execing, an engine
relaunching itself into a new pid.

It had no bound. Honouring the flag reset the confirm window every pass,
so a flag Steam left set — it does that whenever it doesn't cleanly
observe the exit: it crashed, it was closed first, the game re-parented —
pinned the lease in `running` for the life of the host. The console kept
showing the game, `session_on_game_exit` never fired, and the only way to
get the stream back was a manual "End". Reported from the field on
Windows 0.24.0. `steam_running_hint` also believes the FIRST hive that
says so, so a stale flag in any loaded profile was enough.

The absence timer now keeps running instead of being reset, and that is
what bounds it: past `VETO_LIMIT` (30 s) with nothing of the game on the
box, the launcher's opinion is stale rather than early and the session
ends anyway, logged at WARN so it is visible. Ending a moment early is
the cheaper failure — the stream drops while the game lives, the user
reconnects, and nothing is ever killed. Ending never was the bug.

The rule is now a pure `exit_confirmed(gone_for, hint_running)` with a
test. The watch loop polls a live process table and can't be unit-tested,
which is exactly how an unbounded veto shipped unnoticed.
2026-08-06 13:58:24 +02:00
enricobuehler 509843f0d4 test(client): the D3D11VA rung's ten-bit path, measured too
The companion to the Vulkan ten-bit leg, over the same vector and the same
P010 goldens — one golden file serves both rungs because a D3D11 P010 surface
and Vulkan's 3PACK16 family hold the ten bits in the same place.

This is the rung where the gap mattered most. D3D11VA exposes no per-picture
status query at all, so its HDR evidence was a session that built a Main10
decoder and streamed without complaint — which is precisely what a Main10
stream decoding to garbage would also produce. Now there is a number.

It exercises geometry the eight-bit legs cannot reach: P010 samples are two
bytes, so a row is width * 2 rather than width, and HEVC's 128-line granule
pads a 240-line picture to a 256-line surface — so the chroma plane starts a
long way from where the display height alone would put it. Getting either
wrong is the smeared-rows failure this project has already paid for once, and
it would have looked like a decoder fault.

The run body now takes the stream format and the expected access-unit count
rather than assuming the eight-bit envelope and 250 frames.

A CPU guard pins the vector at ten bits — 4:2:0, both depths minus8 == 2,
320x240, 50 access units. A regenerated eight-bit vector would otherwise turn
this into a second run of the eight-bit path under a ten-bit name, passing,
because its goldens would have been regenerated with it.

Hardware: HEVC Main 10 50/50 bit-identical on the RTX 4090 and on the AMD
Radeon iGPU, alongside the unchanged eight-bit legs at 250/250 on both. With
the Vulkan leg's two drivers that is four independent drivers across two
rungs for the ten-bit path, where yesterday there were none.
2026-08-06 13:53:23 +02:00
enricobuehler fbdad8d917 Merge pull request 'fix(clients): host discovery heals itself, and every client can rescan' (#67) from worktree-host-discovery-refresh into main
ci / web (push) Successful in 1m14s
apple / swift (push) Successful in 1m26s
ci / docs-site (push) Successful in 1m20s
deb / build-publish (push) Successful in 3m53s
deb / build-publish-host (push) Successful in 4m14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
ci / rust-arm64 (push) Successful in 6m58s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 17s
docker / builders-arm64cross (push) Skipped
deb / build-publish-client-arm64 (push) Successful in 2m33s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
android / android (push) Canceled after 8m10s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Successful in 8m22s
ci / rust (push) Canceled after 8m34s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m13s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 7m29s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 1m13s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m37s
flatpak / build-publish (push) Failing after 11m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 13m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 15m37s
Reviewed-on: #67
2026-08-06 11:51:30 +00:00
enricobuehler 78ad675507 feat(clients): a safe-area resolution that keeps the picture out of the notch
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / docs-site (pull_request) Successful in 1m24s
ci / web (pull_request) Successful in 2m2s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m55s
ci / rust (pull_request) Successful in 7m19s
Picking the device's native mode on a phone hands the host the panel's own
aspect ratio, so the aspect-fit presenter fills every pixel — including the ones
behind the sensor housing and under the four rounded corners. That is why the
corners look cut off at max resolution while 1080p has always been fine: a 16:9
mode on a 20:9 phone pillarboxes, and those black bars land exactly on the
unsafe regions.

So the fix is entirely a sizing one — no layout change, no input change. Ask the
host for a mode narrowed by the unsafe inset and the existing aspect-fit centres
it inside the safe region; pointer mapping follows for free, because both
clients derive the picture rect from the live host mode rather than assuming
full-bleed.

Apple: `SafeDisplay` (PunktfunkShared, pure + unit-tested) and a "This device
(safe area)" row beside the native one, using Moonlight's formula — full native
height, width less the left+right safe insets. The stream is always landscape
but the settings screen may be portrait, where the same housing is reported on
`top` and the horizontal insets read zero; the portrait top inset stands in,
gated so an iPad's status bar never fabricates an inset.

Android: the same shape via `SafeArea` + a `SAFE_AREA_MODE` sentinel resolved at
connect like the existing `0`=native one. The cutout insets get the same
portrait fallback, and the rounded corners are added on top — Android does not
count them as cutout, and a full-height picture needs exactly the corner radius
of horizontal clearance.

Both even-floor and clamp, since `validate_dimensions` rejects odd dimensions
and an inset subtraction lands odd about half the time. Where a display has
neither cutout nor rounded corners the safe mode equals the native one, which on
Apple lets the existing dedup drop the duplicate row.
2026-08-06 13:44:01 +02:00
enricobuehler c91a482b4e test(vkdecode): the ten-bit path finally has pixels
Every golden set in this program was eight-bit. So the strongest thing anyone
could say about ten-bit decode was that a Main10 session BUILDS and streams
clean — which is not the same claim, and is exactly the shape of claim this
program has been burned by. A Main10 stream decoding to garbage logs just as
cleanly: HEVC Main10 on D3D11VA has no per-picture status query at all, and on
the Vulkan side the devices that matter report queryResultStatusSupport=false.
The HDR legs were measuring that the pipe ran, not that the pixels were right.

So: a Main10 vector and its goldens, and a ten-bit leg that runs them.

The vector is 50 frames of 320x240 HEVC Main 10 4:2:0 from libx265 — 48 KB,
generated by a command recorded in the golden file's header along with
everything else needed to regenerate it. The goldens come from libavcodec's
software decoder and were cross-checked between two independent builds on two
architectures (ffmpeg 8.1.1 Homebrew/macOS-arm64 and 8.0.1 Ubuntu/x86_64),
which agreed on all 50.

The goldens are P010, NOT yuv420p10le, and that distinction is the whole
reason this could have quietly gone wrong: P010 puts the ten bits in the HIGH
bits of each little-endian 16-bit word with the low six zeroed, which is what
a D3D11 P010 surface and Vulkan's G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16
both contain. Hashing LSB-aligned samples against MSB-aligned ones would fail
on every frame on every driver, for a reason that has nothing to do with
decoding. One golden file therefore serves both native rungs.

The readback is now depth-aware. Its only eight-bit assumption was the second
region's buffer_offset, which is a BYTE offset where the extents around it are
TEXELS — that plus the buffer size are the whole change, because
bufferRowLength = 0 already packs rows at the copy extent. The expected pool
format moved onto the readback so the sizing and the per-frame assertion come
from one source; a readback sized for eight bits that then accepted a ten-bit
frame would hash half a picture and blame the decoder.

A CPU guard asserts the vector really is ten-bit — 4:2:0, both depths
minus8 == 2, 320x240, 50 access units, 50 planned outputs. Without it a
regenerated eight-bit vector would turn the ten-bit leg into a second run of
the eight-bit path wearing a ten-bit name, and it would PASS, because its
goldens would have been regenerated alongside it. That guard is not ignored,
so it runs on macOS and in the container rather than only on the fleet.

Hardware: HEVC Main 10 50/50 bit-identical on NVIDIA 610.43.03 (Linux) and on
the Steam Deck's RADV/VanGogh — first run on both, which also confirms the
P010/3PACK16 layout match rather than assuming it. The four eight-bit legs are
unchanged and still green on both boxes.
2026-08-06 13:32:31 +02:00
enricobuehler b25e6eda91 fix(clients): host discovery heals itself, and every client can rescan
ci / web (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m32s
ci / docs-site (pull_request) Successful in 4m16s
android / android (pull_request) Successful in 6m25s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 7m14s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 3m30s
ci / rust (pull_request) Successful in 15m13s
A field report from an iPad: the host is not found on first run, and
restarting the client finds it. Pull-to-refresh appeared to do nothing.

Both were real. The Apple client's discovery had three ways to go
permanently deaf, each needing an app relaunch to clear:

- A failed resolve was never retried. `browseResultsChangedHandler`
  only fires when the result SET changes, and a host whose resolve
  failed is still in the set — so nothing ever re-offered it.
- A stuck resolve never ended. `NWConnection` has no timeout, so the
  throwaway UDP flow used to resolve an address could sit in
  `.preparing`/`.waiting` forever, and a service with a connection in
  flight was skipped.
- `NWBrowser` parking in `.waiting` was ignored (only `.failed`
  re-armed). On iOS that is where the local-network privacy prompt
  lands on first launch after install: the browse starts, the system
  asks, and the browser waits. Granting does not revive that browser —
  only a new one sees the grant. That is the reported first-run bug.

HostDiscovery now runs a 1 Hz sweep that times out stuck resolves,
retries failed ones on a 1→30 s backoff, and re-arms a browser that
stopped working; the advert's TXT is re-read on every browse report, so
a host that re-keys or flips its pairing policy is followed. Returning
to the foreground re-arms the browse (iOS/tvOS: `onAppear` does not
fire across background/foreground, and a suspended browse stays dead).

Pull-to-refresh did nothing because there was no `.refreshable` in the
client at all. Added, plus the explicit control the report asked for:
a toolbar Refresh on iOS/macOS, an action-row button on tvOS, a Rescan
tile in the gamepad launcher, Scan Again on the empty state, a
header-bar button in the GTK client, a hosts-page button on Windows,
and Scan again on Android. Decky already had one.

The desktop/Android browses needed a rescan trigger to make those
buttons mean anything: mdns-sd re-queries on a doubling backoff capped
at ONE HOUR, so a long-lived browse is effectively passive and a host
that appears later can stay invisible. `discovery::Rescan` forces a
fresh query; the wake-and-wait loops use it too, so a host that just
booted is noticed in seconds rather than at the next backoff tick.

Also fixed, found on the way: clients/windows/src/discovery.rs is a
second copy of the browse that d0fa8bd3 ("pin mDNS discovery to IPv4 on
every client") missed. It took an arbitrary first address, so when a
host's OS responder answered AAAA the Windows GUI rendered a card that
failed on every click. It also never noticed a dropped receiver, leaking
a thread and a :5353 socket per wake-and-wait.

Gates: Apple macOS + iOS (arm64-apple-ios17.0, proven non-vacuous) build
clean, 195 tests pass incl. a new one asserting a rescan re-finds a
still-advertising host. On .21: fmt, clippy --all-targets -D warnings
and build clean for pf-client-core + client-linux + client-session,
117 tests pass. Android :kit: and :app: compileDebugKotlin clean.
The Windows client is UNGATED — its CI runner was unreachable.
2026-08-06 13:30:10 +02:00
enricobuehler c79d9397fe Merge pull request 'fix(flatpak): the WSI layer module builds again — vkroots was declared twice' (#65) from worktree-flatpak-vkroots into main
ci / rust-arm64 (push) Successful in 1m22s
ci / web (push) Successful in 1m21s
ci / docs-site (push) Successful in 1m44s
flatpak / build-publish (push) Successful in 6m22s
ci / rust (push) Successful in 7m50s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 51s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
docker / deploy-docs (push) Failing after 10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders-arm64cross (push) Skipped
Reviewed-on: #65
2026-08-06 11:12:50 +00:00
enricobuehler d3e000768d feat(client): M6 begins — the libva layouts, measured rather than transcribed
The VAAPI rung's crate, in the shape the other two native rungs established:
everything that can be a pure decision or a pure conversion lives in a
cross-platform crate the ordinary gates run, and only the parts that genuinely
need a device stay behind a platform cfg. This lands the first half of that —
the buffer layouts and the decoder-creation decisions — with the conversion to
follow.

Route: minimal FFI rather than cros-libva. The plan of record permits either
("cros-libva (or minimal FFI)"), and hand-declaring keeps the crate building
and testing on macOS and in the Linux container, which is the property that
made pf-dxvadec's defects findable on a laptop instead of on a box.

The layouts are not eyeballed. A C probe compiled against real libva 2.23.0
headers printed sizeof/alignof/offsetof for every field and set individual
bit-fields to read the resulting word back; those numbers are pinned as const
assertions, so a transcription slip is a compile error rather than a driver
reading the wrong byte. The probe is committed beside them, with the command
that runs it, because evidence that cannot be re-run is a claim.

What the probe settled that a reader would otherwise get wrong: VAPictureH264
is 36 bytes and is embedded 81 times across the two buffers, so its size is
load-bearing for every later offset; the three DEPRECATED FMO fields still
occupy bytes 624..628, and dropping them would shift everything after; and C
bit-fields allocate from the least significant bit on this ABI — proven, since
that is ABI-defined rather than standardised.

Groundwork for the conversion, established here so the next work package
starts from facts:

slice_data_bit_offset needs no new parsing. VAAPI is the only backend that
wants a bit position — DXVA takes a byte offset, Vulkan takes none — and the
vendored parser already records exactly it as SliceHeader::header_bit_size,
computed as (nalu.size - epb) * 8 - bits_left: from and including the NAL
header byte, emulation-prevention bytes removed. That is the field's
definition verbatim, and it is there because cros-codecs' own production
backend is VAAPI.

The slice data buffer starts at the NAL header byte, so the start code is
skipped — SlicePlan::data is start-code-inclusive and the prefix is three OR
four bytes, the host emitting four on 100% of access units.

reference_frames is the marked DPB, the same statement DXVA's RefFrameList
makes, so it comes from the dpb_refs snapshot; Vulkan's pReferenceSlots is the
opposite and takes the access unit's own set. All three conventions now have a
written home, which is the distinction that cost M5 a defect.

Unlike DXVA short-format, VAAPI wants the per-slice reference lists and the
full prediction weight tables inline — hence the 3128-byte slice record. One
wrinkle recorded rather than left to be discovered: the vendored
PredWeightTable stores luma_offset_l0 as [i8; 32] but luma_offset_l1 as
[i16; 32], and libva wants i16 for both.

Profile selection resolves H.264 to High for every 8-bit 4:2:0 stream instead
of reading profile_idc, because High is a superset for the tools our hosts
emit and picking Main for a stream that turns out to use 8x8 transforms is a
mid-stream failure where picking High is not. 4:4:4 and 10-bit H.264 are
refused rather than narrowed to an 8-bit profile — that class of silent
narrowing decodes to garbage instead of failing.

11 tests: the probe's bit patterns, a disjointness check per bit-field word
(two probe vectors alone would not catch a shift typo that overlapped two
fields), and the envelope refusals. Gates: rustfmt, clippy, cargo doc with no
unresolved links, and the Linux container's clippy -D warnings, tests and
workspace check.
2026-08-06 13:04:07 +02:00
enricobuehler 3edb01f1b8 Merge pull request 'Gamepad UI: section tabs, background palettes, and a backdrop that moves everywhere' (#66) from worktree-gamepad-ui-polish into main
ci / web (push) Successful in 1m14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 20s
apple / swift (push) Successful in 1m37s
ci / docs-site (push) Successful in 1m43s
ci / rust-arm64 (push) Successful in 1m55s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m51s
android / android (push) Successful in 7m2s
docker / deploy-docs (push) Failing after 39s
ci / rust (push) Successful in 7m14s
deb / build-publish (push) Successful in 5m14s
deb / build-publish-host (push) Successful in 5m56s
flatpak / build-publish (push) Failing after 7m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m21s
deb / build-publish-client-arm64 (push) Failing after 11m14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 11m56s
arch / build-publish (push) Failing after 13m25s
docker / builders-arm64cross (push) Skipped
release / apple (push) Successful in 12m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m41s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m21s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m25s
apple / screenshots (push) Successful in 6m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m52s
Reviewed-on: #66
2026-08-06 10:50:25 +00:00
enricobuehler 5a7f7f0fc5 feat(clients/gamepad-ui): section tabs, background palettes, and a backdrop that moves everywhere
ci / web (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 1m42s
ci / rust-arm64 (pull_request) Successful in 2m36s
android / android (pull_request) Successful in 3m33s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 8m37s
ci / rust (pull_request) Successful in 8m58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m47s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
The console settings were one 30-row scroll, which on a Deck meant thumbing past
Video and Audio to reach the pad settings. They are now split across sections —
Stream · Video · Audio · Controller · Interface · Profiles, plus Input on the
desktop console, which alone carries the touch/mouse rows. L1/R1 walks them,
each section remembers where its cursor was, and the names are the same word on
every client so a setting is where you looked for it last.

Shoulders are not the only route, because a D-pad remote hasn't got any: on
Android, Up from the first row moves onto the strip (left/right walks sections
there, A drops back in), and on tvOS the pills are focusable, so the focus
engine handles it — a Siri Remote has no extended gamepad profile and never
reaches the input poll at all. The desktop console needs neither; PageUp and
PageDown already map to the same events.

New "Background" row, six palettes: Violet (the brand default), Tide, Forest,
Ember, Rose, Graphite. A palette is a hue rotation plus a saturation scale over
the ONE colour field each client already draws, so every palette inherits its
structure and Violet is the identity transform — existing installs see exactly
what they see today. The maths is ported three times (Rust/Swift/Kotlin) under
one shared `ui_palette` key, with the same assertions pinned in each language.
It is presentation only, so it is a device preference and never part of a
profile.

The form screens no longer have a backdrop of their own. Settings, add-host and
pair used to sit on a still gradient; they now wear the same living field at a
calm mix — pools dimmed onto the palette's own corner colour, vignette halved so
rows that run to the edges don't get crushed. On the desktop console that
collapsed the old aurora-over-static crossfade into one shader pass with a
chased uniform. Motion speed is identical in both modes on purpose: changing it
would make the field jump mid-transition. Nothing in the gamepad UI is backed by
a static image now, and Reduce Motion (Apple) / "remove animations" (Android)
still freeze it.

Also: the settings screen had no raster coverage at all — the eyeball dump is
`#[ignore]`d — so a new test draws every tab, and the Android screenshot set
gains a console-settings scene. Both earned their keep immediately: the renders
showed the extra hint pushing "Done" off a 360 dp phone (the legend scrolls now,
and the Section cell only appears where shoulders exist) and the form backdrop
crushing its own edges.
2026-08-06 12:39:30 +02:00
enricobuehler 1573a9876b fix(client): a d3d11va soak could not tell which rung it soaked
The `stats:` line's decode-path tag is derived from the DecodedImage variant,
and both D3D11VA rungs deliver DecodedImage::D3d11 — they share the hand-off
ring on purpose. So a native-d3d11va session and an FFmpeg-d3d11va session
emitted a byte-identical tag, and nothing downstream could tell them apart.
The native Vulkan rung never had this: it carries its own variant, hence its
own `native-vulkan` tag.

That is not cosmetic, and it was found the only way it could be — by running
the rung on glass and having to grep the log to prove which one had answered.
A native pin that fails to initialise falls through to the FFmpeg rung by
design; the line it then emits is exactly the line the native rung would have
emitted. M5's owed soak and M9's vendor-matrix bake both rest on attributing
a session to a rung, and until now the machine-readable half of that evidence
could not do it. This project has already shipped one measurement that could
not tell "clean" from "unmeasured"; this is the same shape.

D3d11Frame now records which rung wrote the surface, keyed off the pin
constant itself rather than a second field the two rungs could set
inconsistently — the native rung passes DECODER_PIN into the hand-off and
nothing else does.

The stats line stays additive for every shipping session: the only value that
changes belongs to a rung that is pin-only and deliberately absent from the
automatic ladder, and the Windows shell passes the line through opaquely
rather than matching on the tag.

Verified on glass on .173, both directions: pinned native-d3d11va gives 85
windows tagged `native-d3d11va` and 0 plain, pinned d3d11va gives 64 plain and
0 native, zero errors either way. Gates: clippy -D warnings on Windows, the
Linux container's clippy/tests/workspace check, rustfmt.
2026-08-06 12:34:31 +02:00
enricobuehler 3041d9bb43 test(client): M5's decoded pixels now answer to libavcodec's
The native D3D11VA rung had no pixel evidence at all. Its DXVA bytes were
checked against libavcodec's own captured bytes, and its Intel bring-up proved
the driver accepts the submission — but nothing had ever compared what came
out. This is that comparison, against the same goldens and the same reference
the Vulkan rung was held to: libavcodec's SOFTWARE decode, which is ground
truth rather than a peer implementation, so the two rungs' verdicts are now
directly comparable numbers.

It reads back the DECODE surface, before the VideoProcessorBlt, so what is
hashed is the half this rung is responsible for; the hand-off is the shared,
field-proven half and is deliberately not in the measurement.

Finding, recorded rather than papered over: this rung presents in DECODE
order. It never consults AuPlan::dpb.outputs — submit blits setup_slot and
returns. The native Vulkan rung keeps a display-order queue for exactly that
reason, and libavcodec's D3D11VA rung reorders internally, so this rung
differs from both. It cannot bite on punktfunk streams, which are zero-reorder
and carry no B pictures, but that is a convention of our hosts rather than a
structural guarantee, and a stream that did reorder would present out of order
with nothing to say so.

Both vendored vectors DO reorder — the H.265 one's first B picture at AU 3 is
what localised the RPS slot defect — so a harness hashing in decode order
would report a permutation against display-order goldens and read like a
decoder fault. Instead each decoded surface is hashed against the PicId the
planner gave it and the hashes are emitted in the planner's own output order.
The reordering is the test's, done by the planner the rung already trusts, and
`both_vendored_vectors_really_do_reorder` asserts the reason so the docs
cannot go stale silently.

The crop reads the chroma plane at RowPitch * texture height, not display
height: the decode pool is aligned to the codec's granule and is taller than
the picture. That is the 1088-row smear this project has already paid for.

Two CPU guards run in ordinary CI. This file needs its own Annex-B splitter
(pf-client-core does not depend on the vendored parser), and a splitter that
disagreed with pf-bitstream's would fail on hardware as a frame-count mismatch
that reads like a decoder defect; instead it fails on CPU, saying so.

PF_DXVA_ADAPTER pins a GPU by description substring and every run prints the
adapters it saw — .173 enumerates its AMD iGPU alongside the 4090, and which
one answered is a fact worth printing rather than inferring.

Hardware: H.264 and H.265 both 250/250 bit-identical on NVIDIA GeForce RTX
4090 and on the AMD Radeon iGPU, Windows. Gates: clippy -D warnings and the
lib tests on Windows, the Linux container's clippy/tests/workspace check, and
rustfmt.
2026-08-06 12:17:24 +02:00
enricobuehler 5d0b269e58 test(vkdecode): parity over the start-code form the host actually emits
Both vendored vectors carry three-byte Annex-B start codes throughout. The
real host emits four-byte ones on 100% of access units in both codecs —
1514/1514 H.264 and 1133/1133 HEVC, measured off the M0 NVENC corpus through
the capture hook's own .idx offsets. So every parity verdict this program has
recorded was taken on a prefix form that never ships, and the one form that
does ship was exercised by nothing.

That gap is not hypothetical. Submitting four-byte start codes to
vkCmdDecodeVideoKHR unchanged is exactly what made HEVC unplayable on every
driver tested: drivers are validated on the three-byte form, and a fixed
+3 + 2 skip into a four-byte-prefixed slice reads a nonsense pps_id — the
115 and 119 both NVIDIAs printed. H.264 was never safe here by structure,
only by its vendored encoder's convention, which is why the cure lives in the
shared ring layer and why this coverage is generic over both codecs.

Each codec's parity body now takes its access units as a parameter and runs
twice: once over the vector as it sits, once over the same vector rewritten
to four-byte prefixes. Prefix width carries no information, so both runs must
reproduce the same goldens — sharing one body is what makes that an equality
rather than two assertions that can drift.

The rewrite copies nalu.data[nalu.offset..], the same nal_size bytes the
parser hands the planner, so trailing_zero_8bits are dropped exactly where
the production parser drops them: the only difference between the two streams
is the width of every prefix.

Two CPU guards keep the new legs from passing vacuously, which is the failure
mode they are most exposed to — a rewrite that quietly returned its input
would make them trivially green and nothing on the fleet would notice. They
assert the original really does carry three-byte prefixes, that the rewritten
stream carries none, that the NAL count is preserved exactly, and that the
planner still yields 250 pictures.

Hardware: all four legs 250/250 bit-identical to libavcodec on two
independent driver stacks — AMD VanGogh on RADV/Mesa 26.0-devel (the Steam
Deck) and NVIDIA 610.43.03 on Linux. NVIDIA is the family that rejected the
four-byte form outright, so it is the meaningful witness for this regression.
2026-08-06 12:02:03 +02:00
enricobuehler db15c2615d fix(vkdecode): HEVC decoded from the wrong references, and told drivers the
wrong slice offsets

Two independent defects, both in this crate. pf-bitstream is untouched — its
HEVC plans were sound all along, which the D3D11VA rung proves by rendering
correctly from the same AuPlans.

The corrupter: StdVideoDecodeH265PictureInfo's RefPicSetStCurrBefore,
StCurrAfter and LtCurr carry DPB SLOT indices. We wrote positions in the
reference list. libavcodec's Vulkan HEVC hwaccel — the implementation every
driver is validated against — writes the index into its own DPB array and
passes that same value as slotIndex, while packing pReferenceSlots densely
over the used entries; the two numberings are provably different there, and
the RPS arrays follow the slot.

The two readings coincide on a freshly anchored stream, because the references
then occupy slots 0..n in reference-list order. They first diverge at the
vendored vector's first B picture, AU 3, where refs are slots 0, 2, 1 — so we
named slots 1 and 2 where the picture wanted 2 and 1, and every later access
unit inherited the error through its own references. That is why this shipped
and why review could not see it: correct for the opening pictures, wrong from
the first reordering onward.

It also accounts for the measurements exactly. Display order maps to decode
order as display 0 from AU 0, display 3 from AU 1, display 2 from AU 2,
display 1 from AU 3 — so the three frames that matched on AMD are precisely
the three access units where positions and slots agree, and 250 - 3 = 247 is
the divergence count that was measured. From AU 6 the named slots stop being
merely wrong and become unbindable by that operation, which is where NVIDIA
stopped reporting a verdict at all.

The diagnostic: every slice offset must point at a THREE-byte start code.
libavcodec discards the stream's prefix and writes 00 00 01, so that is the
only pattern drivers are validated on, and pf-dxvadec's packer already
normalised for exactly this reason and said so in its docs. This path uploaded
the prefix verbatim. 249 of 250 HEVC slice segments in the vendored vector
carry a four-byte prefix; all 500 H.264 slices carry three.

That derives the driver's own complaint bit for bit. A decoder reaching the
slice header by a fixed skip lands on the NAL header's second byte, reads
first_slice_segment_in_pic_flag as 0, and then takes six bits of the real
slice header as the tail of a long ue(v): 0xd0 gives 115, 0xe0 gives 119. A
P-slice header and a B-slice header — which is why exactly two bogus pps_id
values ever appeared.

⚠ H.264 was NOT protected structurally, only by its encoder's convention, and
the real host does not share that convention: every one of 1514 H.264 access
units and 1133 HEVC access units captured from an NVENC host prefixes its
slices with FOUR bytes. The vendored H.264 vector is therefore not
representative of what ships, and its bit-exactness was passing on a prefix
form the field never sends. The normalisation lives in the shared ring layer
and covers both codecs for that reason.

rebased_offsets is replaced by pack_slices, which trims the leading zero byte
and computes the offsets from the trimmed lengths in one call, so the bytes and
the offsets cannot drift apart; upload and the CPU test go through the same
pack_into.

Hardware, after the fix — H.264 AND H.265 both 250/250 bit-identical to
libavcodec, all four smoke legs green:

  NVIDIA RTX 4090      610.88     Windows   coincide
  AMD Adrenalin        25.10.30.02 Windows  distinct
  NVIDIA RTX 5070 Ti   610.43.03  Linux     coincide

On glass on the 4090 against a real NVENC host, 2800x1260 HEVC through the
auto ladder: 73 one-second windows all native-vulkan, fps avg 59.3 of 60,
decode 1.1 ms, e2e 4.5 ms p50, and ZERO driver-reported status failures where
the same session before the fix logged 1489 in 181 seconds and had dragged ABR
down to a 5 Mb/s target. No refusals, demotions, PlanWarnings, concealment,
DEVICE_LOSTs or panics.

Both defects now have CPU tests that were confirmed FAILING before the fix:
one walks every access unit of both vendored vectors and asserts each declared
offset opens on a three-byte start code, its own NAL header and a
first_slice_segment_in_pic_flag consistent with the segment index; the other
resolves every RPS entry by slot and asserts that 247 access units disagree
with the positional reading, so it cannot go vacuous on a stream where the two
happen to agree.
2026-08-06 09:56:25 +02:00
enricobuehler 5c6b09a5c5 test(vkdecode): the HEVC GPU legs, which find M3 broken on every driver
M3 was recorded as code complete. Its exit criteria named the HEVC gpu_smoke
and gpu_parity legs, and the goldens for them were committed — 250 per-frame
NV12 hashes, cross-checked between two independent FFmpeg builds, with a
header saying they are "consumed the same way by the HEVC parity test". No
such test existed. Both GPU files were H.264 only, with zero references to
h265, so nothing had ever decoded a single HEVC frame through this crate on
hardware.

They exist now, and the first run answered. On AMD Adrenalin 25.10.30.02
(distinct mode, queryResultStatusSupport=false) 247 of 250 frames diverge
from libavcodec, and that device's smoke leg PASSES — because smoke only
reads the driver's verdict and that driver reports none. That is the Ally X
class, reproduced in-house on demand: output that is wrong everywhere the
picture is looked at and clean everywhere the decoder is asked. Both NVIDIA
drivers reject the stream outright and name the cause themselves,
"Invalid PPS/SPS id in slice header (pps_id=119 / 115)" — the identical two
values, and the smoke leg dies at the identical AU 9, on a 4090 under 610.88
on Windows and on an RTX 5070 Ti under 610.43.03 on Linux. Same wrong values,
same access unit, two GPU generations, two operating systems: deterministic,
and therefore ours rather than any driver's.

It is not an ordering fault. Five of the divergent hashes appear nowhere in
the 250 goldens, so the pixels are wrong rather than correct-but-reordered.
Parity dies at frame 1 while smoke dies at AU 9 only because smoke holds four
frames before it looks; the first inter-predicted picture is already corrupt.

The legs are committed ahead of the fix deliberately. They are the regression
test for the defect, they are #[ignore]d so no CI leg changes colour, and the
evidence above is worth recording in the order it was obtained.

Adding a third and fourth copy of ~150 lines of unsafe Vulkan bring-up was
not acceptable, so it moved to tests/common. The two behavioural differences
between the callers are now named parameters rather than accidents: the parity
legs read back on a graphics queue and require one, while the smoke legs
accept a decode-only device and fall back to the decode family — which also
decides whether pool images are EXCLUSIVE or CONCURRENT, so it is load-bearing
rather than cosmetic. H.264 came through the refactor unchanged, verified two
ways: argument-by-argument against the previous file, and on hardware, still
250/250 bit-identical on NVIDIA Windows, AMD Windows and now NVIDIA Linux.

The loader is deliberately leaked at teardown. ash::Entry owns the Arc<Library>,
so dropping it unloads the Vulkan loader with every ICD and implicit layer;
harmless while each binary held one GPU leg, but each now holds two, and the
second would re-open a loader the first had torn down.

Three guards run without a GPU, because everything above is #[ignore]d: the
golden file's count and digest shape, the HEVC access-unit split agreeing with
what the CPU planner emits (with iraps == 1 pinning "no CRA anywhere", so a
re-synced vector that opens with one fails here rather than as a frame-count
mismatch on the fleet), the vector staying Main 4:2:0 8-bit since both legs
hard-code that probe, and a refusal to run the smoke legs with
PF_VKD_TEST_READBACK set, which would quietly grow the pool a usage flag
production never carries.
2026-08-06 09:47:30 +02:00
enricobuehler 9c10b97e31 fix(client): M5's DXVA bytes now answer to libavcodec's own
The native D3D11VA rung was committed with zero hardware evidence and, more
to the point, zero evidence of any kind: every claim it made about the DXVA
structures rested on reading the specification and reading libavcodec, and
reading is exactly the method that produced the four defects the last review
found. Three of those lived where a smoke test cannot look — in the buffer
descriptors — so a passing session would have proved nothing about them.

So this compares our submission against libavcodec's, byte for byte, on the
same access units of the same two vendored vectors. The reference side comes
from an FFmpeg n8.1 cross-built for Windows with the DXVA paths instrumented
(the recipe is in the harness docs); the comparison covers picture parameters,
quantisation matrices AND the buffer descriptors, 250 AUs per codec:

    H.264 / HEVC picture parameters    250 AUs, no undocumented divergence
    H.264 / HEVC quantisation matrices 250 AUs, no undocumented divergence
    H.264 / HEVC buffer descriptors    250 AUs, no undocumented divergence

It found a real defect immediately. The DXVA short slice record is TEN bytes
— dxva.h packs these bitstream-layout structs to a byte — and this crate
declared it `repr(C)`, which pads {u32,u32,u16} to twelve. libavcodec's own
descriptors say so twice over: 20 bytes of slice control for a two-slice
H.264 picture, 10 for a one-slice HEVC one. Record 0 survives either way
(its fields sit at 0/4/8 regardless), so the mistake is invisible on a
single-slice stream and displaces every later record by two bytes on a
multi-slice one — which punktfunk hosts emit. Both records are now
`repr(C, packed)`, and the HEVC slice-control test grew a second record
because one record is the shape that hid this.

The audit that followed matters more than the fix. Per-field offset asserts
cannot see TAIL padding, which is what this was, so all six hand-declared
structs now also assert that their size equals the last field's offset plus
that field's own size. Under that rule the slice records were the only place
packed and natural alignment disagree — 1040, 232, 224 and 1000 were right
all along, and now provably rather than luckily. The module docs claimed
`repr(C)` "reproduces MSVC's default packing exactly for that shape"; that
was a guess wearing a proof's clothes, and it is gone.

Two differences are documented rather than fixed, each with the argument for
why it is inert. libavcodec seeds prev_poc_msb = 1 << 16 at every IDR, so its
POCs are the specification's plus 65536 uniformly; every use a driver makes
of those fields is a difference, and references match on FrameNumList, so the
harness compares POCs relative to that constant and requires it to hold on
every AU rather than importing a magic number into a derivation the Vulkan
rung shares. And HEVC's loop_filter_across_tiles_enabled_flag is inferred 1
by 7.4.3.3.1 when the PPS codes no tiles while libav leaves it 0, with tiles
disabled either way. Both ride a channel that always prints, and both are
guarded by tests that synthesise the differences an allowance must NOT
absorb — a documented divergence that swallows a real defect would be worse
than no harness at all.

Everything checkable without a capture is now a non-ignored test: the buffer
set and order per codec, NumMBsInBuffer's codec asymmetry (mb_width*mb_height
on H.264's bitstream and slice-control buffers, zero everywhere for HEVC),
the three 7.4.5 scaling-list cases, contiguous slice records tiling DataSize,
the 128-byte padding charged to the last record and no other. That is the
part which would have caught the last round's defects with no hardware at all.
2026-08-06 09:46:53 +02:00
enricobuehler 31087697a9 feat(client): M5 — native D3D11VA decode, pin-only pending hardware
The Windows fallback rung, and auto's first choice on Intel, now has a
native implementation driven by pf-bitstream's plans instead of
libavcodec. New crate pf-dxvadec holds everything that can be a pure
function — the DXVA structure layouts, both codec conversions, bitstream
packing, config selection — deliberately CROSS-PLATFORM, because a
cfg(windows) module is verified by a remote cargo check and nothing
else, and this milestone's riskiest code is exactly the part no local
test can see. Only the FFI lives in video_d3d11_native.rs.

windows-rs does not generate dxva.h at the pinned rev, so the DXVA
structures are hand-declared: compile-time assertions on every struct
size AND every field offset, packed bitfield words as plain integers
with named builders and the bit positions written beside the C
declaration, and a const zeroed() per struct so construction needs no
unsafe at all. The crate's only unsafe is a sealed byte view over those
PODs. Review round 13 checked all seven layouts field by field in
declaration order — sizes, widths, array lengths, the PicEntry
index/flag packing, and every named bit's position and width.

The decode pool reproduces libavcodec's rather than inventing one:
ONE texture with ArraySize = pool size, BIND_DECODER and nothing else,
MiscFlags 0, aligned 16 for H.264 and 128 for HEVC. That is deliberate.
This rung's predecessor records that a hand-built pool which validated
on NVIDIA was rejected by Intel at the first SubmitDecoderBuffers — and
Intel is the vendor this rung exists for. The VideoProcessorBlt into
shareable RGBA is untouched: importing a multiplanar NV12 D3D11 texture
into Vulkan device-losts on NVIDIA, so that hand-off is load-bearing
field-proven code. It was extracted into a shared HandoffRing so both
rungs fill one implementation; the review diffed the blit statement by
statement, including the keyed-mutex pairing.

Review round 13's four defects are fixed. The blocking one: the HEVC
quantisation matrix was submitted unconditionally, and the vendored
parser leaves it ALL ZEROS unless the stream codes one — unlike FFmpeg,
which seeds the spec defaults. On a stream saying 'use the default
matrices' the driver is obliged to apply what it is handed, so every
residual would dequantise to zero and the picture would drift to flat
prediction. It is now gated on scaling_list_enabled_flag exactly as
libav gates it, with the Table 7-5/7-6 defaults supplied when enabled
but uncoded.

Second: NumMBsInBuffer was 0 where libav's H.264 path sets
mb_width * mb_height. This module's whole method is verbatim
reproduction on precisely the call that once failed for Intel, so an
omitted descriptor field is the same class of bug as the pool.

Third, and the one to watch on hardware: RefFrameList carried the
frame's reference set rather than the pictures marked used for
reference. Vulkan defines pReferenceSlots as the slots this operation
uses, so a subset is correct there; DXVA defines RefFrameList as a
statement about the DPB. The list DERIVATION survives a subset — which
is exactly why a smoke test would have passed — but a long-term
reference held across frames that none of them name would vanish and
reappear, and a driver keeping per-reference state is entitled to
discard it in between. That is the Ally X symptom shape. pf-bitstream
now exposes a per-AU DPB snapshot for both codecs and the converters
build the array from it, frame references first, marked tail appended.
121 of the 250 vendored AUs carry a marked picture the frame never
names, so this is exercised, not theoretical.

Fourth: the session identity omitted bit depth and chroma, while the
Windows host flips an HDR desktop to PQ in-band with a new SPS — a
depth change at unchanged size would have decoded 10-bit samples into
an NV12 pool. Identity now derives from the SPS per AU and rebuilds.

Wired PIN-ONLY (PUNKTFUNK_DECODER=native-d3d11va), absent from every
auto arm. Nothing has decoded a frame yet, and M2's discipline was that
auto admission comes only after hardware parity. A runtime streak
demotes to the FFmpeg D3D11VA rung first, then software.

Also scaffolded: a byte-diff harness against libavcodec's own DXVA
picture parameters, with the FFmpeg patch and capture recipe in its
docs. Nothing here is checked against libav's actual bytes the way M3
was checked against its pixels, and that is the cheap way to buy the
confidence before hardware.

Gates: fmt clean; container clippy -D warnings zero across
pf-client-core + pf-presenter + pf-vkdecode + pf-dxvadec +
punktfunk-core; tests 73/131/63/129/354 green; cargo check --workspace
clean; Windows cargo check and clippy -D warnings clean on .173.
2026-08-06 06:11:21 +02:00
enricobuehler 2a57ee36f8 feat(client): M4 — the decoder's own verdict reaches the session
This program exists because a field corruption was architecturally
undetectable through FFmpeg: no decode-status read, no corrupt-frame
flag, errors only as scraped log lines, and no recovery-point signal so
intra-refresh healing was invisible. The native decoder has all of those.
M4 is where they stop being internal.

DecodeHealth counts, per session and without allocating per frame, what
the three answers actually are: damaged (the stream arrived incomplete),
refused (the rung would not decode it at all) and driver-failed (the
hardware says it could not decode what arrived), plus the current and
worst concealment run — the figures that separate one bad AU from a
stream that never came back. They ride the stats line additively, so an
FFmpeg session and a healthy native session emit byte-identical output
to today. The status-query capability is reported too: without it a
clean report cannot be told from an unmeasured one, which is the whole
nb_queries=0 lesson.

The headline is local recovery. Until now the pump could only learn that
intra-refresh healing finished from wire flags the host sends; absent
those it froze until the 500 ms backstop forced an IDR. The parsed
recovery-point SEI now feeds the re-anchor gate directly, so a session
lifts on the picture that is actually clean. Wire semantics are
untouched for every client that never calls it.

Detection now asks for recovery instead of erroring — an integrity
warning ticking the error streak would demote the native rung on exactly
the lossy links it exists to diagnose, where an FFmpeg rung conceals
silently and keeps its job.

Review round 12 found that trade had removed the escape hatch entirely.
Concealment returning Ok(None) reset the demotion streak, and worse: the
driver-verdict ledger is only populated when a frame ships, so under
continuous concealment no verdict was ever read and the erroring arm
could not fire at all. A host framing regression of the 0.23.0
slice-wire class — which does not self-heal, and which a keyframe does
not clear — would have frozen indefinitely with no demotion and a clean
integrity line, where before it demoted to FFmpeg-Vulkan and showed a
picture. Now only an answer that proves the rung works clears the
streak: a shipped frame, or a clean no-frame. Concealment neither ticks
nor clears, so a lossy link still cannot demote a healthy rung while a
driver failure interleaved with concealment reaches the threshold again.

Two more honesty defects from the same round. A rung refusing every AU
reported no integrity line at all — the founding failure mode, wearing
the shape of a clean bill of health; refusals are now counted. And
driver-failed could be non-zero on a device that cannot produce driver
verdicts, because a degraded timeline read looked the same as one; the
attribution is now withheld inside the counter rather than at call
sites, so the self-contradictory line is unrepresentable.

Local recovery also no longer trusts any recovery-point SEI: only one
whose target advances past an outstanding wave counts as a new wave, so
an encoder re-announcing the current wave with a decreasing count — legal,
and what x264 intra-refresh does — cannot lift the freeze early onto a
partially stale picture. Frames buffered across an arm are dropped by
decode order for the same reason.

Fault injection is a first-class tool now (PUNKTFUNK_AU_FAULT, inert
unless set, env read once). Its test replays the vendored vectors
through the real planners and asserts a negative the plan assumed away:
truncation and bit flips are PROVABLY invisible to the parser — Annex-B
carries no NALU length, so a cut slice is just a shorter slice and a
flipped payload byte is syntactically perfect. Only dropped AUs are
parser-detectable; the rest need the driver verdict, which is why the
status query matters. The H.265 leg found a second: three of that
vector's faulted AUs are sub-layer non-reference pictures, so dropping
them damages nothing and silence is correct — the test asserts both
verdicts and guards that neither half goes vacuous.

Per-frame decode latency was deliberately NOT built. Polling answers
only 'complete by now', and the pump polls once per AU, so every sample
would quantise up by as much as a frame interval — 8.3 ms at 120 Hz
against decodes of 0.1-2 ms. Sampling faster needs a spin or a second
thread on a decoder that is deliberately not Sync. A blocking per-frame
wait is the field scar that once capped a stream at 51 fps. The honest
sampled stat stands.

Also fixed, pre-existing: the re-anchor gate re-armed on every damaged
AU, so sustained damage permanently zeroed the mark count — meaning the
wire's two-mark rule could never complete on exactly the lossy links it
was written for.

Field note recorded while wiring this: intra_refresh_recovery is set by
exactly one encoder backend (Linux libav-NVENC under
PUNKTFUNK_INTRA_REFRESH). AMF and QSV run a wave with no wire mark, and
AMF emits no recovery-point SEI either, so AMD/Windows intra-refresh
sessions still have no clean recovery point by either route.

Gates: fmt clean; container clippy -D warnings zero across
pf-client-core + pf-presenter + pf-vkdecode + punktfunk-core; tests
69/131/129/354/41 plus 5 fault-detection green; cargo check --workspace
clean.
2026-08-06 04:30:40 +02:00
enricobuehler e4d8573475 feat(client): the native rung now decodes HEVC as well as H.264
The last piece of M3 WP-2 — VkH265Decoder was built and hardware-gated
but nothing drove it. video_vk_native.rs holds a two-arm codec enum and
forwards to it; the ledger, release tokens, status-query settling and
timeline waits are byte-for-byte what they were, since they were always
codec-agnostic over one DecodedVkFrame contract. The forwarders are
written out per arm rather than macro'd so the unchanged H.264 arm is
visible to a reviewer.

The picture's own format now reaches the presenter, which picks bit
depth and MSB packing from it instead of assuming the H.264 envelope.
That incidentally fixes a live bug on the SHIPPING FFmpeg-Vulkan path:
it derived ten-bit-ness by comparing against the 10-bit 4:2:0 format
alone, so a 10-bit two-plane 4:4:4 surface — which its own format table
accepts, and which NVIDIA reports for HEVC RExt — got 8-bit range and
transfer maths. Reachable today with Full chroma plus 10-bit: decoded
correctly, displayed wrong.

Review round 11 caught a regression this WP would otherwise have
shipped. pf-vkdecode refuses a stream whose (chroma, depth) pair has no
picture format on the device, but the session is built lazily from the
first SPS, so the refusal arrived AFTER construction — past the point
where a native init failure falls through to FFmpeg-Vulkan. It burned
the error streak instead and demoted to VAAPI/D3D11VA, which on
NVIDIA/Linux means software. Turning on Full chroma on any non-NVIDIA
GPU was enough: a 4K HEVC session that ran on FFmpeg-Vulkan before this
branch would have landed on software decode.

Both halves are fixed. The negotiated chroma and bit depth — already at
the call site, the PyroWave arm four lines up uses them — are threaded
into the backend, which probes the same caps path ensure_state would
run, so the whole class refuses at CONSTRUCTION where the fall-through
already exists. For the legs no negotiation can carry (a level above
maxLevelIdc, an SPS that disagrees with the Welcome) the decoder latches
'never delivered a frame' and routes that first streak to FFmpeg-Vulkan
rather than down the hardware ladder. H.264 is deliberately not probed:
its envelope is fixed, so a probe would only add a profile guess on the
bit-exact path; it gets the latch as its backstop.

Two more from the round. Planner warnings are typed again rather than
Debug strings — pf-vkdecode simply lacked the h265 re-export its h264
twin already had — which restores the H.264 log rendering exactly and
unblocks M4, whose job is counting concealment by kind. And concealment
is now the integrity set only: NonZeroReorder is documented spec-legal
and fully planned, but the client treated every warning as damage, so
the opening IDR and every ABR renegotiation's IDR were released unshown
and re-anchored — a visible hitch on a healthy stream.

Also: a raw-format newtype so a neighbouring i32 field cannot be passed
to the colour maths, the presenter's depth table now pinned against
pf-vkdecode's actual output vocabulary rather than the FFmpeg lane's,
a per-format warn latch, and four stale docs.

Gates: fmt clean; container clippy -D warnings zero across
pf-client-core + pf-presenter + pf-vkdecode; tests 69/125/108/40 green;
cargo check --workspace clean.
2026-08-06 03:02:09 +02:00
enricobuehler 6d8f3b45b5 feat(pf-vkdecode): the GPU half of HEVC decode — session, pools, recording
M3 WP-2 complete. caps_h265.rs builds the profile the stream actually
needs (profile idc + chroma + bit depths, all three stated on every
Vulkan object) and resolves its picture format — Main to NV12, Main 10
to P010, RExt 4:4:4 to the two-plane 4:4:4 formats — validating it
against the format list of every role the chosen arrangement creates
images in. A Main 10 stream on an 8-bit-only device is refused BEFORE a
session exists, never narrowed: decoding 10-bit into an 8-bit surface is
the silent-wrongness class this crate exists to refuse. session_h265.rs
adds the three-array parameters ledger; decoder_h265.rs adds
VkH265Decoder, mirroring VkH264Decoder method-for-method so the client
wiring is a two-arm dispatch away.

H.264 and H.265 now SHARE the machinery instead of duplicating it:
derive_arrangement (one coincide/distinct/layered decision table),
ring::rebased_offsets (the slices-only rebase — non-VCL NALUs in the
decode range hang VCN firmware), session::bind_session_memory, and a
parameterised build_frame. A DecodeProfile enum replaces the bare
profile idc that images.rs and ring.rs used to take: both codecs' idc
types are c_uint, so handing an H.265 idc to the H.264 path COMPILED
SILENTLY and built a mismatched profile chain. That is now
unrepresentable.

The VPS leg is the ledger's real work. The vendored parser attaches a
VPS to an SPS only when it saw the NALU, and clients join live streams,
so VpsSource is Parsed-or-FromSps and is stored BY VALUE: re-activating
a VPS-less SPS is Current (no churn), but the real VPS arriving under
the same id is a content change and RECREATES onto it, because Vulkan
cannot replace a stored parameter set.

Review round 10 (adversarial) confirmed the hardware-proven H.264 path
is NOT regressed — derive_arrangement's check order and error identity
are byte-for-byte the original, build_frame's call sites still pass the
granularity-aligned extent (the 1088-row scar stays shut), and
rebased_offsets reproduces the deleted inline loop for every input while
moving the sum to u64 so overflow errors instead of wrapping. Also
verified: the refs-order contract on every path, the RESULT_STATUS caps
gate (each of reset/begin/end individually gated, no pool created when
unsupported — recording one on RADV hangs its VCN), pNext lifetimes, and
that no panic is reachable on stream input.

Its 10 findings are fixed. The two that mattered:

- A failed decode stranded a DPB slot. Once plan_to_vk_h265 had mutated
  the slot map, five later failure paths returned without restoring it,
  so planner and slot map both believed a picture was resident while no
  image held it — and every later AU referencing it failed, where H.264
  soft-degrades and keeps delivering. Fail-closed is kept (substituting
  a reference silently is the corruption-hiding this program exists to
  end) but made RECOVERABLE: a latch flushes the planner to AwaitingIdr
  and resets the bindings on the next decode, which composes with the
  client already requesting a keyframe on every decode error. The fix
  deliberately covers pre-mutation failures too — those strand the
  picture the other way round and wedge identically.
- DecodedVkFrame carried no picture format, so a Main 10 frame would
  decode correctly and be rendered with 8-bit transfer/range math. It
  now carries one, stamped from the pool so it is truthful for both
  decoders by construction. The presenter comment says depth 8 is
  because only H.264 is WIRED, not a decoder limit.

Plus: bind_session_memory freed allocations before the session that may
hold them was destroyed (an ordering regression from the extraction,
with a SAFETY comment asserting the opposite) — the bind-stage exit now
hands them back so Drop destroys first; max_level_idc is codec-tagged
rather than an H.264 type carrying H.265 code points; and the decode
family's videoCodecOperations is now checked, turning 'create an H.265
session on a device without the extension' from UB into a clean ladder
demote.

Deferred by design: no HEVC gpu_smoke/gpu_parity yet (its goldens are
already in tests/data/test-25fps-h265.nv12.sha256), and no codec
dispatch in the client — both later legs.

Gates: fmt clean; mac clippy zero warnings, pf-vkdecode 106 +
pf-bitstream 69 green; container clippy -D warnings zero for
pf-client-core + pf-presenter + pf-vkdecode, tests 69/121/106 green.

HARDWARE (.173, after the refactor — review saying the proven path is
safe is not the GPU saying it): gpu_parity '250 frames bit-identical to
libavcodec software decode' on BOTH the NVIDIA 4090 (610.88, coincide
mode) and the AMD iGPU (Adrenalin 25.10.30.02, distinct mode), gpu_smoke
green on both. Two independent drivers, both DPB modes, still bit-exact.
The smoke trace also shows the new videoCodecOperations capture reading
DECODE_H264 | DECODE_H265 | DECODE_AV1 off the real decode family.
2026-08-06 01:59:39 +02:00
enricobuehler c985438db1 test(pf-bitstream): replay real host captures through the planners + HEVC goldens
M0's capture hook has been in since 119ec0dd with nothing consuming its
output. corpus_replay.rs is that consumer: point PF_CORPUS at an
au-<stamp>.<codec> capture and every AU walks back through the H.264 or
H.265 planner, asserting no errors and no warnings — a clean capture of
a healthy session must plan whole. Ignored by default (captures are
hundreds of MB and live outside the repo).

It earns its keep immediately. Captured on .173 against the live host
(NVENC, 2800x1260, ~30 s each, client-side codec pin only — no host
config touched):

  h265  1133/1133 AUs planned, 0 errors, 0 warnings
  h264  1514/1514 AUs planned, 0 errors, 0 warnings

The HEVC number is the point: it is the FIRST validation of the WP-1
h265 planner against real host output rather than the vendored
conformance vectors, and it lands before the client's HEVC rung exists
to produce on-glass evidence.

Two real-capture facts the harness had to learn, both from this run:
ending a capture means killing the client, so the final .idx line is
routinely half-written and the final AU's bytes may not all have landed.
Both are tolerated at the TAIL only — a malformed line anywhere else, or
a gap the data cannot cover mid-file, still fails loudly rather than
silently replaying a subset.

Also adds tests/data/test-25fps-h265.nv12.sha256: 250 per-frame NV12
hashes of the vendored HEVC vector from libavcodec's software decoder,
cross-checked frame-for-frame between two independent FFmpeg builds
(8.0.1 in pf-lxcheck2, 8.1.1 from Homebrew) — the sibling of the H.264
goldens, ready for WP-2's parity leg.

Gates: fmt clean; pf-bitstream clippy clean, 69 tests green (the replay
stays ignored in normal runs).
2026-08-06 01:08:50 +02:00
enricobuehler 25b08916b6 fix(flatpak): the WSI layer module builds again — vkroots was declared twice
ci / web (pull_request) Successful in 57s
ci / docs-site (pull_request) Successful in 1m45s
ci / rust-arm64 (pull_request) Successful in 2m19s
ci / rust (pull_request) Successful in 6m22s
The flatpak has not built since 35ba64ca. Every push to main fails at "Build the
flatpak", before a single build command runs:

  cp: cannot overwrite non-directory
    '.../build/gamescope-wsi-layer-1/subprojects/vkroots/.git'
    with directory '.../git/https_github.com_Joshua-Ashton_vkroots.git'
  Error: module gamescope-wsi-layer: Child process exited with code 1

vkroots was declared twice. flatpak-builder clones git sources WITH SUBMODULES by
default, and `subprojects/vkroots` is a real gamescope submodule — `git ls-tree
8c676c39 subprojects/` shows it as mode 160000 at 5106d8a0, which is byte-for-byte
the commit the explicit source pinned. So the submodule checkout already produced
the right tree and left `subprojects/vkroots/.git` as a gitlink FILE; the second,
redundant source then tried to copy the bare mirror onto that path as a DIRECTORY,
and cp refused. Source extraction died there — `buildsystem: simple` and the
hand-applied glm/stb patch_directory copies were never reached, so neither is at
fault.

Removing the redundant source is therefore a no-op on the resulting tree: the
submodule supplies that exact rev. glm and stb are NOT submodules — `subprojects/
glm.wrap` and `stb.wrap` are plain blobs at that rev — so nothing else populates
them and their explicit sources have to stay. That asymmetry is the whole trap,
and it is now written down in the manifest next to the sources, along with the
disable-submodules escape hatch for anyone who later needs to pin a subproject
away from the gamescope rev.

Why this reached main: flatpak.yml has no `pull_request:` trigger — only `push` on
main with path filters, `tags: ['v*']`, and workflow_dispatch. PR #64's checks were
green because the flatpak was never built on the PR; run 15775 was the first time
this module had ever been built in CI. Adding a PR trigger (or a manifest lint) is
the durable follow-up, deliberately not bundled here.

This blocks the release, not just main. flatpak.yml runs on `tags: ['v*']`, and the
failing step gates the bundle export, the generic-registry publish, the OSTree push
to flatpak.unom.io and the release-asset attach — all of which stay skipped. A
v0.25.0 tag cut today would ship with NO Linux/Steam Deck flatpak at all, on the
release whose headline Linux change is Deck HDR working out of the box.

NOT VALIDATED LOCALLY: this cannot be built on macOS. The reasoning is confirmed
against the upstream tree (the ls-tree above) but the green run is still owed —
dispatch flatpak.yml on this branch before merging.
2026-08-06 00:49:25 +02:00
enricobuehler 1535d69852 docs(release): the 0.25.0 notes cover the 37 commits since the bump
ci / docs-site (pull_request) Successful in 1m13s
ci / web (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m14s
android / android (pull_request) Successful in 4m34s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m45s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m27s
ci / rust (pull_request) Successful in 14m5s
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.
2026-08-06 00:45:25 +02:00
enricobuehler 55bc664eca docs(pf-bitstream): the two upstream cros-codecs bugs are now reported
PROVENANCE deviations #6 and #7 carried 'report upstream' — done:
- chromeos/cros-codecs#99: h264 PictureData display_resolution
  double-counts the left/top crop and underflow-panics on
  parser-valid crop offsets (answers their open #81).
- chromeos/cros-codecs#100: h265 parse_slice_header index-OOB panic
  when num_long_term_sps + num_long_term_pics > 16 — a hostile-input
  panic on the LTR path (an instance of their #78).

Both reports offer the downstream patch for the AOSP tree.
2026-08-06 00:44:18 +02:00
enricobuehler a34f4051fc feat(pf-vkdecode): the CPU half of HEVC decode — StdVideo H265 conversion + slot map
M3 WP-2, first half. params_h265.rs: VPS/SPS/PPS -> StdVideoH265* with
owned pointer-backing (the params.rs contract), scaling lists incl. the
32x32 two-matrix quirk and +8 DC convention, short-term RPS re-encoded
from the parser's RESOLVED DeltaPoc arrays back into delta_poc_sX_minus1
syntax under monotonicity checks, fallback_vps_from_sps for streams
whose VPS NALU was lost. pic_h265.rs: plan_to_vk_h265 — h265 AuPlan ->
StdVideoDecodeH265PictureInfo + per-reference infos; the binding set is
the union of the three current RPS sets with the Std index arrays
indexing into refs (0xFF unused; the GPU half must lay pReferenceSlots
out in refs order); NumDeltaPocsOfRefRpsIdx from the predicted-from
candidate; transactional SlotMap lifecycle identical to pic.rs. SlotMap
reused unmodified — HEVC's ceiling equals H.264's 16+1.

Envelope fails closed: Main/Main10/MainStill/RExt only, 4:2:0-8/10 +
4:4:4 only (separate_colour_plane_flag rejected — ChromaArrayType 0 in
disguise), SCC palette predictors out, >64 ST RPS sets / >16 per side /
>32 LT SPS candidates out, checked narrowing on every narrower Std
field. No panics on untrusted input.

Review round 9 (adversarial): RPS re-encode math, Std field-by-field
conformance, transactionality and slot ceiling verified clean; 6
findings fixed pre-commit. Headline (BLOCKING): long_term_ref_pics_
present_flag=1 with num=0 left pLongTermRefPicsSps NULL — the header
demands a valid pointer whenever the flag is set, and flag=1/num=0 is
exactly the punktfunk LTR/RFI recovery stream shape; the all-zero
backing now rides whenever the flag is set. Also: the slice_offsets doc
in BOTH pic modules claimed submit-as-planned while decoder.rs packs
slices-only and rebases (non-VCL NALUs in the decode range hang VCN
firmware) — reworded so the HEVC GPU half cannot implement the hang; a
concealment-produced ST/LT duplicate now ORs the long-term flag across
occurrences; NumDeltaPocs clamps became a typed error; dead
UnmappableLevelIdc variant dropped.

Deferred to the GPU half: HEVC caps/profile chain, session parameters
(VPS leg in the ledger), P010/4:4:4 pool selection, recording, and the
pReferenceSlots-in-refs-order contract consumption.

Gates: fmt clean; mac pf-vkdecode 80 + pf-bitstream 69 green, clippy
clean; container clippy -D warnings zero (pf-client-core, pf-presenter,
pf-vkdecode) + tests green (69/121/80).
2026-08-06 00:41:23 +02:00
enricobuehler 745864423b Merge remote-tracking branch 'origin/main' into worktree-release-0250 2026-08-06 00:40:26 +02:00
enricobuehler 35ba64ca0f Merge pull request 'fix(flatpak): Deck HDR works on a plain install' (#64) from worktree-deck-hdr-wsi-env into main
ci / rust-arm64 (push) Failing after 4s
ci / rust (push) Failing after 4s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 23s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m8s
docker / deploy-docs (push) Successful in 28s
flatpak / build-publish (push) Failing after 3m9s
Reviewed-on: #64
2026-08-05 22:21:51 +00:00
enricobuehler 5f71aeb024 feat(flatpak): vendor the gamescope WSI layer so Deck HDR works on a plain install
ci / web (pull_request) Successful in 56s
ci / docs-site (pull_request) Successful in 1m6s
ci / rust-arm64 (pull_request) Successful in 2m10s
ci / rust (pull_request) Successful in 7m59s
HDR on a Deck needed a manual second step nobody took:
  flatpak install --user flathub org.freedesktop.Platform.VulkanLayer.gamescope//25.08
documented only in a comment in this file. Build the layer ourselves
instead, so a plain `flatpak install` is all it takes.

The layer is genuinely required, not legacy. Measured on SteamOS 3.8.16
(gamescope 3.16.23.4): the gamescope-0 socket advertises
gamescope_swapchain_factory_v2 but NOT wp_color_manager_v1, with HDR both
off and on — so Mesa's Wayland WSI has no colour-management protocol to
negotiate HDR10 through, and this layer is the only thing that can append
the ST.2084 surface formats. Removing the extension gives zero
[Gamescope WSI] lines and hdr10_format=None.

Vendored rather than declared via add-extensions autodownload: the
extension is 94 MB of whole-gamescope for one 4 MB .so, its layer JSON
hardcodes a /usr library_path that an app-scoped extension mounted under
/app would not satisfy, and it would make flathub a hard install-time
dependency of an app we self-host on flatpak.unom.io.

enable_gamescope=false skips subdir('src') and every compositor
dependency, so only protocol/ and layer/ build. buildsystem is simple
rather than meson because glm and stb ship no meson.build of their own -
the wraps' patch_directory supplies it, and without that copy configure
dies with "Subproject exists but has no meson.build file".

meson generates the layer JSON from prefix+libdir, so it self-writes
library_path=/app/lib/... into /app/share/vulkan/implicit_layer.d, which
XDG_DATA_DIRS already covers. VK_ADD_IMPLICIT_LAYER_PATH is therefore
dropped - keeping it would also risk double-loading two same-named layers
for anyone who still has the flathub extension installed.

Pinned to the same gamescope rev as packaging/gamescope/PKGBUILD so the
client's layer and the host's punktfunk-gamescope come from one tree.

Verified on a Deck OLED: builds offline (--wrap-mode=nodownload) in
org.gnome.Sdk//50, and the resulting .so drives the Deck's system
gamescope to "hdr formats exposed to client: true" with
hdr10_format=Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT).

Still user-side, and not fixable in packaging: gamescope's hdr_enabled
convar (Steam's HDR display setting) must be on.
2026-08-06 00:15:16 +02:00
enricobuehler 370b0ab494 feat(client): native Vulkan decode joins the automatic ladder, above FFmpeg-Vulkan
Closes M2. The 2026-08-05 ladder decision: WP-D shut with bit-exact
parity vs libavcodec (250/250 AUs on RADV, AMD-proprietary and NVIDIA)
and a 92-minute clean soak, and the program's goal is dropping FFmpeg
from the client — so on H.264 sessions where caps pass, auto now tries
pf-vkdecode FIRST, exactly where the ladder would reach FFmpeg-Vulkan.
No bake period.

native_vulkan_gate widens from by-name-only to the auto family
(auto/""/hardware); the vendor-first rungs are untouched (Linux
Intel/unknown still VAAPI-first, Windows Intel/unknown still
D3D11VA-first — NVIDIA and ALL AMD go native first). A native INIT
failure or caps refusal logs and falls through to FFmpeg-Vulkan, so
admission can't cost a session its decoder at start; runtime error
streaks ride the existing demotion machinery unchanged (past
FFmpeg-Vulkan to VAAPI/D3D11VA/software — a native→FFmpeg-Vulkan
runtime rung is deliberately absent, FFmpeg is on its way out).
PUNKTFUNK_DECODER=native-vulkan stays as the explicit pin; vulkan
keeps naming the FFmpeg backend specifically. A native_tried guard
keeps a failed pin init from re-attempting construction in auto.

Review round 8 (adversarial): no blocking code defect — no demote
bounce-back (Decoder::new is session-start-only; demotion mutates in
place), no double attempt, no cfg imbalance. 5 findings fixed: two doc
overclaims ("nothing regresses" now scoped to init; the ladder
enumerations no longer claim desktop-AMD Linux is VAAPI-first —
prefer_vulkan_first is vendor-wide), stale opt-in claims in Cargo.toml,
stale user-facing ladder text (console-ui row, trust.rs decoder field,
session README incl. the env-knob list), and the gate test now pins the
H264 codec-op bit to the literal 0x1 so a typo'd constant can't make
native silently never engage.

Gates: fmt clean; container clippy -D warnings zero for pf-client-core +
pf-presenter + pf-vkdecode; container tests green (pf-client-core lib +
pf-vkdecode + pf-bitstream); pf-console-ui check clean; mac
pf-vkdecode/pf-bitstream/cros-codecs 167 tests green.

On-glass sanity CLOSED 2026-08-05 ~22:10 UTC on .173 (4090, coincide
mode), decoder=auto and NO env var: the ladder picked native on its
own ("pf-vkdecode auto rung" log line), 525/526 stats windows on
native-vulkan over ~8m46s / 31550 frames, fps 0/59.4/61 with 6
windows <55 incl. startup zeros, bad-signature grep over the whole
log EMPTY, zero TDR events, host service Running after teardown.
2026-08-06 00:12:35 +02:00
enricobuehler e1adc5d6d7 fix(flatpak): export GAMESCOPE_WAYLAND_DISPLAY so the Deck actually gets HDR
The gamescope WSI layer decides whether to engage from one signal:
isRunningUnderGamescope() reads $GAMESCOPE_WAYLAND_DISPLAY and nothing
else. flatpak does not forward host env into the sandbox, so it arrived
unset and the layer's CreateInstance early-returned before creating a
GamescopeInstance — no gamescope surface, so the HDR10/ST.2084 formats
were never appended and the surface stayed SDR.

The layer still loads and still logs its generic bits in that state, so
it reads as working. It is not: the three settings already here (layer
search path, ENABLE_GAMESCOPE_WSI, the socket bind) all sit downstream
of this gate and buy nothing without it.

Measured on a Deck OLED (Galileo, SteamOS 3.8.16), client --browse,
reading "swapchain config":
  unset              -> no [Gamescope WSI] Surface state block, None
  set, hdr_enabled=0 -> server hdr output enabled: false, None
  set, hdr_enabled=1 -> hdr formats exposed to client: true,
                        Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT)

Matches the field report of "HDR->SDR" in the stats overlay on a
correct HDR host. DXVK_HDR was ruled out by measurement. The remaining
gate (gamescope's hdr_enabled convar = Steam's HDR display setting) is
a user-side step, not a packaging one.
2026-08-05 23:59:45 +02:00
enricobuehler 76a271b97a Merge pull request 'Worktree decky brand name' (#63) from worktree-decky-brand-name into main
ci / docs-site (push) Successful in 1m21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 46s
ci / web (push) Successful in 1m26s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 12s
ci / rust-arm64 (push) Successful in 2m1s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 13s
docker / builders-arm64cross (push) Skipped
decky / build-publish (push) Successful in 39s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
ci / rust (push) Successful in 6m40s
docker / deploy-docs (push) Successful in 6m32s
Reviewed-on: #63
2026-08-05 21:41:17 +00:00
enricobuehler b53568c99f fix(decky): a host saved under its own IP now shows the name it advertises
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 2m35s
ci / rust (pull_request) Successful in 6m51s
The panel captioned most rows with an IP address. The saved records were
the source: `hosts add` falls back to the address when the pairing path
knew nothing better, so `name` is literally "192.168.1.21" — and
`mergeHosts` took `s.name || s.addr` unconditionally. The fallback only
ever fired for an EMPTY name, so a name that was already a copy of the
address sailed through as if it were meaningful, and the row printed the
address twice: once as its title, once as its subtitle.

The friendly name was in hand the whole time. The row is built by joining
the saved record to the live advert, and that advert carries the host's
actual hostname — the join was already trusted for address, port, online
and OS, and only the name was read from the saved side alone.

So treat a name equal to the record's own address as the placeholder it is
and yield to the advert. A real saved name still wins, even when stale: it
may be one the user chose, and an advert must never silently overwrite it.
The comparison is against the SAVED address, so a host that moved DHCP
lease still recognises its old address as a placeholder rather than
mistaking it for a chosen name.

Checked against the Deck that reported this, over its actual store and
browse: three online rows turn into home-worker-5, ENRICOS-DESKTOP and
steamdeck, the four offline ones keep their address (nothing is
advertising a better name for them yet), and a user-chosen name survives a
conflicting advert.
2026-08-05 23:37:18 +02:00
enricobuehler db0637928b fix(decky): the shortcut liveness guard answered "alive" for every appId
`shortcutStillExists()` extracted the store method before calling it:

    const get = appStore?.GetAppOverviewByAppID;
    return get(appId) != null;

`GetAppOverviewByAppID` reads the store's own state (`this.m_mapApps`), so
the unbound call throws on the lost `this` — and the function's own
`catch { return true }` swallowed it. The guard therefore returned "still
exists" for EVERY appId. Not a stale-data bug: it never once answered no.

Everything downstream of it was consequently inert. A dangling appId — the
documented hazard this guard exists to catch, since the id outlives the
shortcut in Steam's CEF localStorage across a plugin reinstall — was never
dropped, so `ensureGamepadUiShortcut` always took the reuse branch and
`SetShortcut*`'d a dead id (silent no-ops). The visible library entry never
came back, `recreateShortcuts` reported success having done nothing (its
toast only checks for a non-null appId, and the dead one is non-null), and
"Open Punktfunk" ran `RunGame` on the dead id — Steam answers that with
"Game configuration unavailable".

Call it as a method so `this` survives, and guard the global with `typeof`
first: `appStore` is Steam-injected, and a bare reference to a missing one
is a ReferenceError that optional chaining does not prevent — which would
have landed in the same catch.

Verified against the live Deck that hit this: evaluated both versions over
its actual appIds, and where the old guard says alive/alive, the fixed one
says alive for the live stream shortcut and dead for the dangling UI id —
so the stale key now drops and the entry is recreated on the next mount.
2026-08-05 23:33:25 +02:00
enricobuehler cf5db2d485 feat(pf-bitstream): H.265 DecodePlan layer — M3's AU-to-hardware contract
H265Planner mirrors the H.264 layer's contract exactly: plan_au -> AuPlan
{ picture, slices with ref lists by stable PicId, DpbUpdate, warnings },
same concealment posture (warnings never abort, in-place reference
substitution preserving ref_idx positions, outputs survive failed AUs,
flush gates on AwaitingIdr, any IRAP resumes). Ported logic: RPS 8.3.2
(short-term AND long-term incl. PocLsbLt/MSB-cycle - the hosts' RFI
recovery rides long-term refs), ref lists 8.3.3/8.3.4, DPB C.5.2.2/C.5.2.3
via the vendored dpb; POC 8.3.1 from the vendored PictureData. Written
fresh: the plan surface, AU walk, envelope gates (multilayer, interlaced,
SCC self-reference, DPB>16, conf-window overflow - checked at EVERY
activation, not just parse), HEVC recovery-point SEI (prefix NALU 39,
se(v) recovery_poc_cnt), VUI colour with E.3.1 inference, and a test-only
HEVC bitstream synthesizer (upstream has none).

Upstream deviations worth naming (all in-code with spec anchors): the
empty-RPS inter slice cannot infinite-loop (upstream bug); RASL behind a
joined CRA refuses BEFORE any state change (PlanError::RaslSkipped - the
WP-2 wiring must map it to skip, not reanchor; module docs carry the
contract note); MaxPicOrderCntLsb reads from the ACTIVATING SPS (upstream
latches at parse - a latent multi-SPS bug); C.5.2.2's exemption is
picture 0 of the BITSTREAM (EobNut), never first-after-EOS.

Vendored parser gained PROVENANCE deviation 7 (report upstream): hostile
slice headers with num_long_term_sps+num_long_term_pics > 16 indexed out
of bounds of SliceHeader's [_;16] arrays - a production panic on exactly
the long-term-reference path, now a parse error.

Port review round 7: 10 findings (3 blocking: the vendor panic, an
EOS-boundary output interleave, an envelope bypass through PPS-only SPS
rebind reaching wrapping crop arithmetic) - 9 fixed with a regression
test each, 1 documented as the WP-2 contract note. Known follow-up: the
h264 AU-tail truncation detector shares h265's dead-arm shape (its arm
also cuts reserved NALU types, so the fix is not identical - deferred).

Tests: 29 h265 planner + 2 HEVC SEI + full test-25fps.h265/bear/bbb clip
walks with real invariants (every stored id output exactly once,
ascending POC per IRAP period). Gates: fmt clean; clippy -D warnings zero
(mac + pf-lxcheck2 incl. pf-client-core/pf-presenter); tests 45+69 mac,
69+121+53 container.
2026-08-05 23:29:45 +02:00
enricobuehler 22bc81238d fix(decky): Decky's plugin list says "Punktfunk", not "punktfunk"
The label Decky shows for an installed plugin is plugin.json "name", which
we had set to the lowercase directory name — so the one place every user
sees the plugin listed was the one place it was off-brand, while the panel
header (titleView) already read "Punktfunk".

The two were conflated because the name looked load-bearing: the zip's
top-level dir becomes ~/homebrew/plugins/<dir>, and the scripts derived
that dir FROM plugin.json "name". They are in fact independent — Decky
extracts the zip as-is and locates an installed plugin by MATCHING
plugin.json "name", never by folder name (that is how a plugin can live in
DeckWebBrowser/ and list itself as "Web Browser").

So brand-case the label and pin the on-disk dir to the literal `punktfunk`
in package.sh/deploy.sh/CI instead of deriving it. Pinning is the part that
matters: had the dir followed the label, this rename would have installed a
second `Punktfunk/` folder beside the existing `punktfunk/` and the plugin
would have shown up twice.

The self-update call passes the name Decky uninstalls before extracting, so
it moves to "Punktfunk" with it. The upgrade INTO this build still passes
"punktfunk" (the installed build's own value), which matches that build's
plugin.json — so the old folder is removed and the new zip lands in the
same lowercase dir either way. Decky's per-plugin settings dir is unused
(all state lives in ~/.config/punktfunk), so nothing is stranded.
2026-08-05 23:20:28 +02:00
enricobuehler de6b9e94ec Merge pull request 'fix(client/windows): settings persist when the app isn't installed on C:' (#62) from worktree-client-msix-persist into main
ci / web (push) Successful in 1m13s
ci / docs-site (push) Successful in 1m22s
apple / swift (push) Successful in 1m25s
ci / rust-arm64 (push) Successful in 1m39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
deb / build-publish-client-arm64 (push) Successful in 2m40s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
flatpak / build-publish (push) Failing after 4s
deb / build-publish-host (push) Successful in 4m43s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 33s
ci / rust (push) Failing after 9m30s
apple / screenshots (push) Successful in 10m16s
android / android (push) Successful in 13m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 13m28s
deb / build-publish (push) Successful in 14m47s
arch / build-publish (push) Successful in 15m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m26s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m12s
Reviewed-on: #62
2026-08-05 20:53:38 +00:00