From 0985726415e5c4dd58e1edbb7c28c9786074c43e Mon Sep 17 00:00:00 2001
From: enricobuehler
Date: Sat, 1 Aug 2026 00:37:52 +0200
Subject: [PATCH 1/6] fix(host,web): an empty update channel stops looking like
a broken host
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A channel nobody has published to answers `manifest.json` with a 404, and the
check reported that the same way it reports a dead registry or a bad signature:
"Last check failed: feed returned HTTP 404". Every host on the stable channel
shows it today, because the stable manifest only publishes when someone
dispatches `announce` for a release tag — so the first thing an operator sees
from the new Updates card is a red failure caused by nothing being wrong.
The shared checker now distinguishes the two. `feed::fetch_manifest_blocking`
returns a typed `FeedError` instead of a string, and only a 404 on the manifest
ITSELF becomes `NotPublished` — a 404 on the detached signature still fails
loudly, because that is the half-published pair the manifest-then-signature
upload order can produce, and it must stay fail-closed.
The host carries that through as `UpdateStatus.not_published`, mutually
exclusive with `last_error`. It is benign only while no manifest has ever been
seen for the channel: once a check has succeeded, the same 404 means the feed
LOST a document it used to serve, which stays an error. The console then shows a
plain sentence naming the channel instead of the failure banner, and "None
published yet" rather than "Not checked yet".
The Linux client makes the same distinction but deliberately NOT the same
choice: `--check-update` keeps exiting 1 and keeps `error` set, because its
consumer is a shell script and an empty channel is the absence of evidence that
this build is current — not a confirmation that it is.
Co-Authored-By: Claude Opus 5 (1M context)
---
api/openapi.json | 9 +-
clients/linux/src/cli.rs | 19 +++-
crates/pf-client-core/src/update.rs | 16 +++-
crates/pf-update-check/src/feed.rs | 111 ++++++++++++++++++++---
crates/pf-update-check/src/lib.rs | 1 +
crates/punktfunk-host/src/mgmt/update.rs | 9 +-
crates/punktfunk-host/src/update.rs | 68 ++++++++++++--
web/messages/de.json | 2 +
web/messages/en.json | 2 +
web/src/sections/Host/UpdateCard.tsx | 15 ++-
10 files changed, 225 insertions(+), 27 deletions(-)
diff --git a/api/openapi.json b/api/openapi.json
index 2cd138f7..13ed549f 100644
--- a/api/openapi.json
+++ b/api/openapi.json
@@ -3495,7 +3495,7 @@
"operationId": "forceUpdateCheck",
"responses": {
"200": {
- "description": "Refreshed update-check state (`last_error` carries a failed check)",
+ "description": "Refreshed update-check state (`last_error` carries a failed check; `not_published` an empty channel, which is not one)",
"content": {
"application/json": {
"schema": {
@@ -7372,7 +7372,8 @@
"apply",
"channel_hint",
"check_disabled",
- "available"
+ "available",
+ "not_published"
],
"properties": {
"apply": {
@@ -7452,6 +7453,10 @@
}
]
},
+ "not_published": {
+ "type": "boolean",
+ "description": "The check reached the feed and found this channel has **no release published yet** —\nan expected state (a channel nobody has announced to answers with a 404), not a\nfailure. Mutually exclusive with `last_error`, so a UI can say \"nothing published yet\"\ninstead of painting an empty feed as a broken host. Never set once a manifest has been\nseen for this channel: a feed that loses a document it used to serve stays an error."
+ },
"opt_in_hint": {
"type": [
"string",
diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs
index 97fddc09..f4729d00 100644
--- a/clients/linux/src/cli.rs
+++ b/clients/linux/src/cli.rs
@@ -480,8 +480,23 @@ fn headless_check_update() -> glib::ExitCode {
"installed {} ({}, {})",
status.current, status.kind, status.channel
);
- println!("available {}", status.latest);
- if let Some(err) = &status.error {
+ // `latest` falls back to `current` when the check couldn't run — printing that as
+ // "available" would read as a confirmed answer we don't have.
+ if status.error.is_some() {
+ println!("available unknown");
+ } else {
+ println!("available {}", status.latest);
+ }
+ if status.not_published {
+ // Says what it is, in words, instead of a raw HTTP status. The exit code still
+ // reports "could not tell" (see the doc comment above): an empty channel is the
+ // absence of evidence that this build is current, and a mistyped
+ // PUNKTFUNK_UPDATE_FEED is indistinguishable from one out here.
+ println!(
+ "update nothing published on the {} channel yet",
+ status.channel
+ );
+ } else if let Some(err) = &status.error {
eprintln!("check-update: {err}");
} else if status.update_available {
println!("update yes");
diff --git a/crates/pf-client-core/src/update.rs b/crates/pf-client-core/src/update.rs
index 5d788087..b33d9cb3 100644
--- a/crates/pf-client-core/src/update.rs
+++ b/crates/pf-client-core/src/update.rs
@@ -99,6 +99,18 @@ pub struct Status {
/// Why the check couldn't complete. `update_available` is always false when set.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option,
+ /// The feed answered, but this channel has **no release published yet** — an expected
+ /// state rather than a malfunction, so a caller can say so plainly instead of showing a
+ /// raw "HTTP 404".
+ ///
+ /// Deliberately NOT symmetric with the host's `UpdateStatus`, which clears `last_error`
+ /// for this case: there the consumer is a human reading a console, and a red "last check
+ /// failed" on an empty feed is the bug being fixed. Here the consumer is a shell script
+ /// reading an exit code, so `error` stays set and `--check-update` keeps returning 1.
+ /// An empty channel is not evidence that this build is current, and a mistyped
+ /// `PUNKTFUNK_UPDATE_FEED` is indistinguishable from one out here.
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub not_published: bool,
}
/// The Ed25519 keys trusted for update manifests — pinned once in [`pf_update_check`] so the
@@ -302,6 +314,7 @@ pub fn check(current: &str) -> Status {
opt_in_hint: opt_in_would_help(kind, caps).then(opt_in_hint),
notes_url: String::new(),
error: None,
+ not_published: false,
};
let (apply, applier) = apply_route(kind, caps);
status.apply = apply;
@@ -320,7 +333,8 @@ pub fn check(current: &str) -> Status {
) {
Ok(m) => m,
Err(e) => {
- status.error = Some(e);
+ status.not_published = e.is_not_published();
+ status.error = Some(e.to_string());
return status;
}
};
diff --git a/crates/pf-update-check/src/feed.rs b/crates/pf-update-check/src/feed.rs
index a44bd84d..002b0daa 100644
--- a/crates/pf-update-check/src/feed.rs
+++ b/crates/pf-update-check/src/feed.rs
@@ -19,6 +19,43 @@ pub const DEFAULT_FEED_BASE: &str =
/// One fetch's wall-clock budget.
const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
+/// Why a fetch didn't produce a manifest.
+///
+/// Exactly one failure mode is an *expected* steady state: a channel nobody has published to
+/// answers `manifest.json` with a 404. Collapsing that into the same string as a transport or
+/// signature failure is what made an empty stable feed render as "last check failed: feed
+/// returned HTTP 404" — telling operators their box is broken when the feed is merely empty.
+/// Everything else stays a real failure, loudly.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum FeedError {
+ /// This channel has no manifest at all. Note the deliberate narrowness: only a 404 on
+ /// `manifest.json` itself counts. A 404 on the *signature* means the manifest exists
+ /// without its proof — a half-published pair (the publisher's manifest-then-signature
+ /// window, or a botched upload), which must stay fail-closed and noisy.
+ NotPublished,
+ /// A real failure: transport, an HTTP status that isn't the empty-channel 404, the size
+ /// cap, or signature/schema rejection.
+ Failed(String),
+}
+
+impl FeedError {
+ /// Is this the benign "nothing published on this channel yet" state?
+ pub fn is_not_published(&self) -> bool {
+ matches!(self, Self::NotPublished)
+ }
+}
+
+impl std::fmt::Display for FeedError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::NotPublished => f.write_str("no release has been published on this channel yet"),
+ Self::Failed(msg) => f.write_str(msg),
+ }
+ }
+}
+
+impl std::error::Error for FeedError {}
+
/// The feed base, with a `PUNKTFUNK_UPDATE_FEED` override for tests and dev feeds. This is
/// operator config (an env var on the process), never request-time input; the `https://` (or
/// loopback) requirement keeps a stray value from silently downgrading the transport.
@@ -36,9 +73,11 @@ pub fn fetch_manifest_blocking(
channel: &str,
keys: &[PublicKey],
user_agent: &str,
-) -> Result {
+) -> Result {
if keys.is_empty() {
- return Err("no update key is pinned in this build".into());
+ return Err(FeedError::Failed(
+ "no update key is pinned in this build".into(),
+ ));
}
let agent = ureq::AgentBuilder::new()
.timeout(FETCH_TIMEOUT)
@@ -48,29 +87,42 @@ pub fn fetch_manifest_blocking(
let url = format!("{base}/{channel}/manifest.json");
let sig_url = format!("{url}.sig");
- let body = read_capped(agent.get(&url).call().map_err(fetch_err)?)?;
+ // Only the MANIFEST leg can report an empty channel; see [`FeedError::NotPublished`].
+ let body = read_capped(agent.get(&url).call().map_err(manifest_err)?)?;
let sig = read_capped(agent.get(&sig_url).call().map_err(fetch_err)?)?;
- let sig_text = String::from_utf8(sig).map_err(|_| "signature file is not text".to_string())?;
+ let sig_text = String::from_utf8(sig)
+ .map_err(|_| FeedError::Failed("signature file is not text".into()))?;
- manifest::verify_and_parse(&body, &sig_text, keys, channel).map_err(|e| format!("{e:#}"))
+ manifest::verify_and_parse(&body, &sig_text, keys, channel)
+ .map_err(|e| FeedError::Failed(format!("{e:#}")))
}
-fn fetch_err(e: ureq::Error) -> String {
+/// The manifest leg: a 404 here means the channel is empty, not broken.
+fn manifest_err(e: ureq::Error) -> FeedError {
match e {
- ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"),
- other => format!("feed fetch failed: {other}"),
+ ureq::Error::Status(404, _) => FeedError::NotPublished,
+ other => fetch_err(other),
}
}
-fn read_capped(resp: ureq::Response) -> Result, String> {
+fn fetch_err(e: ureq::Error) -> FeedError {
+ FeedError::Failed(match e {
+ ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"),
+ other => format!("feed fetch failed: {other}"),
+ })
+}
+
+fn read_capped(resp: ureq::Response) -> Result, FeedError> {
use std::io::Read as _;
let mut buf = Vec::new();
let mut reader = resp.into_reader().take(MAX_MANIFEST_BYTES as u64 + 1);
reader
.read_to_end(&mut buf)
- .map_err(|e| format!("read failed: {e}"))?;
+ .map_err(|e| FeedError::Failed(format!("read failed: {e}")))?;
if buf.len() > MAX_MANIFEST_BYTES {
- return Err("response exceeds the manifest size cap".into());
+ return Err(FeedError::Failed(
+ "response exceeds the manifest size cap".into(),
+ ));
}
Ok(buf)
}
@@ -85,7 +137,42 @@ mod tests {
// licence to trust whatever the feed serves.
let err =
fetch_manifest_blocking("https://127.0.0.1:1", "stable", &[], "test").unwrap_err();
- assert!(err.contains("no update key"), "{err}");
+ assert!(err.to_string().contains("no update key"), "{err}");
+ // And it is a real failure — never the benign empty-channel state.
+ assert!(!err.is_not_published());
+ }
+
+ fn status(code: u16) -> ureq::Error {
+ ureq::Error::Status(code, ureq::Response::new(code, "status", "").unwrap())
+ }
+
+ /// The whole point of the split: an empty channel is not a broken feed.
+ #[test]
+ fn manifest_404_is_not_published_but_other_statuses_are_failures() {
+ assert_eq!(manifest_err(status(404)), FeedError::NotPublished);
+ for code in [403, 500, 502] {
+ let e = manifest_err(status(code));
+ assert!(!e.is_not_published(), "HTTP {code} must stay a failure");
+ assert!(e.to_string().contains(&code.to_string()), "{e}");
+ }
+ }
+
+ /// A missing SIGNATURE is a half-published pair, not an empty channel — the manifest leg
+ /// already answered 200. Treating it as "nothing published yet" would quietly excuse the
+ /// one window where a manifest exists without its proof.
+ #[test]
+ fn signature_404_stays_a_failure() {
+ let e = fetch_err(status(404));
+ assert!(!e.is_not_published());
+ assert_eq!(e.to_string(), "feed returned HTTP 404");
+ }
+
+ #[test]
+ fn not_published_reads_as_plain_english() {
+ assert_eq!(
+ FeedError::NotPublished.to_string(),
+ "no release has been published on this channel yet"
+ );
}
#[test]
diff --git a/crates/pf-update-check/src/lib.rs b/crates/pf-update-check/src/lib.rs
index 000d8c12..5fdf6cfb 100644
--- a/crates/pf-update-check/src/lib.rs
+++ b/crates/pf-update-check/src/lib.rs
@@ -35,6 +35,7 @@ pub mod sig;
pub mod version;
pub use detect::{InstallKind, Product};
+pub use feed::FeedError;
pub use manifest::{Manifest, MAX_MANIFEST_BYTES, SCHEMA};
pub use sig::{verify_signature, PublicKey};
pub use version::{canary_run, is_newer, triple, Channel};
diff --git a/crates/punktfunk-host/src/mgmt/update.rs b/crates/punktfunk-host/src/mgmt/update.rs
index ba263af9..7b9e2d39 100644
--- a/crates/punktfunk-host/src/mgmt/update.rs
+++ b/crates/punktfunk-host/src/mgmt/update.rs
@@ -83,6 +83,12 @@ pub(crate) struct UpdateStatus {
pub last_checked_unix: Option,
/// Why the last check failed, verbatim, if it did.
pub last_error: Option,
+ /// The check reached the feed and found this channel has **no release published yet** —
+ /// an expected state (a channel nobody has announced to answers with a 404), not a
+ /// failure. Mutually exclusive with `last_error`, so a UI can say "nothing published yet"
+ /// instead of painting an empty feed as a broken host. Never set once a manifest has been
+ /// seen for this channel: a feed that loses a document it used to serve stays an error.
+ pub not_published: bool,
/// This install could one-click apply, but the operator hasn't opted in yet — the
/// command to run (Linux: join the `punktfunk-update` group).
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -142,6 +148,7 @@ fn status_from(snap: update::Snapshot) -> UpdateStatus {
}),
last_checked_unix: snap.checked.as_ref().map(|c| c.fetched_unix),
last_error: snap.last_error,
+ not_published: snap.not_published,
opt_in_hint: update::opt_in_hint(),
job,
last_result: snap.last_result.as_ref().map(|r| UpdateResultInfo {
@@ -186,7 +193,7 @@ pub(crate) async fn get_update_status() -> Json {
tag = "update",
operation_id = "forceUpdateCheck",
responses(
- (status = OK, description = "Refreshed update-check state (`last_error` carries a failed check)", body = UpdateStatus),
+ (status = OK, description = "Refreshed update-check state (`last_error` carries a failed check; `not_published` an empty channel, which is not one)", body = UpdateStatus),
(status = CONFLICT, description = "Update checks are disabled on this host", body = ApiError),
(status = TOO_MANY_REQUESTS, description = "A forced check ran less than 30 s ago", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
diff --git a/crates/punktfunk-host/src/update.rs b/crates/punktfunk-host/src/update.rs
index 86398fd9..095af660 100644
--- a/crates/punktfunk-host/src/update.rs
+++ b/crates/punktfunk-host/src/update.rs
@@ -27,7 +27,7 @@ pub(crate) use pf_update_check::manifest;
pub(crate) mod windows;
use manifest::Manifest;
-use pf_update_check::PublicKey;
+use pf_update_check::{FeedError, PublicKey};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@@ -141,6 +141,9 @@ pub(crate) struct Checked {
struct Runtime {
checked: Option,
last_error: Option,
+ /// The channel has nothing published (and never had, for us) — an expected state, kept
+ /// out of `last_error` so a console never paints an empty feed as a broken host.
+ not_published: bool,
/// Refresh in flight (status kicks at most one).
refreshing: bool,
/// Wall-clock guard for the forced-check rate limit.
@@ -214,7 +217,7 @@ fn store_floor(path: &Path, channel: &str, serial: u64) {
/// Fetch + verify the channel manifest through the shared checker. Blocking — call from a
/// blocking thread.
-fn fetch_manifest_blocking(channel: &str) -> Result {
+fn fetch_manifest_blocking(channel: &str) -> Result {
pf_update_check::feed::fetch_manifest_blocking(
&pf_update_check::feed::feed_base(),
channel,
@@ -227,18 +230,18 @@ fn fetch_manifest_blocking(channel: &str) -> Result {
}
/// One full refresh: fetch, verify, enforce + raise the serial floor, update the cache,
-/// announce a newly available release on the event bus. Returns the user-facing error string
-/// on failure (also cached for status).
-pub(crate) fn refresh_blocking() -> Result {
+/// announce a newly available release on the event bus. Returns the user-facing error on
+/// failure (also cached for status).
+pub(crate) fn refresh_blocking() -> Result {
let (kind, channel) = detect::detect();
let result = fetch_manifest_blocking(channel.as_str()).and_then(|m| {
let path = state_path();
let floor = load_floor(&path, channel.as_str());
if m.serial < floor {
- return Err(format!(
+ return Err(FeedError::Failed(format!(
"manifest serial {} is older than the last accepted {} — refusing rollback",
m.serial, floor
- ));
+ )));
}
store_floor(&path, channel.as_str(), m.serial);
Ok(m)
@@ -268,16 +271,32 @@ pub(crate) fn refresh_blocking() -> Result {
});
}
rt.last_error = None;
+ rt.not_published = false;
rt.checked = Some(checked.clone());
Ok(checked)
}
Err(e) => {
- rt.last_error = Some(e.clone());
+ let (last_error, not_published) = classify_failure(&e, rt.checked.is_some());
+ rt.last_error = last_error;
+ rt.not_published = not_published;
Err(e)
}
}
}
+/// Split a failed refresh into `(last_error, not_published)` — the two are never both set.
+///
+/// An empty channel is only benign while we have never seen a manifest for it. Once a check
+/// has succeeded, the same 404 means the feed LOST a document it used to serve, which is a
+/// regression that must stay a loud error rather than being excused as "nothing yet".
+fn classify_failure(e: &FeedError, had_manifest: bool) -> (Option, bool) {
+ if e.is_not_published() && !had_manifest {
+ (None, true)
+ } else {
+ (Some(e.to_string()), false)
+ }
+}
+
/// The status handler's read: current cache + errors, kicking a background refresh when the
/// cache is cold and checks are enabled.
pub(crate) fn snapshot_and_maybe_refresh() -> Snapshot {
@@ -296,6 +315,7 @@ pub(crate) fn snapshot_and_maybe_refresh() -> Snapshot {
Snapshot {
checked: rt.checked.clone(),
last_error: rt.last_error.clone(),
+ not_published: rt.not_published,
job: rt.job.clone(),
last_result: jobs::read_result(&jobs::result_path()),
}
@@ -329,6 +349,7 @@ pub(crate) async fn force_check() -> Result {
Ok(Snapshot {
checked: rt.checked.clone(),
last_error: rt.last_error.clone(),
+ not_published: rt.not_published,
job: rt.job.clone(),
last_result: jobs::read_result(&jobs::result_path()),
})
@@ -570,6 +591,8 @@ pub(crate) fn reconcile_at_boot() {
pub(crate) struct Snapshot {
pub checked: Option,
pub last_error: Option,
+ /// The channel simply has no release yet — mutually exclusive with `last_error`.
+ pub not_published: bool,
/// The live apply job, when one runs. When the process was restarted mid-apply this is
/// `None` but a fresh intent still reads as in-flight — the API layer surfaces that via
/// [`Snapshot::applying_from_intent`].
@@ -640,6 +663,34 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
+ /// The console paints `last_error` as a failure and `not_published` as a plain sentence,
+ /// so the two must never arrive together — and the benign reading must not survive a
+ /// channel that has already served us a manifest.
+ #[test]
+ fn empty_channel_is_benign_only_until_a_manifest_has_been_seen() {
+ let (err, not_published) = classify_failure(&FeedError::NotPublished, false);
+ assert_eq!(err, None);
+ assert!(not_published);
+
+ // Same 404, but the feed used to answer — that is a regression, not "nothing yet".
+ let (err, not_published) = classify_failure(&FeedError::NotPublished, true);
+ assert_eq!(
+ err.as_deref(),
+ Some("no release has been published on this channel yet")
+ );
+ assert!(!not_published);
+
+ // Every real failure stays an error whether or not we have a cached manifest.
+ for had_manifest in [false, true] {
+ let (err, not_published) = classify_failure(
+ &FeedError::Failed("feed returned HTTP 500".into()),
+ had_manifest,
+ );
+ assert_eq!(err.as_deref(), Some("feed returned HTTP 500"));
+ assert!(!not_published);
+ }
+ }
+
#[test]
fn pinned_keys_skip_empty_rotation_slot() {
let keys = pinned_keys();
@@ -664,6 +715,7 @@ mod tests {
fetched_unix: now_unix(),
}),
last_error: None,
+ not_published: false,
job: None,
last_result: None,
};
diff --git a/web/messages/de.json b/web/messages/de.json
index 3a51bf70..0f78fabe 100644
--- a/web/messages/de.json
+++ b/web/messages/de.json
@@ -543,6 +543,8 @@
"update_checking": "Prüfe…",
"update_last_checked": "Zuletzt geprüft",
"update_never_checked": "Noch nicht geprüft",
+ "update_none_published": "Noch keine veröffentlicht",
+ "update_not_published": "Auf dem Kanal {channel} wurde noch nichts veröffentlicht. Die Prüfung funktioniert — es gibt nur noch keine Version zum Vergleichen.",
"update_stale": "Der Update-Feed hat sich seit über 45 Tagen nicht geändert — Prüfungen gelingen, aber es kommt nichts Neues an. Falls das nicht stimmen kann, prüfe die Verbindung dieses Hosts zu git.unom.io.",
"update_disabled": "Update-Prüfungen sind auf diesem Host deaktiviert (PUNKTFUNK_UPDATE_CHECK=0).",
"update_error": "Letzte Prüfung fehlgeschlagen:",
diff --git a/web/messages/en.json b/web/messages/en.json
index 6b095454..7104f83a 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -543,6 +543,8 @@
"update_checking": "Checking…",
"update_last_checked": "Last checked",
"update_never_checked": "Not checked yet",
+ "update_none_published": "None published yet",
+ "update_not_published": "Nothing has been published to the {channel} channel yet. The check itself is working — there's just no release to compare against.",
"update_stale": "The update feed hasn't changed in over 45 days — checks succeed but nothing new arrives. If that seems wrong, check this host's connectivity to git.unom.io.",
"update_disabled": "Update checks are disabled on this host (PUNKTFUNK_UPDATE_CHECK=0).",
"update_error": "Last check failed:",
diff --git a/web/src/sections/Host/UpdateCard.tsx b/web/src/sections/Host/UpdateCard.tsx
index 0e1978ef..710ea30c 100644
--- a/web/src/sections/Host/UpdateCard.tsx
+++ b/web/src/sections/Host/UpdateCard.tsx
@@ -162,7 +162,9 @@ export const UpdateCard: FC<{
) : (
- {m.update_never_checked()}
+ {s.not_published
+ ? m.update_none_published()
+ : m.update_never_checked()}
)
}
@@ -211,6 +213,17 @@ export const UpdateCard: FC<{
{m.update_stale()}
)}
+ {/* An empty channel is a normal state, not a fault: it looks like a
+ 404 down at the transport, but it means "nobody has announced a
+ release here yet". Rendering it in the failure style told
+ operators their host was broken when nothing was. The host keeps
+ the two apart (`not_published` is never set alongside
+ `last_error`), so this stays a straight either/or. */}
+ {!inFlight && s.not_published && (
+
+ {m.update_not_published({ channel: s.channel })}
+
+ )}
{!inFlight && s.last_error && (
{m.update_error()} {s.last_error}
From c767a904d2b4aad54438bc8345ffe66347be597a Mon Sep 17 00:00:00 2001
From: enricobuehler
Date: Fri, 31 Jul 2026 18:26:29 +0200
Subject: [PATCH 2/6] feat(android): the decode stage answers where its time
goes, HUD on or off
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
P3 decode science: every AU is stamped as its last piece enters the codec, so
the decode stage splits into feed (received→queued: hand-off + input-slot wait)
and codec (queued→decoded: the decoder alone — a slice head start would show
here). The split + an always-on capture→decoded e2e ride the 1 Hz pf-present
line, so a wireless HUD-off A/B reads everything from logcat; the HUD equation
gains the split (indices 30/31), the skipped counter tells benign newest-wins
pacing from parked-AU overflow (32), and a −2-refresh Apple-HUD-equivalent twin
makes iPhone comparisons honest (Apple shaves its OS floor; Android shows raw).
Connect now logs the per-mime decoder picks + FEATURE_PartialFrame verdicts
(tag pf.caps) — on the NP3 all three c2.qti low-latency decoders say no, so
parts delivery never arms and P2d is inert there; a debug.punktfunk.force_parts
sysprop overrides the probe for the on-glass question the API cannot answer.
Forced on glass: c2.qti accepts PARTIAL_FRAME pieces without erroring but only
assembles them — codec time unchanged, so the overlap is dead on SM8735 either
way.
Co-Authored-By: Claude Fable 5
---
.../kotlin/io/unom/punktfunk/HostConnect.kt | 18 +++--
.../kotlin/io/unom/punktfunk/StatsOverlay.kt | 29 +++++++-
.../io/unom/punktfunk/kit/VideoDecoders.kt | 28 ++++++++
.../android/native/src/decode/async_loop.rs | 70 +++++++++++++-----
clients/android/native/src/decode/latency.rs | 23 +++++-
.../android/native/src/decode/presenter.rs | 72 +++++++++++++++++--
clients/android/native/src/session/connect.rs | 47 +++++++++++-
clients/android/native/src/session/planes.rs | 19 +++--
clients/android/native/src/stats.rs | 70 ++++++++++++++++++
9 files changed, 340 insertions(+), 36 deletions(-)
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
From 849baea881116b4b89a24d17dd2309e5c533303a Mon Sep 17 00:00:00 2001
From: enricobuehler
Date: Fri, 31 Jul 2026 18:26:30 +0200
Subject: [PATCH 3/6] feat(android): the stream re-votes its refresh rate and
touches keep their curvature
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
surfaceChanged re-asserts the frame-rate vote (FIXED_SOURCE; ALWAYS only on the
TV low-latency path, mirroring the native hint) — a buffer-geometry change on
some OEM builds silently drops the 120 Hz pin mid-stream. Touch passthrough and
direct-pointer moves forward the MotionEvent historical samples before the
current point, so a fast swipe lands with its real shape; the trackpad path
keeps summed deltas on purpose — its acceleration curve is tuned for per-frame
dt and historicals would change the feel, not the sum.
Co-Authored-By: Claude Fable 5
---
.../kotlin/io/unom/punktfunk/StreamScreen.kt | 20 +++++++++++++++++-
.../kotlin/io/unom/punktfunk/TouchInput.kt | 21 ++++++++++++++++---
2 files changed, 37 insertions(+), 4 deletions(-)
diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt
index f93c0a01..3761bf8d 100644
--- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt
+++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt
@@ -14,6 +14,7 @@ import android.os.Build
import android.text.InputType
import android.util.Log
import android.view.KeyEvent
+import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
@@ -612,7 +613,24 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
if (micWanted) NativeBridge.nativeStartMic(handle)
}
- override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
+ override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
+ // Re-assert the frame-rate vote: a buffer-geometry change can reset
+ // the surface's frame-rate setting on some OEM builds, silently
+ // dropping the 120 Hz pin mid-stream. Mirrors the native hint's
+ // policy (FIXED_SOURCE; ALWAYS only on the TV low-latency path —
+ // phones stay seamless so a re-hint can never force a mode flicker).
+ if (streamHz > 0) runCatching {
+ holder.surface.setFrameRate(
+ streamHz.toFloat(),
+ Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
+ if (isTv && lowLatencyMode) {
+ Surface.CHANGE_FRAME_RATE_ALWAYS
+ } else {
+ Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
+ },
+ )
+ }
+ }
override fun surfaceDestroyed(holder: SurfaceHolder) {
// Surface gone (backgrounding, or on the way out). Stop the threads that
diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/TouchInput.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/TouchInput.kt
index 5adfe0ff..a6db199d 100644
--- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/TouchInput.kt
+++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/TouchInput.kt
@@ -105,8 +105,20 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, sw, sh)
}
c.positionChanged() ->
- ids[c.id]?.let {
- NativeBridge.nativeSendTouch(handle, it, 1, x, y, sw, sh)
+ ids[c.id]?.let { id ->
+ // Batched MotionEvents coalesce intermediate points into the
+ // historical list — forward them in order so a fast swipe keeps
+ // its real curvature on the host (usually empty during a stream:
+ // unbuffered dispatch is requested, so this costs nothing).
+ for (hs in c.historical) {
+ NativeBridge.nativeSendTouch(
+ handle, id, 1,
+ hs.position.x.roundToInt().coerceIn(0, sw - 1),
+ hs.position.y.roundToInt().coerceIn(0, sh - 1),
+ sw, sh,
+ )
+ }
+ NativeBridge.nativeSendTouch(handle, id, 1, x, y, sw, sh)
}
}
c.consume()
@@ -289,7 +301,10 @@ internal suspend fun PointerInputScope.streamTouchInput(
accY -= outY
}
} else {
- moveAbs(p.position.x, p.position.y) // direct: cursor follows the finger
+ // Direct: cursor follows the finger — historical points first (batched
+ // MotionEvent samples), so the host cursor traces the finger's real path.
+ for (hs in p.historical) moveAbs(hs.position.x, hs.position.y)
+ moveAbs(p.position.x, p.position.y)
}
}
ev.changes.forEach { it.consume() }
From 4d45a96ff99ce893bb204cae884cf67aa1e00144 Mon Sep 17 00:00:00 2001
From: enricobuehler
Date: Fri, 31 Jul 2026 18:45:41 +0200
Subject: [PATCH 4/6] feat(encode/windows): sub-frame readback defaults on
where the GPU supports it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Linux parity, validated by the .173 on-glass A/B (no regression; the win goes
to clients that actually consume slice-progressive parts): the caps probe now
reads NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK and seeds resolve_subframe with it
instead of a hard false, so PUNKTFUNK_NVENC_SUBFRAME becomes the tri-state
escape it already is on Linux, and the split×sub-frame arbitration hears the
real forced flag for its log severity.
The A/B also caught the default path opening every session with a WARN: the
submit-time idr_hint missed that NVENC emits the session-opening frame as an
IDR regardless of pic flags, so frame 1's early chunks went out unflagged and
the divergence check fired at every start. The hint now carries the Linux
twin's `opening` term.
Co-Authored-By: Claude Fable 5
---
crates/pf-encode/src/enc/nvenc_core.rs | 9 ++++---
crates/pf-encode/src/enc/windows/nvenc.rs | 31 ++++++++++++++++-------
2 files changed, 27 insertions(+), 13 deletions(-)
diff --git a/crates/pf-encode/src/enc/nvenc_core.rs b/crates/pf-encode/src/enc/nvenc_core.rs
index 77abf136..b591f754 100644
--- a/crates/pf-encode/src/enc/nvenc_core.rs
+++ b/crates/pf-encode/src/enc/nvenc_core.rs
@@ -117,10 +117,11 @@ pub(super) fn resolve_split_mode(bit_depth: u8, pixel_rate: u64) -> u32 {
/// deserves a `warn`, a default being tuned an `info`. Callers LATCH this once next to their
/// resolved subframe state (an env re-read at reconfigure would violate the "open and
/// reconfigure present identical init params" invariant).
-/// Linux-cfg'd like its ONLY caller (the `nvenc_cuda` query_caps latch) — Windows sessions have
-/// `subframe == forced` by construction (env opt-in only) and never consult this; without the
-/// cfg it is dead code on every Windows leg (item-level dead_code, the recurring trap).
-#[cfg(target_os = "linux")]
+/// Both direct-SDK backends latch it now: Linux at the `nvenc_cuda` query_caps latch, Windows at
+/// session init since sub-frame defaults on there too (it used to be env opt-in only, so
+/// `subframe == forced` held by construction and the item was Linux-cfg'd to avoid being dead
+/// code on the Windows leg — the recurring item-level `dead_code` trap).
+#[cfg(any(target_os = "linux", windows))]
pub(super) fn subframe_env_forced() -> bool {
matches!(
std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref(),
diff --git a/crates/pf-encode/src/enc/windows/nvenc.rs b/crates/pf-encode/src/enc/windows/nvenc.rs
index cc9cec1c..c960080b 100644
--- a/crates/pf-encode/src/enc/windows/nvenc.rs
+++ b/crates/pf-encode/src/enc/windows/nvenc.rs
@@ -45,7 +45,7 @@
use super::nvenc_core::{
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
resolve_slices, resolve_split_mode, resolve_split_subframe, resolve_subframe, store_ceiling,
- CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan,
+ subframe_env_forced, CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan,
};
use super::nvenc_status;
use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
@@ -588,6 +588,10 @@ pub struct NvencD3d11Encoder {
input_ring_depth: Option,
/// `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` from the caps probe — gates the async retrieve mode.
async_supported: bool,
+ /// `NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK` from the caps probe — gates the DEFAULT-on
+ /// sub-frame readback (the Linux backend's rule since its Phase 3; Windows joined after the
+ /// 2026-07-31 on-glass A/B), so a GPU without it never has sub-frame forced by default.
+ subframe_cap: bool,
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
/// in-flight encode. The fourth field tags the first frame encoded after a successful
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) — the clean re-anchor P-frame the
@@ -748,6 +752,7 @@ impl NvencD3d11Encoder {
async_rt: None,
input_ring_depth: None,
async_supported: false,
+ subframe_cap: false,
pending: VecDeque::new(),
frame_idx: 0,
force_kf: false,
@@ -922,6 +927,7 @@ impl NvencD3d11Encoder {
nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_CUSTOM_VBV_BUF_SIZE,
);
let async_enc = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT);
+ let subframe = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK);
let _ = (api().destroy_encoder)(enc);
// Reject an over-range mode with a clear message instead of an opaque InvalidParam.
@@ -955,10 +961,12 @@ impl NvencD3d11Encoder {
self.rfi_supported = rfi != 0;
self.custom_vbv = custom_vbv != 0;
self.async_supported = async_enc != 0;
+ self.subframe_cap = subframe != 0;
tracing::info!(
rfi = self.rfi_supported,
custom_vbv = self.custom_vbv,
async_encode = self.async_supported,
+ subframe_readback = self.subframe_cap,
max = %format!("{wmax}x{hmax}"),
ten_bit = ten_bit != 0,
"NVENC capabilities probed"
@@ -1152,11 +1160,14 @@ impl NvencD3d11Encoder {
// VIDEO_CAP_MULTI_SLICE / Moonlight slices-per-frame client gets real slices.
// `PUNKTFUNK_NVENC_SLICES` stays the operator override in both directions.
self.slices = resolve_slices(self.codec, 4.min(self.max_slices));
- // Split × sub-frame arbitration (Phase 8) before the ladder/ceiling key. On Windows
- // sub-frame is env-opt-in only, so resolved == forced by construction.
- let subframe_req = resolve_subframe(false);
+ // Split × sub-frame arbitration (Phase 8) before the ladder/ceiling key. Sub-frame
+ // defaults ON where the GPU advertises SUBFRAME_READBACK (Linux parity; validated by
+ // the 2026-07-31 .173 on-glass A/B — no regression, and slice-progressive clients
+ // gain the encode/wire overlap); `PUNKTFUNK_NVENC_SUBFRAME` stays the tri-state
+ // operator escape in both directions.
+ let subframe_req = resolve_subframe(self.subframe_cap);
let (split_mode, subframe_req) =
- resolve_split_subframe(self.codec, split_mode, subframe_req, subframe_req);
+ resolve_split_subframe(self.codec, split_mode, subframe_req, subframe_env_forced());
// Find the highest bitrate the GPU's codec LEVEL accepts and CLAMP to it. NVENC rejects
// `initialize_encoder` (InvalidParam) when the bitrate exceeds the level ceiling (e.g. a
// 1 Gbps request on HEVC). Strategy: try the requested rate; if the only problem is a forced
@@ -1318,8 +1329,7 @@ impl NvencD3d11Encoder {
self.session_async = use_async;
// Sub-frame chunked poll (P2f, the Windows leg of the slice pipeline): sync
// retrieve only — chunked poll is a depth-1 sync feature; the async retrieve's
- // thread owns the bitstream lock. Sub-frame write itself stays env-gated
- // (`PUNKTFUNK_NVENC_SUBFRAME=1`) until the Windows on-glass A/B validates it.
+ // thread owns the bitstream lock.
self.subframe_chunks = self.slices >= 2 && subframe_req && !use_async;
if self.subframe_chunks {
tracing::info!(
@@ -1659,8 +1669,11 @@ impl Encoder for NvencD3d11Encoder {
let anchor = std::mem::take(&mut self.pending_anchor) && flags == 0;
// Submit-time IDR intent: chunked poll must flag an AU's EARLY chunks before the
// driver reports `pictureType` (only the finishing lock sees it). Exact under
- // P-only + infinite GOP: IDRs happen only when forced.
- let idr_hint = flags != 0;
+ // P-only + infinite GOP: IDRs happen only when forced — or on the session-opening
+ // frame, which NVENC emits as an IDR regardless of pic flags (the Linux twin's
+ // `is_idr`; without the `opening` term frame 1's early chunks went out unflagged
+ // and the divergence WARN fired at every session start).
+ let idr_hint = flags != 0 || opening;
let mut pic = nv::NV_ENC_PIC_PARAMS {
version: nv::NV_ENC_PIC_PARAMS_VER,
inputWidth: self.width,
From b69ef02f4d3957ed7d76759caf57499a28509c12 Mon Sep 17 00:00:00 2001
From: enricobuehler
Date: Fri, 31 Jul 2026 19:20:04 +0200
Subject: [PATCH 5/6] fix(android): a cold-start connect no longer loses HDR or
the native mode
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A punktfunk:// deep link can reach the connect before the activity is attached
to its display; context.display then throws and the display probes silently
fell to their worst answers — displaySupportsHdr advertised SDR (the whole
session pinned to 8-bit BT.709) and nativeDisplayMode fell back to 1080p60.
Seen live on the NP3: one cold connect advertised hdr=false, the warm retry
true, nothing in the log either way.
Both probes now share probeDisplay: the context display when attached, else
DisplayManager DEFAULT_DISPLAY — which IS the panel on phones and TVs; the
activity-display distinction only matters on multi-display setups, where the
attached path still wins whenever available. Each fallback leg logs itself, so
a downgraded session can never again be silent about why.
Co-Authored-By: Claude Fable 5
---
.../main/kotlin/io/unom/punktfunk/Settings.kt | 33 ++++++++++++++++---
1 file changed, 28 insertions(+), 5 deletions(-)
diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt
index 7ab44805..26f1ab37 100644
--- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt
+++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt
@@ -1,6 +1,7 @@
package io.unom.punktfunk
import android.content.Context
+import android.hardware.display.DisplayManager
import android.os.Build
import android.util.Log
import android.view.Display
@@ -319,14 +320,31 @@ class SettingsStore(context: Context) {
}
}
+/**
+ * The display to probe for capability/mode queries: the context's own display when it is already
+ * associated with one, else the DEFAULT display via [DisplayManager]. A `punktfunk://` deep-link
+ * COLD start can reach the connect before the activity is attached to its display —
+ * `context.display` then throws, and the old `false`/1080p60 fallbacks silently downgraded the
+ * whole session (no HDR advertised / non-native mode) with nothing in the log. The default
+ * display IS the panel on phones and TVs; the activity-display distinction only matters on
+ * multi-display setups, where the attached path still wins whenever it is available.
+ */
+private fun probeDisplay(context: Context): Display? =
+ runCatching { context.display }.getOrNull()
+ ?: runCatching {
+ context.getSystemService(DisplayManager::class.java)
+ ?.getDisplay(Display.DEFAULT_DISPLAY)
+ }.getOrNull().also {
+ if (it != null) Log.i("punktfunk", "display probe: context unattached — using DEFAULT_DISPLAY")
+ }
+
/**
* The device's native display mode as a landscape `(width, height, hz)` — the long edge is the
- * width, since we stream a desktop. Falls back to 1920×1080@60 if the display can't be read.
- * [context] must be a visual (Activity) context.
+ * width, since we stream a desktop. Falls back to 1920×1080@60 if no display can be read at all
+ * (see [probeDisplay] for the cold-start fallback that makes that a last resort).
*/
fun nativeDisplayMode(context: Context): Triple {
- // getDisplay() throws on a non-visual context rather than returning null — guard it.
- val display = runCatching { context.display }.getOrNull() ?: return Triple(1920, 1080, 60)
+ val display = probeDisplay(context) ?: return Triple(1920, 1080, 60)
val mode = display.mode
val w = mode.physicalWidth
val h = mode.physicalHeight
@@ -341,7 +359,12 @@ fun nativeDisplayMode(context: Context): Triple {
* capability gate the Apple/Windows clients apply.
*/
fun displaySupportsHdr(context: Context): Boolean {
- val display = runCatching { context.display }.getOrNull() ?: return false
+ val display = probeDisplay(context)
+ if (display == null) {
+ // Distinguishable from a real SDR verdict — a silent `false` here cost an HDR session.
+ Log.w("punktfunk", "display HDR probe: no display reachable — advertising SDR")
+ return false
+ }
val types = buildSet {
// API 34+: the sanctioned per-mode query (Display.Mode.getSupportedHdrTypes). The
// deprecated Display-level hdrCapabilities can return EMPTY on Android 14+ devices
From f9c56eaf5cfb89c279253008ab83e09ee5dc8de2 Mon Sep 17 00:00:00 2001
From: enricobuehler
Date: Fri, 31 Jul 2026 19:33:40 +0200
Subject: [PATCH 6/6] feat(android): Automatic prefers AV1 where the silicon
says it should
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The P3 format A/B (NP3 ↔ RTX 4090, identical conditions) measured AV1 ~1.2 ms
faster end-to-end than HEVC with slightly better codec-pure decode time. Under
"Automatic" the client now sends AV1 as its soft preference when this device
hardware-decodes it (the advertised AV1 bit is already gated on a real,
non-blocked hardware decoder) AND it lacks FEATURE_PartialFrame — a
partial-frame device keeps HEVC, whose slice-progressive overlap AV1 cannot
ride (no slices, the chunked poll never arms). The host honors the preference
only inside its probed shared codec set, so an AV1-less encoder still resolves
HEVC, and an explicit user choice wins unchanged. The codec picker caption
mirrors the same rule so "Automatic" says what it does on this device.
Co-Authored-By: Claude Fable 5
---
.../kotlin/io/unom/punktfunk/HostConnect.kt | 21 +++++++++++++++----
.../io/unom/punktfunk/SettingsScreen.kt | 12 ++++++++++-
2 files changed, 28 insertions(+), 5 deletions(-)
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 37ed8a26..100a8e27 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
@@ -47,15 +47,27 @@ suspend fun connectToHost(
// sockets) — must be applied before connect, since sockets are tagged at creation.
NativeBridge.nativeSetLowLatencyMode(settings.lowLatencyMode)
val multiSlice = VideoDecoders.multiSliceTolerant()
+ val partialFrame = VideoDecoders.partialFrameCapable()
// 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()
+ val frameParts = settings.lowLatencyMode && partialFrame
+ val codecBits = VideoDecoders.decodableCodecBits()
+ // Automatic codec (P5, measured NP3 ↔ RTX 4090): AV1 beat HEVC by ~1.2 ms end-to-end at
+ // identical conditions, so under "Automatic" this device prefers AV1 when it hardware-
+ // decodes it (the AV1 bit is only ever set for a real, non-blocked hardware decoder) AND
+ // it lacks FEATURE_PartialFrame — a partial-frame device keeps HEVC, whose slice overlap
+ // AV1 cannot ride (AV1 has no slices; the host's chunked poll never arms). The host
+ // honors the preference only inside the probed shared codec set, so an AV1-less encoder
+ // still resolves HEVC. An explicit user choice always wins unchanged.
+ val preferredCodec = settings.preferredCodec().takeIf { it != 0 }
+ ?: if (codecBits and 4 != 0 && !partialFrame) 4 else 0
// 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})",
+ " → multiSlice=$multiSlice parts=$frameParts prefer=$preferredCodec" +
+ " (lowLatency=${settings.lowLatencyMode})",
)
NativeBridge.nativeConnect(
host, port, w, h, hz,
@@ -65,8 +77,9 @@ suspend fun connectToHost(
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.
- VideoDecoders.decodableCodecBits(), settings.preferredCodec(), timeoutMs,
+ // the soft codec preference (user choice, or the Automatic AV1 rule above) — the
+ // host resolves the emitted codec from both.
+ codecBits, preferredCodec, timeoutMs,
launch,
// The host's approval-list / trust-store label for this device — the same
// Build.MODEL convention the pairing dialogs use for nativePair.
diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt
index 20ae8660..7ae7c11c 100644
--- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt
+++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt
@@ -673,12 +673,22 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
// Only codecs this device can actually decode are offered — a preference the client never
// advertises would be a dead setting (see [codecOptionsFor]).
val av1Capable = remember { VideoDecoders.pickDecoder("video/av01") != null }
+ // Mirror the Automatic AV1 rule in HostConnect (hardware AV1 AND no partial-frame
+ // support) so the picker says what "Automatic" actually does on THIS device.
+ val autoPrefersAv1 = remember {
+ VideoDecoders.decodableCodecBits() and 4 != 0 && !VideoDecoders.partialFrameCapable()
+ }
SettingDropdown(
label = "Video codec",
options = codecOptionsFor(s.codec, av1Capable),
selected = s.codec,
field = "codec",
- caption = "A preference — the host falls back if it can't encode this one.",
+ caption = if (autoPrefersAv1) {
+ "A preference — the host falls back if it can't encode this one. " +
+ "Automatic prefers AV1 on this device."
+ } else {
+ "A preference — the host falls back if it can't encode this one."
+ },
) { c -> update(s.copy(codec = c)) }
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle is