test(pf-vkdecode): diff a field capture against ffmpeg, frame by frame #461

Merged
enricobuehler merged 6 commits from worktree-vkdecode-field-parity into main 2026-08-30 23:36:49 +00:00
Owner

Two field reporters described the same picture on a Steam Deck — grey, with fresh motion painted
over it, static regions stuck — and one of them had Moonlight clean on the same host. Every
counter we own read zero while that was on screen, and the golden parity legs stayed green,
because they decode vendored conformance vectors: short, feature-sparse streams that no punktfunk
host ever emits. The one class of defect that matters here is the one none of them can see, so
the rung was effectively unfalsifiable in the field.

This adds the leg that can see it, and then reports what it saw.

What is here

field_h265_stream_writes_frame_hashes_for_ffmpeg_diff decodes any PUNKTFUNK_DUMP_VIDEO
capture on the real GPU and writes one SHA-256 per delivered frame, in display order, to
<stream>.pfhash. scripts/vkdecode-field-parity.sh does the same with ffmpeg's software
decoder over the identical bytes and names the FIRST divergent frame — the only number that
localises this class, since everything after it may be downstream of one bad picture.

It is shaped for field captures rather than fixtures: it honours the .idx sidecar (skipping
complete == 0 lines, because the native lanes are only ever fed whole AUs), finds the join
point the way the client did — plan until an AU succeeds — tolerates and counts per-AU errors
instead of failing the run, and releases off-size frames unshown when a capture spans a
renegotiation. PF_VKD_FIELD_YUV=<n,m> dumps named frames' planes once the diff has named them.

The second commit is what makes a result readable. A capture taken over a lossy link holds AUs
whose references never arrived, and ffmpeg conceals those differently from us — so a divergence
at one of them says nothing about this decoder. The leg records which AUs needed concealment and
prints the verdict last, so the run answers either "no AU needed concealment — any divergence is
OURS" or "a divergence at or after frame N is likely ffmpeg concealing differently".

The third commit fixes a bug in the script itself: it extracted the digest by matching a
sha256= prefix that ffmpeg's framehash muxer does not emit — its rows are
0, <dts>, <pts>, <duration>, <size>, <bare hex>, with the algorithm named once in a #hash:
header. The reference file came out EMPTY and the script reported "ffmpeg=0 frames", which reads
exactly like ffmpeg having failed to decode the capture.

What it found

Run on the reporting Deck itself — AMD Custom GPU 0932 (RADV VANGOGH), Mesa 26.0.0-devel,
mode=distinct.

A clean capture (5 850 AUs, no concealment): 5 850 / 5 850 frames bit-identical to
libavcodec. Zero AU errors, zero off-size frames, and the frame count matched the session's own
total_frames exactly.

The capture taken while the grey was on screen (55 478 AUs, genuine 5 % packet loss):

Frames 0 – 45 271 0 mismatches
First divergence frame 45 272
First AU needing concealment 45 272

The divergence begins at exactly the frame where a reference genuinely went missing, and not one
frame earlier. The 2 293 mismatches that follow fall into bounded ranges that each open at a
concealed AU and then heal at the next IDR — two decoders concealing a missing reference
differently, drifting, and re-converging.

Together that is 51 122 consecutive frames of real host stream, bit-identical to libavcodec, on
the hardware the reports came from
. The decoder does not produce wrong pixels for valid
bitstream; grey requires a missing reference. That retires the decoder, the parameter-set
conversion, the DPB bookkeeping and the driver as suspects, and moves the investigation to why
references go missing in the first place.

What it does not settle: the harness is SERIALIZED (fence wait plus readback per frame) while the
client PIPELINES, so a decode/present timing race would not reproduce here. That remains the one
untested mechanism.

Gates

cargo fmt --check, clippy --all-targets and cargo test --lib clean on pf-vkdecode (197
tests); bash -n on the script; check-docs-drift.sh exits 0 (its PUNKTFUNK_STANDBY_SINK_KEEP
note is pre-existing on main and untouched here). The golden legs were re-run on the Deck
unchanged — H.265 250/250, Main 10, and the low-delay host fixture all still bit-identical.
The #[ignore]d legs do not run in CI, as before.

User-facing fact changed? n/a. The two new variables are test-only and follow the existing
PF_VKD_* convention (PF_VKD_TEST_READBACK, PF_VKD_SMOKE_VENDOR), which the docs-drift gate
does not scan — it reads PUNKTFUNK_* only. No knob, port, install step or feature behaviour
changes: this PR adds a test leg and a script and touches no shipping code path.

Two field reporters described the same picture on a Steam Deck — grey, with fresh motion painted over it, static regions stuck — and one of them had Moonlight clean on the same host. Every counter we own read zero while that was on screen, and the golden parity legs stayed green, because they decode vendored conformance vectors: short, feature-sparse streams that no punktfunk host ever emits. The one class of defect that matters here is the one none of them can see, so the rung was effectively unfalsifiable in the field. This adds the leg that can see it, and then reports what it saw. ## What is here `field_h265_stream_writes_frame_hashes_for_ffmpeg_diff` decodes any `PUNKTFUNK_DUMP_VIDEO` capture on the real GPU and writes one SHA-256 per delivered frame, in display order, to `<stream>.pfhash`. `scripts/vkdecode-field-parity.sh` does the same with ffmpeg's software decoder over the identical bytes and names the FIRST divergent frame — the only number that localises this class, since everything after it may be downstream of one bad picture. It is shaped for field captures rather than fixtures: it honours the `.idx` sidecar (skipping `complete == 0` lines, because the native lanes are only ever fed whole AUs), finds the join point the way the client did — plan until an AU succeeds — tolerates and counts per-AU errors instead of failing the run, and releases off-size frames unshown when a capture spans a renegotiation. `PF_VKD_FIELD_YUV=<n,m>` dumps named frames' planes once the diff has named them. The second commit is what makes a result readable. A capture taken over a lossy link holds AUs whose references never arrived, and ffmpeg conceals those differently from us — so a divergence at one of them says nothing about this decoder. The leg records which AUs needed concealment and prints the verdict last, so the run answers either "no AU needed concealment — any divergence is OURS" or "a divergence at or after frame N is likely ffmpeg concealing differently". The third commit fixes a bug in the script itself: it extracted the digest by matching a `sha256=` prefix that ffmpeg's framehash muxer does not emit — its rows are `0, <dts>, <pts>, <duration>, <size>, <bare hex>`, with the algorithm named once in a `#hash:` header. The reference file came out EMPTY and the script reported "ffmpeg=0 frames", which reads exactly like ffmpeg having failed to decode the capture. ## What it found Run on the reporting Deck itself — AMD Custom GPU 0932 (RADV VANGOGH), Mesa 26.0.0-devel, `mode=distinct`. **A clean capture (5 850 AUs, no concealment):** 5 850 / 5 850 frames bit-identical to libavcodec. Zero AU errors, zero off-size frames, and the frame count matched the session's own `total_frames` exactly. **The capture taken while the grey was on screen (55 478 AUs, genuine 5 % packet loss):** | | | |---|---| | Frames 0 – 45 271 | **0 mismatches** | | First divergence | frame **45 272** | | First AU needing concealment | **45 272** | The divergence begins at exactly the frame where a reference genuinely went missing, and not one frame earlier. The 2 293 mismatches that follow fall into bounded ranges that each open at a concealed AU and then heal at the next IDR — two decoders concealing a missing reference differently, drifting, and re-converging. Together that is **51 122 consecutive frames of real host stream, bit-identical to libavcodec, on the hardware the reports came from**. The decoder does not produce wrong pixels for valid bitstream; grey requires a missing reference. That retires the decoder, the parameter-set conversion, the DPB bookkeeping and the driver as suspects, and moves the investigation to why references go missing in the first place. What it does not settle: the harness is SERIALIZED (fence wait plus readback per frame) while the client PIPELINES, so a decode/present timing race would not reproduce here. That remains the one untested mechanism. ## Gates `cargo fmt --check`, `clippy --all-targets` and `cargo test --lib` clean on pf-vkdecode (197 tests); `bash -n` on the script; `check-docs-drift.sh` exits 0 (its `PUNKTFUNK_STANDBY_SINK_KEEP` note is pre-existing on main and untouched here). The golden legs were re-run on the Deck unchanged — H.265 250/250, Main 10, and the low-delay host fixture all still bit-identical. The `#[ignore]`d legs do not run in CI, as before. **User-facing fact changed?** n/a. The two new variables are test-only and follow the existing `PF_VKD_*` convention (`PF_VKD_TEST_READBACK`, `PF_VKD_SMOKE_VENDOR`), which the docs-drift gate does not scan — it reads `PUNKTFUNK_*` only. No knob, port, install step or feature behaviour changes: this PR adds a test leg and a script and touches no shipping code path.
enricobuehler added 3 commits 2026-08-30 22:03:41 +00:00
The golden parity legs only decode streams our hosts never emit, and the
field defect they cannot see is exactly the one two Deck reporters now
show: a smear that accumulates through the reference chain while every
integrity counter stays at zero. The full client audit (wire, planner,
params conversion, slots, scope, ring, submission, caps/session) found
no defect by inspection, so the fix needs the first divergent frame off
real silicon.

This leg turns any PUNKTFUNK_DUMP_VIDEO capture into that number:
PF_VKD_FIELD_STREAM points at the .h265 (the .idx sidecar is honoured
when present, complete==0 lines skipped), the join point is found the
way the client found it (plan until an AU succeeds), and every
delivered frame is read back and hashed to <stream>.pfhash in display
order. Per-AU errors are tolerated and counted, off-size frames from a
mid-capture renegotiation are released unshown, and PF_VKD_FIELD_YUV
dumps named frames' planes for eyes-on inspection.

scripts/vkdecode-field-parity.sh is the other half: ffmpeg -f framehash
over the same bytes (nv12/p010le match the readback's packing exactly),
first divergent index, ffprobe facts for the divergent picture.
consume_frame now returns the planes and the hashing moved to its
callers, which is the whole extent of the change to the golden legs.
A capture taken over a lossy link holds AUs whose references never
arrived, and ffmpeg conceals those differently from us — so a divergence
at one of them says nothing about this decoder. Recording the AU indices
(and printing the verdict last) is what separates "our bug" from "the
field lost packets", which is the only reading that makes a field diff
actionable. Split out of a dropped commit whose other half was wrong.
fix(scripts): read framehash's bare hex digest, not a prefix that never existed
ci / bun-nix (pull_request) Successful in 30s
ci / docs-drift (pull_request) Successful in 41s
ci / web (pull_request) Successful in 1m2s
ci / docs-site (pull_request) Successful in 1m3s
ci / rust-arm64 (pull_request) Successful in 1m26s
ci / rust (pull_request) Failing after 4m38s
android / android (pull_request) Successful in 5m28s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 10m21s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Canceled after 3m10s
2629508a10
The extraction matched on `sha256=`, which ffmpeg's framehash muxer
does not emit: its data rows are `0, <dts>, <pts>, <duration>, <size>,
<bare hex>` and the algorithm is named once in a `#hash:` header. So
the reference file came out EMPTY and the script reported "ffmpeg=0
frames" — indistinguishable, to a reader, from ffmpeg having failed to
decode the capture at all. Take the last comma-separated field of every
non-comment row instead.
enricobuehler added 1 commit 2026-08-30 22:18:01 +00:00
test(pf-vkdecode): one lock-proving helper replaces six set_var copies
apple / swift (pull_request) Successful in 2m15s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 2m29s
ci / bun-nix (pull_request) Successful in 31s
ci / web (pull_request) Successful in 2m53s
ci / docs-site (pull_request) Successful in 2m18s
ci / docs-drift (pull_request) Successful in 1m0s
ci / rust-arm64 (pull_request) Successful in 1m45s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m44s
android / android (pull_request) Successful in 8m21s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m14s
5ee6468b2b
CI gate C counts `env::set_var` mentions per file and this file was at
its baseline of five, so the field leg's sixth copy failed the build.
Raising the baseline is what the gate tells you not to do, and it would
have been the wrong answer anyway: the six copies were the same two-line
safety argument written out six times, which is precisely the shape a
ratchet exists to discourage.

`arm_test_readback` takes the GPU lock guard BY REFERENCE. `env::set_var`
is safe to call and unsound from a live multithreaded process, so "the
caller holds the binary-wide lock" is the entire safety argument — and a
borrow states it in the type system, where it cannot drift out of date,
instead of in prose repeated at each call site.

The file drops from six mentions to two (the call, and the doc comment
that names the API it is arguing about — gate C counts comments too, by
design, and writing around its grep would be gaming it). The baseline
moves 5 -> 2 to match, so the ratchet stays tight rather than merely
satisfied.

Gates: fmt, clippy --all-targets and 197 lib tests clean;
check-unsafe-hygiene.sh gate C now passes. Gate B's punktfunk-tray
finding is untouched by this branch and fails identically on main.
enricobuehler added 1 commit 2026-08-30 22:40:30 +00:00
fix(tray): a poisoned status lock stops aborting the tray process
ci / bun-nix (pull_request) Successful in 41s
ci / docs-drift (pull_request) Successful in 43s
ci / docs-site (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 1m59s
apple / swift (pull_request) Successful in 2m15s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m45s
ci / rust (pull_request) Failing after 7m32s
android / android (pull_request) Successful in 8m44s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m6s
1ec673add8
`ci / rust` has been red on main: gate B of check-unsafe-hygiene.sh flags
`.unwrap()` on the status mutex inside `wndproc`, and it is right to. A
panic crossing an `extern "system"` boundary does not unwind, it ABORTS
the process — so a thread that panicked while holding this lock would
take down the host's only visible surface, on a box where the icon is
also how the operator stops the host.

Poisoning is not a reason to do that here. What the mutex guards is a
display enum; reading it after another thread died is harmless, and
`PoisonError::into_inner` hands it over — the idiom already used
elsewhere in the tree.

Routed through one `App::status()` rather than patched at the line CI
could see. The gate is lexical, so it flagged only the call written
inline in `wndproc`; the other four sit in helpers that `wndproc` calls,
where a panic unwinds into the very same boundary and aborts just the
same. Fixing only the visible one would have left four identical aborts
a function call away and made the gate look satisfied rather than the
bug fixed.

⚠ NOT verified locally: the tray cannot be type-checked for Windows from
a Mac (aws-lc-sys' build script cross-compiles C and dies in cc-rs long
before win.rs), and scripts/xcheck.sh does not carry punktfunk-tray.
windows-host.yml builds AND clippies this crate and triggers on
`crates/punktfunk-tray/**`, so CI is the verifier here.
enricobuehler added 1 commit 2026-08-30 23:28:27 +00:00
Merge main, keeping this branch's App::status() accessor
apple / swift (pull_request) Successful in 2m14s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m36s
android / android (pull_request) Canceled after 0s
ci / rust (pull_request) Canceled after 0s
ci / rust-arm64 (pull_request) Canceled after 0s
ci / web (pull_request) Canceled after 0s
ci / docs-site (pull_request) Canceled after 0s
ci / bun-nix (pull_request) Canceled after 0s
ci / docs-drift (pull_request) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Canceled after 0s
7db513683d
Both this branch and #460 fixed the same abort, independently and within
the hour. #460 merged first, patching four call sites inline; this one
routes all five through one `App::status()`, which is the better shape
and also covers the writer in `run()` that the inline pass left alone.

Conflict resolved to this branch for `win.rs`. Afterwards the file holds
exactly one `lock()` — inside the accessor — so every reader goes
through it. `check-unsafe-hygiene.sh` reports all three gates clean and
rustfmt is clean under the pinned 2021 style edition.
enricobuehler merged commit b5a1996f5b into main 2026-08-30 23:36:49 +00:00
enricobuehler deleted branch worktree-vkdecode-field-parity 2026-08-30 23:37:00 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#461