diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt index 31e89b29..37ed8a26 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt @@ -2,6 +2,7 @@ package io.unom.punktfunk import android.content.Context import android.os.Build +import android.util.Log import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.VideoDecoders @@ -45,14 +46,23 @@ suspend fun connectToHost( // Transport-level half of "Low-latency mode (experimental)" (DSCP marking on the media // sockets) — must be applied before connect, since sockets are tagged at creation. NativeBridge.nativeSetLowLatencyMode(settings.lowLatencyMode) + val multiSlice = VideoDecoders.multiSliceTolerant() + // Slice-progressive delivery: decoder truth AND the async decode loop — the legacy + // sync loop feeds whole AUs only, so parts must never arrive when it is selected. + val frameParts = settings.lowLatencyMode && VideoDecoders.partialFrameCapable() + // The connect-time capability readout (`adb logcat -s pf.caps`): the P2 slice pipeline + // is client-inert unless BOTH probes pass — this line says which decoder failed one. + Log.i( + "pf.caps", + VideoDecoders.capsReport() + + " → multiSlice=$multiSlice parts=$frameParts (lowLatency=${settings.lowLatencyMode})", + ) NativeBridge.nativeConnect( host, port, w, h, hz, identity.certPem, identity.privateKeyPem, pinHex, settings.bitrateKbps, settings.compositor, gamepadPref, - hdrEnabled, VideoDecoders.multiSliceTolerant(), - // Slice-progressive delivery: decoder truth AND the async decode loop — the legacy - // sync loop feeds whole AUs only, so parts must never arrive when it is selected. - settings.lowLatencyMode && VideoDecoders.partialFrameCapable(), + hdrEnabled, multiSlice, + frameParts, settings.audioChannels, // What this device can decode (H.264|HEVC always, AV1 when a real decoder exists) + // the user's soft codec preference — the host resolves the emitted codec from both. diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt index 1b909097..8160226d 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt @@ -129,10 +129,30 @@ internal fun StatsOverlay( } else { "" } + // P3 decode split (s[30]/s[31]): `feed` = received→queued (hand-off + input-slot + // wait) + `codec` = queued→decoded (codec-pure) — rendered when a sample landed. + val decodeTerm = if (s.size >= 33 && (s[30] > 0 || s[31] > 0)) { + "decode ${"%.1f".format(s[15])} " + + "(feed ${"%.1f".format(s[30])} + codec ${"%.1f".format(s[31])})" + } else { + "decode ${"%.1f".format(s[15])}" + } statLine( - "= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm$presents", + "= $hostTerms + $decodeTerm$displayTerm$presents", Color.White, ) + // Metric fairness: the Apple client's HUD shaves ~2 refresh periods of OS + // pipeline floor off its shown display/end-to-end; Android shows raw. This twin + // applies the same shave so iPhone↔Android HUD numbers compare directly. + if (dispValid && hz > 0) { + val shave = 2000.0 / hz + statLine( + "≈ Apple-HUD equiv: end-to-end " + + "${"%.1f".format((s[24] - shave).coerceAtLeast(0.0))} · display " + + "${"%.1f".format((s[23] - shave).coerceAtLeast(0.0))} (−2 refresh)", + Color(0xFFA8D8B8), + ) + } } } counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) } @@ -178,12 +198,17 @@ private fun counterLine(s: DoubleArray, lostTotal: Long): String? { val fec = s[20].toLong() val frames = s[21].toLong() if (lost == 0L && skipped == 0L && fec == 0L) return null + // The overflow subset of `skipped` (s[32]): whole AUs dropped before feeding — the decoder + // fell behind. Absent (0 / old layout) the plain count keeps meaning benign pacing drops. + val overflow = if (s.size >= 33) s[32].toLong() else 0L return buildList { if (lost > 0) { val pct = 100.0 * lost / (frames + lost).coerceAtLeast(1) add("lost $lost (${"%.1f".format(pct)}%)") } - if (skipped > 0) add("skipped $skipped") + if (skipped > 0) { + add(if (overflow > 0) "skipped $skipped (⚠ $overflow overflow)" else "skipped $skipped") + } if (fec > 0) add("FEC $fec") }.joinToString(" · ") } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/VideoDecoders.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/VideoDecoders.kt index bbf591c6..2e4124c0 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/VideoDecoders.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/VideoDecoders.kt @@ -100,6 +100,34 @@ object VideoDecoders { } } + /** + * One-line per-mime probe readout for the connect log (`adb logcat -s pf.caps`): which + * decoder each advertised mime resolves to and whether it declares `FEATURE_PartialFrame` — + * the P2 slice-pipeline gate that is otherwise invisible until a stream behaves differently. + */ + fun capsReport(): String { + val mimes = buildList { + add("video/avc") + add("video/hevc") + if (decodableCodecBits() and 4 != 0) add("video/av01") + } + val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos } + .getOrNull() ?: return "codec list unavailable" + return mimes.joinToString(" ") { mime -> + val pick = pickDecoder(mime)?.name + val partial = pick?.let { p -> + infos.firstOrNull { it.name == p }?.let { info -> + runCatching { + info.getCapabilitiesForType(mime) + .isFeatureSupported(CodecCapabilities.FEATURE_PartialFrame) + }.getOrNull() + } + } + "${mime.removePrefix("video/")}=${pick ?: "platform-default"}" + + " partialFrame=${partial ?: "?"}" + } + } + fun pickDecoder(mime: String): DecoderChoice? { if (mime.isEmpty()) return null val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos } diff --git a/clients/android/native/src/decode/async_loop.rs b/clients/android/native/src/decode/async_loop.rs index fc4dcac7..68ca50e1 100644 --- a/clients/android/native/src/decode/async_loop.rs +++ b/clients/android/native/src/decode/async_loop.rs @@ -16,7 +16,7 @@ use std::time::{Duration, Instant}; use super::display::{ apply_hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker, }; -use super::latency::{note_decoded_pts, now_realtime_ns, take_flags}; +use super::latency::{note_decoded_pts, now_realtime_ns, take_flags, take_stamp}; use super::presenter::{presenter_disabled_by_sysprop, PresentMeter, PresentPriority, Presenter}; use super::setup::{ android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime, @@ -280,6 +280,10 @@ pub(super) fn run_async( let mut oversized_dropped: u64 = 0; // Slice-progressive continuity ledger (see `PartFeed`). let mut part_open: Option = None; + // Queued-instant stamps (pts → realtime ns at the AU's LAST piece entering the codec) — the + // P3 decode-split ledger: `feed` = received→queued, `codec` = queued→decoded. Always on + // (one vDSO clock read per AU); consumed by `present_ready`. + let mut queued_stamps: VecDeque<(u64, i128)> = VecDeque::new(); // Freeze-until-reanchor gate (see the sync loop for the rationale). Armed on a frame-index gap // (the feeder's Au verdict), a parked-AU overflow drop, a dropped-count climb, or a recoverable // codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to @@ -349,7 +353,7 @@ pub(super) fn run_async( p.on_vsync(); } } - stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too + stats.note_skipped_overflow(aus_dropped); // parked-AU overflow: skips, flagged as such if fmt_dirty { apply_hdr_dataspace(&codec, &window, &mut applied_ds); } @@ -361,6 +365,7 @@ pub(super) fn run_async( &mut fed, &mut oversized_dropped, &mut part_open, + &mut queued_stamps, &mut gate, ); let had_output = !ready.is_empty(); @@ -372,6 +377,8 @@ pub(super) fn run_async( &mut ready, &stats, &in_flight, + &mut queued_stamps, + &meter, clock_offset.load(Ordering::Relaxed), &tracker, &mut presenter, @@ -762,6 +769,7 @@ pub(super) struct PartFeed { /// [`BUFFER_FLAG_PARTIAL_FRAME`] except the AU's last, all at the AU's pts. `part_open` is the /// continuity ledger — any break (gap, orphan, oversize) abandons the AU per [`PartFeed::pts_us`]'s /// close contract and re-syncs at the next `first`. +#[allow(clippy::too_many_arguments)] // one call site; the split ledger threads through like the gate fn feed_ready( codec: &MediaCodec, client: &NativeClient, @@ -770,6 +778,7 @@ fn feed_ready( fed: &mut u64, oversized_dropped: &mut u64, part_open: &mut Option, + queued_stamps: &mut VecDeque<(u64, i128)>, gate: &mut ReanchorGate, ) { while !pending_aus.is_empty() && !free_inputs.is_empty() { @@ -859,9 +868,15 @@ fn feed_ready( } } else { // `fed` counts ACCESS UNITS toward the HUD's fed/decoded balance — the closing - // piece (or a whole AU) bumps it. + // piece (or a whole AU) bumps it. The queued stamp marks the same instant (the AU + // is fully in the codec's hands): the P3 decode split measures `codec` from here, + // so a slice-progressive head start shows up as codec-pure shrink. if last { *fed += 1; + queued_stamps.push_back((pts_us, now_realtime_ns())); + if queued_stamps.len() > IN_FLIGHT_CAP { + queued_stamps.pop_front(); // stale — codec never echoed it back + } } *part_open = if last { None @@ -876,7 +891,8 @@ fn feed_ready( } } -/// Route the ready outputs toward glass. With the timeline presenter (default): fold each output +/// Route the ready outputs toward glass, recording each one's decode-split + e2e first. With the +/// timeline presenter (default): fold each output /// through the re-anchor gate in pts order, hand the approved ones to the presenter's store /// (newest-wins / smoothing FIFO — the actual release happens in `Presenter::pump`, budgeted and /// timeline-timed), and release withheld concealment unrendered. Legacy (`arrival` sysprop): @@ -892,6 +908,8 @@ fn present_ready( ready: &mut Vec, stats: &crate::stats::VideoStats, in_flight: &Mutex>, + queued_stamps: &mut VecDeque<(u64, i128)>, + meter: &PresentMeter, clock_offset: i64, tracker: &DisplayTracker, presenter: &mut Option, @@ -903,22 +921,42 @@ fn present_ready( if ready.is_empty() { return; } - // Pair each output's decode stage (feeds the ABR decode signal always; the HUD histogram only - // while visible) — both consume the receipt map, so enter for either. - if stats.enabled() || measure_decode { + // Pair each output's decode stage (the ABR decode signal + the HUD histogram consume the + // receipt map; the P3 split's codec-pure half needs only the queued stamp, so it records + // even with both off — that keeps the 1 Hz pf.present mirror HUD-off readable). + { + let want_stage = stats.enabled() || measure_decode; let mut g = in_flight .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); for o in ready.iter() { - note_decoded_pts( - client, - measure_decode, - stats, - &mut g, - clock_offset, - o.pts_us, - o.decoded_ns, - ); + let received_ns = if want_stage { + note_decoded_pts( + client, + measure_decode, + stats, + &mut g, + clock_offset, + o.pts_us, + o.decoded_ns, + ) + } else { + None + }; + let queued = take_stamp(queued_stamps, o.pts_us); + let codec_us = queued.map(|q| ((o.decoded_ns - q).max(0) / 1000) as u64); + let feed_us = match (queued, received_ns) { + (Some(q), Some(r)) => Some(((q - r).max(0) / 1000) as u64), + _ => None, + }; + // Always-on e2e for the 1 Hz pf.present mirror (same formula + clamp as the HUD's + // capture→decoded headline in `note_decoded_pts`). + let e2e_ns = o.decoded_ns + clock_offset as i128 - o.pts_us as i128 * 1000; + let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64); + meter.note_decode(feed_us, codec_us, e2e_us); + if let Some(c) = codec_us { + stats.note_decode_split(feed_us, c); + } } } // Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards — diff --git a/clients/android/native/src/decode/latency.rs b/clients/android/native/src/decode/latency.rs index f8e57f1a..10b96118 100644 --- a/clients/android/native/src/decode/latency.rs +++ b/clients/android/native/src/decode/latency.rs @@ -20,7 +20,8 @@ pub(super) fn now_realtime_ns() -> i128 { /// entries older than it are evicted (decode order == input order here — low-latency, no /// B-frames — so anything before it was dropped inside the codec or stamped before a flush). /// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's -/// stamp (async loop). +/// stamp (async loop). Returns the receipt stamp it paired (if any) so the caller can split the +/// `decode` stage further (feed wait vs codec-pure) without re-walking the map. pub(super) fn note_decoded_pts( client: &NativeClient, measure_decode: bool, @@ -29,7 +30,7 @@ pub(super) fn note_decoded_pts( clock_offset: i64, pts_us: u64, decoded_ns: i128, -) { +) -> Option { // Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go. let mut received_ns = None; while let Some(&(p, r)) = in_flight.front() { @@ -61,6 +62,24 @@ pub(super) fn note_decoded_pts( let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64); stats.note_decoded(e2e_us, decode_us); } + received_ns +} + +/// The queued-instant stamp for a decoded output, keyed by the echoed `presentationTimeUs` — the +/// same monotonic evict-as-you-go pairing as [`take_flags`], over an `(pts_us, realtime_ns)` map +/// (the feed side stamps each AU as its last piece enters the codec). A miss returns `None` — +/// the split is simply not recorded for that frame. +pub(super) fn take_stamp(map: &mut VecDeque<(u64, i128)>, pts_us: u64) -> Option { + while let Some(&(p, t)) = map.front() { + if p > pts_us { + break; // future frame — leave it for its own output buffer + } + map.pop_front(); + if p == pts_us { + return Some(t); + } + } + None } /// The AU `user_flags` for a decoded output, keyed by the echoed `presentationTimeUs`. Recovery diff --git a/clients/android/native/src/decode/presenter.rs b/clients/android/native/src/decode/presenter.rs index 025947f2..1eda9928 100644 --- a/clients/android/native/src/decode/presenter.rs +++ b/clients/android/native/src/decode/presenter.rs @@ -126,6 +126,15 @@ pub(super) struct PresentMeter { struct PresentMeterInner { latch_us: Vec, displays: u64, + /// The `decode` stage's feed split, received→queued µs (P3 science: hand-off + input-slot + /// wait). Empty when no receipt stamp matched (HUD off and ABR not measuring decode). + feed_us: Vec, + /// The codec-pure half, queued→decoded µs, measured from the AU's LAST piece — always on, + /// so a HUD-off logcat A/B still reads the decoder's own time. + codec_us: Vec, + /// Capture→decoded end-to-end µs (skew-corrected, clamped) — always on for the same reason: + /// the wireless A/B's headline without having to reach the on-screen HUD. + e2e_us: Vec, } impl PresentMeter { @@ -134,6 +143,9 @@ impl PresentMeter { inner: Mutex::new(PresentMeterInner { latch_us: Vec::with_capacity(256), displays: 0, + feed_us: Vec::with_capacity(256), + codec_us: Vec::with_capacity(256), + e2e_us: Vec::with_capacity(256), }), } } @@ -152,14 +164,51 @@ impl PresentMeter { } } - fn drain(&self) -> (Vec, u64) { + /// One decoded frame's always-on measurements: the `decode`-stage split (feed = + /// received→queued when a receipt stamp matched; codec = queued→decoded when the queued + /// stamp did) and the capture→decoded end-to-end, µs. Decode thread; poison-proof. + pub(super) fn note_decode( + &self, + feed_us: Option, + codec_us: Option, + e2e_us: Option, + ) { + let mut g = self + .inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(f) = feed_us { + if g.feed_us.len() < 4096 { + g.feed_us.push(f); + } + } + if let Some(c) = codec_us { + if g.codec_us.len() < 4096 { + g.codec_us.push(c); + } + } + if let Some(e) = e2e_us { + if g.e2e_us.len() < 4096 { + g.e2e_us.push(e); + } + } + } + + #[allow(clippy::type_complexity)] // one caller unpacks it in place; a struct would be noise + fn drain(&self) -> (Vec, u64, Vec, Vec, Vec) { let mut g = self .inner .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let displays = g.displays; g.displays = 0; - (std::mem::take(&mut g.latch_us), displays) + ( + std::mem::take(&mut g.latch_us), + displays, + std::mem::take(&mut g.feed_us), + std::mem::take(&mut g.codec_us), + std::mem::take(&mut g.e2e_us), + ) } } @@ -386,7 +435,9 @@ impl Presenter { /// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) / /// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) / /// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) / - /// `vsync` (the measured panel period). + /// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the + /// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless + /// A/B headline) / `vsync` (the measured panel period). /// /// Returns this window's CIRCULAR latch statistics `(vector-mean latch ns mod panel period, /// coherence ‰)` when a window actually flushed — the phase-lock reporter's v2 error signal @@ -400,11 +451,14 @@ impl Presenter { return None; } self.last_flush = Instant::now(); - let (latch, displays) = meter.drain(); + let (latch, displays, feed, codec, e2e) = meter.drain(); if self.released == 0 && displays == 0 { return None; // idle stream — nothing worth a line } let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us)); + let (feed_p50, feed_max) = p50_max_ms(feed); + let (codec_p50, codec_max) = p50_max_ms(codec); + let (e2e_p50, e2e_max) = p50_max_ms(e2e); let circ = clock.and_then(|c| { punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns())) }); @@ -416,7 +470,9 @@ impl Presenter { log::info!( target: "pf.present", "released={} displays={} paced={} noBudget={} forced={} qDry={} \ - paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} circ={:.2}ms coh={} \ + paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \ + feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \ + e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \ vsyncMs={:.2} panelMs={:.2}", self.released, displays, @@ -428,6 +484,12 @@ impl Presenter { pace_max, latch_p50, latch_max, + feed_p50, + feed_max, + codec_p50, + codec_max, + e2e_p50, + e2e_max, circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0), circ.map(|(_, c)| c).unwrap_or(0), period_ms, diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index e8ab5652..0056e1a6 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -83,6 +83,28 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLaten punktfunk_core::transport::set_dscp_default(enabled != 0); } +/// `debug.punktfunk.force_parts` = 1: arm slice-progressive parts delivery even when the +/// Kotlin `FEATURE_PartialFrame` probe said no — the rebuild-free on-glass experiment for a +/// decoder that may accept `BUFFER_FLAG_PARTIAL_FRAME` without declaring the feature (the NP3's +/// c2.qti decoders declare nothing). Android-only; everywhere else the probe verdict stands. +#[cfg(target_os = "android")] +fn force_parts_sysprop() -> bool { + let mut buf = [0u8; 92]; // PROP_VALUE_MAX + // SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe. + let n = unsafe { + libc::__system_property_get( + c"debug.punktfunk.force_parts".as_ptr(), + buf.as_mut_ptr().cast(), + ) + }; + n > 0 && std::str::from_utf8(&buf[..n as usize]).unwrap_or("").trim() == "1" +} + +#[cfg(not(target_os = "android"))] +fn force_parts_sysprop() -> bool { + false +} + /// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps, /// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch, /// deviceName): Long`. @@ -155,6 +177,24 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo } else { Some((cert, key)) }; + // Slice-progressive parts, by decoder truth (Kotlin's FEATURE_PartialFrame probe) — with a + // sysprop escape hatch for the on-glass science question the probe can't answer: does the + // decoder ACTUALLY choke on BUFFER_FLAG_PARTIAL_FRAME input, or does it merely not declare + // the feature? (`adb shell setprop debug.punktfunk.force_parts 1` + stream restart; a codec + // that can't take parts errors recoverably and the reanchor gate + keyframe path recovers.) + let force_parts = force_parts_sysprop(); + let frame_parts = frame_parts_ok != 0 || force_parts; + // The connect-time capability readout (`adb logcat -s pf.caps`): the P2 slice pipeline is + // inert client-side unless BOTH probes pass — this line is the one place that says which. + log::info!( + target: "pf.caps", + "decoder caps: multi_slice={} partial_frame={}{} hdr={} codec_bits={:#x}", + multi_slice_ok != 0, + frame_parts_ok != 0, + if force_parts { " (FORCED by sysprop)" } else { "" }, + hdr_enabled != 0, + video_codecs, + ); let pin: Option<[u8; 32]> = if pin_hex.is_empty() { None } else { @@ -230,9 +270,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo // should say what the client does). punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK, // Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on - // every decoder this device would use): AU prefixes then arrive as `Frame::part` - // pieces and the decode loop feeds them with BUFFER_FLAG_PARTIAL_FRAME. - frame_parts_ok != 0, + // every decoder this device would use; `debug.punktfunk.force_parts` overrides for the + // on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode + // loop feeds them with BUFFER_FLAG_PARTIAL_FRAME. + frame_parts, launch, // a store-qualified library id to boot into a game, or None for the desktop device_name, // Kotlin's Build.MODEL — the host's approval-list / trust-store label pin, // Some → Crypto on host-fp mismatch diff --git a/clients/android/native/src/session/planes.rs b/clients/android/native/src/session/planes.rs index 366dc17a..fc72840e 100644 --- a/clients/android/native/src/session/planes.rs +++ b/clients/android/native/src/session/planes.rs @@ -177,11 +177,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo( } /// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD -/// (unified stats spec, `design/stats-unification.md`). Returns 26 doubles +/// (unified stats spec, `design/stats-unification.md`). Returns 33 doubles /// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost, /// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, /// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms, -/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive]` +/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive, +/// feedP50Ms, codecP50Ms, skippedOverflowWindow]` /// (the flags are 1.0/0.0; indexes 0–21 match the previous 22-double layout — 0–13 the original /// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15 /// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17 @@ -198,7 +199,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo( /// term — `pace` = decoded→release (store + glass budget) p50 at 26, `latch` = /// release→displayed (SurfaceFlinger) p50 at 27, the window's on-glass confirm count at 28 /// (`presents` vs `fps` is the presenter-health pair), and 29 = 1.0 while the timeline presenter -/// is active this session), or `null` when no decode thread is running. +/// is active this session; 30/31 are the `decode` stage's split p50s — `feed` = +/// received→queued (hand-off + input-slot wait) at 30 and `codec` = queued→decoded (codec-pure, +/// from the AU's last piece) at 31, both 0.0 when no sample landed (sync loop); 32 is the +/// parked-AU overflow subset of the window's `skipped` at 19 (decoder fell behind, vs benign +/// newest-wins pacing)), or `null` when no decode thread is running. /// Poll ~1 Hz from the UI; each call /// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on /// the host build too (Kotlin only ever calls it on device). @@ -222,7 +227,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats( .drain(h.client.frames_dropped(), h.client.fec_recovered_shards()); let mode = h.client.mode(); let color = h.client.color; - let buf: [f64; 30] = [ + let buf: [f64; 33] = [ snap.fps, snap.mbps, snap.e2e_p50_ms, @@ -270,6 +275,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats( snap.latch_p50_ms, snap.presents as f64, if h.stats.presenter_active() { 1.0 } else { 0.0 }, + // The `decode` stage's split (P3 science): feed = received→queued (hand-off + + // input-slot wait), codec = queued→decoded (codec-pure) — and the parked-AU + // overflow subset of `skipped` (decoder-health vs benign pacing drops). + snap.feed_p50_ms, + snap.codec_p50_ms, + snap.skipped_overflow as f64, ]; let arr = match env.new_double_array(buf.len() as jsize) { Ok(a) => a, diff --git a/clients/android/native/src/stats.rs b/clients/android/native/src/stats.rs index f89a9ba0..0c36fc22 100644 --- a/clients/android/native/src/stats.rs +++ b/clients/android/native/src/stats.rs @@ -76,6 +76,12 @@ struct Inner { /// The other half of the split, release→displayed (SurfaceFlinger's latch + scanout), µs — /// from the `OnFrameRendered` render timestamps. `pace + latch ≈ display` per frame. latch_us: Vec, + /// The `decode` stage's feed split, received→queued (hand-off + input-slot wait), µs. Empty + /// when no receipt stamp matched (HUD off and ABR not measuring decode). + feed_us: Vec, + /// The other half, queued→decoded (codec-pure: the decoder's own time on the AU, measured + /// from its LAST piece so a slice-progressive head start shows up as a shrink here), µs. + codec_us: Vec, /// Frames confirmed on glass this window (`OnFrameRendered` callbacks) — the `presents`-vs- /// `fps` health pair: presents ≪ fps means the presenter is dropping/serializing; an fps /// deficit is upstream. @@ -83,6 +89,10 @@ struct Inner { /// Client-side newest-wins/pacing drops this window (decoded frames released without /// rendering, or parked AUs dropped on overflow) — the spec's `skipped` counter. skipped: u64, + /// The subset of `skipped` that was parked-AU OVERFLOW (the decoder fell behind and whole + /// AUs were dropped before feeding) — a decoder-health signal, vs the benign newest-wins + /// pacing majority. Always ≤ `skipped`. + skipped_overflow: u64, /// Baselines for windowing the session-cumulative connector counters: the unrecoverable-drop /// and FEC-recovered totals as of the last drain (or the enable that opened the window), so /// each snapshot reports only THIS window's `lost` / `FEC` (spec line 4). @@ -119,6 +129,11 @@ pub struct Snapshot { /// path / no render callbacks). pub pace_p50_ms: f64, pub latch_p50_ms: f64, + /// The `decode` stage's split p50s (ms): `feed` = received→queued (hand-off + input-slot + /// wait), `codec` = queued→decoded (codec-pure, from the AU's last piece). 0.0 when no + /// sample landed (sync loop / no receipt stamps). + pub feed_p50_ms: f64, + pub codec_p50_ms: f64, /// Frames confirmed on glass this window (`OnFrameRendered` callbacks). pub presents: u64, /// Phase-2 `host` / `network` split p50s (ms) — 0.0 when no 0xCF timing matched this window @@ -135,6 +150,8 @@ pub struct Snapshot { pub lost: u64, /// Client-side newest-wins/pacing drops this window (spec `skipped`). pub skipped: u64, + /// The parked-AU overflow subset of `skipped` (decoder fell behind; ≤ `skipped`). + pub skipped_overflow: u64, /// FEC shards recovered this window (spec `FEC`, windowed from the cumulative counter). pub fec: u64, } @@ -167,8 +184,11 @@ impl VideoStats { e2e_disp_us: Vec::with_capacity(256), pace_us: Vec::with_capacity(256), latch_us: Vec::with_capacity(256), + feed_us: Vec::with_capacity(256), + codec_us: Vec::with_capacity(256), presents: 0, skipped: 0, + skipped_overflow: 0, last_dropped_total: 0, last_fec_total: 0, skew_corrected: false, @@ -219,8 +239,11 @@ impl VideoStats { g.e2e_disp_us.clear(); g.pace_us.clear(); g.latch_us.clear(); + g.feed_us.clear(); + g.codec_us.clear(); g.presents = 0; g.skipped = 0; + g.skipped_overflow = 0; g.last_dropped_total = dropped_total; g.last_fec_total = fec_total; } @@ -314,6 +337,45 @@ impl VideoStats { g.skipped += n; } + /// Record parked-AU OVERFLOW drops (whole AUs dropped before feeding — the decoder fell + /// behind). Counts into `skipped` too, plus the overflow-only counter, so the HUD can tell + /// benign newest-wins pacing from a decoder that can't keep up. + // Driven only by the android-only decode thread; unreferenced on the host build — expected. + #[cfg_attr(not(target_os = "android"), allow(dead_code))] + pub fn note_skipped_overflow(&self, n: u64) { + if n == 0 || !self.enabled.load(Ordering::Relaxed) { + return; // HUD hidden — skip the lock + } + // Poison-proof for the same reason as `note_received`. + let mut g = self + .inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + g.skipped += n; + g.skipped_overflow += n; + } + + /// Record one decoded frame's `decode`-stage split: `feed` = received→queued (hand-off + + /// input-slot wait; absent when no receipt stamp matched) and `codec` = queued→decoded + /// (codec-pure, measured from the AU's LAST piece — a slice-progressive head start shows + /// as a shrink here), both µs. + // Driven only by the android-only decode thread; unreferenced on the host build — expected. + #[cfg_attr(not(target_os = "android"), allow(dead_code))] + pub fn note_decode_split(&self, feed_us: Option, codec_us: u64) { + if !self.enabled.load(Ordering::Relaxed) { + return; // HUD hidden — skip the lock + } + // Poison-proof for the same reason as `note_received`. + let mut g = self + .inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(f) = feed_us { + g.feed_us.push(f); + } + g.codec_us.push(codec_us); + } + /// Record one decoded output frame: its capture→decoded `end-to-end` sample and its /// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for /// this pts predates the HUD being shown). @@ -408,6 +470,8 @@ impl VideoStats { g.e2e_disp_us.sort_unstable(); g.pace_us.sort_unstable(); g.latch_us.sort_unstable(); + g.feed_us.sort_unstable(); + g.codec_us.sort_unstable(); let snap = Snapshot { fps, mbps, @@ -421,6 +485,8 @@ impl VideoStats { disp_valid: !g.e2e_disp_us.is_empty(), pace_p50_ms: pctl_ms(&g.pace_us, 0.50), latch_p50_ms: pctl_ms(&g.latch_us, 0.50), + feed_p50_ms: pctl_ms(&g.feed_us, 0.50), + codec_p50_ms: pctl_ms(&g.codec_us, 0.50), presents: g.presents, host_p50_ms: pctl_ms(&g.host_us, 0.50), net_p50_ms: pctl_ms(&g.net_us, 0.50), @@ -429,6 +495,7 @@ impl VideoStats { frames: g.frames, lost: dropped_total.saturating_sub(g.last_dropped_total), skipped: g.skipped, + skipped_overflow: g.skipped_overflow, fec: fec_total.saturating_sub(g.last_fec_total), }; g.window_start = Instant::now(); @@ -443,8 +510,11 @@ impl VideoStats { g.e2e_disp_us.clear(); g.pace_us.clear(); g.latch_us.clear(); + g.feed_us.clear(); + g.codec_us.clear(); g.presents = 0; g.skipped = 0; + g.skipped_overflow = 0; g.last_dropped_total = dropped_total; g.last_fec_total = fec_total; snap