From 119ec0dd834861b3525a4af6c0499a54dd1e9c92 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 5 Aug 2026 11:02:37 +0200 Subject: [PATCH] feat(client): PUNKTFUNK_DUMP_VIDEO captures the exact decoder input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixture-corpus enabler for the native-decode program (M0, design/client-native-decode.md): every AU exactly as the pump hands it to decode_frame — the raw concatenation plus a sidecar .idx carrying the AU boundaries and wire flags a byte stream cannot. Best-effort by design: any I/O error disables the capture, never the stream. --- crates/pf-client-core/src/au_dump.rs | 124 +++++++++++++++++++++++++++ crates/pf-client-core/src/lib.rs | 2 + crates/pf-client-core/src/session.rs | 8 ++ 3 files changed, 134 insertions(+) create mode 100644 crates/pf-client-core/src/au_dump.rs diff --git a/crates/pf-client-core/src/au_dump.rs b/crates/pf-client-core/src/au_dump.rs new file mode 100644 index 00000000..7a6802d1 --- /dev/null +++ b/crates/pf-client-core/src/au_dump.rs @@ -0,0 +1,124 @@ +//! Decoder-input capture behind `PUNKTFUNK_DUMP_VIDEO` (fixture corpus for the +//! native-decode program, design/client-native-decode.md M0). +//! +//! Writes every AU exactly as the pump hands it to [`crate::video::Decoder::decode_frame`]: +//! the data file is the raw concatenation (a valid Annex-B / OBU stream for clean +//! captures), and the sidecar `.idx` keeps what a byte stream cannot carry — the exact +//! AU boundaries plus the wire `flags`/`complete` bits — one `offset len flags complete` +//! line per AU, so parser fixtures never have to re-derive framing from start codes. +//! +//! Capture is best-effort by design: any I/O error logs once and disables the dump for +//! the rest of the session; the streaming path is never failed on its account. The +//! final buffered tail flushes on drop (session end), errors swallowed — a truncated +//! last AU in a debug capture is acceptable, a stream torn down over one is not. + +use std::io::BufWriter; +use std::io::Write; +use std::path::Path; + +pub(crate) struct AuDump { + data: BufWriter, + idx: BufWriter, + offset: u64, +} + +/// Wire-codec byte → fixture file extension (also the corpus naming convention). +fn codec_ext(codec: u8) -> &'static str { + match codec { + punktfunk_core::quic::CODEC_H264 => "h264", + punktfunk_core::quic::CODEC_HEVC => "h265", + punktfunk_core::quic::CODEC_AV1 => "av1", + punktfunk_core::quic::CODEC_PYROWAVE => "pyrowave", + _ => "bin", + } +} + +impl AuDump { + /// Read `PUNKTFUNK_DUMP_VIDEO`; `None` (the overwhelmingly common case) means the + /// variable is unset or the capture files could not be created — both already logged + /// where they matter. + pub(crate) fn from_env(codec: u8) -> Option { + let dir = std::env::var_os("PUNKTFUNK_DUMP_VIDEO")?; + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + Self::create(Path::new(&dir), &format!("au-{stamp}"), codec) + } + + fn create(dir: &Path, base: &str, codec: u8) -> Option { + let ext = codec_ext(codec); + let data_path = dir.join(format!("{base}.{ext}")); + let idx_path = dir.join(format!("{base}.{ext}.idx")); + let made = std::fs::create_dir_all(dir).and_then(|()| { + Ok(( + std::fs::File::create(&data_path)?, + std::fs::File::create(&idx_path)?, + )) + }); + match made { + Ok((data, idx)) => { + tracing::info!( + path = %data_path.display(), + "PUNKTFUNK_DUMP_VIDEO: capturing decoder input" + ); + Some(AuDump { + data: BufWriter::new(data), + idx: BufWriter::new(idx), + offset: 0, + }) + } + Err(e) => { + tracing::warn!( + error = %e, + dir = %dir.display(), + "PUNKTFUNK_DUMP_VIDEO set but capture files could not be created" + ); + None + } + } + } + + /// Append one AU. Returns `false` once the dump should be dropped (error logged). + pub(crate) fn write(&mut self, au: &[u8], flags: u32, complete: bool) -> bool { + let r = self.data.write_all(au).and_then(|()| { + writeln!( + self.idx, + "{} {} {:#x} {}", + self.offset, + au.len(), + flags, + u8::from(complete) + ) + }); + self.offset += au.len() as u64; + match r { + Ok(()) => true, + Err(e) => { + tracing::warn!(error = %e, "PUNKTFUNK_DUMP_VIDEO write failed — capture disabled"); + false + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_capture_is_a_byte_faithful_concatenation_with_a_boundary_index() { + let dir = std::env::temp_dir().join(format!("pf-au-dump-test-{}", std::process::id())); + let mut dump = AuDump::create(&dir, "t", punktfunk_core::quic::CODEC_HEVC) + .expect("capture files should be creatable in a temp dir"); + assert!(dump.write(&[1, 2, 3], 0x04, true)); + assert!(dump.write(&[9, 8], 0x00, false)); + drop(dump); + + let data = std::fs::read(dir.join("t.h265")).unwrap(); + let idx = std::fs::read_to_string(dir.join("t.h265.idx")).unwrap(); + assert_eq!(data, &[1, 2, 3, 9, 8]); + assert_eq!(idx, "0 3 0x4 1\n3 2 0x0 0\n"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/pf-client-core/src/lib.rs b/crates/pf-client-core/src/lib.rs index 40e575c2..e8ef8fbc 100644 --- a/crates/pf-client-core/src/lib.rs +++ b/crates/pf-client-core/src/lib.rs @@ -20,6 +20,8 @@ // instead of an argument precisely because nothing required one. #![deny(clippy::undocumented_unsafe_blocks)] +#[cfg(any(target_os = "linux", windows))] +mod au_dump; #[cfg(target_os = "linux")] pub mod audio; #[cfg(windows)] diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index c0949c02..0c23df98 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -630,6 +630,9 @@ fn pump( // ahead of `frames_dropped` (the reassembler only declares a straggler lost once it ages out of // the loss window, by which point the concealment already reached the screen). let mut next_expected_index: Option = None; + // Fixture capture for the native-decode program: every AU exactly as it reaches + // `decode_frame`, plus a boundary/flags index — see `au_dump.rs` for the format. + let mut au_dump = crate::au_dump::AuDump::from_env(connector.codec); let end: Option = loop { if stop.load(Ordering::SeqCst) { @@ -743,6 +746,11 @@ fn pump( Some(n) if frame.frame_index.wrapping_sub(n) > u32::MAX / 2 => n, _ => frame.frame_index, }); + if let Some(d) = au_dump.as_mut() { + if !d.write(&frame.data, frame.flags, frame.complete) { + au_dump = None; + } + } match decoder.decode_frame(&frame.data, frame.flags, frame.complete) { Ok(Some(image)) => { // Fold this decoded frame through the shared freeze gate: it reads the AU's