forked from unom/punktfunk
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7fe326b99 | ||
|
|
65b4af9e51 | ||
|
|
5c746fbea5 | ||
|
|
59fc9294ea | ||
|
|
f5f11f84f0 | ||
|
|
fb9f605120 | ||
|
|
9e71cda298 | ||
|
|
89fd4c6f87 | ||
|
|
93c1ed0723 | ||
|
|
26d06195f5 | ||
|
|
5c44887732 | ||
|
|
23edf4e702 | ||
|
|
f67a913ea7 | ||
|
|
9c6e34ab5d | ||
|
|
22f873392d | ||
|
|
fb05145e36 | ||
|
|
db8874f944 | ||
|
|
9140a2e6e1 | ||
|
|
1c426dc85f | ||
|
|
c64a1a6767 | ||
|
|
60d0cdfc0f | ||
|
|
d227db06e8 | ||
|
|
0c254cc62b | ||
|
|
94be547da0 | ||
|
|
165a42fcc9 | ||
|
|
8ef9b18d20 | ||
|
|
33d0b77e07 |
@@ -741,9 +741,10 @@ struct ContentView: View {
|
||||
/// `libraryTarget` every internal surface writes, so the link lands in whichever presentation
|
||||
/// the current mode owns: the gamepad console's in-place library screen, the touch cover, the
|
||||
/// macOS sheet, or tvOS's cover. Connect's posture minus the connect itself: a pin conflict
|
||||
/// refuses, a live session is never preempted, and an unsaved host can't be browsed — the
|
||||
/// library fetch rides the paired mTLS identity, so there is nothing to show before the host
|
||||
/// is saved (the notice says what to do instead).
|
||||
/// refuses, a live session is never preempted (same host → the open already foregrounded the
|
||||
/// app, which is all "focus it" can mean mid-stream; different host → say so), and an unsaved
|
||||
/// host can't be browsed — the library fetch rides the paired mTLS identity, so there is
|
||||
/// nothing to show before the host is saved (the notice says what to do instead).
|
||||
private func openLibrary(from link: DeepLink) {
|
||||
// A `profile=` on a browse link picks the shelf, exactly as it picks the settings on a
|
||||
// connect link — and refuses the same way (§10.6): an unknown or ambiguous reference must
|
||||
@@ -772,9 +773,12 @@ struct ContentView: View {
|
||||
return
|
||||
}
|
||||
guard model.phase == .idle else {
|
||||
let current = model.activeHost?.displayName ?? "a host"
|
||||
deepLinkNotice = "Already streaming \(current). End that session first."
|
||||
return
|
||||
guard model.activeHost?.id == host.id else {
|
||||
let current = model.activeHost?.displayName ?? "a host"
|
||||
deepLinkNotice = "Already streaming \(current). End that session first."
|
||||
return
|
||||
}
|
||||
return // browsing the host we're already streaming — nothing to do
|
||||
}
|
||||
libraryTarget = LibraryTarget(host: host, profile: selection)
|
||||
case .unknown(let address, _, let name, _):
|
||||
|
||||
@@ -63,9 +63,10 @@ private extension Image {
|
||||
/// Sequentially tries cover-art URLs over `loader` (so a paired client can reach the host's own
|
||||
/// art proxy, not just public CDNs — see `LibraryArtLoader`), advancing past any that fail to
|
||||
/// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame
|
||||
/// regardless of its own aspect ratio: a portrait capsule fills it as intended, but a fallback
|
||||
/// banner (wide hero/header art, used when a title has no portrait capsule) would otherwise report
|
||||
/// a much wider intrinsic size than the card and overflow into neighboring cards. Not `private` —
|
||||
/// regardless of its own aspect ratio: a portrait capsule fills it as intended, and a fallback
|
||||
/// banner (wide hero/header art, used when a title has no portrait capsule) is cropped to the same
|
||||
/// tile rather than allowed to size it — see the `Color.clear` in `body` for why that takes more
|
||||
/// than a `.frame(maxWidth:)` and a `.clipped()`. Not `private` —
|
||||
/// the gamepad coverflow (`LibraryCoverflowView`) reuses it directly rather than re-fetching art.
|
||||
struct PosterImage: View {
|
||||
let candidates: [URL]
|
||||
@@ -84,9 +85,20 @@ struct PosterImage: View {
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image {
|
||||
Image(platformImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
// `Color.clear` is what takes the proposed size; the art rides along as its
|
||||
// overlay, where it can be DRAWN but never MEASURED. Handing the image the sizing
|
||||
// role instead is what let a fallback banner escape the tile: `scaledToFill`
|
||||
// reports a size that covers the proposal, and the flexible frame below clamps it
|
||||
// to `.infinity` — i.e. not at all. Measured offscreen, a 460×215 `header.jpg` in a
|
||||
// 170pt grid column resolved the tile to 545×255 and overran its neighbours, while
|
||||
// a 300×450 cover in the same chain came out correct — which is why this only ever
|
||||
// showed on the titles whose cover was missing.
|
||||
Color.clear
|
||||
.overlay {
|
||||
Image(platformImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
}
|
||||
.transition(.opacity)
|
||||
} else if index < candidates.count {
|
||||
ZStack { placeholder; ProgressView() }
|
||||
|
||||
@@ -22,6 +22,9 @@ mod cli;
|
||||
mod shortcuts;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod spawn;
|
||||
// The guarded FlowBox `child-activated → activate` bridge every card grid needs.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_flow;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_hosts;
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! One shared fix for a GTK4 footgun every card grid in this shell walks into.
|
||||
//!
|
||||
//! A pointer click (and keyboard activate) on a [`gtk::FlowBoxChild`] emits
|
||||
//! `child-activated` on the *FlowBox*, never the child's own `activate` signal — so a
|
||||
//! grid whose per-card handler hangs off `child.connect_activate()` has to bridge the
|
||||
//! one to the other. The naive bridge is a stack overflow: `FlowBoxChild`'s default
|
||||
//! `activate` handler re-emits `child-activated` on its parent, which calls the bridge,
|
||||
//! which activates the child again, forever.
|
||||
//!
|
||||
//! [`bridge_child_activation`] is that bridge with the re-entrancy guard that breaks the
|
||||
//! cycle. Use it for every `child-activated → child.activate()` hop; do not hand-roll it,
|
||||
//! since a bare `flow.connect_child_activated(|_, c| c.activate())` aborts the process on
|
||||
//! the first click and looks perfectly reasonable in review.
|
||||
|
||||
use gtk::prelude::*;
|
||||
|
||||
/// Bridge a FlowBox's `child-activated` to the activated child's own `activate` signal,
|
||||
/// exactly once per click. The re-entrant emission the child's default handler bounces
|
||||
/// back is swallowed rather than recursed into.
|
||||
pub(crate) fn bridge_child_activation(flow: >k::FlowBox) {
|
||||
let activating = std::cell::Cell::new(false);
|
||||
flow.connect_child_activated(move |_, child| {
|
||||
if activating.replace(true) {
|
||||
return;
|
||||
}
|
||||
child.activate();
|
||||
activating.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::bridge_child_activation;
|
||||
use gtk::prelude::*;
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
// Reproduces the exact FlowBox/FlowBoxChild wiring the card grids use: the bridge
|
||||
// calls `child.activate()`, whose own default handler re-emits `child-activated` —
|
||||
// that ping-pong recursed forever (a real stack overflow on every card click/Enter,
|
||||
// reported on the hosts page and then again on the library page) until the
|
||||
// re-entrancy guard landed here, where both pages share it.
|
||||
#[test]
|
||||
#[ignore = "needs a Wayland/X display"]
|
||||
fn flow_box_activation_bridge_does_not_recurse() {
|
||||
assert!(gtk::init().is_ok(), "no display");
|
||||
|
||||
let flow = gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.activate_on_single_click(true)
|
||||
.build();
|
||||
bridge_child_activation(&flow);
|
||||
|
||||
let child = gtk::FlowBoxChild::new();
|
||||
flow.insert(&child, -1);
|
||||
let fired = Rc::new(Cell::new(0u32));
|
||||
{
|
||||
let fired = fired.clone();
|
||||
child.connect_activate(move |_| fired.set(fired.get() + 1));
|
||||
}
|
||||
|
||||
flow.emit_by_name::<()>("child-activated", &[&child]);
|
||||
|
||||
assert_eq!(
|
||||
fired.get(),
|
||||
1,
|
||||
"the per-card handler should fire exactly once"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -781,18 +781,11 @@ impl SimpleComponent for HostsPage {
|
||||
|
||||
// A pointer click (and keyboard activate) emits `child-activated` on the
|
||||
// *FlowBox*, never the child's own `activate` signal — bridge it back to the
|
||||
// child, where each card wires its connect handler. The re-entrancy flag breaks
|
||||
// the child-activated ↔ activate ping-pong that otherwise recurses forever
|
||||
// (a real stack overflow on every card click; see the ignored display test).
|
||||
// child, where each card wires its connect handler. The guard inside the bridge
|
||||
// breaks the child-activated ↔ activate ping-pong that otherwise recurses forever
|
||||
// (a real stack overflow on every card click; see `ui_flow`'s display test).
|
||||
for flow in [saved.widget(), discovered.widget()] {
|
||||
let activating = std::cell::Cell::new(false);
|
||||
flow.connect_child_activated(move |_, child| {
|
||||
if activating.replace(true) {
|
||||
return;
|
||||
}
|
||||
child.activate();
|
||||
activating.set(false);
|
||||
});
|
||||
crate::ui_flow::bridge_child_activation(flow);
|
||||
}
|
||||
|
||||
// Shown under the discovered heading while no (unsaved) advert is live yet.
|
||||
@@ -1466,49 +1459,3 @@ impl HostsPage {
|
||||
dialog.present(Some(&self.widgets.stack));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use adw::prelude::*;
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
// Reproduces the exact FlowBox/FlowBoxChild wiring from `init()`: `child-activated`
|
||||
// bridges to `child.activate()`, whose own default handler re-emits
|
||||
// `child-activated` — that ping-pong recursed forever (stack overflow on every
|
||||
// host-card click/Enter) until the re-entrancy guard was added.
|
||||
#[test]
|
||||
#[ignore = "needs a Wayland/X display"]
|
||||
fn flow_box_activation_bridge_does_not_recurse() {
|
||||
assert!(gtk::init().is_ok(), "no display");
|
||||
|
||||
let flow = gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.activate_on_single_click(true)
|
||||
.build();
|
||||
let activating = Cell::new(false);
|
||||
flow.connect_child_activated(move |_, child| {
|
||||
if activating.replace(true) {
|
||||
return;
|
||||
}
|
||||
child.activate();
|
||||
activating.set(false);
|
||||
});
|
||||
|
||||
let child = gtk::FlowBoxChild::new();
|
||||
flow.insert(&child, -1);
|
||||
let fired = Rc::new(Cell::new(0u32));
|
||||
{
|
||||
let fired = fired.clone();
|
||||
child.connect_activate(move |_| fired.set(fired.get() + 1));
|
||||
}
|
||||
|
||||
flow.emit_by_name::<()>("child-activated", &[&child]);
|
||||
|
||||
assert_eq!(
|
||||
fired.get(),
|
||||
1,
|
||||
"the per-card handler should fire exactly once"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,10 +116,9 @@ fn build(
|
||||
.valign(gtk::Align::Start)
|
||||
.build();
|
||||
// Click/keyboard activation fires `child-activated` on the FlowBox, not the child's own
|
||||
// `activate` — bridge it so each poster's connect handler (below) runs on click.
|
||||
flow.connect_child_activated(|_, child| {
|
||||
child.activate();
|
||||
});
|
||||
// `activate` — bridge it so each poster's connect handler (below) runs on click. The
|
||||
// bridge must be the guarded one: bare, it recurses until the stack overflows.
|
||||
crate::ui_flow::bridge_child_activation(&flow);
|
||||
// The launcher shelf: same tile geometry as the games grid, its own FlowBox so the two
|
||||
// groups never interleave and each wraps on its own.
|
||||
let launcher_flow = gtk::FlowBox::builder()
|
||||
@@ -132,9 +131,7 @@ fn build(
|
||||
.row_spacing(18)
|
||||
.valign(gtk::Align::Start)
|
||||
.build();
|
||||
launcher_flow.connect_child_activated(|_, child| {
|
||||
child.activate();
|
||||
});
|
||||
crate::ui_flow::bridge_child_activation(&launcher_flow);
|
||||
let launchers_heading = gtk::Label::new(Some("Launchers"));
|
||||
launchers_heading.add_css_class("pf-group-heading");
|
||||
launchers_heading.set_halign(gtk::Align::Start);
|
||||
|
||||
@@ -1604,21 +1604,42 @@ pub fn show_scoped(
|
||||
"Hold Select alone for the host's guide button — a tap still goes through",
|
||||
GUIDE_GESTURE_LABELS,
|
||||
);
|
||||
// Controller audio (the 0xD1 plane): a wired DualSense's own voice coils and its little
|
||||
// built-in speaker, streamed from the host and rendered on the pad in your hands. Both are
|
||||
// negotiated — they change nothing without a capable host AND a wired DualSense — so the
|
||||
// rows say what they are for rather than promising an effect.
|
||||
//
|
||||
// Deliberately NOT profileable: which pad is in your hands is a property of this device,
|
||||
// not of the host a profile is authored against (the forwarded-pad pin below sits out for
|
||||
// the same reason).
|
||||
let haptics_row = adw::SwitchRow::builder()
|
||||
.title("Controller haptics")
|
||||
.subtitle("Play a DualSense's voice-coil haptics on the pad itself — wired pads only")
|
||||
.build();
|
||||
let pad_speaker_row = adw::SwitchRow::builder()
|
||||
.title("Controller speaker")
|
||||
.subtitle("Play the audio a game sends to the pad's own speaker on the pad, not here")
|
||||
.build();
|
||||
// The pad rows only mean something while something is being forwarded (the same
|
||||
// relationship mic → echo cancellation draws just above, initial state included: the
|
||||
// seed's `set_active` fires this only when it CHANGES the switch).
|
||||
// seed's `set_active` fires this only when it CHANGES the switch). Controller audio
|
||||
// belongs in that set too — forwarding off never OPENS the pad, so nothing can detect
|
||||
// that it has an audio device, let alone render on it.
|
||||
{
|
||||
let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone());
|
||||
let (sb, gg) = (sysbtn_row.widget().clone(), gesture_row.widget().clone());
|
||||
f.set_sensitive(seed.gamepad_forwarding);
|
||||
t.set_sensitive(seed.gamepad_forwarding);
|
||||
sb.set_sensitive(seed.gamepad_forwarding);
|
||||
gg.set_sensitive(seed.gamepad_forwarding);
|
||||
let (ha, sp) = (haptics_row.clone(), pad_speaker_row.clone());
|
||||
for w in [&f, &t, &sb, &gg] {
|
||||
w.set_sensitive(seed.gamepad_forwarding);
|
||||
}
|
||||
ha.set_sensitive(seed.gamepad_forwarding);
|
||||
sp.set_sensitive(seed.gamepad_forwarding);
|
||||
pad_forward_row.connect_active_notify(move |r| {
|
||||
f.set_sensitive(r.is_active());
|
||||
t.set_sensitive(r.is_active());
|
||||
sb.set_sensitive(r.is_active());
|
||||
gg.set_sensitive(r.is_active());
|
||||
for w in [&f, &t, &sb, &gg] {
|
||||
w.set_sensitive(r.is_active());
|
||||
}
|
||||
ha.set_sensitive(r.is_active());
|
||||
sp.set_sensitive(r.is_active());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1632,6 +1653,8 @@ pub fn show_scoped(
|
||||
scale_row.set_selected(index::render_scale(s));
|
||||
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||||
pad_forward_row.set_active(s.gamepad_forwarding);
|
||||
haptics_row.set_active(s.pad_haptics);
|
||||
pad_speaker_row.set_active(pf_client_core::pad_audio::speaker_active(&s.pad_speaker));
|
||||
pad_row.set_selected(index::gamepad(s));
|
||||
sysbtn_row.set_selected(index::system_buttons(s));
|
||||
gesture_row.set_selected(index::guide_gesture(s));
|
||||
@@ -2088,6 +2111,12 @@ pub fn show_scoped(
|
||||
controllers_group.add(pad_row.widget());
|
||||
controllers_group.add(sysbtn_row.widget());
|
||||
controllers_group.add(gesture_row.widget());
|
||||
// Global scope only — see the rows' own note. In profile scope they would have no
|
||||
// override marker and no way to record a touch, so a toggle would be silently discarded.
|
||||
if !profile_mode {
|
||||
controllers_group.add(&haptics_row);
|
||||
controllers_group.add(&pad_speaker_row);
|
||||
}
|
||||
controllers.add(&controllers_group);
|
||||
|
||||
// Cap every caption in one pass, after the rows exist: a per-row call would be sixteen
|
||||
@@ -2163,6 +2192,15 @@ pub fn show_scoped(
|
||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||
s.invert_scroll = invert_row.is_active();
|
||||
s.gamepad_forwarding = pad_forward_row.is_active();
|
||||
s.pad_haptics = haptics_row.is_active();
|
||||
// `"mix"` is a stored value this switch cannot express (it renders as off today,
|
||||
// pending the mixer leg), so writing the switch back unconditionally would erase
|
||||
// it just by opening and closing the dialog — the same trap the gamepad-type row
|
||||
// guards above. Only write when the user actually moved it.
|
||||
let want_speaker = pad_speaker_row.is_active();
|
||||
if want_speaker != pf_client_core::pad_audio::speaker_active(&s.pad_speaker) {
|
||||
s.pad_speaker = if want_speaker { "pad" } else { "off" }.to_string();
|
||||
}
|
||||
s.mic_enabled = mic_row.is_active();
|
||||
s.echo_cancel = echo_row.is_active();
|
||||
s.hdr_enabled = hdr_row.is_active();
|
||||
|
||||
@@ -821,6 +821,29 @@ mod session_main {
|
||||
};
|
||||
}
|
||||
|
||||
// `--pad-audio-test [--seconds N] [--speaker] [--coils]`: the controller-audio
|
||||
// correlation, printed, then a tone driven into the pad. The one tool that separates
|
||||
// "the plane never arrived" from "it arrived and the graph folded the coil pair away"
|
||||
// — no host, no game, no pairing needed, just a wired DualSense.
|
||||
#[cfg(target_os = "linux")]
|
||||
if arg_flag("--pad-audio-test") {
|
||||
let seconds = arg_value("--seconds")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3);
|
||||
// Coils by default: they are the half that silently disappears, so they are the
|
||||
// half worth testing. `--speaker` adds (or, with nothing else, selects) the
|
||||
// speaker pair.
|
||||
let speaker = arg_flag("--speaker");
|
||||
let coils = arg_flag("--coils") || !speaker;
|
||||
return match pf_client_core::pad_audio::pad_audio_test(seconds, coils, speaker) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
eprintln!("pad-audio-test: {e:#}");
|
||||
EXIT_PRESENTER_FAILED
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// `--pair <PIN>`: enrol this machine against a host and exit. DEPRECATED — pairing is
|
||||
// a trust ceremony and belongs to the brain, fronted by `punktfunk pair` or a shell
|
||||
// (design/client-architecture-split.md §5). It still works, with a notice, for the one
|
||||
|
||||
@@ -277,6 +277,19 @@ pub struct PicturePlan {
|
||||
/// Colour signalling, per picture and never latched — the same rule the other two
|
||||
/// planners follow, because a host can switch an HDR desktop to PQ/BT.2020 in band.
|
||||
pub colour: ColourDescription,
|
||||
/// Every picture this frame predicts from was itself decoded from a fully-available
|
||||
/// reference chain — so a host claim that this frame is a clean re-anchor
|
||||
/// (`USER_FLAG_RECOVERY_ANCHOR`) can be corroborated rather than taken on trust.
|
||||
/// `true` for a key or intra-only frame (nothing to predict from) and for any
|
||||
/// frame whose whole reference chain is clean; `false` from the moment this frame
|
||||
/// — or anything it descends from — needed concealment.
|
||||
///
|
||||
/// On a `show_existing_frame` this describes the picture being DISPLAYED, which is
|
||||
/// the only thing such a frame puts on the screen: it decodes nothing of its own.
|
||||
///
|
||||
/// Purely additive observation. See [`crate::clean`] for why it propagates and why
|
||||
/// every rule errs toward `false`.
|
||||
pub references_clean: bool,
|
||||
}
|
||||
|
||||
/// One planned access unit.
|
||||
@@ -329,6 +342,35 @@ pub enum PlanWarning {
|
||||
TruncatedAu { offset: usize },
|
||||
}
|
||||
|
||||
impl PlanWarning {
|
||||
/// Does this warning mean the PICTURE is damaged? The AV1 twin of
|
||||
/// [`crate::h264::PlanWarning::is_integrity`], and
|
||||
/// `pf_vkdecode::is_integrity_warning_av1` delegates here.
|
||||
///
|
||||
/// Every variant AV1 has IS damage, and that is a fact about the codec rather than
|
||||
/// an oversight: AV1 puts nothing in this channel resembling h265's
|
||||
/// `NonZeroReorder` or h264's `Mmco5Rebase`. It has no reorder envelope to report
|
||||
/// (no bumping process, no `max_num_reorder_pics`) and no MMCO to rebase — the
|
||||
/// frame header states the whole reference update outright — so the only things
|
||||
/// left to warn about are pictures that went missing and an OBU walk that stopped
|
||||
/// early.
|
||||
///
|
||||
/// `MissingShowExisting` is the one that could be argued, and it is damage: a
|
||||
/// `show_existing_frame` naming an empty slot means the picture the STREAM chose
|
||||
/// to display was lost upstream. Nothing is displayed for that frame, so the
|
||||
/// screen keeps the previous one — exactly the "silently stale picture" state a
|
||||
/// re-anchor exists to end.
|
||||
///
|
||||
/// Exhaustive with no wildcard, for the reason the H.264 twin spells out.
|
||||
pub fn is_integrity(&self) -> bool {
|
||||
match self {
|
||||
PlanWarning::MissingReference { .. }
|
||||
| PlanWarning::MissingShowExisting { .. }
|
||||
| PlanWarning::TruncatedAu { .. } => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an access unit cannot be planned at all.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PlanError {
|
||||
@@ -366,6 +408,10 @@ pub struct Av1Planner {
|
||||
slots: [Option<RefPic>; NUM_REF_SLOTS],
|
||||
next_id: PicId,
|
||||
sequence: Option<Rc<SequenceHeaderObu>>,
|
||||
/// Which resident pictures came off a BROKEN reference chain — the fact behind
|
||||
/// [`PicturePlan::references_clean`]. Empty on a healthy stream; see
|
||||
/// [`crate::clean::CleanLedger`] for the propagation rules.
|
||||
clean: crate::clean::CleanLedger,
|
||||
}
|
||||
|
||||
impl Default for Av1Planner {
|
||||
@@ -381,6 +427,7 @@ impl Av1Planner {
|
||||
slots: [None; NUM_REF_SLOTS],
|
||||
next_id: 1,
|
||||
sequence: None,
|
||||
clean: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,7 +596,22 @@ impl Av1Planner {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let picture = picture_plan(&header, &sequence);
|
||||
// A `show_existing_frame` decodes nothing, so the only picture it puts on
|
||||
// the screen is the one it displays: report THAT picture's cleanliness. A
|
||||
// slot that held nothing already warned above and shows nothing at all,
|
||||
// which is damage in its own right — reporting it unclean keeps the two
|
||||
// statements consistent.
|
||||
let references_clean = match shown {
|
||||
Some(pic) => self.clean.references_clean([pic.id]),
|
||||
None => false,
|
||||
};
|
||||
let picture = picture_plan(&header, &sequence, references_clean);
|
||||
// A key-frame `show_existing_frame` rewrote every slot with the shown
|
||||
// picture (7.20), so the ledger has to follow that aliasing: the refreshed
|
||||
// slots all hold `pic.id`, whose mark already stands. Nothing new is
|
||||
// stored, so there is no verdict to fold — only residency to re-bound.
|
||||
self.clean
|
||||
.retain_live(self.slots.iter().flatten().map(|p| p.id));
|
||||
return Ok(AuPlan {
|
||||
picture,
|
||||
tiles,
|
||||
@@ -597,12 +659,34 @@ impl Av1Planner {
|
||||
}
|
||||
let removed = self.refresh_slots(header.refresh_frame_flags, id, RefState::of(&header));
|
||||
|
||||
let picture = picture_plan(&header, &sequence);
|
||||
// Was every picture this frame predicts from decoded off an intact chain?
|
||||
// Over the resolved names only: a `None` hole is a lost reference, which has
|
||||
// already pushed `MissingReference` and therefore condemns this frame through
|
||||
// `concealed` below. A key or intra-only frame names nothing, so this is
|
||||
// vacuously true for it (`CleanLedger::references_clean`).
|
||||
let references_clean = self
|
||||
.clean
|
||||
.references_clean(refs.iter().flatten().map(|r| r.id));
|
||||
|
||||
let picture = picture_plan(&header, &sequence, references_clean);
|
||||
let outputs = if header.show_frame {
|
||||
vec![id]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// Fold this frame's verdict, then bound the ledger to slot residency. After
|
||||
// `refresh_slots`, so the live set reflects the writes this frame performed.
|
||||
// `concealed` mirrors what a consumer conceals on, via the ONE classification
|
||||
// (`PlanWarning::is_integrity`), so the ledger and the consumer can never
|
||||
// disagree about whether this frame was damaged.
|
||||
self.clean.note_stored(
|
||||
id,
|
||||
references_clean,
|
||||
warnings.iter().any(PlanWarning::is_integrity),
|
||||
);
|
||||
self.clean
|
||||
.retain_live(self.slots.iter().flatten().map(|p| p.id));
|
||||
|
||||
Ok(AuPlan {
|
||||
picture,
|
||||
tiles,
|
||||
@@ -655,7 +739,11 @@ impl Av1Planner {
|
||||
}
|
||||
}
|
||||
|
||||
fn picture_plan(header: &FrameHeaderObu, sequence: &SequenceHeaderObu) -> PicturePlan {
|
||||
fn picture_plan(
|
||||
header: &FrameHeaderObu,
|
||||
sequence: &SequenceHeaderObu,
|
||||
references_clean: bool,
|
||||
) -> PicturePlan {
|
||||
let color = &sequence.color_config;
|
||||
let bit_depth = if color.high_bitdepth {
|
||||
if color.twelve_bit {
|
||||
@@ -698,6 +786,7 @@ fn picture_plan(header: &FrameHeaderObu, sequence: &SequenceHeaderObu) -> Pictur
|
||||
matrix_coefficients: color.matrix_coefficients as u8,
|
||||
video_full_range: color.color_range,
|
||||
},
|
||||
references_clean,
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Which decoded pictures came off a fully-available reference chain — the fact a
|
||||
//! client needs to CORROBORATE a host's claim that a frame is a clean re-anchor.
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! After a loss the client freezes on its last good picture and lifts only on a proven
|
||||
//! re-anchor. One of those proofs — `USER_FLAG_RECOVERY_ANCHOR`, the host's LTR-RFI
|
||||
//! recovery frame — lifts the freeze on its FIRST occurrence, exactly like a real IDR,
|
||||
//! because the host says the frame was coded against a known-good reference.
|
||||
//!
|
||||
//! The host's "known-good" is an inference from what the client RECEIVED. The client's
|
||||
//! own DPB is the only place that knows what it actually DECODED, and it did not
|
||||
//! previously record it: a picture the planner concealed (a reference the DPB could not
|
||||
//! resolve, an AU that stopped early) entered the DPB looking exactly like a clean one.
|
||||
//! So an anchor naming that picture lifted the freeze onto a gray plate, and every
|
||||
//! frame after it chained off the corruption — the freeze gone, nothing left to
|
||||
//! re-arm it, and the picture stayed broken until an unrelated signal forced an IDR.
|
||||
//!
|
||||
//! This ledger is the missing fact, and it is deliberately the SMALLEST one that
|
||||
//! answers the question: a set of picture ids that are NOT clean. Membership is
|
||||
//! per-picture, so it costs one `u64` per damaged picture and nothing at all on a
|
||||
//! healthy stream — the overwhelmingly common case, where the set stays empty for the
|
||||
//! life of the session.
|
||||
//!
|
||||
//! # Damage propagates; that is the whole point
|
||||
//!
|
||||
//! A picture is unclean when the AU that produced it needed concealment, OR when
|
||||
//! ANYTHING it predicted from was unclean. Without the second half the ledger would be
|
||||
//! useless: the concealed picture itself is rarely the one an anchor names — it is the
|
||||
//! chain of ordinary P-frames DESCENDING from it, each of which planned perfectly and
|
||||
//! raised no warning of its own, that carries the corruption forward.
|
||||
//!
|
||||
//! # It errs toward "unclean", never toward "clean"
|
||||
//!
|
||||
//! Every rule here is one-way. An id the ledger has forgotten (evicted from the DPB,
|
||||
//! dropped at a flush) reads as clean, which is correct — a picture no longer in the
|
||||
//! DPB cannot be referenced. An id it holds stays unclean until the picture leaves the
|
||||
//! DPB. There is no path that clears the mark on a picture that is still resident, so
|
||||
//! the ledger can only ever make a consumer MORE conservative: hold the freeze longer
|
||||
//! and take an IDR it might not have needed. The opposite mistake — reporting a damaged
|
||||
//! chain as clean — is the failure this exists to end, so the asymmetry is deliberate.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Per-picture "this came off a broken chain" marks for one planner.
|
||||
///
|
||||
/// Keyed by the planner's own `PicId` (a `u64` in all three codecs), so this type is
|
||||
/// codec-agnostic and the H.264, H.265 and AV1 planners share ONE implementation rather
|
||||
/// than three hand-copies that can drift apart.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CleanLedger {
|
||||
/// Ids of resident pictures that are NOT clean. Empty on a healthy stream — the
|
||||
/// set only ever gains an entry when a plan needed concealment.
|
||||
unclean: BTreeSet<u64>,
|
||||
}
|
||||
|
||||
impl CleanLedger {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Is every id in `references` clean? — i.e. may a picture predicted from exactly
|
||||
/// these be trusted?
|
||||
///
|
||||
/// Vacuously true for an empty list, which is what makes an IRAP/IDR clean by
|
||||
/// construction: it predicts from nothing, so there is nothing to distrust.
|
||||
pub fn references_clean<I>(&self, references: I) -> bool
|
||||
where
|
||||
I: IntoIterator<Item = u64>,
|
||||
{
|
||||
// Short-circuits on the first unclean reference, and — because the set is
|
||||
// empty on a healthy stream — degenerates to one `is_empty`-cheap lookup per
|
||||
// reference in the case that matters for throughput.
|
||||
self.unclean.is_empty() || !references.into_iter().any(|id| self.unclean.contains(&id))
|
||||
}
|
||||
|
||||
/// Record the verdict for the picture this AU stored.
|
||||
///
|
||||
/// `references_clean` is what [`Self::references_clean`] answered for this AU's
|
||||
/// reference lists; `concealed` is whether the AU's own plan carried an integrity
|
||||
/// warning. Either one being bad makes the stored picture unclean, and its
|
||||
/// descendants inherit that through their own `references_clean` call.
|
||||
pub fn note_stored(&mut self, id: u64, references_clean: bool, concealed: bool) {
|
||||
if references_clean && !concealed {
|
||||
// The common path. Nothing is inserted, so a healthy stream never allocates
|
||||
// — and `remove` still runs below because an id can be REUSED after the
|
||||
// planner recycles it, and a stale mark would then condemn a fresh picture.
|
||||
self.unclean.remove(&id);
|
||||
} else {
|
||||
self.unclean.insert(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the marks of pictures that have left the DPB.
|
||||
///
|
||||
/// Called with the ids still live after each plan. Bounding the set to DPB
|
||||
/// residency is what keeps it from growing without limit across a long lossy
|
||||
/// session, and it is safe precisely because a picture outside the DPB can never
|
||||
/// appear in a later reference list.
|
||||
pub fn retain_live<I>(&mut self, live: I)
|
||||
where
|
||||
I: IntoIterator<Item = u64>,
|
||||
{
|
||||
if self.unclean.is_empty() {
|
||||
return;
|
||||
}
|
||||
let live: BTreeSet<u64> = live.into_iter().collect();
|
||||
self.unclean.retain(|id| live.contains(id));
|
||||
}
|
||||
|
||||
/// Forget everything — the DPB was drained (a flush, a stream discontinuity), so no
|
||||
/// mark describes a resident picture any more.
|
||||
pub fn clear(&mut self) {
|
||||
self.unclean.clear();
|
||||
}
|
||||
|
||||
/// Is this picture known to have come off a broken chain? (Diagnostics and tests;
|
||||
/// the plan path uses [`Self::references_clean`].)
|
||||
pub fn is_unclean(&self, id: u64) -> bool {
|
||||
self.unclean.contains(&id)
|
||||
}
|
||||
|
||||
/// How many resident pictures are marked unclean (diagnostics and tests).
|
||||
pub fn unclean_count(&self) -> usize {
|
||||
self.unclean.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The headline: damage propagates down the prediction chain. The concealed
|
||||
/// picture is rarely the one an anchor names — it is the ordinary P-frames
|
||||
/// descending from it, each of which planned perfectly and warned about nothing.
|
||||
#[test]
|
||||
fn damage_propagates_to_every_descendant() {
|
||||
let mut led = CleanLedger::new();
|
||||
// An IDR: no references, no concealment.
|
||||
assert!(led.references_clean([]));
|
||||
led.note_stored(0, true, false);
|
||||
assert!(!led.is_unclean(0));
|
||||
|
||||
// A clean P off it.
|
||||
assert!(led.references_clean([0]));
|
||||
led.note_stored(1, true, false);
|
||||
|
||||
// Picture 2's plan needed concealment.
|
||||
let refs_clean = led.references_clean([1]);
|
||||
assert!(refs_clean, "its reference was still fine");
|
||||
led.note_stored(2, refs_clean, true);
|
||||
assert!(led.is_unclean(2));
|
||||
|
||||
// …and picture 3 predicts from it, raising NO warning of its own.
|
||||
let refs_clean = led.references_clean([2]);
|
||||
assert!(!refs_clean, "the chain is broken from here down");
|
||||
led.note_stored(3, refs_clean, false);
|
||||
assert!(led.is_unclean(3), "3 inherited 2's damage");
|
||||
|
||||
// The rot keeps travelling, arbitrarily far from the original loss.
|
||||
let refs_clean = led.references_clean([3]);
|
||||
assert!(!refs_clean);
|
||||
led.note_stored(4, refs_clean, false);
|
||||
assert!(led.is_unclean(4));
|
||||
}
|
||||
|
||||
/// A picture that references BOTH a clean and an unclean predecessor is unclean —
|
||||
/// one broken reference is enough to make the reconstruction wrong.
|
||||
#[test]
|
||||
fn one_unclean_reference_is_enough() {
|
||||
let mut led = CleanLedger::new();
|
||||
led.note_stored(0, true, false);
|
||||
led.note_stored(1, true, true); // damaged
|
||||
assert!(!led.references_clean([0, 1]));
|
||||
assert!(!led.references_clean([1, 0]), "order does not matter");
|
||||
assert!(led.references_clean([0]));
|
||||
}
|
||||
|
||||
/// An IDR predicts from nothing, so it is clean however broken the stream was
|
||||
/// before it. This is the property that lets a real keyframe end a damaged run.
|
||||
#[test]
|
||||
fn a_picture_with_no_references_is_clean_however_bad_the_stream_was() {
|
||||
let mut led = CleanLedger::new();
|
||||
led.note_stored(0, true, true);
|
||||
led.note_stored(1, false, false);
|
||||
assert_eq!(led.unclean_count(), 2);
|
||||
// The IDR: an empty reference list is vacuously clean.
|
||||
assert!(led.references_clean([]));
|
||||
led.note_stored(2, true, false);
|
||||
assert!(!led.is_unclean(2));
|
||||
}
|
||||
|
||||
/// Marks are bounded by DPB residency: a picture that left the DPB can never be
|
||||
/// referenced again, so keeping its mark would only grow the set forever.
|
||||
#[test]
|
||||
fn marks_are_dropped_when_their_picture_leaves_the_dpb() {
|
||||
let mut led = CleanLedger::new();
|
||||
led.note_stored(7, true, true);
|
||||
led.note_stored(8, false, false);
|
||||
assert_eq!(led.unclean_count(), 2);
|
||||
led.retain_live([8, 9]);
|
||||
assert!(!led.is_unclean(7), "7 was evicted");
|
||||
assert!(led.is_unclean(8), "8 is still resident and still damaged");
|
||||
assert_eq!(led.unclean_count(), 1);
|
||||
}
|
||||
|
||||
/// A flush drains the whole DPB, so no mark describes anything resident.
|
||||
#[test]
|
||||
fn a_flush_forgets_every_mark() {
|
||||
let mut led = CleanLedger::new();
|
||||
led.note_stored(1, true, true);
|
||||
led.note_stored(2, false, false);
|
||||
led.clear();
|
||||
assert_eq!(led.unclean_count(), 0);
|
||||
assert!(led.references_clean([1, 2]));
|
||||
}
|
||||
|
||||
/// Planners hand out ids from a counter the flush path can rewind, so an id CAN be
|
||||
/// reused. A stale mark must not condemn the fresh picture that inherits the id.
|
||||
#[test]
|
||||
fn a_reused_id_is_not_condemned_by_its_predecessors_mark() {
|
||||
let mut led = CleanLedger::new();
|
||||
led.note_stored(5, true, true);
|
||||
assert!(led.is_unclean(5));
|
||||
// The same id, planned cleanly this time.
|
||||
led.note_stored(5, true, false);
|
||||
assert!(!led.is_unclean(5));
|
||||
assert!(led.references_clean([5]));
|
||||
}
|
||||
|
||||
/// A healthy stream never marks anything, forever — the property that makes this
|
||||
/// free to carry on every session that is working correctly.
|
||||
#[test]
|
||||
fn a_stream_without_loss_never_marks_a_picture() {
|
||||
let mut led = CleanLedger::new();
|
||||
for id in 0..512u64 {
|
||||
let refs = if id == 0 { vec![] } else { vec![id - 1] };
|
||||
let clean = led.references_clean(refs.iter().copied());
|
||||
assert!(clean, "picture {id} must read clean");
|
||||
led.note_stored(id, clean, false);
|
||||
led.retain_live(id.saturating_sub(3)..=id);
|
||||
}
|
||||
assert_eq!(led.unclean_count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,18 @@ pub struct PicturePlan {
|
||||
/// DPB size in frames per A.3.1 — backends size their slot pool from this.
|
||||
pub max_dpb_frames: usize,
|
||||
pub recovery_point: Option<RecoveryPoint>,
|
||||
/// Every picture this AU predicts from was itself decoded from a fully-available
|
||||
/// reference chain — so a host claim that this AU is a clean re-anchor
|
||||
/// (`USER_FLAG_RECOVERY_ANCHOR`) can be corroborated rather than taken on trust.
|
||||
/// `true` for an IDR (nothing to predict from) and for any picture whose whole
|
||||
/// reference chain is clean; `false` from the moment this AU — or anything it
|
||||
/// descends from — needed concealment.
|
||||
///
|
||||
/// Purely additive observation: nothing in the plan, the warnings or the DPB
|
||||
/// changes because of it, and on a stream that never loses a reference it is
|
||||
/// `true` on every picture forever. See [`crate::clean`] for why it propagates and
|
||||
/// why every rule errs toward `false`.
|
||||
pub references_clean: bool,
|
||||
}
|
||||
|
||||
/// The region of the coded picture that is actually displayed.
|
||||
@@ -252,6 +264,41 @@ pub enum PlanWarning {
|
||||
},
|
||||
}
|
||||
|
||||
impl PlanWarning {
|
||||
/// Does this warning mean the PICTURE is damaged — the plan was completed with a
|
||||
/// SUBSTITUTE in place of something that was lost — rather than reporting a
|
||||
/// spec-legal fact about the stream's envelope?
|
||||
///
|
||||
/// The distinction decides two things that must never disagree: whether a consumer
|
||||
/// releases the AU's output unshown and asks for a re-anchor, and whether the
|
||||
/// picture enters [`crate::clean::CleanLedger`] as unclean. It lives HERE, on the
|
||||
/// enum, because those two consumers sit in different crates and a second copy of
|
||||
/// the list would let one of them conceal damage the other reports — the exact
|
||||
/// shape of the invisible-corruption failure the native-decode program exists to
|
||||
/// end. `pf_vkdecode::is_integrity_warning` delegates to this.
|
||||
///
|
||||
/// `Mmco5Rebase` is not damage: the AU carried an MMCO 5 and this planner planned
|
||||
/// it in full (the plan holds the pre-rebase 8.2.1 values; later AUs reference the
|
||||
/// rebased ones). `LevelDerivedDpb` is not either: the picture is intact and fully
|
||||
/// planned — it reports that the SPS never declared its DPB depth, so the plan had
|
||||
/// to size from A.3.1's level ceiling, a property of the STREAM's signalling which
|
||||
/// a backend answers by failing to open a session, not by showing a damaged frame.
|
||||
///
|
||||
/// Written as an EXHAUSTIVE match with no wildcard, deliberately. A `matches!` (or
|
||||
/// a `_ => false`) makes "damage" the opt-in and silence the default, so a variant
|
||||
/// added later — by definition one nobody here has classified — would be reported
|
||||
/// as clean and its picture shown. The compiler is the only reviewer guaranteed to
|
||||
/// be present when that variant is written, so it gets the decision.
|
||||
pub fn is_integrity(&self) -> bool {
|
||||
match self {
|
||||
PlanWarning::FrameNumGap { .. }
|
||||
| PlanWarning::MissingReference { .. }
|
||||
| PlanWarning::TruncatedAu { .. } => true,
|
||||
PlanWarning::Mmco5Rebase | PlanWarning::LevelDerivedDpb { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The AU cannot be planned at all.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PlanError {
|
||||
@@ -482,6 +529,10 @@ pub struct H264Planner {
|
||||
reported_live: BTreeSet<PicId>,
|
||||
/// Set by [`Self::flush`]: planning resumes only at an IDR (upstream: `Reset`).
|
||||
awaiting_idr: bool,
|
||||
/// Which resident pictures came off a BROKEN reference chain — the fact behind
|
||||
/// [`PicturePlan::references_clean`]. Empty on a healthy stream; see
|
||||
/// [`crate::clean::CleanLedger`] for the propagation rules.
|
||||
clean: crate::clean::CleanLedger,
|
||||
}
|
||||
|
||||
impl H264Planner {
|
||||
@@ -600,9 +651,20 @@ impl H264Planner {
|
||||
let cur = current
|
||||
.ok_or_else(|| PlanError::Parse("access unit contains no coded picture".into()))?;
|
||||
|
||||
// Was every picture this AU predicts from decoded off an intact chain? Asked
|
||||
// over the SLICE reference lists rather than the DPB snapshot, because those
|
||||
// are what this picture actually predicts from — a resident-but-unreferenced
|
||||
// damaged picture says nothing about this one. An IDR references nothing, so
|
||||
// this is vacuously true for it (`CleanLedger::references_clean`).
|
||||
let references_clean = self.clean.references_clean(
|
||||
slices
|
||||
.iter()
|
||||
.flat_map(|s: &SlicePlan| s.ref_list0.iter().chain(&s.ref_list1))
|
||||
.map(|r| r.id),
|
||||
);
|
||||
// Captured before finish_picture: MMCO5 rewrites the stored POC afterwards, but
|
||||
// backends submit the picture with its 8.2.1 values.
|
||||
let picture = Self::picture_plan(&cur, recovery_point);
|
||||
let picture = Self::picture_plan(&cur, recovery_point, references_clean);
|
||||
// The activated parameter sets ride out with the plan (AuPlan field docs);
|
||||
// cloned before finish_picture consumes `cur`.
|
||||
let pps = Rc::clone(&cur.first_slice_pps);
|
||||
@@ -619,6 +681,20 @@ impl H264Planner {
|
||||
let removed = previously_live.difference(&live_after).copied().collect();
|
||||
self.reported_live = live_after;
|
||||
|
||||
// Fold this picture's verdict, then bound the ledger to DPB residency. Both
|
||||
// AFTER `finish_picture`, so `stored` is the id the picture really got and
|
||||
// `live_after` reflects the marking this AU performed — a mark written against
|
||||
// a pre-marking view could survive an eviction it should have died with.
|
||||
// `concealed` mirrors what a consumer conceals on, via the ONE classification
|
||||
// (`PlanWarning::is_integrity`), so the ledger and the consumer can never
|
||||
// disagree about whether this AU was damaged.
|
||||
self.clean.note_stored(
|
||||
stored,
|
||||
references_clean,
|
||||
warnings.iter().any(PlanWarning::is_integrity),
|
||||
);
|
||||
self.clean.retain_live(self.reported_live.iter().copied());
|
||||
|
||||
Ok(AuPlan {
|
||||
picture,
|
||||
slices,
|
||||
@@ -650,6 +726,9 @@ impl H264Planner {
|
||||
self.max_long_term_frame_idx = Default::default();
|
||||
self.negotiation_info = Default::default();
|
||||
self.awaiting_idr = true;
|
||||
// The DPB is drained, so no mark describes a resident picture any more — and
|
||||
// planning resumes at an IDR, which is clean by construction.
|
||||
self.clean.clear();
|
||||
|
||||
DpbUpdate {
|
||||
stored: None,
|
||||
@@ -1747,7 +1826,11 @@ impl H264Planner {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn picture_plan(cur: &CurrentPicState, recovery_point: Option<RecoveryPoint>) -> PicturePlan {
|
||||
fn picture_plan(
|
||||
cur: &CurrentPicState,
|
||||
recovery_point: Option<RecoveryPoint>,
|
||||
references_clean: bool,
|
||||
) -> PicturePlan {
|
||||
let pic = &cur.pic;
|
||||
// The first slice's PPS defines the picture's parameters (upstream's
|
||||
// start_picture semantics); `cur.pps` may have drifted to a later slice's.
|
||||
@@ -1791,6 +1874,7 @@ impl H264Planner {
|
||||
chroma_format_idc: sps.chroma_format_idc,
|
||||
max_dpb_frames: dpb_limit(sps),
|
||||
recovery_point,
|
||||
references_clean,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2343,6 +2427,110 @@ mod tests {
|
||||
assert!(missing_seen);
|
||||
}
|
||||
|
||||
/// The clean bit, end to end through the real planner: a `frame_num` gap
|
||||
/// concealed one picture, and EVERY picture descending from it reports
|
||||
/// `references_clean == false` even though their own plans are spotless. That
|
||||
/// propagation is the whole point — the concealed picture is rarely the one a host
|
||||
/// recovery anchor names; the ordinary P-frames after it are.
|
||||
#[test]
|
||||
fn a_concealed_picture_makes_every_descendant_report_unclean_references() {
|
||||
let (sps, pps) = authored_sps_pps();
|
||||
let mut au0 = param_set_au(&sps, &pps);
|
||||
au0.extend(write_idr_slice());
|
||||
|
||||
let mut planner = H264Planner::new();
|
||||
let p0 = planner.plan_au(&au0).unwrap();
|
||||
assert!(
|
||||
p0.picture.references_clean,
|
||||
"an IDR references nothing, so it is clean by construction"
|
||||
);
|
||||
|
||||
// A healthy P off the IDR: still clean.
|
||||
let p1 = planner.plan_au(&write_p_slice(1, 2, 1, 1, None)).unwrap();
|
||||
assert!(picture_warnings(&p1).is_empty());
|
||||
assert!(p1.picture.references_clean);
|
||||
|
||||
// frame_num 2 never arrives — 8.2.5.2 fabricates a placeholder and the plan
|
||||
// conceals. THIS picture's references were still intact; the damage is its own.
|
||||
let p3 = planner.plan_au(&write_p_slice(3, 6, 1, 3, None)).unwrap();
|
||||
assert!(p3
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| matches!(w, PlanWarning::FrameNumGap { .. })));
|
||||
|
||||
// …and every picture after it inherits the damage with a clean plan of its own.
|
||||
let p4 = planner.plan_au(&write_p_slice(4, 8, 1, 1, None)).unwrap();
|
||||
assert!(
|
||||
picture_warnings(&p4).is_empty(),
|
||||
"p4's own plan raises nothing — which is exactly why the bit is needed"
|
||||
);
|
||||
assert!(
|
||||
!p4.picture.references_clean,
|
||||
"p4 predicts from the concealed chain, so it must not read as clean"
|
||||
);
|
||||
|
||||
let p5 = planner.plan_au(&write_p_slice(5, 10, 1, 1, None)).unwrap();
|
||||
assert!(picture_warnings(&p5).is_empty());
|
||||
assert!(!p5.picture.references_clean, "the rot keeps travelling");
|
||||
}
|
||||
|
||||
/// An IDR ends a damaged run: it predicts from nothing, so it reads clean however
|
||||
/// broken the stream was before it. Without this a session could never recover a
|
||||
/// trustworthy anchor.
|
||||
#[test]
|
||||
fn an_idr_reports_clean_references_however_damaged_the_run_before_it() {
|
||||
let (sps, pps) = authored_sps_pps();
|
||||
let mut au0 = param_set_au(&sps, &pps);
|
||||
au0.extend(write_idr_slice());
|
||||
|
||||
let mut planner = H264Planner::new();
|
||||
planner.plan_au(&au0).unwrap();
|
||||
planner.plan_au(&write_p_slice(1, 2, 1, 1, None)).unwrap();
|
||||
// Gap: frame_num 2 lost.
|
||||
let p3 = planner.plan_au(&write_p_slice(3, 6, 1, 3, None)).unwrap();
|
||||
assert!(p3
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| matches!(w, PlanWarning::FrameNumGap { .. })));
|
||||
let p4 = planner.plan_au(&write_p_slice(4, 8, 1, 1, None)).unwrap();
|
||||
assert!(!p4.picture.references_clean);
|
||||
|
||||
// A fresh IDR re-anchors, and the pictures after it are clean again.
|
||||
let mut idr = param_set_au(&sps, &pps);
|
||||
idr.extend(write_idr_slice());
|
||||
let p5 = planner.plan_au(&idr).unwrap();
|
||||
assert!(p5.picture.references_clean, "an IDR is always clean");
|
||||
let p6 = planner.plan_au(&write_p_slice(1, 2, 1, 1, None)).unwrap();
|
||||
assert!(
|
||||
p6.picture.references_clean,
|
||||
"the damaged chain died with the IDR's DPB flush"
|
||||
);
|
||||
}
|
||||
|
||||
/// A stream that never loses a reference reports `references_clean` on every
|
||||
/// picture, forever — the property that makes this free to carry in production.
|
||||
#[test]
|
||||
fn a_healthy_stream_reports_clean_references_on_every_picture() {
|
||||
let (sps, pps) = authored_sps_pps();
|
||||
let mut au0 = param_set_au(&sps, &pps);
|
||||
au0.extend(write_idr_slice());
|
||||
|
||||
let mut planner = H264Planner::new();
|
||||
assert!(planner.plan_au(&au0).unwrap().picture.references_clean);
|
||||
// log2_max_frame_num_minus4 = 0 and pic_order_cnt_lsb is u(4): both wrap at 16.
|
||||
for n in 1..16u32 {
|
||||
let plan = planner
|
||||
.plan_au(&write_p_slice(n, (n * 2) % 16, 1, 1, None))
|
||||
.unwrap();
|
||||
assert!(
|
||||
picture_warnings(&plan).is_empty(),
|
||||
"frame {n} should plan cleanly: {:?}",
|
||||
picture_warnings(&plan)
|
||||
);
|
||||
assert!(plan.picture.references_clean, "frame {n} must read clean");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gap_placeholder_inside_a_ref_list_is_substituted_in_place_not_compacted() {
|
||||
let (sps, pps) = authored_sps_pps();
|
||||
|
||||
@@ -166,6 +166,18 @@ pub struct PicturePlan {
|
||||
/// came from the SPS by index) — Vulkan's `NumBitsForSTRefPicSetInSlice`.
|
||||
pub short_term_ref_pic_set_size_bits: u32,
|
||||
pub recovery_point: Option<RecoveryPointHevc>,
|
||||
/// Every picture this AU predicts from was itself decoded from a fully-available
|
||||
/// reference chain — so a host claim that this AU is a clean re-anchor
|
||||
/// (`USER_FLAG_RECOVERY_ANCHOR`) can be corroborated rather than taken on trust.
|
||||
/// `true` for an IRAP (nothing to predict from) and for any picture whose whole
|
||||
/// reference chain is clean; `false` from the moment this AU — or anything it
|
||||
/// descends from — needed concealment.
|
||||
///
|
||||
/// Purely additive observation: nothing in the plan, the warnings or the DPB
|
||||
/// changes because of it, and on a stream that never loses a reference it is
|
||||
/// `true` on every picture forever. See [`crate::clean`] for why it propagates and
|
||||
/// why every rule errs toward `false`.
|
||||
pub references_clean: bool,
|
||||
}
|
||||
|
||||
/// A reference list / RPS entry: the minimum every backend picparams format needs.
|
||||
@@ -232,6 +244,28 @@ pub enum PlanWarning {
|
||||
NonZeroReorder { max_num_reorder_pics: u8 },
|
||||
}
|
||||
|
||||
impl PlanWarning {
|
||||
/// Does this warning mean the PICTURE is damaged? The H.265 twin of
|
||||
/// [`crate::h264::PlanWarning::is_integrity`] — the same one-list argument applies,
|
||||
/// and `pf_vkdecode::is_integrity_warning_h265` delegates here.
|
||||
///
|
||||
/// `NonZeroReorder` is NOT damage, and excluding it matters more here than the
|
||||
/// H.264 exclusions do: it fires on the AU that ACTIVATES an SPS — the opening
|
||||
/// IRAP, and the fresh IRAP at every ABR resolution change — so treating it as
|
||||
/// concealment would cost a released-unshown frame plus a keyframe round trip at
|
||||
/// every renegotiation, on a stream the planner says it planned correctly. It
|
||||
/// would also poison the [`crate::clean::CleanLedger`] at exactly those IRAPs,
|
||||
/// marking the one picture that is clean by construction as broken.
|
||||
///
|
||||
/// Exhaustive with no wildcard, for the reason the H.264 twin spells out.
|
||||
pub fn is_integrity(&self) -> bool {
|
||||
match self {
|
||||
PlanWarning::MissingReference { .. } | PlanWarning::TruncatedAu { .. } => true,
|
||||
PlanWarning::NonZeroReorder { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The AU cannot be planned at all.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PlanError {
|
||||
@@ -452,6 +486,10 @@ pub struct H265Planner {
|
||||
reported_live: BTreeSet<PicId>,
|
||||
/// Set by [`Self::flush`]: planning resumes only at an IRAP (upstream: `Reset`).
|
||||
awaiting_idr: bool,
|
||||
/// Which resident pictures came off a BROKEN reference chain — the fact behind
|
||||
/// [`PicturePlan::references_clean`]. Empty on a healthy stream; see
|
||||
/// [`crate::clean::CleanLedger`] for the propagation rules.
|
||||
clean: crate::clean::CleanLedger,
|
||||
}
|
||||
|
||||
impl Default for H265Planner {
|
||||
@@ -471,6 +509,7 @@ impl Default for H265Planner {
|
||||
pending_outputs: Vec::new(),
|
||||
reported_live: BTreeSet::new(),
|
||||
awaiting_idr: false,
|
||||
clean: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -690,7 +729,20 @@ impl H265Planner {
|
||||
let cur = current
|
||||
.ok_or_else(|| PlanError::Parse("access unit contains no coded picture".into()))?;
|
||||
|
||||
let picture = Self::picture_plan(&cur, recovery_point);
|
||||
// Was every picture this AU predicts from decoded off an intact chain? Asked
|
||||
// over the SLICE reference lists rather than the RPS or the DPB snapshot,
|
||||
// because those are what this picture actually predicts from: 8.3.2 RETAINS
|
||||
// pictures in the RPS that the current picture does not use
|
||||
// (`used_by_curr_pic` clear), and a damaged one among those says nothing about
|
||||
// this picture. An IRAP's lists are empty, so this is vacuously true for it
|
||||
// (`CleanLedger::references_clean`).
|
||||
let references_clean = self.clean.references_clean(
|
||||
slices
|
||||
.iter()
|
||||
.flat_map(|s: &SlicePlan| s.ref_list0.iter().chain(&s.ref_list1))
|
||||
.map(|r| r.id),
|
||||
);
|
||||
let picture = Self::picture_plan(&cur, recovery_point, references_clean);
|
||||
let rps = cur.rps_plan.clone();
|
||||
let dpb_refs = cur.dpb_refs.clone();
|
||||
// The activated parameter sets ride out with the plan (AuPlan field docs);
|
||||
@@ -708,6 +760,20 @@ impl H265Planner {
|
||||
let removed = previously_live.difference(&live_after).copied().collect();
|
||||
self.reported_live = live_after;
|
||||
|
||||
// Fold this picture's verdict, then bound the ledger to DPB residency. Both
|
||||
// AFTER `finish_picture`, so `stored` is the id the picture really got and
|
||||
// `live_after` reflects the C.3.4/8.3.2 marking this AU performed — a mark
|
||||
// written against a pre-marking view could survive an eviction it should have
|
||||
// died with. `concealed` mirrors what a consumer conceals on, via the ONE
|
||||
// classification (`PlanWarning::is_integrity`), so the ledger and the consumer
|
||||
// can never disagree about whether this AU was damaged.
|
||||
self.clean.note_stored(
|
||||
stored,
|
||||
references_clean,
|
||||
warnings.iter().any(PlanWarning::is_integrity),
|
||||
);
|
||||
self.clean.retain_live(self.reported_live.iter().copied());
|
||||
|
||||
Ok(AuPlan {
|
||||
picture,
|
||||
rps,
|
||||
@@ -745,6 +811,9 @@ impl H265Planner {
|
||||
// re-entry sound.
|
||||
self.first_picture_after_eos = true;
|
||||
self.awaiting_idr = true;
|
||||
// The DPB is drained, so no mark describes a resident picture any more — and
|
||||
// planning resumes at an IRAP, which is clean by construction.
|
||||
self.clean.clear();
|
||||
|
||||
DpbUpdate {
|
||||
stored: None,
|
||||
@@ -1452,6 +1521,7 @@ impl H265Planner {
|
||||
fn picture_plan(
|
||||
cur: &CurrentPicState,
|
||||
recovery_point: Option<RecoveryPointHevc>,
|
||||
references_clean: bool,
|
||||
) -> PicturePlan {
|
||||
let pic = &cur.pic;
|
||||
// The first slice's PPS defines the picture's parameters; `cur.pps` may have
|
||||
@@ -1498,6 +1568,7 @@ impl H265Planner {
|
||||
max_dpb_frames: dpb_limit(sps),
|
||||
short_term_ref_pic_set_size_bits: pic.short_term_ref_pic_set_size_bits,
|
||||
recovery_point,
|
||||
references_clean,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1550,11 +1621,15 @@ mod tests {
|
||||
/// `NonZeroReorder` is excluded: the vendored conformance clips are general
|
||||
/// (reordering) encodes, and the planner deliberately plans them while flagging
|
||||
/// the envelope fact.
|
||||
///
|
||||
/// Delegates rather than restating the list. This harness exists to prove the
|
||||
/// planner conceals exactly where production conceals, so a second copy here
|
||||
/// could drift and quietly prove the wrong thing — and a `matches!` in
|
||||
/// particular reads any FUTURE variant as clean, which is the one answer a
|
||||
/// damage predicate must never default to. [`PlanWarning::is_integrity`] is an
|
||||
/// exhaustive match, so a new variant stops the compiler there instead.
|
||||
fn is_integrity_warning(w: &PlanWarning) -> bool {
|
||||
matches!(
|
||||
w,
|
||||
PlanWarning::MissingReference { .. } | PlanWarning::TruncatedAu { .. }
|
||||
)
|
||||
w.is_integrity()
|
||||
}
|
||||
|
||||
/// Plan a whole vendored clip and assert the global invariants: every AU plans,
|
||||
@@ -1658,6 +1733,30 @@ mod tests {
|
||||
assert!(!bbb.is_empty());
|
||||
}
|
||||
|
||||
/// The false-positive guard for [`PicturePlan::references_clean`], on REAL
|
||||
/// bitstreams rather than authored ones: two conformance clips that lose nothing
|
||||
/// must report every single picture clean. A regression that let the ledger mark a
|
||||
/// healthy stream would refuse every host recovery anchor and force an IDR on
|
||||
/// every loss — the cheap re-anchor path gone, silently.
|
||||
///
|
||||
/// These clips carry B-slices and real reordering, so they also exercise the
|
||||
/// "reference lists, not the RPS" reading: 8.3.2 retains pictures the current
|
||||
/// picture does not use, and folding those in would condemn pictures at random.
|
||||
#[test]
|
||||
fn a_lossless_conformance_clip_reports_clean_references_on_every_picture() {
|
||||
for (name, clip) in [("bear", TEST_BEAR), ("bbb", TEST_BBB)] {
|
||||
let (_, plans) = plan_whole_clip(clip);
|
||||
assert!(!plans.is_empty(), "{name} produced no plans");
|
||||
for (i, plan) in plans.iter().enumerate() {
|
||||
assert!(
|
||||
plan.picture.references_clean,
|
||||
"{name} picture {i} (poc {}) must read clean on a lossless clip",
|
||||
plan.picture.pic_order_cnt
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b_slices_get_a_future_led_list1_distinct_from_list0() {
|
||||
let aus = split_into_aus(TEST_64X64_I_P_B_P);
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod av1;
|
||||
pub mod clean;
|
||||
pub mod h264;
|
||||
pub mod h265;
|
||||
pub mod sei;
|
||||
|
||||
@@ -789,6 +789,19 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
|
||||
/// in `punktfunk-core`, the only crate they share. So this is a deliberate second copy, and
|
||||
/// [`ds5_offsets_track_the_usb_report`](ds5_feedback_tests) pins the `−1` relationship rather than
|
||||
/// leaving it to a comment.
|
||||
/// A `u8` field lever: decimal, or `0x`-prefixed hex (these name DS5 report BYTES, and every
|
||||
/// reference to them — SDL's source, the reverse-engineering notes, this module's own comments —
|
||||
/// writes them in hex). `None` when unset or unparseable, so a typo falls back to the default
|
||||
/// rather than to zero.
|
||||
fn env_u8(key: &str) -> Option<u8> {
|
||||
let v = std::env::var(key).ok()?;
|
||||
let v = v.trim();
|
||||
match v.strip_prefix("0x").or_else(|| v.strip_prefix("0X")) {
|
||||
Some(hex) => u8::from_str_radix(hex, 16).ok(),
|
||||
None => v.parse().ok(),
|
||||
}
|
||||
}
|
||||
|
||||
struct Ds5Feedback;
|
||||
|
||||
impl Ds5Feedback {
|
||||
@@ -844,6 +857,39 @@ impl Ds5Feedback {
|
||||
[0u8; 47]
|
||||
}
|
||||
|
||||
/// Point the pad's audio at its own SPEAKER, and give that speaker a volume.
|
||||
///
|
||||
/// Without this the speaker is silent no matter how correct the PCM routing is, which is
|
||||
/// exactly what a wired DS5 on a Steam Deck did: haptics felt, speaker inaudible. The
|
||||
/// reason is that **channel 1 of the pad's audio function is shared** — it is the headphone
|
||||
/// jack's right channel AND the built-in mono speaker — and which one physically sounds is
|
||||
/// chosen by `ucAudioEnableBits` (report byte 8, struct offset 7). A pad powers up pointing
|
||||
/// at the headphone jack, so with nothing plugged in the speaker pair goes nowhere. The
|
||||
/// voice coils are channels 2/3 and are NOT affected by that select, which is why haptics
|
||||
/// work the instant the samples are routed right and the speaker does not.
|
||||
///
|
||||
/// We only ever wrote these bytes when a host forwarded a game's [`HidOutput::AudioCtl`],
|
||||
/// so a title that manages no audio settings of its own left the speaker dead. This is the
|
||||
/// default that makes the stream audible; a later `AudioCtl` still overrides it verbatim
|
||||
/// ([`Self::audio_ctl_packet`]), so a game that does drive its own volume still wins.
|
||||
///
|
||||
/// ⚠ `ucEnableBits1` bits 0/1 stay CLEAR — they are "enable rumble emulation" and "disable
|
||||
/// audio haptics", and asserting either would mute the coils this plane drives.
|
||||
///
|
||||
/// ⚠ `path` is empirical. Measured on a DualSense (`054c:0ce6`) using the pad's OWN
|
||||
/// microphone as the detector: `0x20` was loudest (~5× the noise floor at the test tone),
|
||||
/// `0x30` also sounded, `0x10` was silent. Overridable per-run with
|
||||
/// `PUNKTFUNK_PAD_SPEAKER_PATH` / `PUNKTFUNK_PAD_SPEAKER_VOLUME` so a field report can
|
||||
/// bisect it without a rebuild.
|
||||
fn speaker_enable_packet(volume: u8, path: u8) -> [u8; 47] {
|
||||
let mut p = [0u8; 47];
|
||||
// bit5 = ucSpeakerVolume is valid, bit7 = the audio-control byte is valid.
|
||||
p[0] = 0x20 | 0x80;
|
||||
p[Self::AUDIO + 1] = volume; // ucSpeakerVolume
|
||||
p[Self::AUDIO + 3] = path; // ucAudioEnableBits
|
||||
p
|
||||
}
|
||||
|
||||
/// Fold a host [`HidOutput::AudioCtl`] into an effects packet: `raw` is DS5 output report
|
||||
/// `0x02` bytes 5..=10 verbatim → struct offsets 4..=9 ([`Self::AUDIO`] — headphone/
|
||||
/// speaker/mic volumes + routing), and `p[0]` re-asserts the report's audio-valid flags
|
||||
@@ -1333,10 +1379,48 @@ impl Worker {
|
||||
// ("Leaving emulated rumble bits off will restore audio haptics" —
|
||||
// SDL_hidapi_ps5.c); wire rumble for this slot is suppressed in
|
||||
// render_feedback so SDL never re-arms them.
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet());
|
||||
//
|
||||
// ⚠ This needs SDL's HIDAPI driver to be the one on the pad — the
|
||||
// packet is a raw DS5 effects report, and SDL can only send it where
|
||||
// it owns the HID link. On a Linux box where the kernel's
|
||||
// `hid-playstation` has the pad instead, the call fails, and it is
|
||||
// worth SAYING so: `hid-playstation` asserts the same disable bit on
|
||||
// every force-feedback update it makes, so a pad some other program
|
||||
// has rumbled stays deaf to this plane until it is re-plugged. Not
|
||||
// fatal — nothing else asserts the bit in our own path, so the pad's
|
||||
// power-on default (audio haptics live) usually still stands.
|
||||
if let Err(e) = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet()) {
|
||||
tracing::info!(
|
||||
index,
|
||||
error = %e,
|
||||
"could not re-arm the DualSense's audio-haptics bit (SDL does \
|
||||
not own this pad's HID link) — haptics still work unless \
|
||||
something else has rumbled the pad this plug-in"
|
||||
);
|
||||
}
|
||||
}
|
||||
if slot.audio_caps & 0x02 != 0 {
|
||||
// Speaker activation: point the pad's shared channel-1 output at its
|
||||
// own speaker instead of the headphone jack it powers up on, and give
|
||||
// it a volume. Without this the speaker stream is routed perfectly and
|
||||
// heard by nobody — see `speaker_enable_packet`.
|
||||
let path = env_u8("PUNKTFUNK_PAD_SPEAKER_PATH").unwrap_or(0x20);
|
||||
let volume = env_u8("PUNKTFUNK_PAD_SPEAKER_VOLUME").unwrap_or(0x7F);
|
||||
if let Err(e) = slot
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::speaker_enable_packet(volume, path))
|
||||
{
|
||||
tracing::info!(
|
||||
index,
|
||||
error = %e,
|
||||
"could not point the DualSense at its own speaker (SDL does \
|
||||
not own this pad's HID link) — the pad's speaker may stay \
|
||||
silent even though the stream reaches it"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Hand the pad to the session's renderer worker. Windows correlation
|
||||
// needs the HID interface path; Linux matches the sink by signature.
|
||||
// needs the HID interface path; Linux matches by card identity.
|
||||
crate::pad_audio::register_tier_a(index, slot.pad.path());
|
||||
tracing::info!(
|
||||
index,
|
||||
@@ -2914,6 +2998,55 @@ mod slot_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The speaker-enable default: volume and output-path land in the audio-control region at
|
||||
/// the same offsets an `AudioCtl` fold writes them, the two validity bits are set — and,
|
||||
/// most importantly, `ucEnableBits1` bits 0/1 stay CLEAR. Asserting either would enable
|
||||
/// rumble emulation / disable audio haptics and mute the very coils this plane drives, so
|
||||
/// making the speaker audible must never cost the haptics.
|
||||
#[test]
|
||||
fn speaker_enable_sets_volume_and_path_without_touching_the_haptics_bits() {
|
||||
let p = Ds5Feedback::speaker_enable_packet(0x7F, 0x20);
|
||||
assert_eq!(
|
||||
p[0] & 0x03,
|
||||
0,
|
||||
"rumble-emulation / disable-audio-haptics must stay clear"
|
||||
);
|
||||
assert_eq!(
|
||||
p[0],
|
||||
0x20 | 0x80,
|
||||
"speaker-volume + audio-control validity bits"
|
||||
);
|
||||
// ucSpeakerVolume is report byte 6 and ucAudioEnableBits report byte 8 — struct
|
||||
// offsets 5 and 7, i.e. AUDIO+1 and AUDIO+3.
|
||||
assert_eq!(p[5], 0x7F);
|
||||
assert_eq!(p[7], 0x20);
|
||||
// Nothing else in the packet moves (no rumble, no triggers, no LEDs).
|
||||
for (i, b) in p.iter().enumerate() {
|
||||
if !matches!(i, 0 | 5 | 7) {
|
||||
assert_eq!(*b, 0, "byte {i} should be untouched");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The field levers parse hex (how every reference writes these report bytes) and decimal,
|
||||
/// and a typo falls back to the default rather than silently meaning zero.
|
||||
#[test]
|
||||
fn env_u8_reads_hex_and_decimal() {
|
||||
assert_eq!(env_u8("PF_TEST_ABSENT_KEY_XYZ"), None);
|
||||
// Parsing is what is under test; the lookup is exercised by the None case above.
|
||||
for (s, want) in [
|
||||
("0x20", Some(0x20)),
|
||||
("0X7f", Some(0x7F)),
|
||||
("32", Some(32u8)),
|
||||
] {
|
||||
let parsed = match s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
|
||||
Some(hex) => u8::from_str_radix(hex, 16).ok(),
|
||||
None => s.parse().ok(),
|
||||
};
|
||||
assert_eq!(parsed, want, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The AudioCtl fold: the 6 raw bytes (DS5 report 0x02 bytes 5..=10) land at effect-struct
|
||||
/// offsets 4..=9, the report's audio-valid flags (AudioCtl.flags bits1..4) come back as
|
||||
/// p[0] bits 4..7, and the rumble-emulation / disable-audio-haptics bits (p[0] bits 0/1)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1093,9 +1093,38 @@ fn pump(
|
||||
// `image.is_keyframe()` as the decoder's own IDR belt, applies the two-mark
|
||||
// rule + the mark-patience backstop, clears the no-output streak, and returns
|
||||
// whether to present this frame or withhold it as a post-loss concealment.
|
||||
let present =
|
||||
gate.on_decoded(frame.flags, image.is_keyframe(), Instant::now())
|
||||
== GateVerdict::Present;
|
||||
//
|
||||
// CORROBORATED (the grey-frame fix): the wire's RECOVERY_ANCHOR is the host
|
||||
// asserting something about THIS decoder — "the picture I coded this
|
||||
// P-frame against is one you still hold, intact" — and it lifts the freeze
|
||||
// on the FIRST occurrence, no two-mark wait. The host derives that from
|
||||
// bookkeeping that tracks what the client RECEIVED, not what it managed to
|
||||
// DECODE, and when those diverge the anchor lifts the freeze onto a
|
||||
// concealed picture and LEAVES it lifted: grey with motion painted on it
|
||||
// until some later signal re-arms and the 500 ms backstop extracts a real
|
||||
// IDR. A rung that planned the AU itself knows better, so it says so here.
|
||||
//
|
||||
// What a refusal costs is exactly one thing: the freeze keeps holding the
|
||||
// last good picture until the backstop fires on its ORIGINAL deadline and
|
||||
// forces the IDR the anchor failed to be. That is strictly the better half
|
||||
// of the trade — the alternative is presenting a picture this client can
|
||||
// prove is damaged — and it is the same direction every rule in the gate
|
||||
// errs in. Every non-native lane reports `Unavailable` and is untouched.
|
||||
let evidence = image.anchor_evidence();
|
||||
if evidence == punktfunk_core::reanchor::AnchorEvidence::ReferencesDamaged
|
||||
&& frame.flags & punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR != 0
|
||||
{
|
||||
tracing::debug!(
|
||||
"refused a host recovery anchor: this AU predicts from a picture \
|
||||
this decoder had to conceal — holding for a real IDR"
|
||||
);
|
||||
}
|
||||
let present = gate.on_decoded_corroborated(
|
||||
frame.flags,
|
||||
image.is_keyframe(),
|
||||
evidence,
|
||||
Instant::now(),
|
||||
) == GateVerdict::Present;
|
||||
total_frames += 1;
|
||||
// ⚠ The `stats:` decode-path tag is a machine interface —
|
||||
// additive only. M10 removed the rungs whose tags were `vaapi`,
|
||||
|
||||
@@ -493,6 +493,17 @@ pub struct NativeVkFrame {
|
||||
/// the host, and it cannot be lost separately from the picture. Fed to
|
||||
/// [`ReanchorGate::on_local_recovery`](punktfunk_core::reanchor::ReanchorGate::on_local_recovery).
|
||||
pub recovery: punktfunk_core::reanchor::LocalRecovery,
|
||||
/// Every picture this AU predicts from was itself decoded from a fully-available
|
||||
/// reference chain (pf-vkdecode's `DecodedVkFrame::references_clean`).
|
||||
///
|
||||
/// The corroboration for the host's `USER_FLAG_RECOVERY_ANCHOR`, which is a claim
|
||||
/// about THIS decoder that only this decoder can check. The host derives its
|
||||
/// anchor from slot bookkeeping that tracks what the client RECEIVED; this tracks
|
||||
/// what the client managed to DECODE. When they disagree the anchor lifts the
|
||||
/// post-loss freeze onto a concealed picture and leaves it lifted, which is the
|
||||
/// grey-with-motion field report. `true` on every ordinary frame of a healthy
|
||||
/// stream, so the flag is only ever load-bearing on the AU that carries an anchor.
|
||||
pub references_clean: bool,
|
||||
/// This picture's position in DECODE order (pf-vkdecode's strictly increasing
|
||||
/// per-session ordinal). Delivery order is not decode order: after a failed AU
|
||||
/// the H.265 decoder flushes its DPB, handing back every buffered picture at
|
||||
@@ -559,6 +570,40 @@ impl DecodedImage {
|
||||
}
|
||||
}
|
||||
|
||||
/// What this lane can say about the host's re-anchor claim on this frame — the
|
||||
/// corroboration for `USER_FLAG_RECOVERY_ANCHOR`, fed to
|
||||
/// [`ReanchorGate::on_decoded_corroborated`](punktfunk_core::reanchor::ReanchorGate::on_decoded_corroborated).
|
||||
///
|
||||
/// An anchor is the host asserting a fact about THIS decoder — *the picture I
|
||||
/// coded this P-frame against is one you still hold, intact* — and the gate lifts
|
||||
/// its post-loss freeze on the first one, no two-mark wait. Only a rung that
|
||||
/// planned the AU itself knows which pictures it predicts from and whether each of
|
||||
/// those decoded cleanly, so only such a rung can catch the host being wrong.
|
||||
///
|
||||
/// The native Vulkan rung answers; everyone else reports
|
||||
/// [`AnchorEvidence::Unavailable`](punktfunk_core::reanchor::AnchorEvidence::Unavailable)
|
||||
/// and the gate treats them exactly as it did before this existed — silence is not
|
||||
/// refutation, so no lane becomes stricter by accident.
|
||||
///
|
||||
/// ⚠ The CPU rung's H.264 leg plans every AU with the same `H264Planner` and so
|
||||
/// COULD answer; it does not yet, because its frame type carries no equivalent of
|
||||
/// [`NativeVkFrame::references_clean`]. Reporting `Unavailable` there is the
|
||||
/// conservative reading (today's behaviour), not a claim that its references are
|
||||
/// fine.
|
||||
pub fn anchor_evidence(&self) -> punktfunk_core::reanchor::AnchorEvidence {
|
||||
use punktfunk_core::reanchor::AnchorEvidence;
|
||||
match self {
|
||||
DecodedImage::NativeVk(f) => {
|
||||
if f.references_clean {
|
||||
AnchorEvidence::ReferencesClean
|
||||
} else {
|
||||
AnchorEvidence::ReferencesDamaged
|
||||
}
|
||||
}
|
||||
_ => AnchorEvidence::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
/// This frame's position in DECODE order, where the lane knows one — see
|
||||
/// [`NativeVkFrame::decode_order`]. `None` everywhere else, which is what the
|
||||
/// pump reads as "this lane reports no local recovery either, so there is
|
||||
|
||||
@@ -3060,6 +3060,11 @@ mod tests {
|
||||
picture: pf_vaadec::PicturePlanAv1 {
|
||||
frame_type: pf_vaadec::FrameTypeAv1::KeyFrame,
|
||||
is_key: true,
|
||||
// Vacuously true for a key frame: it predicts from nothing. This fixture
|
||||
// exists to exercise the SIZING path (sequence max vs coded vs render), so
|
||||
// the clean bit is incidental here — but it must state the honest value,
|
||||
// because `false` is the answer that withholds a re-anchor.
|
||||
references_clean: true,
|
||||
show_frame: true,
|
||||
showable_frame: false,
|
||||
order_hint: 0,
|
||||
|
||||
@@ -741,6 +741,12 @@ fn project_frame(frame: &DecodedVkFrame, guard: NativeReleaseGuard) -> NativeVkF
|
||||
sei_here: frame.recovery.sei_here,
|
||||
is_recovery_point: frame.recovery.is_recovery_point,
|
||||
},
|
||||
// Whether this picture's own references decoded cleanly — the corroboration
|
||||
// the shared gate weighs a host `USER_FLAG_RECOVERY_ANCHOR` against. The
|
||||
// planner already knows it (it is the one party that resolved this AU's
|
||||
// reference lists), and it is the only thing that can catch the host
|
||||
// asserting a re-anchor over a picture THIS decoder had to conceal.
|
||||
references_clean: frame.references_clean,
|
||||
// Which side of a loss this picture was DECODED on. Carried beside the
|
||||
// recovery mark because the mark is worthless without it: a post-failure
|
||||
// DPB flush delivers pre-loss pictures after the loss, and their marks
|
||||
@@ -1690,6 +1696,11 @@ mod tests {
|
||||
sei_here: true,
|
||||
is_recovery_point: true,
|
||||
},
|
||||
// SET, per this fixture's no-boolean-is-false rule — and it earns the
|
||||
// rule: a projection that dropped this would default it to `false`,
|
||||
// which reads as "this picture's references were concealed" and would
|
||||
// make the gate refuse EVERY host recovery anchor on a healthy stream.
|
||||
references_clean: true,
|
||||
// Distinct from every other number here for the same reason: a
|
||||
// projection that dropped the decode ordinal would make every frame
|
||||
// look pre-loss (0) and silently disable the local-recovery path.
|
||||
@@ -1792,6 +1803,7 @@ mod tests {
|
||||
keyframe,
|
||||
poc,
|
||||
recovery,
|
||||
references_clean,
|
||||
decode_order,
|
||||
guard: _,
|
||||
} = p;
|
||||
@@ -1838,6 +1850,12 @@ mod tests {
|
||||
"the recovery point SEI's verdict reaches the gate — it is the ONLY \
|
||||
clean point an intra-refresh session has"
|
||||
);
|
||||
assert!(
|
||||
references_clean,
|
||||
"the reference-cleanliness verdict rides along — without it the gate \
|
||||
cannot refute a host recovery anchor that names a picture this decoder \
|
||||
had to conceal, which is the grey-with-motion field report"
|
||||
);
|
||||
assert_eq!(
|
||||
decode_order, 17,
|
||||
"the decode ordinal rides along — without it the pump cannot tell a \
|
||||
@@ -2150,6 +2168,7 @@ mod tests {
|
||||
keyframe: true,
|
||||
poc: 0,
|
||||
recovery: punktfunk_core::reanchor::LocalRecovery::NONE,
|
||||
references_clean: true,
|
||||
decode_order: 1,
|
||||
guard: NativeReleaseGuard::new(
|
||||
tx,
|
||||
|
||||
@@ -368,6 +368,32 @@ pub trait Encoder: Send {
|
||||
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
|
||||
false
|
||||
}
|
||||
/// Mark every resident reference UNTRUSTED FOR RFI ANCHORING — the answer to "the client told
|
||||
/// us it has damage and we did NOT repair it".
|
||||
///
|
||||
/// Why this exists at all. The slot-family RFI trust domain is the WIRE index each reference
|
||||
/// holds, which answers *did the client receive this frame*; what an anchor pick actually needs
|
||||
/// is *did the client DECODE it intact*. [`super::rfi`]'s taint sweep bridges that gap, but it
|
||||
/// only runs inside [`invalidate_ref_frames`] — reachable from exactly ONE of the client's five
|
||||
/// damage signals (the frame-index gap, which carries a loss RANGE). The other four report
|
||||
/// through [`request_keyframe`](Self::request_keyframe), which carries no range and so cannot
|
||||
/// sweep anything. That is self-healing while the IDR is actually emitted — an IDR flushes the
|
||||
/// DPB and rebuilds trust from scratch — but the host coalesces those requests (a keyframe
|
||||
/// storm is a 20-40× spike that deepens the very loss it recovers), and a coalesced request
|
||||
/// leaves the client's damage unrepaired AND unrecorded. Those references stay anchor
|
||||
/// candidates, and the next loss is answered with one of them tagged `recovery_anchor` — the
|
||||
/// client's *definitive* clean re-anchor signal, which lifts its post-loss freeze on the first
|
||||
/// occurrence. Grey frames, presented, with the freeze lifted.
|
||||
///
|
||||
/// Distrust is deliberately NOT "unusable": ordinary prediction runs off the backend's own slot
|
||||
/// INDEX, never the wire domain, so this costs nothing but the next anchor pick — which
|
||||
/// declines and falls through to the (still coalesced, so still non-storming) keyframe path.
|
||||
/// It is also self-correcting on all three backends: a slot re-marked with a fresh frame, or an
|
||||
/// IDR flushing the DPB, restores trust within a few frames. So this can suppress RFI briefly,
|
||||
/// never permanently.
|
||||
///
|
||||
/// Default: no-op — the backends with no reference bookkeeping have no trust to withdraw.
|
||||
fn distrust_references(&mut self) {}
|
||||
/// Escalate into a pipelined (two-thread) retrieve mode under sustained GPU contention — the
|
||||
/// encoder analog of the capturer depth escalation: AUs ride ~one loop tick behind (`poll`
|
||||
/// may return `None` while an encode is in flight) in exchange for capture/submit no longer
|
||||
|
||||
@@ -3991,6 +3991,35 @@ impl Encoder for VulkanVideoEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Withdraw anchor trust from every resident reference (trait docs carry the why).
|
||||
///
|
||||
/// The mechanism is this backend's half of the split `enc::rfi` documents: blank `slot_wire`
|
||||
/// ONLY. `slot_poc` MUST keep naming every physically-resident DPB picture — it is what
|
||||
/// [`build_h265_rps_s0`] retains the RPS from, and an RPS that stops naming a resident lets a
|
||||
/// conforming decoder mark it "unused for reference" and reclaim it (8.3.2), so a later anchor
|
||||
/// would reference a picture the client has already dropped. That is its own grey-screen bug,
|
||||
/// documented on `build_h265_rps_s0`, and it is the exact failure this method exists to
|
||||
/// prevent — so getting the two domains the wrong way round here would trade one for the other.
|
||||
/// `slot_wire` is the RFI/loss domain; `slot_poc` is the reference-delta domain.
|
||||
///
|
||||
/// `pending_loss` is deliberately left armed, matching this backend's decline arm: a stale arm
|
||||
/// is re-resolved at frame-build, where the re-pick now finds nothing trusted and forces the
|
||||
/// IDR that heals the stream. Clearing it here would ship an untagged plain P instead.
|
||||
///
|
||||
/// Ordinary prediction is untouched — it runs off `prev_slot`, an index, not a wire.
|
||||
fn distrust_references(&mut self) {
|
||||
let trusted = self.slot_wire.iter().filter(|&&w| w >= 0).count();
|
||||
if trusted == 0 {
|
||||
return; // already fully distrusted — nothing to log or clear
|
||||
}
|
||||
self.slot_wire.iter_mut().for_each(|w| *w = -1);
|
||||
tracing::debug!(
|
||||
trusted,
|
||||
"vulkan-encode: client reported unrepaired damage — withdrawing RFI anchor trust from \
|
||||
every resident reference (prediction and the RPS are unaffected)"
|
||||
);
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
// Backpressure-drained frames (already read, oldest) come out first, then the oldest slot
|
||||
// still in flight — both in submission order. BLOCKING, per the depth-1 pump contract
|
||||
|
||||
@@ -175,4 +175,56 @@ mod tests {
|
||||
apply(&mut all, plan.tainted);
|
||||
assert_eq!(pick_anchor(&view(&all), 5), None);
|
||||
}
|
||||
|
||||
/// `Encoder::distrust_references` — the OTHER way trust is withdrawn, and the one that needs no
|
||||
/// loss range. The host calls it when the client reports damage the host did not repair (a
|
||||
/// coalesced keyframe request, or an RFI anchor the client kept asking past): the sweep cannot
|
||||
/// run there because a keyframe request carries no range, so every resident reference is
|
||||
/// withdrawn wholesale instead. All three backends persist that through their own marker; what
|
||||
/// the shared policy must guarantee is the consequence — the next pick finds nothing and
|
||||
/// declines, so the caller keyframes instead of serving an anchor over unrepaired damage.
|
||||
#[test]
|
||||
fn distrusting_every_reference_makes_the_next_anchor_pick_decline() {
|
||||
// A table with plenty of pre-loss candidates: without the withdrawal, wire 7 anchors.
|
||||
let mut wires = [4i64, 5, 6, 7, -1, -1, -1, -1];
|
||||
assert_eq!(
|
||||
pick_anchor(&view(&wires), 9),
|
||||
Some((3, 7)),
|
||||
"precondition: this table would happily anchor"
|
||||
);
|
||||
|
||||
// The Vulkan mechanism (blank the wire) stands in for all three: AMF clears its mirror and
|
||||
// QSV raises `ltr_tainted`, but each is filtered out of the trusted view identically —
|
||||
// which is exactly what makes one pure policy serve three persistence schemes.
|
||||
apply(&mut wires, u32::MAX);
|
||||
assert_eq!(
|
||||
pick_anchor(&view(&wires), 9),
|
||||
None,
|
||||
"every reference withdrawn → no anchor, caller falls through to its keyframe path"
|
||||
);
|
||||
// And it holds for ANY later loss, not just this one — the point of persisting distrust.
|
||||
assert_eq!(pick_anchor(&view(&wires), 100), None);
|
||||
}
|
||||
|
||||
/// The withdrawal must be temporary, or one coalesced keyframe request would cost a session its
|
||||
/// RFI recovery for good and every later loss would ride the 20-40× IDR path. Each backend
|
||||
/// restores trust the same way it always did — a slot re-marked with a fresh frame (and an IDR
|
||||
/// flush, which empties the table first) — so a refilled slot anchors again.
|
||||
#[test]
|
||||
fn a_re_marked_slot_restores_anchor_trust_after_a_full_withdrawal() {
|
||||
let mut wires = [4i64, 5, 6, 7, -1, -1, -1, -1];
|
||||
apply(&mut wires, u32::MAX);
|
||||
assert_eq!(pick_anchor(&view(&wires), 20), None);
|
||||
|
||||
// Encoding continues; the ring refills two slots with post-withdrawal frames. Those really
|
||||
// are clean — the client's damage was repaired by the IDR the withdrawal forced — so they
|
||||
// are legitimate anchors and the sweep must not keep rejecting them.
|
||||
wires[0] = 14;
|
||||
wires[1] = 15;
|
||||
assert_eq!(
|
||||
pick_anchor(&view(&wires), 20),
|
||||
Some((1, 15)),
|
||||
"a re-marked slot is trusted again — the suppression is a few frames, not the session"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2010,6 +2010,30 @@ impl Encoder for AmfEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Withdraw anchor trust from every live LTR (trait docs carry the why).
|
||||
///
|
||||
/// This backend's mechanism, unchanged from the sweep's: distrust = clear the mirror slot.
|
||||
/// Dropped slots stay dropped and the marking cadence re-marks a clean frame within ~1/4 s, so
|
||||
/// the suppression is brief by construction.
|
||||
///
|
||||
/// `pending_force` is cleared with them, matching the decline arm above: an un-consumed force
|
||||
/// would otherwise point at a slot this call just distrusted, and the next submit would
|
||||
/// force-reference it anyway — shipping the corruption tagged `recovery_anchor`, which is the
|
||||
/// whole failure being closed.
|
||||
fn distrust_references(&mut self) {
|
||||
let live = self.ltr_slots.iter().filter(|m| m.is_some()).count();
|
||||
if live == 0 && self.pending_force.is_none() {
|
||||
return;
|
||||
}
|
||||
self.ltr_slots = [None; NUM_LTR_SLOTS];
|
||||
self.pending_force = None;
|
||||
tracing::debug!(
|
||||
live,
|
||||
"AMF LTR-RFI: client reported unrepaired damage — withdrawing anchor trust from every \
|
||||
live LTR (the marking cadence re-marks a clean frame within ~1/4 s)"
|
||||
);
|
||||
}
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps {
|
||||
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
|
||||
|
||||
@@ -1467,6 +1467,39 @@ impl Encoder for QsvEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Withdraw anchor trust from every live LTR (trait docs carry the why).
|
||||
///
|
||||
/// This backend's mechanism, unchanged from the sweep's: distrust is the SEPARATE
|
||||
/// `ltr_tainted` flag, never a cleared mirror slot. `ltr_slots` mirrors the HARDWARE DPB and
|
||||
/// nulling an entry issues no VPL call, so the frame stays marked long-term in the encoder —
|
||||
/// and the RejectedRefList built at submit only names `Some` slots, so a cleared mirror would
|
||||
/// silently SKIP the very entry being distrusted and the recovery frame could still predict
|
||||
/// from it. Taint keeps the mirror intact and the rejection reachable.
|
||||
///
|
||||
/// The taint lifts itself: an IDR flush and a re-mark both clear it, so this suppresses RFI
|
||||
/// for a few frames, never for the session.
|
||||
///
|
||||
/// `pending_force` is cleared for the same reason as the decline arm above — an un-consumed
|
||||
/// force would point at a slot this call just distrusted.
|
||||
fn distrust_references(&mut self) {
|
||||
let live = self
|
||||
.ltr_slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(slot, m)| m.is_some() && !self.ltr_tainted[slot])
|
||||
.count();
|
||||
if live == 0 && self.pending_force.is_none() {
|
||||
return;
|
||||
}
|
||||
self.ltr_tainted = [true; NUM_LTR_SLOTS];
|
||||
self.pending_force = None;
|
||||
tracing::debug!(
|
||||
live,
|
||||
"QSV LTR-RFI: client reported unrepaired damage — withdrawing anchor trust from every \
|
||||
live LTR (cleared by the next re-mark or IDR flush)"
|
||||
);
|
||||
}
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps {
|
||||
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
|
||||
|
||||
@@ -274,6 +274,14 @@ impl Encoder for TrackedEncoder {
|
||||
fn invalidate_ref_frames(&mut self, first_frame: i64, last_frame: i64) -> bool {
|
||||
self.inner.invalidate_ref_frames(first_frame, last_frame)
|
||||
}
|
||||
// Same trap class as `set_wire_chunking`, and the one where it would hurt most: unforwarded,
|
||||
// the default no-op would leave every session serving RFI anchors over damage the client
|
||||
// reported and the host never repaired — the failure this method exists to close, silently
|
||||
// reintroduced by the wrapper. (The `every_encoder_method_is_forwarded` guard below catches
|
||||
// it, which is exactly why that guard is there.)
|
||||
fn distrust_references(&mut self) {
|
||||
self.inner.distrust_references()
|
||||
}
|
||||
// Forwarded for the same reason as `set_wire_chunking` below — the unforwarded default
|
||||
// (`false` = "backend can't pipeline, stop asking") silently killed the §7 LN3 contention
|
||||
// escalation for every session, since the host loop only ever holds the wrapped box.
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
//! Where the host session's keyboard LAYOUT comes from.
|
||||
//!
|
||||
//! punktfunk's key wire is **US-positional**: a client sends the Windows VK of the *physical* key
|
||||
//! it saw, [`vk_to_evdev`](super::keymap::vk_to_evdev) turns that into a Linux evdev code, and the
|
||||
//! **session's keymap** is what decides which character that position finally produces. The
|
||||
//! standing contract is therefore "host layout == the layout printed on the client's keyboard" —
|
||||
//! a German keyboard needs a German session, or its ISO keys render as their US neighbours
|
||||
//! (`#`→`\`, `ä`→`'`, `-`→`/`, the y↔z swap).
|
||||
//!
|
||||
//! Nothing on a Wayland box arranges that by itself. `localectl set-x11-keymap de` records the
|
||||
//! choice in `/etc/X11/xorg.conf.d/00-keyboard.conf` (and, on systemd ≥ 249, `/etc/vconsole.conf`),
|
||||
//! but that file is read by **Xorg** — a Wayland compositor never opens it. libxkbcommon's own
|
||||
//! fallback chain stops at the `XKB_DEFAULT_*` env vars, and no session manager exports those. So
|
||||
//! compiling a keymap from empty names on a properly-configured German box still silently yields
|
||||
//! evdev/pc105/**us**, which is exactly the scramble above.
|
||||
//!
|
||||
//! [`system_layout`] reads what the machine actually recorded so the injected keyboard follows the
|
||||
//! box. It is advisory for backends whose keymap we do not own (libei/KWin/gamescope resolve our
|
||||
//! evdev codes against the compositor's own keymap — see [`crate::text_input_supported`]); there it
|
||||
//! only feeds the diagnostic, and the operator has to fix the session itself.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The five xkb rule names, each `None` when nothing configured it (libxkbcommon then applies its
|
||||
/// own built-in default — `evdev`/`pc105`/`us`/``/``).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct XkbNames {
|
||||
pub rules: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub layout: Option<String>,
|
||||
pub variant: Option<String>,
|
||||
pub options: Option<String>,
|
||||
}
|
||||
|
||||
impl XkbNames {
|
||||
/// Nothing at all was configured ⇒ the compiled keymap will be libxkbcommon's US default.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_none()
|
||||
&& self.model.is_none()
|
||||
&& self.layout.is_none()
|
||||
&& self.variant.is_none()
|
||||
&& self.options.is_none()
|
||||
}
|
||||
|
||||
/// Fill every field this one leaves unset from `fallback` (per-field precedence, mirroring how
|
||||
/// libxkbcommon itself falls back one `XKB_DEFAULT_*` variable at a time rather than taking a
|
||||
/// source whole — a box that exports only `XKB_DEFAULT_LAYOUT` still keeps its configured
|
||||
/// variant).
|
||||
fn fill_from(&mut self, fallback: XkbNames) {
|
||||
for (slot, value) in [
|
||||
(&mut self.rules, fallback.rules),
|
||||
(&mut self.model, fallback.model),
|
||||
(&mut self.layout, fallback.layout),
|
||||
(&mut self.variant, fallback.variant),
|
||||
(&mut self.options, fallback.options),
|
||||
] {
|
||||
if slot.is_none() {
|
||||
*slot = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The names as `xkb_keymap_new_from_names` wants them: an empty string means "unset", which
|
||||
/// is where libxkbcommon applies its own default for that field.
|
||||
pub fn as_args(&self) -> (&str, &str, &str, &str, Option<String>) {
|
||||
(
|
||||
self.rules.as_deref().unwrap_or(""),
|
||||
self.model.as_deref().unwrap_or(""),
|
||||
self.layout.as_deref().unwrap_or(""),
|
||||
self.variant.as_deref().unwrap_or(""),
|
||||
self.options.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The same names as `XKB_DEFAULT_*` environment pairs, for a compositor punktfunk spawns
|
||||
/// itself. Only the fields we actually resolved are emitted — exporting an empty
|
||||
/// `XKB_DEFAULT_VARIANT` is not the same as leaving it unset, since an explicit empty string
|
||||
/// *overrides* a variant the rules file would otherwise supply.
|
||||
pub fn env_pairs(&self) -> Vec<(&'static str, String)> {
|
||||
[
|
||||
("XKB_DEFAULT_RULES", &self.rules),
|
||||
("XKB_DEFAULT_MODEL", &self.model),
|
||||
("XKB_DEFAULT_LAYOUT", &self.layout),
|
||||
("XKB_DEFAULT_VARIANT", &self.variant),
|
||||
("XKB_DEFAULT_OPTIONS", &self.options),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(k, v)| v.as_ref().map(|v| (k, v.clone())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `de(nodeadkeys)` / `de` / `us (libxkbcommon default)` — for one readable log field.
|
||||
pub fn describe(&self) -> String {
|
||||
match (&self.layout, &self.variant) {
|
||||
(Some(l), Some(v)) if !v.is_empty() => format!("{l}({v})"),
|
||||
(Some(l), _) => l.clone(),
|
||||
(None, _) => "us (libxkbcommon default)".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The resolved layout plus where it came from, so the log line can name the file an operator
|
||||
/// would have to edit.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SystemLayout {
|
||||
pub names: XkbNames,
|
||||
/// Origin of `names.layout` specifically — the field that matters and the only one worth
|
||||
/// naming in a one-line log.
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// What `localectl set-x11-keymap` writes.
|
||||
const X11_CONF_DIR: &str = "/etc/X11/xorg.conf.d";
|
||||
/// systemd ≥ 249 mirrors the X11 keymap here as `XKBLAYOUT=`/`XKBVARIANT=`/…
|
||||
const VCONSOLE_CONF: &str = "/etc/vconsole.conf";
|
||||
|
||||
/// Resolve the host's configured keyboard layout: `XKB_DEFAULT_*` env (explicit operator intent,
|
||||
/// and what libxkbcommon would have used anyway) → `/etc/X11/xorg.conf.d/*keyboard*.conf` →
|
||||
/// `/etc/vconsole.conf`. Per-field, so a partially-configured box keeps whatever each source knows.
|
||||
pub fn system_layout() -> SystemLayout {
|
||||
resolve_from(
|
||||
from_env(),
|
||||
Path::new(X11_CONF_DIR),
|
||||
Path::new(VCONSOLE_CONF),
|
||||
)
|
||||
}
|
||||
|
||||
/// [`system_layout`] with its three inputs injected — the env block is a parameter so the tests
|
||||
/// never depend on the `XKB_DEFAULT_*` of whatever machine runs them.
|
||||
fn resolve_from(env: XkbNames, x11_dir: &Path, vconsole: &Path) -> SystemLayout {
|
||||
let mut names = env;
|
||||
let mut source = if names.layout.is_some() {
|
||||
"XKB_DEFAULT_LAYOUT".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
for path in x11_keyboard_confs(x11_dir) {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let parsed = parse_x11_keyboard_conf(&text);
|
||||
if source.is_empty() && parsed.layout.is_some() {
|
||||
source = path.display().to_string();
|
||||
}
|
||||
names.fill_from(parsed);
|
||||
}
|
||||
|
||||
if let Ok(text) = std::fs::read_to_string(vconsole) {
|
||||
let parsed = parse_vconsole_conf(&text);
|
||||
if source.is_empty() && parsed.layout.is_some() {
|
||||
source = vconsole.display().to_string();
|
||||
}
|
||||
names.fill_from(parsed);
|
||||
}
|
||||
|
||||
if source.is_empty() {
|
||||
source = "unconfigured".to_string();
|
||||
}
|
||||
SystemLayout { names, source }
|
||||
}
|
||||
|
||||
fn from_env() -> XkbNames {
|
||||
let get = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
|
||||
XkbNames {
|
||||
rules: get("XKB_DEFAULT_RULES"),
|
||||
model: get("XKB_DEFAULT_MODEL"),
|
||||
layout: get("XKB_DEFAULT_LAYOUT"),
|
||||
variant: get("XKB_DEFAULT_VARIANT"),
|
||||
options: get("XKB_DEFAULT_OPTIONS"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `*keyboard*.conf` in the Xorg snippet directory, in **reverse** lexical order. Xorg
|
||||
/// merges these low-to-high with the later file winning; [`resolve_from`] fills fields first-hit-
|
||||
/// wins, so handing it the highest-numbered snippet first reproduces that precedence.
|
||||
fn x11_keyboard_confs(dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<PathBuf> = entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.extension().is_some_and(|e| e == "conf")
|
||||
&& p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.contains("keyboard"))
|
||||
})
|
||||
.collect();
|
||||
out.sort();
|
||||
out.reverse();
|
||||
out
|
||||
}
|
||||
|
||||
/// Pull `Option "XkbLayout" "de"` style entries out of an Xorg `InputClass` snippet. Deliberately
|
||||
/// section-blind: `localectl` writes exactly one `MatchIsKeyboard` class, and scanning every
|
||||
/// `Option` line beats half-implementing the Xorg config grammar.
|
||||
fn parse_x11_keyboard_conf(text: &str) -> XkbNames {
|
||||
let mut names = XkbNames::default();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let mut fields = line.split('"');
|
||||
// `Option ` | key | ` ` | value
|
||||
let Some(head) = fields.next() else { continue };
|
||||
if !head.trim_end().eq_ignore_ascii_case("Option") {
|
||||
continue;
|
||||
}
|
||||
let (Some(key), Some(_), Some(value)) = (fields.next(), fields.next(), fields.next())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let slot = match key.to_ascii_lowercase().as_str() {
|
||||
"xkbrules" => &mut names.rules,
|
||||
"xkbmodel" => &mut names.model,
|
||||
"xkblayout" => &mut names.layout,
|
||||
"xkbvariant" => &mut names.variant,
|
||||
"xkboptions" => &mut names.options,
|
||||
_ => continue,
|
||||
};
|
||||
*slot = Some(value.to_string());
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Pull `XKBLAYOUT=de` / `XKBVARIANT="nodeadkeys"` out of `/etc/vconsole.conf`.
|
||||
///
|
||||
/// ⚠ `KEYMAP=` is deliberately ignored: it names a **console** keymap (`de-nodeadkeys`, `uk`,
|
||||
/// `sg-latin1`), whose namespace only coincides with xkb's by accident — `uk` is xkb `gb`, and a
|
||||
/// wrong guess here would mis-type every key rather than fall back to a visible default.
|
||||
fn parse_vconsole_conf(text: &str) -> XkbNames {
|
||||
let mut names = XkbNames::default();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let value = value.trim().trim_matches(['"', '\'']).to_string();
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let slot = match key.trim().to_ascii_uppercase().as_str() {
|
||||
"XKBRULES" => &mut names.rules,
|
||||
"XKBMODEL" => &mut names.model,
|
||||
"XKBLAYOUT" => &mut names.layout,
|
||||
"XKBVARIANT" => &mut names.variant,
|
||||
"XKBOPTIONS" => &mut names.options,
|
||||
_ => continue,
|
||||
};
|
||||
*slot = Some(value);
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Verbatim `localectl set-x11-keymap de pc105 nodeadkeys` output.
|
||||
const LOCALECTL_DE: &str = r#"# Written by systemd-localed(8), read by systemd-localed and Xorg. It's
|
||||
# probably wise not to edit this file manually. Use localectl(1) to
|
||||
# update this file.
|
||||
Section "InputClass"
|
||||
Identifier "system-keyboard"
|
||||
MatchIsKeyboard "on"
|
||||
Option "XkbLayout" "de"
|
||||
Option "XkbModel" "pc105"
|
||||
Option "XkbVariant" "nodeadkeys"
|
||||
EndSection
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_localectl_x11_snippet() {
|
||||
let n = parse_x11_keyboard_conf(LOCALECTL_DE);
|
||||
assert_eq!(n.layout.as_deref(), Some("de"));
|
||||
assert_eq!(n.model.as_deref(), Some("pc105"));
|
||||
assert_eq!(n.variant.as_deref(), Some("nodeadkeys"));
|
||||
assert_eq!(n.rules, None);
|
||||
assert_eq!(n.describe(), "de(nodeadkeys)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x11_snippet_ignores_comments_and_non_xkb_options() {
|
||||
let n = parse_x11_keyboard_conf(
|
||||
"# Option \"XkbLayout\" \"fr\"\n\
|
||||
Identifier \"system-keyboard\"\n\
|
||||
Option \"XkbLayout\" \"ch\"\n\
|
||||
Option \"SomethingElse\" \"nope\"\n",
|
||||
);
|
||||
assert_eq!(n.layout.as_deref(), Some("ch"));
|
||||
assert!(n.options.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_vconsole_and_ignores_console_keymap() {
|
||||
// The KEYMAP= line must NOT become an xkb layout: console and xkb namespaces differ.
|
||||
let n = parse_vconsole_conf("KEYMAP=\"de-nodeadkeys\"\nFONT=\"eurlatgr\"\n");
|
||||
assert!(n.is_empty(), "KEYMAP must not be read as an xkb layout");
|
||||
|
||||
let n = parse_vconsole_conf("XKBLAYOUT=de\nXKBVARIANT=\"nodeadkeys\"\nKEYMAP=de\n");
|
||||
assert_eq!(n.layout.as_deref(), Some("de"));
|
||||
assert_eq!(n.variant.as_deref(), Some("nodeadkeys"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_names_describe_as_the_us_default() {
|
||||
let n = XkbNames::default();
|
||||
assert!(n.is_empty());
|
||||
assert_eq!(n.describe(), "us (libxkbcommon default)");
|
||||
assert_eq!(n.as_args(), ("", "", "", "", None));
|
||||
assert!(n.env_pairs().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_field_fallback_keeps_the_more_specific_source() {
|
||||
// Env named only the layout; the X11 snippet still supplies model/variant.
|
||||
let mut n = XkbNames {
|
||||
layout: Some("fr".into()),
|
||||
..Default::default()
|
||||
};
|
||||
n.fill_from(parse_x11_keyboard_conf(LOCALECTL_DE));
|
||||
assert_eq!(n.layout.as_deref(), Some("fr"), "env layout must win");
|
||||
assert_eq!(n.variant.as_deref(), Some("nodeadkeys"));
|
||||
assert_eq!(n.model.as_deref(), Some("pc105"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_pairs_round_trip_only_the_resolved_fields() {
|
||||
let n = XkbNames {
|
||||
layout: Some("de".into()),
|
||||
variant: Some("nodeadkeys".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
n.env_pairs(),
|
||||
vec![
|
||||
("XKB_DEFAULT_LAYOUT", "de".to_string()),
|
||||
("XKB_DEFAULT_VARIANT", "nodeadkeys".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A throwaway `/etc` stand-in; the caller writes the two files it cares about.
|
||||
fn scratch(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("pf-layout-{}-{tag}", std::process::id()));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
std::fs::create_dir_all(dir.join("xorg.conf.d")).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_reads_the_x11_snippet_then_vconsole() {
|
||||
let dir = scratch("x11-then-vconsole");
|
||||
let x11 = dir.join("xorg.conf.d");
|
||||
std::fs::write(x11.join("00-keyboard.conf"), LOCALECTL_DE).unwrap();
|
||||
let vconsole = dir.join("vconsole.conf");
|
||||
std::fs::write(&vconsole, "XKBLAYOUT=fr\nXKBOPTIONS=compose:ralt\n").unwrap();
|
||||
|
||||
let got = resolve_from(XkbNames::default(), &x11, &vconsole);
|
||||
// The X11 snippet outranks vconsole, so its layout stands; vconsole still fills the
|
||||
// options nothing else supplied.
|
||||
assert_eq!(got.names.layout.as_deref(), Some("de"));
|
||||
assert_eq!(got.names.variant.as_deref(), Some("nodeadkeys"));
|
||||
assert_eq!(got.names.options.as_deref(), Some("compose:ralt"));
|
||||
assert!(got.source.ends_with("00-keyboard.conf"), "{}", got.source);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_higher_numbered_xorg_snippet_wins() {
|
||||
let dir = scratch("xorg-precedence");
|
||||
let x11 = dir.join("xorg.conf.d");
|
||||
std::fs::write(x11.join("00-keyboard.conf"), LOCALECTL_DE).unwrap();
|
||||
std::fs::write(
|
||||
x11.join("90-custom-keyboard.conf"),
|
||||
"Option \"XkbLayout\" \"no\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let got = resolve_from(XkbNames::default(), &x11, &dir.join("absent"));
|
||||
assert_eq!(got.names.layout.as_deref(), Some("no"));
|
||||
// The `00-` file still supplies what `90-` left unsaid.
|
||||
assert_eq!(got.names.variant.as_deref(), Some("nodeadkeys"));
|
||||
assert!(
|
||||
got.source.ends_with("90-custom-keyboard.conf"),
|
||||
"{}",
|
||||
got.source
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_outranks_every_file() {
|
||||
let dir = scratch("env-wins");
|
||||
let x11 = dir.join("xorg.conf.d");
|
||||
std::fs::write(x11.join("00-keyboard.conf"), LOCALECTL_DE).unwrap();
|
||||
|
||||
let env = XkbNames {
|
||||
layout: Some("us".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let got = resolve_from(env, &x11, &dir.join("absent"));
|
||||
assert_eq!(got.names.layout.as_deref(), Some("us"));
|
||||
assert_eq!(got.source, "XKB_DEFAULT_LAYOUT");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_on_a_bare_box_reports_unconfigured() {
|
||||
let missing = Path::new("/nonexistent/pf-layout-test");
|
||||
let got = resolve_from(XkbNames::default(), missing, missing);
|
||||
assert_eq!(got.source, "unconfigured");
|
||||
assert!(got.names.is_empty());
|
||||
assert_eq!(got.names.describe(), "us (libxkbcommon default)");
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,12 @@
|
||||
//! share a name; do NOT conflate them.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
/// Which keyboard LAYOUT the box is configured for. Not a `PUNKTFUNK_*` knob — it is read from
|
||||
/// what `localectl` recorded — but it is host configuration every input path needs (the injector
|
||||
/// compiles its keymap from it; the gamescope backend hands it to the session it launches), and it
|
||||
/// lives here so both can reach it without either crate depending on the other.
|
||||
pub mod layout;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Whether a `PUNKTFUNK_*` env var reads as ON, or `None` when it is unset — the host's
|
||||
|
||||
@@ -275,18 +275,37 @@ impl WlrootsInjector {
|
||||
pointer_mgr.create_virtual_pointer_with_output(Some(&seat), target.as_ref(), &qh, ());
|
||||
let keyboard = keyboard_mgr.create_virtual_keyboard(&seat, &qh, ());
|
||||
|
||||
// The keymap the compositor resolves our raw evdev keycodes with. Empty names defer to
|
||||
// the standard `XKB_DEFAULT_RULES/MODEL/LAYOUT/VARIANT/OPTIONS` env vars, then to
|
||||
// libxkbcommon's built-ins (evdev/pc105/us) — so a non-US host sets e.g.
|
||||
// `XKB_DEFAULT_LAYOUT=de` and the positional wire keys render as its layout (parity with
|
||||
// the libei path, where the session compositor's own keymap applies). Previously this
|
||||
// hardcoded "us", which forced US characters for the OEM/umlaut keys on every layout.
|
||||
// The keymap the compositor resolves our raw evdev keycodes with. The wire keys are
|
||||
// US-POSITIONAL, so this keymap is what decides the character each one finally types —
|
||||
// it has to be the layout printed on the client's keyboard, or ISO keys render as their
|
||||
// US neighbours (`#`→`\`, `ä`→`'`, `-`→`/`).
|
||||
//
|
||||
// Resolved from the box's own configuration (`crate::layout`), NOT from empty names:
|
||||
// empty defers to `XKB_DEFAULT_*`, which nothing on a Wayland session exports, so a
|
||||
// `localectl set-x11-keymap de` host silently compiled evdev/pc105/**us**. (Before that
|
||||
// it hardcoded "us" outright.) `XKB_DEFAULT_*` still wins when an operator sets it.
|
||||
let resolved = pf_host_config::layout::system_layout();
|
||||
let (rules, model, layout, variant, options) = resolved.names.as_args();
|
||||
let ctx = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
|
||||
let keymap =
|
||||
xkb::Keymap::new_from_names(&ctx, "", "", "", "", None, xkb::KEYMAP_COMPILE_NO_FLAGS)
|
||||
.context("compile xkb keymap (check XKB_DEFAULT_LAYOUT/VARIANT/RULES if set)")?;
|
||||
let keymap = xkb::Keymap::new_from_names(
|
||||
&ctx,
|
||||
rules,
|
||||
model,
|
||||
layout,
|
||||
variant,
|
||||
options,
|
||||
xkb::KEYMAP_COMPILE_NO_FLAGS,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"compile xkb keymap {} (from {})",
|
||||
resolved.names.describe(),
|
||||
resolved.source
|
||||
)
|
||||
})?;
|
||||
tracing::info!(
|
||||
layout = %std::env::var("XKB_DEFAULT_LAYOUT").unwrap_or_else(|_| "us (default)".into()),
|
||||
layout = %resolved.names.describe(),
|
||||
source = %resolved.source,
|
||||
"virtual keyboard keymap compiled"
|
||||
);
|
||||
let keymap_str = keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1);
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
//! Sway always advertises. We connect as an ordinary Wayland client (the host process
|
||||
//! inherits Sway's `WAYLAND_DISPLAY`/`XDG_RUNTIME_DIR`), bind the two managers, and translate
|
||||
//! events into virtual pointer/keyboard requests. Keyboard codes are Linux evdev; we upload an
|
||||
//! xkb keymap (the host's layout via `XKB_DEFAULT_LAYOUT` et al., defaulting to evdev/US) and
|
||||
//! track modifier state so the compositor resolves shifted keysyms correctly.
|
||||
//! xkb keymap built from the box's configured layout (`pf_host_config::layout` — `XKB_DEFAULT_*`,
|
||||
//! then what `localectl` recorded) and track modifier state so the compositor resolves shifted
|
||||
//! keysyms correctly.
|
||||
//!
|
||||
//! Extracted into a subsystem crate (plan §W6): consumes `punktfunk_core::input` (the neutral
|
||||
//! event vocabulary) + `pf-driver-proto` (the HID wire contract), never the orchestrator.
|
||||
|
||||
@@ -29,7 +29,7 @@ mod splash;
|
||||
use discovery::{
|
||||
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node, gamescope_bin,
|
||||
gamescope_can_composite_external_overlay, gamescope_can_offer_refresh_rates,
|
||||
gamescope_node_present, poll_managed_node, wait_for_node,
|
||||
gamescope_honours_xkb_env, gamescope_node_present, poll_managed_node, wait_for_node,
|
||||
};
|
||||
pub(crate) use discovery::{
|
||||
game_session_exited, gamescope_can_composite_cursor, gamescope_hdr_capable, is_available,
|
||||
@@ -1228,8 +1228,10 @@ fn write_steamos_dropin(shim_dir: &std::path::Path, mode: Mode, hdr: bool) -> Re
|
||||
Environment=PF_H={h}\n\
|
||||
Environment=PF_HZ={hz}\n\
|
||||
Environment=\"PF_HDR_ARGS={hdr_args}\"\n\
|
||||
{xkb}\
|
||||
UnsetEnvironment=DISPLAY WAYLAND_DISPLAY\n",
|
||||
shim = shim_dir.display(),
|
||||
xkb = xkb_unit_lines(),
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
hz = game_hz(mode.refresh_hz),
|
||||
@@ -1315,8 +1317,10 @@ fn write_session_plus_dropin(
|
||||
{binds}\
|
||||
Environment=PF_HZ={hz}\n\
|
||||
Environment=\"PF_HDR_ARGS={hdr_args}\"\n\
|
||||
{xkb}\
|
||||
{wsi}",
|
||||
binds = bind.unit_lines(),
|
||||
xkb = xkb_unit_lines(),
|
||||
hz = game_hz(mode.refresh_hz),
|
||||
hdr_args = hdr_args(hdr)
|
||||
.into_iter()
|
||||
@@ -3653,6 +3657,85 @@ fn point_injector_at_eis() {
|
||||
"gamescope: no connectable gamescope EIS socket found — input won't reach the session"
|
||||
),
|
||||
}
|
||||
sync_session_keyboard_layout();
|
||||
}
|
||||
|
||||
/// Explicit-off kill switch for [`sync_session_keyboard_layout`].
|
||||
const LAYOUT_SYNC_ENV: &str = "PUNKTFUNK_SESSION_LAYOUT";
|
||||
/// `setxkbmap` talks to a local X server; anything slower than this is a server that is not
|
||||
/// answering, and the connecting client is waiting on us.
|
||||
const LAYOUT_SYNC_BUDGET: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Align the session's Xwayland servers with the box's configured keyboard layout.
|
||||
///
|
||||
/// [`xkb_env`] only reaches a session punktfunk LAUNCHES. The common case on a Bazzite / SteamOS
|
||||
/// box is the opposite one: the gaming session is already up from autologin, punktfunk ATTACHES to
|
||||
/// it, and nothing decided at launch time applies — its Xwayland servers keep the `us` they were
|
||||
/// born with. That matters because everything a gamescope session runs (Steam Big Picture, and
|
||||
/// every Proton game) is an X11 client of those servers, so their keymap is what turns punktfunk's
|
||||
/// US-positional key codes into characters. Without this, a German keyboard types `\` for `#`,
|
||||
/// `'` for `ä` and `/` for `-` no matter what `localectl` says.
|
||||
///
|
||||
/// Best-effort and idempotent — re-applying the layout a server already has is a no-op, so this
|
||||
/// runs on every adoption rather than reading the current one back. It does nothing at all when
|
||||
/// the box configured no layout (there is nothing to align *to*, and inventing one would be worse
|
||||
/// than the default), when no gamescope Xwayland is running, or when `PUNKTFUNK_SESSION_LAYOUT` is
|
||||
/// explicitly off.
|
||||
///
|
||||
/// ⚠ Xwayland only. A Wayland-native client under gamescope takes the compositor's own keymap,
|
||||
/// which is [`xkb_env`] plus the `+pfhdr8` patch — the two halves are not interchangeable.
|
||||
fn sync_session_keyboard_layout() {
|
||||
if pf_host_config::env_on(LAYOUT_SYNC_ENV) == Some(false) {
|
||||
return;
|
||||
}
|
||||
let resolved = pf_host_config::layout::system_layout();
|
||||
let Some(layout) = resolved.names.layout.as_deref() else {
|
||||
return;
|
||||
};
|
||||
let targets = xwayland_cursor_targets();
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
let non_empty = |v: &Option<String>| v.as_deref().filter(|s| !s.is_empty()).map(str::to_owned);
|
||||
for (dpy, xauth) in targets {
|
||||
let mut cmd = Command::new("setxkbmap");
|
||||
cmd.args(["-display", &dpy, "-layout", layout]);
|
||||
if let Some(v) = non_empty(&resolved.names.variant) {
|
||||
cmd.args(["-variant", &v]);
|
||||
}
|
||||
if let Some(m) = non_empty(&resolved.names.model) {
|
||||
cmd.args(["-model", &m]);
|
||||
}
|
||||
// Only when configured: `-option ""` is setxkbmap's CLEAR, not its no-op.
|
||||
if let Some(o) = non_empty(&resolved.names.options) {
|
||||
cmd.args(["-option", &o]);
|
||||
}
|
||||
if let Some(xa) = &xauth {
|
||||
cmd.env("XAUTHORITY", xa);
|
||||
}
|
||||
match crate::proc::status_within(&mut cmd, LAYOUT_SYNC_BUDGET) {
|
||||
Ok(st) if st.success() => tracing::info!(
|
||||
display = %dpy,
|
||||
layout = %resolved.names.describe(),
|
||||
source = %resolved.source,
|
||||
"gamescope: aligned the session's keyboard layout with the box"
|
||||
),
|
||||
Ok(st) => tracing::warn!(
|
||||
display = %dpy,
|
||||
status = ?st.code(),
|
||||
"gamescope: setxkbmap rejected the box's layout — the session keeps its own"
|
||||
),
|
||||
// Overwhelmingly "setxkbmap is not installed" (it ships in xorg-x11-xkb-utils /
|
||||
// x11-xkb-utils). Not fatal: only a non-US keyboard notices, and it is exactly the
|
||||
// case the +pfhdr8 gamescope handles without any of this.
|
||||
Err(e) => tracing::warn!(
|
||||
display = %dpy,
|
||||
error = %e,
|
||||
layout = %resolved.names.describe(),
|
||||
"gamescope: could not set the session's keyboard layout (is setxkbmap installed?)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror the physical head this gamescope session is driving (`vdisplay::mirror`'s gamescope arm).
|
||||
@@ -4553,6 +4636,9 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
|
||||
for arg in wsi.setenv_args() {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
for arg in xkb_setenv_args() {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
// Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the
|
||||
// user manager env, which can carry a (possibly stale) desktop DISPLAY/WAYLAND_DISPLAY
|
||||
// that would abort gamescope at startup.
|
||||
@@ -4839,6 +4925,68 @@ fn cursor_args() -> Vec<String> {
|
||||
args
|
||||
}
|
||||
|
||||
/// The box's configured keyboard layout as `XKB_DEFAULT_*`, for a gamescope session we launch.
|
||||
///
|
||||
/// gamescope builds its xkb keymap from exactly these five variables and nothing else — it never
|
||||
/// looks at `/etc/X11/xorg.conf.d/00-keyboard.conf`, which is where `localectl set-x11-keymap`
|
||||
/// records the choice, because that file belongs to Xorg. Nothing in a systemd user session
|
||||
/// exports them either, so a session launched without this runs libxkbcommon's built-in default:
|
||||
/// evdev/pc105/**us**.
|
||||
///
|
||||
/// That matters because punktfunk's key wire is US-POSITIONAL — a client sends the *physical* key
|
||||
/// and the session's keymap decides the character — so a US session renders a German keyboard's
|
||||
/// ISO keys as their US neighbours (`#`→`\`, `ä`→`'`, `-`→`/`).
|
||||
///
|
||||
/// ⚠ Empty on a box that configured nothing, so an unconfigured session keeps behaving exactly as
|
||||
/// it does today rather than being pinned to an invented layout.
|
||||
///
|
||||
/// ⚠ This is necessary but not, on its own, sufficient: gamescope only publishes the keymap to its
|
||||
/// clients once a keyboard is actually bound to the seat, which on a HEADLESS session (no libinput
|
||||
/// devices) needs the `punktfunk-gamescope` patch that gives the seat's stub keyboard the compiled
|
||||
/// keymap. Against a stock gamescope these variables are read and then never reach Xwayland.
|
||||
fn xkb_env() -> Vec<(&'static str, String)> {
|
||||
let resolved = pf_host_config::layout::system_layout();
|
||||
let pairs = resolved.names.env_pairs();
|
||||
if pairs.is_empty() {
|
||||
return pairs;
|
||||
}
|
||||
// Passed either way — it costs nothing against an older binary and starts working the moment
|
||||
// the box updates — but say so, because "I set the layout and the keys are still US" is
|
||||
// otherwise unexplainable from the outside.
|
||||
if gamescope_honours_xkb_env() {
|
||||
tracing::info!(
|
||||
layout = %resolved.names.describe(),
|
||||
source = %resolved.source,
|
||||
"gamescope session: handing it the box's keyboard layout"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
layout = %resolved.names.describe(),
|
||||
source = %resolved.source,
|
||||
"gamescope session: this build ignores XKB_DEFAULT_* (needs punktfunk-gamescope \
|
||||
+pfhdr8) — the session will type US characters whatever the box is configured for"
|
||||
);
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
/// [`xkb_env`] as `systemd-run --setenv=` arguments — mirrors [`WsiPlan::setenv_args`].
|
||||
fn xkb_setenv_args() -> Vec<String> {
|
||||
xkb_env()
|
||||
.into_iter()
|
||||
.map(|(name, value)| format!("--setenv={name}={value}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`xkb_env`] as unit-file lines for a drop-in. Trailing newline included, so whatever the body
|
||||
/// puts after it still parses — same contract as [`WsiPlan::unit_lines`].
|
||||
fn xkb_unit_lines() -> String {
|
||||
xkb_env()
|
||||
.into_iter()
|
||||
.map(|(name, value)| format!("Environment={name}={value}\n"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `--custom-refresh-rates <list>` when the resolved gamescope has it (patch level 3+): the rates a
|
||||
/// HEADLESS session may offer its clients.
|
||||
///
|
||||
@@ -4950,6 +5098,8 @@ fn spawn(
|
||||
cmd.args(app.split_whitespace())
|
||||
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
|
||||
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
|
||||
// The box's keyboard layout — see [`xkb_env`]. Empty on an unconfigured box.
|
||||
.envs(xkb_env())
|
||||
// A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after
|
||||
// a desktop login inherits the user manager's DISPLAY/WAYLAND_DISPLAY — and a stale
|
||||
// WAYLAND_DISPLAY (e.g. a leftover `wayland-kde` in the manager env from a past session)
|
||||
|
||||
@@ -507,6 +507,19 @@ pub(crate) fn gamescope_can_composite_cursor() -> bool {
|
||||
gamescope_patch_level() >= 2 && !flags_lost()
|
||||
}
|
||||
|
||||
/// Does the resolved gamescope actually PUBLISH the keymap it compiles from `XKB_DEFAULT_*`?
|
||||
///
|
||||
/// Below this level it reads the five variables, builds the keymap, and then hands its clients
|
||||
/// nothing: the seat carries a keymap-less stub keyboard that `wlserver_keyboardfocus()` re-binds
|
||||
/// on every focus change, so Xwayland and every Wayland client keep their own built-in `us`. A
|
||||
/// headless session has no libinput devices to ever put the real keymap on the seat, so it never
|
||||
/// recovers. The keys punktfunk injects are US-POSITIONAL and the session's keymap is what turns
|
||||
/// them into characters — so on a stock gamescope a German keyboard types its US neighbours
|
||||
/// (`#`→`\`, `ä`→`'`, `-`→`/`) no matter how the box is configured.
|
||||
pub(crate) fn gamescope_honours_xkb_env() -> bool {
|
||||
gamescope_patch_level() >= 8 && !flags_lost()
|
||||
}
|
||||
|
||||
/// Does the resolved gamescope let us hand a headless session the list of refresh rates it may
|
||||
/// offer (`--custom-refresh-rates`)?
|
||||
///
|
||||
|
||||
@@ -185,6 +185,24 @@ pub struct DecodedVkFrame {
|
||||
/// the loss, and lift a freeze on a wave that completed before it. Comparing
|
||||
/// this ordinal against the one current at the arm is what tells them apart.
|
||||
pub decode_order: u64,
|
||||
/// Every picture this one was predicted from came off a fully-available reference
|
||||
/// chain ([`pf_bitstream::h264::PicturePlan::references_clean`] and its two twins).
|
||||
/// `true` for an IDR/IRAP/key frame and for any picture whose whole chain is clean;
|
||||
/// `false` from the moment this AU — or anything it descends from — needed
|
||||
/// concealment.
|
||||
///
|
||||
/// It exists to let a consumer CORROBORATE a host's claim that a frame is a clean
|
||||
/// re-anchor. `USER_FLAG_RECOVERY_ANCHOR` — the host's LTR-RFI recovery frame —
|
||||
/// lifts a post-loss freeze on its FIRST occurrence, exactly like a real IDR,
|
||||
/// because the host says the frame was coded against a known-good reference. The
|
||||
/// host's "known-good" is an inference from what the client RECEIVED; this is what
|
||||
/// the client actually DECODED. Where they disagree the freeze used to lift onto a
|
||||
/// gray plate and every frame after it chained off the corruption, with nothing
|
||||
/// left to re-arm the gate.
|
||||
///
|
||||
/// A consumer with no such claim to check can ignore it: it is an observation
|
||||
/// about the stream, and nothing in this crate's own behaviour reads it.
|
||||
pub references_clean: bool,
|
||||
/// The decode op's slot in the status query pool.
|
||||
pub query_slot: u32,
|
||||
/// The decode op's submission ordinal (validates the query slot has not been
|
||||
@@ -281,12 +299,24 @@ pub enum VkDecodeError {
|
||||
/// correct consumer can never hit this). The AU was planned but NOT decoded;
|
||||
/// release frames and request a keyframe.
|
||||
NoFreeSlot,
|
||||
/// A DPB slot this AU references holds no bound image. H.265 only, and fatal
|
||||
/// rather than skippable: `StdVideoDecodeH265PictureInfo`'s RPS arrays are
|
||||
/// INDICES into `pReferenceSlots`, so dropping one entry would silently
|
||||
/// re-point every later index at the wrong picture — the exact class of
|
||||
/// plausible-looking corruption this crate refuses to produce. (H.264 carries
|
||||
/// no such index arrays and only traces the case.)
|
||||
/// A DPB slot this AU references holds no bound image. Fatal on all three codecs
|
||||
/// rather than skippable.
|
||||
///
|
||||
/// H.265 and AV1 have a structural argument: their picture info names DPB slots by
|
||||
/// INDEX (the H.265 RPS arrays, AV1's name-indexed `refs`), so dropping one entry
|
||||
/// silently re-points a later index at the wrong picture. H.264 has no such index
|
||||
/// arrays, and used to skip the case with a `trace!` on exactly that reasoning —
|
||||
/// but the reasoning was about the STRUCTURE, not the output. The hardware still
|
||||
/// decodes a P-picture against a reference that was never bound, and on the
|
||||
/// DPB-and-output-COINCIDE path that is a gray plate with the new frame's motion
|
||||
/// painted over it. Nothing warned: the planner's DPB genuinely holds the picture
|
||||
/// (the breakage is this crate's slot→image ledger), so the frame was shipped,
|
||||
/// presented, and cleared the consumer's demotion streak on the way past —
|
||||
/// invisible damage, which is the one outcome this crate exists to make impossible.
|
||||
///
|
||||
/// Failing closed is only safe because it is paired with recovery: the latch
|
||||
/// ([`crate::decoder_h265::RecoveryLatch`]) flushes to the next IRAP/IDR rather
|
||||
/// than leaving the stream wedged on a slot nothing can honour.
|
||||
UnboundReferenceSlot { slot: u8 },
|
||||
/// The frame belongs to a generation whose retired pool is already gone
|
||||
/// (double release, or a frame outliving its graveyard entry).
|
||||
@@ -615,6 +645,11 @@ pub(crate) struct PendingPic {
|
||||
pub(crate) recovery: crate::recovery::RecoveryMark,
|
||||
/// See [`DecodedVkFrame::decode_order`].
|
||||
pub(crate) decode_order: u64,
|
||||
/// Read off the plan at DECODE time and carried here for the same reason
|
||||
/// [`Self::recovery`] is: display order is not decode order, and this describes
|
||||
/// the picture rather than the moment it is delivered. See
|
||||
/// [`DecodedVkFrame::references_clean`].
|
||||
pub(crate) references_clean: bool,
|
||||
}
|
||||
|
||||
/// A retired generation's picture pool: images the presenter still holds live
|
||||
@@ -678,7 +713,15 @@ pub struct VkH264Decoder {
|
||||
/// The outstanding recovery point SEI, if any — see [`crate::recovery`].
|
||||
/// Survives session rebuilds on purpose: it is a fact about the STREAM's
|
||||
/// prediction structure, not about this decoder's Vulkan objects.
|
||||
///
|
||||
/// Named apart from [`Self::recovery`], which is this decoder's DPB-recovery
|
||||
/// latch: the two are unrelated (one is a fact about the stream's prediction
|
||||
/// structure, the other about this decoder's own wedged state).
|
||||
recovery_watch: crate::recovery::RecoveryWatch,
|
||||
/// Post-failure DPB recovery owed — see
|
||||
/// [`crate::decoder_h265::RecoveryLatch`], whose docs carry the whole
|
||||
/// fail-closed/recover argument for all three codecs.
|
||||
recovery: crate::decoder_h265::RecoveryLatch,
|
||||
/// Pictures planned so far — stamped onto each one as
|
||||
/// [`DecodedVkFrame::decode_order`]. Survives session rebuilds for the same
|
||||
/// reason the watch does.
|
||||
@@ -724,6 +767,7 @@ impl VkH264Decoder {
|
||||
graveyard: Vec::new(),
|
||||
last_warnings: Vec::new(),
|
||||
recovery_watch: crate::recovery::RecoveryWatch::new(),
|
||||
recovery: Default::default(),
|
||||
decoded: 0,
|
||||
generation: 0,
|
||||
device_lost: false,
|
||||
@@ -748,6 +792,12 @@ impl VkH264Decoder {
|
||||
}
|
||||
|
||||
fn decode_inner(&mut self, au: &[u8]) -> Result<Option<DecodedVkFrame>, VkDecodeError> {
|
||||
// A previous AU failed after its planning had advanced: clear the stale
|
||||
// DPB residency BEFORE planning this one, or every AU referencing the
|
||||
// stranded picture fails forever ([`RecoveryLatch`] docs).
|
||||
if self.recovery.take() {
|
||||
self.recover_dpb();
|
||||
}
|
||||
// `take_warnings` promises "cleared by the next decode", and this IS a
|
||||
// decode: clear BEFORE planning, so an AU that fails to plan at all cannot
|
||||
// leave the previous AU's warnings behind to be re-read as damage on the
|
||||
@@ -784,7 +834,38 @@ impl VkH264Decoder {
|
||||
);
|
||||
}
|
||||
|
||||
self.ensure_state(&plan)?;
|
||||
// From here the PLANNER has already advanced past this AU — its DPB holds
|
||||
// the picture whatever happens next — so any failure below leaves the
|
||||
// planner's DPB and this decoder's slot/image ledgers able to disagree.
|
||||
// Latch the recovery for the next decode rather than returning into a
|
||||
// permanently wedged state. (Deliberately wider than the paths that mutate
|
||||
// the SlotMap: a failure BEFORE `plan_to_vk` mutates it — an `ensure_state`
|
||||
// refusal, a `NoFreeSlot` — strands the picture the other way round,
|
||||
// planner-resident with no slot at all, and wedges just as hard. One flush
|
||||
// cures both.) The H.265 twin, for the same reason: `decoder_h265`'s
|
||||
// `RecoveryLatch` docs carry the whole argument.
|
||||
let result = self.decode_planned(&plan, au, recovery, decode_order);
|
||||
if result.is_err() {
|
||||
self.recovery.latch();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The submission half of one decode, from the point the planner has already
|
||||
/// advanced. Split out so [`Self::decode_inner`] can latch recovery on ANY
|
||||
/// failure past that line without threading a flag through every exit.
|
||||
/// `au` is the same buffer `plan`'s slice ranges index into; `recovery` is the
|
||||
/// recovery-point verdict already folded for this AU and `decode_order` its
|
||||
/// decode-order ordinal (both advance in decode order, so neither can be
|
||||
/// derived here — this path is not reached for every planned AU).
|
||||
fn decode_planned(
|
||||
&mut self,
|
||||
plan: &AuPlan,
|
||||
au: &[u8],
|
||||
recovery: crate::recovery::RecoveryMark,
|
||||
decode_order: u64,
|
||||
) -> Result<Option<DecodedVkFrame>, VkDecodeError> {
|
||||
self.ensure_state(plan)?;
|
||||
let sps_id = plan.sps.seq_parameter_set_id;
|
||||
|
||||
// Convert, with ONE rebuild retry on CapacityMismatch — the designed
|
||||
@@ -810,7 +891,7 @@ impl VkH264Decoder {
|
||||
// satisfies ensure_parameters' Recreate contract, and Current/Add
|
||||
// touch nothing a submitted decode reads.
|
||||
unsafe { state.session.ensure_parameters(&plan.sps, &plan.pps)? };
|
||||
match plan_to_vk(&plan, &mut state.slots, sps_id) {
|
||||
match plan_to_vk(plan, &mut state.slots, sps_id) {
|
||||
Ok(converted) => {
|
||||
vk_plan = Some(converted);
|
||||
break;
|
||||
@@ -820,7 +901,7 @@ impl VkH264Decoder {
|
||||
required,
|
||||
capacity, "DPB depth renegotiated — rebuilding session"
|
||||
);
|
||||
self.rebuild_state(&plan)?;
|
||||
self.rebuild_state(plan)?;
|
||||
}
|
||||
Err(e) => return Err(VkDecodeError::Convert(e)),
|
||||
}
|
||||
@@ -1002,6 +1083,7 @@ impl VkH264Decoder {
|
||||
is_idr: plan.picture.is_idr,
|
||||
recovery,
|
||||
decode_order,
|
||||
references_clean: plan.picture.references_clean,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
@@ -1358,6 +1440,40 @@ impl VkH264Decoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the DPB state a failed AU left behind, so planning resumes at the
|
||||
/// next IDR instead of erroring on residency nothing can honour.
|
||||
///
|
||||
/// Three ledgers have to agree and, after a post-planning failure, do not:
|
||||
/// the PLANNER's DPB, this decoder's [`SlotMap`], and the slot→image
|
||||
/// bindings. [`Self::flush`] settles the first (and hands back any picture
|
||||
/// that did reach output — those frames are real and are still delivered),
|
||||
/// then [`crate::decoder_h265::reset_slot_bindings`] empties the other two.
|
||||
/// Pool images the stale bindings pinned go back on the free list; images a
|
||||
/// consumer still HOLDS stay pinned by their own `held` counts, exactly as
|
||||
/// they would across a session rebuild.
|
||||
///
|
||||
/// Deliberately not a session rebuild: the session, pools and ring are all
|
||||
/// still valid — only the DPB bookkeeping is stale — and a rebuild would
|
||||
/// churn every image allocation for a condition an IDR fixes anyway.
|
||||
///
|
||||
/// The H.265 twin (`decoder_h265::recover_dpb`) is the same function one codec
|
||||
/// over; the two share `reset_slot_bindings` rather than the whole body because
|
||||
/// each has to call its OWN `flush`, which settles its own planner's DPB.
|
||||
fn recover_dpb(&mut self) {
|
||||
debug!("recovering from a failed AU — flushing the H.264 DPB to the next IDR");
|
||||
self.flush();
|
||||
if let Some(state) = &mut self.state {
|
||||
let unbound = crate::decoder_h265::reset_slot_bindings(
|
||||
&mut state.slots,
|
||||
&mut state.slot_image,
|
||||
&mut state.slot_refs,
|
||||
);
|
||||
for picture in unbound {
|
||||
state.pool.pictures[picture].bound = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session/caps for THIS plan exist and match its extent + profile, and the
|
||||
/// stream sits inside the device's level ceiling. DPB-depth mismatches
|
||||
/// surface later as `plan_to_vk`'s `CapacityMismatch` (the designed trigger)
|
||||
@@ -1657,6 +1773,7 @@ pub(crate) fn build_frame(
|
||||
is_idr: entry.is_idr,
|
||||
recovery: entry.recovery,
|
||||
decode_order: entry.decode_order,
|
||||
references_clean: entry.references_clean,
|
||||
query_slot: entry.query_slot,
|
||||
submission: entry.submission,
|
||||
picture: entry.image as u32,
|
||||
@@ -1848,37 +1965,9 @@ unsafe fn record_and_submit(
|
||||
}
|
||||
|
||||
// ---- bound-slot staging ----
|
||||
// Scope list: this AU's references first, then every other still-held slot
|
||||
// (their resources must stay bound for their associations to persist), then
|
||||
// the setup slot as the ACTIVATION entry (slot index -1 binds its resource
|
||||
// without a current association; the decode op's setup slot then claims it).
|
||||
let mut scope: Vec<(i32, vk::ImageView, hh::StdVideoDecodeH264ReferenceInfo)> = Vec::new();
|
||||
for r in &vk_plan.refs {
|
||||
match slot_view(state, r.slot) {
|
||||
Some(view) => scope.push((i32::from(r.slot), view, r.std)),
|
||||
None => trace!(slot = r.slot, "referenced slot without a bound image"),
|
||||
}
|
||||
}
|
||||
for (slot, _id) in state.slots.held() {
|
||||
if slot == vk_plan.setup_slot
|
||||
|| scope
|
||||
.iter()
|
||||
.any(|&(index, _, _)| index >= 0 && index as u8 == slot)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match (state.slot_refs[usize::from(slot)], slot_view(state, slot)) {
|
||||
(Some(std), Some(view)) => scope.push((i32::from(slot), view, std)),
|
||||
// Unreachable in practice: every held slot was a setup slot once.
|
||||
_ => trace!(
|
||||
slot,
|
||||
"held slot without reference info/binding — left unbound"
|
||||
),
|
||||
}
|
||||
}
|
||||
let reference_count = vk_plan.refs.len().min(scope.len());
|
||||
// The setup/dst resource: the fresh pool image (coincide) or the DPB layer
|
||||
// (distinct — the pool image is the separate decode output).
|
||||
// (distinct — the pool image is the separate decode output). Resolved before the
|
||||
// scope is built, because it is the scope's last entry.
|
||||
let setup_view = if coincide {
|
||||
state.pool.pictures[dst].view
|
||||
} else {
|
||||
@@ -1888,31 +1977,48 @@ unsafe fn record_and_submit(
|
||||
.expect("distinct mode")
|
||||
.dpb_view(vk_plan.setup_slot)
|
||||
};
|
||||
scope.push((-1, setup_view, vk_plan.setup_ref));
|
||||
// Scope list: this AU's references first, then every other still-held slot
|
||||
// (their resources must stay bound for their associations to persist), then
|
||||
// the setup slot as the ACTIVATION entry (slot index -1 binds its resource
|
||||
// without a current association; the decode op's setup slot then claims it).
|
||||
//
|
||||
// Shared with H.265 (`decoder_h265::build_scope`): the two codecs' layout,
|
||||
// fail-closed rule and reference-count derivation are the same algorithm over a
|
||||
// different `StdVideo*` type, and this function's whole job is refusing to guess —
|
||||
// the property least tolerant of two copies drifting apart.
|
||||
let held: Vec<u8> = state.slots.held().map(|(slot, _id)| slot).collect();
|
||||
let (scope, reference_count) = crate::decoder_h265::build_scope(
|
||||
&vk_plan.refs,
|
||||
held.into_iter(),
|
||||
vk_plan.setup_slot,
|
||||
setup_view,
|
||||
vk_plan.setup_ref,
|
||||
&state.slot_refs,
|
||||
|slot| slot_view(state, slot),
|
||||
)?;
|
||||
|
||||
// Staged arrays: resources → std infos → codec slot infos → slot infos. Each
|
||||
// vector is fully built before the next borrows it, so nothing reallocates
|
||||
// under a stored pointer.
|
||||
let resources: Vec<vk::VideoPictureResourceInfoKHR<'_>> = scope
|
||||
.iter()
|
||||
.map(|&(_, view, _)| {
|
||||
.map(|e| {
|
||||
vk::VideoPictureResourceInfoKHR::default()
|
||||
.coded_extent(coded_extent)
|
||||
.base_array_layer(0)
|
||||
.image_view_binding(view)
|
||||
.image_view_binding(e.view)
|
||||
})
|
||||
.collect();
|
||||
let std_refs: Vec<hh::StdVideoDecodeH264ReferenceInfo> =
|
||||
scope.iter().map(|&(_, _, std)| std).collect();
|
||||
let std_refs: Vec<hh::StdVideoDecodeH264ReferenceInfo> = scope.iter().map(|e| e.std).collect();
|
||||
let mut dpb_infos: Vec<vk::VideoDecodeH264DpbSlotInfoKHR<'_>> = std_refs
|
||||
.iter()
|
||||
.map(|std| vk::VideoDecodeH264DpbSlotInfoKHR::default().std_reference_info(std))
|
||||
.collect();
|
||||
let mut begin_slots: Vec<vk::VideoReferenceSlotInfoKHR<'_>> = Vec::with_capacity(scope.len());
|
||||
for (index, &(slot_index, _, _)) in scope.iter().enumerate() {
|
||||
for (index, entry) in scope.iter().enumerate() {
|
||||
begin_slots.push(
|
||||
vk::VideoReferenceSlotInfoKHR::default()
|
||||
.slot_index(slot_index)
|
||||
.slot_index(entry.slot_index)
|
||||
.picture_resource(&resources[index]),
|
||||
);
|
||||
}
|
||||
@@ -2038,8 +2144,96 @@ unsafe fn record_and_submit(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ash::vk::Handle as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A fake, never-dereferenced view handle keyed by slot, so a scope's bindings
|
||||
/// can be checked without a device (the H.265 tests' idiom, one codec over).
|
||||
fn fake_view(slot: u8) -> vk::ImageView {
|
||||
vk::ImageView::from_raw(u64::from(slot) + 1)
|
||||
}
|
||||
|
||||
/// A reference-info value carrying just the field the assertions read.
|
||||
fn h264_std_ref(frame_num: u16) -> hh::StdVideoDecodeH264ReferenceInfo {
|
||||
// SAFETY: StdVideoDecodeH264ReferenceInfo is a plain-C bindgen struct of a
|
||||
// bitfield word and integers; all-zero is valid for every field.
|
||||
let mut std: hh::StdVideoDecodeH264ReferenceInfo = unsafe { std::mem::zeroed() };
|
||||
std.FrameNum = frame_num;
|
||||
std
|
||||
}
|
||||
|
||||
fn h264_ref(slot: u8, frame_num: u16) -> crate::pic::VkRef {
|
||||
crate::pic::VkRef {
|
||||
slot,
|
||||
std: h264_std_ref(frame_num),
|
||||
id: u64::from(slot),
|
||||
}
|
||||
}
|
||||
|
||||
/// The H.264 leg of the fail-closed rule. It used to trace-and-continue here, on
|
||||
/// the grounds that H.264 carries no RPS index arrays — but the hardware still
|
||||
/// decoded the picture against a reference that was never bound, which is a gray
|
||||
/// plate with motion on it, shipped with no warning attached. Fail closed.
|
||||
#[test]
|
||||
fn an_h264_reference_slot_without_a_bound_image_fails_the_whole_op() {
|
||||
let refs = vec![h264_ref(1, 10), h264_ref(3, 20)];
|
||||
let slot_refs = vec![Some(h264_std_ref(0)); 8];
|
||||
let err = crate::decoder_h265::build_scope(
|
||||
&refs,
|
||||
[1u8, 3].into_iter(),
|
||||
0,
|
||||
fake_view(0),
|
||||
h264_std_ref(30),
|
||||
&slot_refs,
|
||||
|slot| (slot != 3).then(|| fake_view(slot)),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, VkDecodeError::UnboundReferenceSlot { slot: 3 }),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `reference_count` must be the number of THIS AU's references and nothing else.
|
||||
/// The old H.264 form (`refs.len().min(scope.len())`) was taken AFTER the
|
||||
/// held-slot pass appended to the same vector, so a short refs list let the decode
|
||||
/// op's reference array run past the references into unrelated held slots — a
|
||||
/// picture predicted from something the stream never named.
|
||||
#[test]
|
||||
fn the_h264_reference_count_covers_the_references_and_never_a_held_slot() {
|
||||
// Two references (slots 1, 3); slots 5 and 6 are held but NOT referenced.
|
||||
let refs = vec![h264_ref(1, 10), h264_ref(3, 20)];
|
||||
let slot_refs = vec![Some(h264_std_ref(77)); 8];
|
||||
let (scope, reference_count) = crate::decoder_h265::build_scope(
|
||||
&refs,
|
||||
[1u8, 3, 5, 6].into_iter(),
|
||||
0,
|
||||
fake_view(0),
|
||||
h264_std_ref(30),
|
||||
&slot_refs,
|
||||
|slot| Some(fake_view(slot)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(reference_count, 2, "exactly this AU's references");
|
||||
assert_eq!(
|
||||
scope[..reference_count]
|
||||
.iter()
|
||||
.map(|e| e.slot_index)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 3],
|
||||
"the decode op's reference prefix is the references, in order"
|
||||
);
|
||||
// The rest of the scope keeps the other slots bound (so their associations
|
||||
// survive) and ends on the setup activation entry — but none of that is a
|
||||
// reference of this AU.
|
||||
assert_eq!(
|
||||
scope.iter().map(|e| e.slot_index).collect::<Vec<_>>(),
|
||||
vec![1, 3, 5, 6, -1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settle_dpb_readies_outputs_in_order_and_returns_never_output_removals() {
|
||||
let mut pending: BTreeMap<PicId, u32> = BTreeMap::new();
|
||||
|
||||
@@ -1114,6 +1114,7 @@ impl VkAv1Decoder {
|
||||
is_idr: plan.picture.is_key,
|
||||
recovery: crate::recovery::RecoveryMark::NONE,
|
||||
decode_order,
|
||||
references_clean: plan.picture.references_clean,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -139,13 +139,20 @@ struct SessionStateH265 {
|
||||
///
|
||||
/// This decoder FAILS CLOSED, and that stays: when an AU cannot be carried
|
||||
/// through to a submitted decode, it returns an error rather than substituting a
|
||||
/// reference or decoding against a slot whose image is gone. H.264's
|
||||
/// soft-degrade (trace the missing binding, drop that reference, decode anyway)
|
||||
/// is not available here because `StdVideoDecodeH265PictureInfo`'s
|
||||
/// reference or decoding against a slot whose image is gone. The structural argument
|
||||
/// is that `StdVideoDecodeH265PictureInfo`'s
|
||||
/// `RefPicSetStCurrBefore`/`StCurrAfter`/`LtCurr` arrays hold INDICES into the
|
||||
/// decode op's reference array — dropping one entry re-points every later index
|
||||
/// at the wrong picture, which is the corruption-hiding class this crate refuses.
|
||||
///
|
||||
/// ⚠ H.264 used to soft-degrade here (trace the missing binding, drop that reference,
|
||||
/// decode anyway) on the grounds that it carries no such index arrays. It now fails
|
||||
/// closed and carries this same latch: the arrays were never the point, the OUTPUT
|
||||
/// was. A P-picture decoded against a reference that was never bound is a gray plate
|
||||
/// with motion painted over it, and because the planner raises no warning for it, that
|
||||
/// frame reached the screen and cleared the consumer's demotion streak. Both codecs
|
||||
/// now fail closed, and both recover through this latch rather than wedging.
|
||||
///
|
||||
/// But failing closed once must not wedge the stream FOREVER, and without this
|
||||
/// latch it did: by the time an AU reaches a failure exit, `plan_to_vk_h265` has
|
||||
/// already mutated the [`SlotMap`] (releases + the setup assignment) and the
|
||||
@@ -636,6 +643,7 @@ impl VkH265Decoder {
|
||||
is_idr: plan.picture.is_idr,
|
||||
recovery,
|
||||
decode_order,
|
||||
references_clean: plan.picture.references_clean,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1247,10 +1255,15 @@ fn profile_key_for(plan: &AuPlan) -> Result<H265ProfileKey, VkDecodeError> {
|
||||
/// let [`build_scope`] bind a slot the planner no longer knows about, which is the
|
||||
/// same "plausible-looking picture in the wrong place" the unbound-reference
|
||||
/// refusal exists to prevent.
|
||||
fn reset_slot_bindings(
|
||||
///
|
||||
/// Generic over the cached reference-info type so H.264's recovery uses this exact
|
||||
/// code rather than a twin: the three ledgers and the "empty them together" rule are
|
||||
/// codec-independent (`SlotMap` is already shared), and only the `StdVideo*` type in
|
||||
/// `slot_refs` differs.
|
||||
pub(crate) fn reset_slot_bindings<S>(
|
||||
slots: &mut SlotMap,
|
||||
slot_image: &mut [Option<usize>],
|
||||
slot_refs: &mut [Option<hh::StdVideoDecodeH265ReferenceInfo>],
|
||||
slot_refs: &mut [Option<S>],
|
||||
) -> Vec<usize> {
|
||||
// `release` is the only way a slot is freed (SlotMap docs); the collect is
|
||||
// because `held` borrows the map the releases mutate.
|
||||
@@ -1279,10 +1292,55 @@ fn slot_view(state: &SessionStateH265, slot: u8) -> Option<vk::ImageView> {
|
||||
/// (No derived equality: `StdVideoDecodeH265ReferenceInfo` is a plain-C bindgen
|
||||
/// struct without it. Assertions compare the fields that carry meaning.)
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ScopeEntry {
|
||||
slot_index: i32,
|
||||
view: vk::ImageView,
|
||||
std: hh::StdVideoDecodeH265ReferenceInfo,
|
||||
pub(crate) struct ScopeEntry<S> {
|
||||
pub(crate) slot_index: i32,
|
||||
pub(crate) view: vk::ImageView,
|
||||
pub(crate) std: S,
|
||||
}
|
||||
|
||||
/// One of this AU's references, as [`build_scope`] needs to see it: a DPB slot and
|
||||
/// the codec reference info to bind with it.
|
||||
///
|
||||
/// It exists so H.264 and H.265 share ONE scope builder instead of two hand-copies of
|
||||
/// a function whose whole job is refusing to guess — the property most in need of a
|
||||
/// single implementation. Their `VkRef`/`VkRefH265` differ only in the `StdVideo*`
|
||||
/// type they carry, so the shape generalises exactly.
|
||||
///
|
||||
/// ⚠ AV1 deliberately keeps its own ([`crate::decoder_av1`]'s `build_scope_av1`): its
|
||||
/// reference array is indexed by reference NAME and may hold HOLES, so its walk is a
|
||||
/// different algorithm rather than the same one over a different Std type. Folding it
|
||||
/// in here would mean a builder with a mode flag, which is how the two would drift.
|
||||
pub(crate) trait ScopeRef {
|
||||
/// The codec's `StdVideoDecode*ReferenceInfo`.
|
||||
type Std: Copy;
|
||||
/// The DPB slot this reference is bound in.
|
||||
fn slot(&self) -> u8;
|
||||
fn std(&self) -> Self::Std;
|
||||
}
|
||||
|
||||
/// [`build_scope`]'s answer: the bound-slot list, and how many of its LEADING entries
|
||||
/// are this AU's own references (the prefix the decode op takes as its reference
|
||||
/// array — see the ordering note in `build_scope`'s docs).
|
||||
pub(crate) type Scope<R> = (Vec<ScopeEntry<<R as ScopeRef>::Std>>, usize);
|
||||
|
||||
impl ScopeRef for crate::pic_h265::VkRefH265 {
|
||||
type Std = hh::StdVideoDecodeH265ReferenceInfo;
|
||||
fn slot(&self) -> u8 {
|
||||
self.slot
|
||||
}
|
||||
fn std(&self) -> Self::Std {
|
||||
self.std
|
||||
}
|
||||
}
|
||||
|
||||
impl ScopeRef for crate::pic::VkRef {
|
||||
type Std = ash::vk::native::StdVideoDecodeH264ReferenceInfo;
|
||||
fn slot(&self) -> u8 {
|
||||
self.slot
|
||||
}
|
||||
fn std(&self) -> Self::Std {
|
||||
self.std
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the coding scope's bound-slot list and say how many leading entries are
|
||||
@@ -1295,36 +1353,51 @@ struct ScopeEntry {
|
||||
/// resources must stay bound even when this AU does not reference them);
|
||||
/// 3. the setup slot as the activation entry, slot index `-1`.
|
||||
///
|
||||
/// A reference whose slot binds no image is a hard error, never a skip:
|
||||
/// `StdVideoDecodeH265PictureInfo`'s `RefPicSetStCurrBefore`/`StCurrAfter`/
|
||||
/// `LtCurr` arrays name DPB slots, and every slot they name is one of `refs`'
|
||||
/// ([`crate::pic_h265`]) — so dropping an entry leaves the hardware with a named
|
||||
/// slot this op never bound, which it can only answer by guessing or failing.
|
||||
/// Output that looks plausible and is wrong is the outcome this refusal exists to
|
||||
/// prevent.
|
||||
fn build_scope(
|
||||
refs: &[crate::pic_h265::VkRefH265],
|
||||
/// A reference whose slot binds no image is a hard error, never a skip. For H.265 the
|
||||
/// argument is `StdVideoDecodeH265PictureInfo`'s `RefPicSetStCurrBefore`/`StCurrAfter`/
|
||||
/// `LtCurr` arrays: they name DPB slots, every slot they name is one of `refs`'
|
||||
/// ([`crate::pic_h265`]), so dropping an entry leaves the hardware with a named slot
|
||||
/// this op never bound — which it can only answer by guessing or failing.
|
||||
///
|
||||
/// H.264 has no such index arrays, and it used to skip the case with a `trace!` on
|
||||
/// exactly that reasoning. The reasoning was wrong about the OUTPUT: the hardware
|
||||
/// still decodes a P-picture against a reference that was never bound, which on the
|
||||
/// DPB-and-output-COINCIDE path is a gray plate with the new frame's motion painted
|
||||
/// over it — and because the planner raised no warning (its DPB genuinely holds the
|
||||
/// picture; the breakage is in this ledger), the frame was shipped, presented, and
|
||||
/// cleared the consumer's demotion streak on its way past. Both codecs fail closed
|
||||
/// here now; the recovery latch is what keeps failing closed from wedging the stream.
|
||||
///
|
||||
/// `reference_count` is captured the instant the `refs` loop ends, BEFORE the
|
||||
/// held-slot pass appends anything. That ordering is load-bearing: the decode op takes
|
||||
/// `scope[..reference_count]` as its reference list, so a count computed after the
|
||||
/// second pass could hand it a still-held slot that this AU does not reference, in
|
||||
/// place of one that failed to resolve. (Fail-closed above makes that unreachable —
|
||||
/// but the construction must be correct on its own, not by depending on a check
|
||||
/// somewhere else.)
|
||||
pub(crate) fn build_scope<R: ScopeRef>(
|
||||
refs: &[R],
|
||||
held_slots: impl Iterator<Item = u8>,
|
||||
setup_slot: u8,
|
||||
setup_view: vk::ImageView,
|
||||
setup_ref: hh::StdVideoDecodeH265ReferenceInfo,
|
||||
slot_refs: &[Option<hh::StdVideoDecodeH265ReferenceInfo>],
|
||||
setup_ref: R::Std,
|
||||
slot_refs: &[Option<R::Std>],
|
||||
view_of: impl Fn(u8) -> Option<vk::ImageView>,
|
||||
) -> Result<(Vec<ScopeEntry>, usize), VkDecodeError> {
|
||||
let mut scope: Vec<ScopeEntry> = Vec::with_capacity(refs.len() + slot_refs.len() + 1);
|
||||
) -> Result<Scope<R>, VkDecodeError> {
|
||||
let mut scope: Vec<ScopeEntry<R::Std>> = Vec::with_capacity(refs.len() + slot_refs.len() + 1);
|
||||
for r in refs {
|
||||
match view_of(r.slot) {
|
||||
match view_of(r.slot()) {
|
||||
Some(view) => scope.push(ScopeEntry {
|
||||
slot_index: i32::from(r.slot),
|
||||
slot_index: i32::from(r.slot()),
|
||||
view,
|
||||
std: r.std,
|
||||
std: r.std(),
|
||||
}),
|
||||
None => return Err(VkDecodeError::UnboundReferenceSlot { slot: r.slot }),
|
||||
None => return Err(VkDecodeError::UnboundReferenceSlot { slot: r.slot() }),
|
||||
}
|
||||
}
|
||||
let reference_count = scope.len();
|
||||
for slot in held_slots {
|
||||
if slot == setup_slot || refs.iter().any(|r| r.slot == slot) {
|
||||
if slot == setup_slot || refs.iter().any(|r| r.slot() == slot) {
|
||||
continue;
|
||||
}
|
||||
match (
|
||||
@@ -1862,8 +1935,11 @@ mod tests {
|
||||
let setup_slot = slots.assign(400).unwrap();
|
||||
assert_eq!(setup_slot, 0, "the freed slots are assignable again");
|
||||
slot_image[usize::from(setup_slot)] = Some(9);
|
||||
// The empty slice needs its element type named now that `build_scope` is
|
||||
// generic over the two codecs' reference types.
|
||||
let no_refs: [VkRefH265; 0] = [];
|
||||
let (scope, reference_count) = build_scope(
|
||||
&[],
|
||||
&no_refs,
|
||||
slots.held().map(|(slot, _id)| slot),
|
||||
setup_slot,
|
||||
fake_view(setup_slot),
|
||||
|
||||
@@ -38,19 +38,20 @@ use crate::{Av1PlanWarning, H265PlanWarning, PlanWarning};
|
||||
/// property of the STREAM's signalling, which the decoder answers by failing to open
|
||||
/// a session, not by showing a damaged frame.
|
||||
///
|
||||
/// Written as an EXHAUSTIVE match with no wildcard, deliberately. A `matches!` (or
|
||||
/// a `_ => false`) makes "damage" the opt-in and silence the default, so a
|
||||
/// `PlanWarning` added later — by definition one nobody here has classified —
|
||||
/// would be reported as clean and its picture shown. Invisible damage is the bug
|
||||
/// this whole program exists to end; the compiler is the only reviewer guaranteed
|
||||
/// to be present when that variant is written, so it gets the decision.
|
||||
/// ⚠ The classification itself now lives on the warning enum, in pf-bitstream
|
||||
/// ([`PlanWarning::is_integrity`]), and this function delegates. It moved there when
|
||||
/// the planners gained the per-picture clean bit
|
||||
/// ([`pf_bitstream::h264::PicturePlan::references_clean`]): that ledger has to mark a
|
||||
/// picture damaged on exactly the warnings a consumer conceals on, and it lives one
|
||||
/// crate DOWN from here. A copy of the list in each crate would let the two disagree —
|
||||
/// the planner recording a picture as clean while the client concealed it, or the
|
||||
/// reverse — which is the same invisible-damage failure the single-list rule below was
|
||||
/// written to prevent, one layer lower. One list, in the crate that owns the enum.
|
||||
///
|
||||
/// This function stays as the crate's public spelling of the question (the fault
|
||||
/// harness, the client and the tests all name it) and keeps its exact semantics.
|
||||
pub fn is_integrity_warning(w: &PlanWarning) -> bool {
|
||||
match w {
|
||||
PlanWarning::FrameNumGap { .. }
|
||||
| PlanWarning::MissingReference { .. }
|
||||
| PlanWarning::TruncatedAu { .. } => true,
|
||||
PlanWarning::Mmco5Rebase | PlanWarning::LevelDerivedDpb { .. } => false,
|
||||
}
|
||||
w.is_integrity()
|
||||
}
|
||||
|
||||
/// The H.265 twin — the same set pf-bitstream's own `h265` conformance harness
|
||||
@@ -59,10 +60,7 @@ pub fn is_integrity_warning(w: &PlanWarning) -> bool {
|
||||
/// Exhaustive for the same reason as [`is_integrity_warning`]: a new H.265 warning
|
||||
/// must not be able to mean "damaged" and read as clean.
|
||||
pub fn is_integrity_warning_h265(w: &H265PlanWarning) -> bool {
|
||||
match w {
|
||||
H265PlanWarning::MissingReference { .. } | H265PlanWarning::TruncatedAu { .. } => true,
|
||||
H265PlanWarning::NonZeroReorder { .. } => false,
|
||||
}
|
||||
w.is_integrity()
|
||||
}
|
||||
|
||||
/// The AV1 twin (M7). Every variant the AV1 planner has today IS damage, and that
|
||||
@@ -93,11 +91,7 @@ pub fn is_integrity_warning_h265(w: &H265PlanWarning) -> bool {
|
||||
/// Exhaustive for the same reason as [`is_integrity_warning`]: a new AV1 warning
|
||||
/// must not be able to mean "damaged" and read as clean.
|
||||
pub fn is_integrity_warning_av1(w: &Av1PlanWarning) -> bool {
|
||||
match w {
|
||||
Av1PlanWarning::MissingReference { .. }
|
||||
| Av1PlanWarning::MissingShowExisting { .. }
|
||||
| Av1PlanWarning::TruncatedAu { .. } => true,
|
||||
}
|
||||
w.is_integrity()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -899,6 +899,9 @@ mod tests {
|
||||
max_dpb_frames,
|
||||
short_term_ref_pic_set_size_bits: 0,
|
||||
recovery_point: None,
|
||||
// These fixtures model a healthy stream; the clean bit is the planner's
|
||||
// observation and nothing in this conversion layer reads it.
|
||||
references_clean: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5167,6 +5167,15 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_arm_expecting_drops(
|
||||
/// `USER_FLAG_RECOVERY_POINT`. Pass `decoder_keyframe = false` where the platform decoder doesn't flag
|
||||
/// IDRs (VideoToolbox/MediaCodec) — the wire `FLAG_SOF` covers it.
|
||||
///
|
||||
/// This is the uncorroborated entry point and deliberately stays that way. Rust embedders whose
|
||||
/// decoder parses the bitstream call [`ReanchorGate::on_decoded_corroborated`] to let their own
|
||||
/// parser refute a `USER_FLAG_RECOVERY_ANCHOR` that names a picture they had to conceal; every
|
||||
/// client reachable through THIS surface (Apple VideoToolbox, Android MediaCodec) uses a platform
|
||||
/// decoder that surfaces no such fact, so it would have nothing to pass but
|
||||
/// `AnchorEvidence::Unavailable` — which is exactly what this wrapper already means. Growing a
|
||||
/// second export for a corroboration no C caller can supply would spend an ABI version bump on
|
||||
/// dead surface.
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` is a valid gate handle; `out_present` is writable or NULL.
|
||||
#[unsafe(no_mangle)]
|
||||
|
||||
@@ -20,6 +20,30 @@
|
||||
//! VideoToolbox, every FFmpeg rung, which exposes no SEI) simply never calls it and every wire
|
||||
//! behaviour above is bit-for-bit unchanged.
|
||||
//!
|
||||
//! # The one claim a client can REFUTE
|
||||
//!
|
||||
//! Of the three lifts, two are self-evident to the client and one is pure hearsay. An IDR predicts
|
||||
//! from nothing, so "this re-anchors decode" is a property of the picture itself. A recovery mark is
|
||||
//! only *half* a re-anchor and the gate says so by requiring two. But
|
||||
//! [`USER_FLAG_RECOVERY_ANCHOR`] is the HOST asserting a fact about the CLIENT's decoder — *the
|
||||
//! picture I coded this P-frame against is one you still hold, intact* — and until
|
||||
//! [`AnchorEvidence`] existed the client took it on faith, on the first occurrence, with no
|
||||
//! scrutiny at all.
|
||||
//!
|
||||
//! When that assertion is wrong the failure is the worst-shaped one in this module: the anchor lifts
|
||||
//! the freeze onto a picture predicted from a reference the client had to conceal, so the gray plate
|
||||
//! reaches the screen AND the gate stops holding, which means it keeps reaching the screen until
|
||||
//! some later signal re-arms. A re-anchor claim the client can refute is therefore worse than no
|
||||
//! claim at all — no claim merely holds the last good frame until the backstop.
|
||||
//!
|
||||
//! So a client whose decoder parses the bitstream corroborates it: it already knows which pictures
|
||||
//! this AU predicts from and whether each of those decoded from a complete reference chain, and
|
||||
//! [`on_decoded_corroborated`](ReanchorGate::on_decoded_corroborated) refuses an anchor whose
|
||||
//! references it can prove were damaged. Refusing can only ever make the gate hold LONGER — the
|
||||
//! freeze stays up, the backstop fires on its ORIGINAL deadline, and the client escalates to a real
|
||||
//! IDR — which is the direction every other rule here errs in, deliberately. Lanes that cannot
|
||||
//! answer pass [`AnchorEvidence::Unavailable`] and behave exactly as they always have.
|
||||
//!
|
||||
//! [`USER_FLAG_RECOVERY_POINT`]: crate::packet::USER_FLAG_RECOVERY_POINT
|
||||
//! [`USER_FLAG_RECOVERY_ANCHOR`]: crate::packet::USER_FLAG_RECOVERY_ANCHOR
|
||||
|
||||
@@ -97,8 +121,9 @@ pub fn index_gap(expected: u32, got: u32) -> Option<u32> {
|
||||
/// Fold one decoded frame into the re-anchor state and decide whether it lifts the post-loss freeze.
|
||||
///
|
||||
/// `is_keyframe` — a real IDR (always a clean re-anchor). `has_anchor` — this AU carried
|
||||
/// [`USER_FLAG_RECOVERY_ANCHOR`](crate::packet::USER_FLAG_RECOVERY_ANCHOR), the host's definitive
|
||||
/// single-frame re-anchor from an LTR-RFI recovery (a clean P-frame coded against a known-good
|
||||
/// [`USER_FLAG_RECOVERY_ANCHOR`](crate::packet::USER_FLAG_RECOVERY_ANCHOR) **and the caller did not
|
||||
/// refute it** ([`AnchorEvidence`]), the host's definitive single-frame re-anchor from an LTR-RFI
|
||||
/// recovery (a clean P-frame coded against a known-good
|
||||
/// reference), so it lifts on the FIRST occurrence exactly like an IDR — no two-mark wait. `has_mark` —
|
||||
/// this AU carried [`USER_FLAG_RECOVERY_POINT`](crate::packet::USER_FLAG_RECOVERY_POINT), a
|
||||
/// host-signalled intra-refresh wave boundary (only *half* a re-anchor). `marks` — recovery marks seen
|
||||
@@ -158,6 +183,44 @@ impl LocalRecovery {
|
||||
};
|
||||
}
|
||||
|
||||
/// What a client's OWN parser can say about the host's re-anchor claim on one decoded frame — the
|
||||
/// corroboration for [`USER_FLAG_RECOVERY_ANCHOR`](crate::packet::USER_FLAG_RECOVERY_ANCHOR).
|
||||
///
|
||||
/// An anchor is the host asserting something about the CLIENT's decoder: *this P-frame is coded
|
||||
/// against a picture you still hold, intact, so decoding it re-anchors you*. The host derives that
|
||||
/// from its own slot bookkeeping — which tracks whether the client RECEIVED a frame, not whether it
|
||||
/// DECODED that frame from a complete reference chain. Those two differ exactly when the client had
|
||||
/// to conceal, and the gap between them is what puts a gray plate on screen with the freeze lifted.
|
||||
///
|
||||
/// Three states rather than a bool, for the same reason [`LocalRecovery`] is two facts: a lane that
|
||||
/// *cannot* answer must be able to say so instead of being folded into "nothing wrong here". Only
|
||||
/// [`Self::ReferencesDamaged`] changes any behaviour; the other two are indistinguishable to the
|
||||
/// gate and differ only in what they claim at the call site.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AnchorEvidence {
|
||||
/// This lane has no local bitstream parser, so it cannot corroborate or refute anything — the
|
||||
/// host's claim stands, exactly as it always has. Android MediaCodec, Apple VideoToolbox and
|
||||
/// every lane reached over the C ABI pass this, and their behaviour is bit-for-bit unchanged.
|
||||
#[default]
|
||||
Unavailable,
|
||||
/// Corroborated: every picture this AU predicts from was itself decoded from a fully-available
|
||||
/// reference chain, so the host's claim is consistent with what this decoder actually holds.
|
||||
ReferencesClean,
|
||||
/// Refuted: this AU predicts from a picture that needed concealment. Whatever the host believes,
|
||||
/// decoding this frame cannot re-anchor a decoder whose reference for it is already damaged, so
|
||||
/// the anchor does not lift the freeze.
|
||||
ReferencesDamaged,
|
||||
}
|
||||
|
||||
impl AnchorEvidence {
|
||||
/// May an [`USER_FLAG_RECOVERY_ANCHOR`](crate::packet::USER_FLAG_RECOVERY_ANCHOR) on this frame
|
||||
/// be honoured? Only an outright refutation withholds it — silence is not refutation, so a lane
|
||||
/// that cannot corroborate never becomes *stricter* than it was.
|
||||
fn honours_anchor(self) -> bool {
|
||||
!matches!(self, AnchorEvidence::ReferencesDamaged)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a decoded frame should be shown or withheld while the gate is (or isn't) frozen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GateVerdict {
|
||||
@@ -333,6 +396,11 @@ impl ReanchorGate {
|
||||
/// A decoded frame always clears the no-output streak. When frozen, a live mark stream pushes the
|
||||
/// backstop out ([`RECOVERY_MARK_PATIENCE`]) so a healing wave isn't pre-empted by a mid-heal IDR.
|
||||
///
|
||||
/// This is the whole-hearsay entry point: it believes an anchor on sight. A client whose decoder
|
||||
/// parses the bitstream should call
|
||||
/// [`on_decoded_corroborated`](Self::on_decoded_corroborated) instead and let its own parser
|
||||
/// check the host's claim.
|
||||
///
|
||||
/// [`USER_FLAG_RECOVERY_ANCHOR`]: crate::packet::USER_FLAG_RECOVERY_ANCHOR
|
||||
/// [`USER_FLAG_RECOVERY_POINT`]: crate::packet::USER_FLAG_RECOVERY_POINT
|
||||
pub fn on_decoded(
|
||||
@@ -340,10 +408,49 @@ impl ReanchorGate {
|
||||
wire_flags: u32,
|
||||
decoder_keyframe: bool,
|
||||
now: Instant,
|
||||
) -> GateVerdict {
|
||||
self.on_decoded_corroborated(
|
||||
wire_flags,
|
||||
decoder_keyframe,
|
||||
AnchorEvidence::Unavailable,
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
/// [`on_decoded`](Self::on_decoded) for a client that can CHECK the host's re-anchor claim
|
||||
/// against its own decoder — the native-decode lanes, which parse every AU themselves and so
|
||||
/// know both which pictures this one predicts from and whether each of those decoded cleanly.
|
||||
///
|
||||
/// `evidence` is consulted for exactly one thing: whether a
|
||||
/// [`USER_FLAG_RECOVERY_ANCHOR`](crate::packet::USER_FLAG_RECOVERY_ANCHOR) on THIS frame may
|
||||
/// lift the freeze. [`AnchorEvidence::ReferencesDamaged`] withholds that lift and nothing else,
|
||||
/// and the two exclusions are as deliberate as the rule itself:
|
||||
///
|
||||
/// * **A real IDR still lifts.** It predicts from nothing, so no evidence about its references
|
||||
/// can bear on it — and the IDR is precisely the escalation a refused anchor is trying to
|
||||
/// provoke. Refusing it would turn the fix into the permanent freeze it exists to avoid.
|
||||
/// * **The two-mark [`USER_FLAG_RECOVERY_POINT`](crate::packet::USER_FLAG_RECOVERY_POINT) rule
|
||||
/// is untouched**, including its [`RECOVERY_MARK_PATIENCE`] deadline push. An intra-refresh
|
||||
/// wave heals by overwriting stripes rather than by predicting from one named picture, so
|
||||
/// "this frame's references were damaged" says nothing about whether the wave completed.
|
||||
///
|
||||
/// A refused anchor also leaves the backstop deadline exactly where the arm put it. That is the
|
||||
/// point rather than an omission: the freeze becomes overdue on its ORIGINAL schedule, [`poll`](Self::poll)
|
||||
/// re-asks, and the client escalates to a real IDR — the recovery the host's anchor failed to
|
||||
/// deliver. Pushing the deadline out on a refusal would reward a host whose anchors do not work
|
||||
/// with a longer wait.
|
||||
pub fn on_decoded_corroborated(
|
||||
&mut self,
|
||||
wire_flags: u32,
|
||||
decoder_keyframe: bool,
|
||||
evidence: AnchorEvidence,
|
||||
now: Instant,
|
||||
) -> GateVerdict {
|
||||
self.no_output_streak = 0;
|
||||
let is_keyframe = decoder_keyframe || (wire_flags & FLAG_SOF as u32 != 0);
|
||||
let has_anchor = wire_flags & USER_FLAG_RECOVERY_ANCHOR != 0;
|
||||
// An anchor the client's own parser refutes is not an anchor. Folded in HERE rather than
|
||||
// inside `reanchor_after_frame` so that function stays a pure statement of the wire rules.
|
||||
let has_anchor = wire_flags & USER_FLAG_RECOVERY_ANCHOR != 0 && evidence.honours_anchor();
|
||||
let has_mark = wire_flags & USER_FLAG_RECOVERY_POINT != 0;
|
||||
if has_mark && self.awaiting {
|
||||
self.deadline = Some(now + RECOVERY_MARK_PATIENCE);
|
||||
@@ -888,4 +995,188 @@ mod tests {
|
||||
assert!(!g.poll(0, t + Duration::from_millis(1)));
|
||||
assert!(g.is_holding());
|
||||
}
|
||||
|
||||
// ---- the corroborated-anchor path (AnchorEvidence) ----
|
||||
|
||||
use AnchorEvidence::{ReferencesClean, ReferencesDamaged, Unavailable};
|
||||
|
||||
/// The headline. The host says "this P-frame re-anchors you"; the client's own parser says the
|
||||
/// picture it predicts from is one IT had to conceal. Both cannot be true, and the client's
|
||||
/// statement is about its OWN decoder — so the anchor does not lift and the gray plate the
|
||||
/// anchor would have presented never reaches the screen.
|
||||
#[test]
|
||||
fn an_anchor_whose_references_the_decoder_concealed_does_not_lift() {
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let now = t0();
|
||||
g.arm(now);
|
||||
assert_eq!(
|
||||
g.on_decoded_corroborated(ANCHOR, false, ReferencesDamaged, now),
|
||||
GateVerdict::Hold,
|
||||
"a refuted anchor is not a re-anchor"
|
||||
);
|
||||
assert!(g.is_holding(), "and the freeze stays up");
|
||||
// Repeating it changes nothing — a host that keeps sending anchors it cannot honour never
|
||||
// talks its way past the gate.
|
||||
assert_eq!(
|
||||
g.on_decoded_corroborated(ANCHOR, false, ReferencesDamaged, now),
|
||||
GateVerdict::Hold
|
||||
);
|
||||
assert!(g.is_holding());
|
||||
}
|
||||
|
||||
/// The escalation a refusal exists to provoke must still work. An IDR predicts from nothing, so
|
||||
/// no evidence about damaged references can bear on it — refusing it too would convert this fix
|
||||
/// into the permanent freeze it is meant to avoid.
|
||||
#[test]
|
||||
fn a_real_idr_lifts_even_while_the_evidence_refutes_anchors() {
|
||||
// The decoder's own keyframe flag...
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let now = t0();
|
||||
g.arm(now);
|
||||
assert_eq!(
|
||||
g.on_decoded_corroborated(ANCHOR, false, ReferencesDamaged, now),
|
||||
GateVerdict::Hold
|
||||
);
|
||||
assert_eq!(
|
||||
g.on_decoded_corroborated(0, true, ReferencesDamaged, now),
|
||||
GateVerdict::Present,
|
||||
"the IDR re-anchors regardless of what the anchor evidence says"
|
||||
);
|
||||
assert!(!g.is_holding());
|
||||
|
||||
// ...and the wire's FLAG_SOF, for the lanes whose decoder does not flag IDRs.
|
||||
let mut g = ReanchorGate::new(0);
|
||||
g.arm(now);
|
||||
assert_eq!(
|
||||
g.on_decoded_corroborated(SOF, false, ReferencesDamaged, now),
|
||||
GateVerdict::Present
|
||||
);
|
||||
assert!(!g.is_holding());
|
||||
}
|
||||
|
||||
/// A corroborated anchor is still an anchor: the whole point is to refuse the ones the client
|
||||
/// can disprove, not to stop honouring the mechanism.
|
||||
#[test]
|
||||
fn a_corroborated_anchor_lifts_on_the_first_occurrence() {
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let now = t0();
|
||||
g.arm(now);
|
||||
assert_eq!(
|
||||
g.on_decoded_corroborated(0, false, ReferencesClean, now),
|
||||
GateVerdict::Hold,
|
||||
"an ordinary frame is still withheld"
|
||||
);
|
||||
assert_eq!(
|
||||
g.on_decoded_corroborated(ANCHOR, false, ReferencesClean, now),
|
||||
GateVerdict::Present
|
||||
);
|
||||
assert!(!g.is_holding());
|
||||
}
|
||||
|
||||
/// `Unavailable` is the promise made to every lane without a local parser: silence is not
|
||||
/// refutation. This walks the same sequences the wire-path tests above assert and requires the
|
||||
/// identical verdicts through the corroborated entry point.
|
||||
#[test]
|
||||
fn an_uncorroborated_lane_behaves_exactly_as_it_always_has() {
|
||||
// The anchor lift, byte for byte the `a_gap_lifts_on_the_first_rfi_anchor` contract.
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let now = t0();
|
||||
g.arm(now);
|
||||
assert_eq!(
|
||||
g.on_decoded_corroborated(0, false, Unavailable, now),
|
||||
GateVerdict::Hold
|
||||
);
|
||||
assert_eq!(
|
||||
g.on_decoded_corroborated(ANCHOR, false, Unavailable, now),
|
||||
GateVerdict::Present
|
||||
);
|
||||
assert!(!g.is_holding());
|
||||
|
||||
// And `on_decoded` — which every such lane actually calls — must agree with it exactly.
|
||||
let mut wire = ReanchorGate::new(0);
|
||||
let mut corroborated = ReanchorGate::new(0);
|
||||
wire.arm(now);
|
||||
corroborated.arm(now);
|
||||
for flags in [0, POINT, 0, ANCHOR, SOF, 0] {
|
||||
assert_eq!(
|
||||
wire.on_decoded(flags, false, now),
|
||||
corroborated.on_decoded_corroborated(flags, false, Unavailable, now),
|
||||
"flags {flags:#x} diverged between the two entry points"
|
||||
);
|
||||
assert_eq!(wire.is_holding(), corroborated.is_holding());
|
||||
}
|
||||
}
|
||||
|
||||
/// A refused anchor must not buy the host time. The freeze becomes overdue on the deadline the
|
||||
/// ARM set — not one pushed out by the refusal — so the client escalates to the real IDR that
|
||||
/// the failed anchor did not deliver.
|
||||
#[test]
|
||||
fn a_refused_anchor_leaves_the_backstop_on_its_original_deadline() {
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let start = t0();
|
||||
g.arm(start);
|
||||
// Anchors keep arriving and keep being refused, right up to the deadline.
|
||||
for ms in [10, 100, 300, 490] {
|
||||
assert_eq!(
|
||||
g.on_decoded_corroborated(
|
||||
ANCHOR,
|
||||
false,
|
||||
ReferencesDamaged,
|
||||
start + Duration::from_millis(ms)
|
||||
),
|
||||
GateVerdict::Hold
|
||||
);
|
||||
assert!(!g.poll(0, start + Duration::from_millis(ms)), "not yet due");
|
||||
}
|
||||
let overdue = start + REANCHOR_FREEZE_MAX + Duration::from_millis(1);
|
||||
assert!(
|
||||
g.poll(0, overdue),
|
||||
"the backstop fires on the arm's own deadline — the refusals did not extend it"
|
||||
);
|
||||
assert!(
|
||||
g.is_holding(),
|
||||
"and it keeps holding, never resuming to gray"
|
||||
);
|
||||
}
|
||||
|
||||
/// Refuting an anchor says nothing about an intra-refresh wave: a wave heals by overwriting
|
||||
/// stripes rather than by predicting from one named picture, so the two-mark rule and its
|
||||
/// patience deadline must be untouched by the evidence.
|
||||
#[test]
|
||||
fn refuted_anchors_do_not_disturb_the_two_mark_rule() {
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let now = t0();
|
||||
g.arm(now);
|
||||
assert_eq!(
|
||||
g.on_decoded_corroborated(POINT, false, ReferencesDamaged, now),
|
||||
GateVerdict::Hold,
|
||||
"mark #1 is still only half a re-anchor"
|
||||
);
|
||||
// An anchor in between is refused and must not consume or reset the mark count.
|
||||
assert_eq!(
|
||||
g.on_decoded_corroborated(ANCHOR, false, ReferencesDamaged, now),
|
||||
GateVerdict::Hold
|
||||
);
|
||||
assert_eq!(
|
||||
g.on_decoded_corroborated(POINT, false, ReferencesDamaged, now),
|
||||
GateVerdict::Present,
|
||||
"mark #2 lifts exactly as it does on the wire path"
|
||||
);
|
||||
assert!(!g.is_holding());
|
||||
}
|
||||
|
||||
/// The evidence is consulted only while an anchor flag is actually present — a refutation on an
|
||||
/// ordinary frame must not become a second, sticky reason to hold.
|
||||
#[test]
|
||||
fn damaged_evidence_alone_neither_holds_nor_arms_an_unfrozen_gate() {
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let now = t0();
|
||||
assert_eq!(
|
||||
g.on_decoded_corroborated(0, false, ReferencesDamaged, now),
|
||||
GateVerdict::Present,
|
||||
"an unfrozen gate presents; the evidence is about anchors, not about frames"
|
||||
);
|
||||
assert!(!g.is_holding());
|
||||
assert!(!g.poll(0, now));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -231,11 +231,13 @@ pub fn dualsense_test(args: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mint one pad-audio PipeWire sink (the Linux 0xD1 source, `audio::pad_sink`) and capture
|
||||
/// from it — the WP3 on-glass gate with no client involved. Verify the identity with
|
||||
/// `pactl list sinks` (name/description/proplist) and drive it with
|
||||
/// `pw-play --target <node.name> <file>` (or `paplay -d <node.name>`); captured chunks print
|
||||
/// a per-second summary here. `--pad N` (default 0), `--edge`, `--seconds N` (default 30).
|
||||
/// Mint one pad's audio node graph (the Linux 0xD1 source, `audio::pad_sink`) and capture the
|
||||
/// mix — the WP3 on-glass gate with no client involved. Three nodes appear, mirroring the split a
|
||||
/// physically connected DualSense presents: a mono `Speaker__sink`, a positioned-quad
|
||||
/// `SpeakerHaptic__sink`, and the hidden AUX parent. Verify the identity with `pactl list sinks`
|
||||
/// (name/description/proplist) and drive any of them with `pw-play --target <node.name>`;
|
||||
/// captured chunks print a per-second summary here. `--pad N` (default 0), `--edge`,
|
||||
/// `--seconds N` (default 30).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn pad_sink_test(args: &[String]) -> Result<()> {
|
||||
use crate::audio::AudioCapturer as _;
|
||||
@@ -256,18 +258,26 @@ pub fn pad_sink_test(args: &[String]) -> Result<()> {
|
||||
let mut cap = crate::audio::pad_sink::PadSinkCapturer::open(pad, edge)
|
||||
.context("mint pad-audio sink (is PipeWire running in this session?)")?;
|
||||
println!(
|
||||
"pad sink minted: node.name = {}\n api.alsa.split.name = {} (what GE-Proton opens as \
|
||||
pipewire:NODE=…)\n inspect: pactl list sinks | grep -A25 Speaker__sink\n \
|
||||
drive it: pw-play --target '{}' --channel-map 'AUX0,AUX1,AUX2,AUX3' <48k-file>\n \
|
||||
(a POSITIONED wav folds into the speaker pair and never reaches the coils — the \
|
||||
channel-map is not optional)\nCapturing for {secs}s…",
|
||||
"pad nodes minted (the split a real DualSense presents):\n \
|
||||
speaker sink = {} (mono — GE-Proton's is_dualsense_speaker_sink target)\n \
|
||||
haptic sink = {} (4ch POSITIONED FL,FR,RL,RR — the public quad a real pad shows)\n \
|
||||
parent = {} (4ch AUX0..AUX3, hidden — what GE opens as pipewire:NODE=…)\n \
|
||||
inspect: pactl list sinks | grep -A25 Speaker\n \
|
||||
drive the coils via the POSITIONED sink (what a real pad's writers use):\n \
|
||||
pw-play --target '{}' --channel-map 'front-left,front-right,rear-left,rear-right' <48k-file>\n \
|
||||
drive the coils via the AUX parent (GE's own leg):\n \
|
||||
pw-play --target '{}' --channel-map 'AUX0,AUX1,AUX2,AUX3' <48k-file>\n \
|
||||
(a POSITIONED wav aimed at the AUX PARENT still folds into the speaker pair — that is \
|
||||
why the positioned sink exists)\nCapturing for {secs}s…",
|
||||
cap.node_name,
|
||||
cap.haptic_name,
|
||||
if cap.split_name.is_empty() {
|
||||
"(suppressed)"
|
||||
} else {
|
||||
cap.split_name.as_str()
|
||||
},
|
||||
cap.node_name
|
||||
cap.haptic_name,
|
||||
cap.split_name,
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
let (mut chunks, mut samples) = (0u64, 0u64);
|
||||
|
||||
@@ -1398,6 +1398,10 @@ async fn serve_session(
|
||||
let live_bitrate = Arc::new(AtomicU32::new(welcome.bitrate_kbps));
|
||||
let encoder_ceiling_kbps = Arc::new(AtomicU32::new(0));
|
||||
let cadence_degraded = Arc::new(AtomicBool::new(false));
|
||||
// The live behind-cadence score behind that flag, so the climb-refusal log line carries its
|
||||
// evidence (a refusal without the score left a 23-minute floor-pinned field session with no
|
||||
// trace of why).
|
||||
let cadence_behind_score = Arc::new(AtomicU32::new(0));
|
||||
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
|
||||
let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
|
||||
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
|
||||
@@ -1520,6 +1524,7 @@ async fn serve_session(
|
||||
live_bitrate.clone(),
|
||||
encoder_ceiling_kbps.clone(),
|
||||
cadence_degraded.clone(),
|
||||
cadence_behind_score.clone(),
|
||||
fec_target_ctl,
|
||||
phase_ctl_control,
|
||||
reconfig_tx,
|
||||
@@ -2103,6 +2108,7 @@ async fn serve_session(
|
||||
live_bitrate,
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
cadence_behind_score,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
chroma,
|
||||
|
||||
@@ -29,6 +29,7 @@ pub(super) async fn run(
|
||||
live_bitrate: Arc<AtomicU32>,
|
||||
encoder_ceiling_kbps: Arc<AtomicU32>,
|
||||
cadence_degraded: Arc<AtomicBool>,
|
||||
cadence_behind_score: Arc<AtomicU32>,
|
||||
fec_target_ctl: Arc<AtomicU8>,
|
||||
// Phase-locked capture bridge: client PhaseReports land here latest-wins; the encode loop's
|
||||
// controller drains at its own ~1 Hz cadence (design/phase-locked-capture.md).
|
||||
@@ -221,6 +222,10 @@ pub(super) async fn run(
|
||||
tracing::info!(
|
||||
requested_kbps = req.bitrate_kbps,
|
||||
held_kbps = live,
|
||||
// The refusal's evidence: without it a field log shows WHAT was
|
||||
// held but never WHY, and a session at the ABR floor is
|
||||
// indistinguishable from a network problem.
|
||||
behind_score = cadence_behind_score.load(Ordering::Relaxed),
|
||||
"bitrate climb refused — encode is behind cadence"
|
||||
);
|
||||
r = live;
|
||||
|
||||
@@ -1314,6 +1314,11 @@ pub(super) struct SessionContext {
|
||||
/// session escalated): while set, the control task refuses bitrate CLIMBS — the network
|
||||
/// isn't the bottleneck, feeding the encoder more bits deepens the miss.
|
||||
pub(super) cadence_degraded: Arc<AtomicBool>,
|
||||
/// The live behind-cadence leaky-bucket score, exported so the control task's climb-refusal
|
||||
/// log line can say WHY (a field session sat at the ABR floor for 23 minutes with no trace
|
||||
/// of what held it there — the score is the missing discriminator between "the detector's
|
||||
/// budget is wrong" and "this encoder genuinely can't hold cadence").
|
||||
pub(super) cadence_behind_score: Arc<AtomicU32>,
|
||||
/// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from
|
||||
/// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of
|
||||
/// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it
|
||||
@@ -1588,6 +1593,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
live_bitrate,
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
cadence_behind_score,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
|
||||
@@ -2286,6 +2292,26 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
let mut cur_depth: usize = 1;
|
||||
let mut behind_score: u32 = 0;
|
||||
let mut depth_frames: u64 = 0;
|
||||
// Observed source-delivery period (EMA over REAL frames' arrival spacing, ns) — the budget
|
||||
// the behind test scores encode work against. The negotiated refresh alone is the wrong
|
||||
// deadline when the game delivers slower than the mode: a field session negotiated at
|
||||
// 120 Hz whose game ran 53–74 fps scored every frame against 8.33 ms while its real budget
|
||||
// was ~2× that, so `behind_score` latched, climbs were refused, and the session sat at the
|
||||
// ABR floor for 23 minutes with the encoder comfortably keeping up with every frame that
|
||||
// actually existed. Repeats are excluded (a keepalive re-encode says nothing about the
|
||||
// game's delivery rate); [`cadence_budget`] clamps to [interval, 4×interval] so a source
|
||||
// faster than the mode keeps today's exact deadline and a hitchy/idle one can't disarm
|
||||
// the detector entirely.
|
||||
let mut src_period_ns: Option<u64> = None;
|
||||
let mut last_real_cap: Option<std::time::Instant> = None;
|
||||
// Transition edge + rate limit for the cadence_degraded log lines: around the latch
|
||||
// threshold the score can cross ±1 every other frame, and 60 lines/s in a field log is
|
||||
// worse than none. One line per direction per 5 s window; flips swallowed by the limiter
|
||||
// are counted so an oscillation is still visible in the next line.
|
||||
let mut was_degraded = false;
|
||||
let mut last_cadence_log: Option<std::time::Instant> = None;
|
||||
let mut cadence_flips_suppressed: u32 = 0;
|
||||
const CADENCE_LOG_MIN_GAP: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
// Second escalation stage (§7 LN3): once depth is maxed (or was never available — Linux),
|
||||
// ask the encoder for pipelined retrieve exactly once. Latched whether it accepts or not.
|
||||
let mut pipeline_asked = false;
|
||||
@@ -2867,15 +2893,46 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
let rfi_echo = last_rfi.is_some_and(|t| t.elapsed() < RFI_ECHO_WINDOW)
|
||||
&& rfi_echo_swallowed < RFI_ECHO_MAX_SWALLOWED;
|
||||
if idr_recent {
|
||||
tracing::debug!("keyframe request coalesced — within the IDR cooldown");
|
||||
// Coalesced, and the client is STILL reporting damage — so whatever the in-flight
|
||||
// IDR will repair, it has not repaired yet, and until it lands no reference in the
|
||||
// table can honestly be called known-good to this client. Withdraw anchor trust for
|
||||
// the duration: without it, a frame-index gap arriving inside this window is
|
||||
// answered with an RFI anchor picked over exactly that unrepaired damage, and the
|
||||
// anchor lifts the client's post-loss freeze on its first occurrence — grey frames,
|
||||
// presented, freeze lifted. The cost is bounded to nothing that matters: the IDR
|
||||
// this branch is waiting on rebuilds trust from scratch when it lands (it flushes
|
||||
// the DPB), and prediction never used the wire domain in the first place.
|
||||
enc.distrust_references();
|
||||
tracing::debug!(
|
||||
"keyframe request coalesced — within the IDR cooldown; RFI anchor trust \
|
||||
withdrawn until the IDR repairs the client"
|
||||
);
|
||||
} else if rfi_echo {
|
||||
// Deliberately NO distrust here, and it is the one branch where that would be
|
||||
// wrong. This branch's whole premise is that the request is the client's ECHO of
|
||||
// the loss the RFI just repaired — the recovery frame is still in flight. Withdraw
|
||||
// trust on the first echo and every successful RFI recovery poisons the table for
|
||||
// the next one, so RFI could never fire twice running and a sustained-loss session
|
||||
// falls straight back to the IDR path this block exists to keep it off. The premise
|
||||
// is a guess, and `RFI_ECHO_MAX_SWALLOWED` is already its hedge: when the client
|
||||
// keeps asking past the budget the guess was wrong, and the `else` arm below both
|
||||
// serves the IDR and withdraws trust then — on evidence rather than on suspicion.
|
||||
rfi_echo_swallowed += 1;
|
||||
tracing::debug!(
|
||||
swallowed = rfi_echo_swallowed,
|
||||
"keyframe request coalesced — echo of an RFI-recovered loss"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!("forcing keyframe (client decode recovery)");
|
||||
// Did we get here THROUGH exhausted echo-swallowing? Then this episode's RFI
|
||||
// anchor demonstrably did not heal the client: we presumed its requests were
|
||||
// echoes, swallowed them, and it kept asking anyway. Serving the IDR (below) fixes
|
||||
// this loss; withdrawing trust is what stops the same un-healing reference being
|
||||
// picked as the anchor for the NEXT one. Read before the reset that follows.
|
||||
let rfi_unhealed = rfi_echo_swallowed > 0;
|
||||
tracing::debug!(rfi_unhealed, "forcing keyframe (client decode recovery)");
|
||||
if rfi_unhealed {
|
||||
enc.distrust_references();
|
||||
}
|
||||
enc.request_keyframe();
|
||||
last_forced_idr = Some(now);
|
||||
rfi_echo_swallowed = 0; // the IDR resets the episode — echoes of IT coalesce via the cooldown
|
||||
@@ -2931,6 +2988,20 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
Ok(Some(f)) => {
|
||||
frame = f;
|
||||
diag_new += 1;
|
||||
// Source-cadence estimate (see the declaration above): `t_cap` on the
|
||||
// frame-driven path is taken right after `wait_arrival` wakes, so real-frame
|
||||
// deltas track the game's actual delivery spacing. Deltas past 8×interval are
|
||||
// a gap/hitch (mid-rebuild, alt-tab), not cadence — skipped, not averaged in.
|
||||
if let Some(prev) = last_real_cap {
|
||||
let d = t_cap.duration_since(prev).as_nanos() as u64;
|
||||
if d <= interval.as_nanos() as u64 * 8 {
|
||||
src_period_ns = Some(match src_period_ns {
|
||||
Some(e) => (e as i64 + (d as i64 - e as i64) / 8) as u64,
|
||||
None => d,
|
||||
});
|
||||
}
|
||||
}
|
||||
last_real_cap = Some(t_cap);
|
||||
// Phase-locked capture: hold the fresh frame so its ARRIVAL at the client lands a
|
||||
// constant small lead before the client's display latch (§3 hold-then-submit; the
|
||||
// capture slot is newest-wins, so a long hold samples fresher content next tick,
|
||||
@@ -3825,7 +3896,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
if idd_adaptive_enabled() {
|
||||
depth_frames += 1;
|
||||
if depth_frames > DEPTH_WARMUP_FRAMES {
|
||||
let behind = std::time::Instant::now() >= next;
|
||||
// The deadline is `next` (post-submit + negotiated interval) stretched by how
|
||||
// much slower the source actually delivers: encode work only has to beat the
|
||||
// NEXT REAL FRAME's arrival, not a refresh the game never reaches. For a
|
||||
// full-rate source the budget equals the interval and this is bit-for-bit the
|
||||
// old test.
|
||||
let budget = cadence_budget(interval, src_period_ns);
|
||||
let behind = std::time::Instant::now() >= next + (budget - interval);
|
||||
behind_score = if behind {
|
||||
(behind_score + 1).min(DEPTH_BEHIND_CAP)
|
||||
} else {
|
||||
@@ -3847,10 +3924,44 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// Escalating exists precisely so cadence CAN be held; once it is (bucket
|
||||
// drained, every frame on time), refusing climbs is refusing the thing that
|
||||
// worked.
|
||||
cadence_degraded.store(
|
||||
encode_behind_cadence(escalated, behind_score, DEPTH_DEGRADE),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
let degraded = encode_behind_cadence(escalated, behind_score, DEPTH_DEGRADE);
|
||||
cadence_degraded.store(degraded, Ordering::Relaxed);
|
||||
cadence_behind_score.store(behind_score, Ordering::Relaxed);
|
||||
// Log every transition (rate-limited, see the state above): a degraded stretch
|
||||
// refuses every ABR climb, so a session can sit at the bitrate floor for its
|
||||
// whole life — that MUST leave a trace saying why, with the numbers needed to
|
||||
// tell "the budget was wrong" from "this encoder genuinely can't keep up".
|
||||
if degraded != was_degraded {
|
||||
let now = std::time::Instant::now();
|
||||
if last_cadence_log.is_none_or(|t| now.duration_since(t) >= CADENCE_LOG_MIN_GAP)
|
||||
{
|
||||
let budget = cadence_budget(interval, src_period_ns);
|
||||
if degraded {
|
||||
tracing::info!(
|
||||
behind_score,
|
||||
escalated,
|
||||
budget_us = budget.as_micros() as u64,
|
||||
interval_us = interval.as_micros() as u64,
|
||||
src_period_us =
|
||||
src_period_ns.map(|p| p / 1_000).unwrap_or_default(),
|
||||
flips_suppressed = cadence_flips_suppressed,
|
||||
"encode behind cadence — ABR climbs will be refused until it \
|
||||
recovers"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
behind_score,
|
||||
flips_suppressed = cadence_flips_suppressed,
|
||||
"encode cadence recovered — ABR climbs allowed again"
|
||||
);
|
||||
}
|
||||
last_cadence_log = Some(now);
|
||||
cadence_flips_suppressed = 0;
|
||||
} else {
|
||||
cadence_flips_suppressed += 1;
|
||||
}
|
||||
was_degraded = degraded;
|
||||
}
|
||||
if deescalating {
|
||||
// A requested wind-back completes at the encoder's drained safe point —
|
||||
// poll it (the call is a cheap latch check until then).
|
||||
@@ -4551,6 +4662,24 @@ fn encode_behind_cadence(escalated: bool, behind_score: u32, degrade_at: u32) ->
|
||||
behind_score >= degrade_at || (escalated && behind_score > 0)
|
||||
}
|
||||
|
||||
/// The behind-cadence budget for one frame: how long its work may run before the frame counts as
|
||||
/// "behind". This is the OBSERVED source-delivery period, not the negotiated refresh — encode
|
||||
/// only has to finish before the next frame that actually exists, and a game delivering 60 fps
|
||||
/// on a 120 Hz mode gives every frame twice the interval's budget. Clamped below to the
|
||||
/// negotiated interval (a source faster than the mode is paced down to it, so the interval IS
|
||||
/// its delivery period) and above to 4× (a hitchy or near-idle source must not disarm the
|
||||
/// detector — past 4× the mode is so mismatched that the wider budget is moot anyway).
|
||||
/// No estimate yet (startup, an all-repeat stretch) keeps the plain interval.
|
||||
fn cadence_budget(
|
||||
interval: std::time::Duration,
|
||||
src_period_ns: Option<u64>,
|
||||
) -> std::time::Duration {
|
||||
match src_period_ns {
|
||||
Some(p) => std::time::Duration::from_nanos(p).clamp(interval, interval * 4),
|
||||
None => interval,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adopt the rate a freshly built pipeline's encoder was actually opened at.
|
||||
///
|
||||
/// The session's own `bitrate_kbps` is the number every later decision reads — the ABR controller's
|
||||
@@ -4867,6 +4996,34 @@ mod tests {
|
||||
assert!(!encode_behind_cadence(true, 0, DEGRADE));
|
||||
}
|
||||
|
||||
/// The 2026-08-15 field session: negotiated 2560×1440@120 (interval 8333 µs) while the game
|
||||
/// delivered 53–74 fps (observed period 13.5–18.9 ms). Scoring encode work against the bare
|
||||
/// interval marked a keeping-up encoder "behind" on most frames, latched `cadence_degraded`,
|
||||
/// and held the session at the 5000 kbps ABR floor for 23 minutes. The budget must be the
|
||||
/// observed delivery period — bounded so the two failure edges (an overdriven source, a
|
||||
/// hitching source) keep the detector honest.
|
||||
#[test]
|
||||
fn the_behind_budget_tracks_the_source_not_the_negotiated_refresh() {
|
||||
let interval = std::time::Duration::from_micros(8333); // 120 Hz mode
|
||||
let us = |d: std::time::Duration| d.as_micros() as u64;
|
||||
|
||||
// No estimate yet (startup / all-repeat stretch): the plain interval, i.e. the old test.
|
||||
assert_eq!(cadence_budget(interval, None), interval);
|
||||
// The field case: a ~60 fps source on the 120 Hz mode gets its real ~2× budget.
|
||||
assert_eq!(
|
||||
us(cadence_budget(interval, Some(16_600_000))),
|
||||
16_600,
|
||||
"a 60 fps source's frames have 16.6 ms of real budget"
|
||||
);
|
||||
// A source at (or paced to) the negotiated rate: unchanged from today.
|
||||
assert_eq!(us(cadence_budget(interval, Some(8_333_000))), 8_333);
|
||||
// An overdriven source can never SHRINK the budget below the interval — pacing floors
|
||||
// delivery at the negotiated rate, so a smaller estimate is measurement noise.
|
||||
assert_eq!(cadence_budget(interval, Some(4_000_000)), interval);
|
||||
// A hitchy/near-idle source is clamped at 4× — the detector must not be disarmed.
|
||||
assert_eq!(cadence_budget(interval, Some(500_000_000)), interval * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopting_a_rebuilt_rate_tells_the_client() {
|
||||
let live = Arc::new(AtomicU32::new(20_000));
|
||||
|
||||
@@ -224,6 +224,20 @@ regular pad). Automatic arms it only where the raw guide press can't reach the h
|
||||
Gaming Mode, iPhone/iPad, Apple TV — because the gesture has a cost: a Select *tap* arrives a beat
|
||||
late, and a game that expects a *held* Select would trigger it. Set **On** or **Off** to overrule.
|
||||
|
||||
**Controller haptics** — *default: on*, and **Controller speaker** — *default: on* on the Linux and
|
||||
Windows apps, *off* on Android. The two halves of [controller audio](/docs/controller-audio): a
|
||||
DualSense's voice-coil haptics, and the little speaker in the middle of the pad. Both need a
|
||||
**wired** DualSense or DualSense Edge — over Bluetooth a controller exposes no audio device at all,
|
||||
and both settings quietly do nothing. Neither costs anything without a host that sends them: the
|
||||
plane is negotiated, and silence is never encoded or transmitted, so leaving haptics on is free even
|
||||
on a pad that never gets any. Turn **Controller speaker** off if you would rather all game audio came
|
||||
out of your speakers or headset.
|
||||
|
||||
Offered by the Linux, Windows and Android apps. On Linux, the client also switches the controller's
|
||||
sound card to Pro Audio while it needs the voice coils, and puts it back afterwards — see
|
||||
[the controller-audio page](/docs/controller-audio#on-a-linux-client-the-pads-own-profile-matters-too)
|
||||
for why that is necessary and how to turn it off.
|
||||
|
||||
**Capture system shortcuts** — *default: on.* Offered by the Linux, Windows and macOS apps and the
|
||||
console home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
|
||||
matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming
|
||||
|
||||
@@ -269,6 +269,8 @@ A few knobs are read by the native **clients**, not the host:
|
||||
| `PUNKTFUNK_DECODER` | `native-vulkan` · `native-vaapi` (Linux) · `native-d3d11va` (Windows) · `software` | Force the decode path. Default auto-selects hardware per GPU vendor and falls back on its own: **Linux** — Vulkan Video first on NVIDIA and AMD, VAAPI first on Intel and anything else; **Windows** — Vulkan Video first on NVIDIA and AMD, D3D11VA first on Intel and anything else. Whichever isn't first is the next thing tried, with software last (OpenH264 for H.264, rav1d for AV1 — there is no software HEVC, so a client that lands there reconnects on a codec it can decode). The names are the ones the [stats overlay](/docs/stats) prints, so a pin and a reading match. The older spellings `vulkan`, `vaapi` and `d3d11va` named the FFmpeg-backed decoders the clients used before and still work — each migrates onto the native path for the same hardware, and the client says so in its log. |
|
||||
| `PUNKTFUNK_VAAPI_DEVICE` | path, e.g. `/dev/dri/renderD129` | **(Linux)** Pin the DRM render node the `native-vaapi` decoder opens. Unset, the client tries the nodes in order and takes the first that can decode the stream — set this on a multi-GPU box when it lands on the wrong one. |
|
||||
| `PUNKTFUNK_PREFER_PYROWAVE` | `1` | Ask for the [PyroWave](/docs/pyrowave) wavelet codec on a wired link, where the client's own setting isn't reachable (the gamepad console, a headless launch). |
|
||||
| `PUNKTFUNK_PAD_SPEAKER_PATH` · `PUNKTFUNK_PAD_SPEAKER_VOLUME` | byte, hex or decimal *(default `0x20` / `0x7F`)* | Which output a DualSense sends [controller audio](/docs/controller-audio) to, and how loud. A controller's channel 1 is shared between its headphone jack and its built-in speaker, and it powers up pointing at the jack — so with no headphones plugged in the speaker stays silent however correctly the audio is routed. Punktfunk points it at the speaker when controller-speaker is on. Change these only if your pad's speaker stays quiet; a game that sets its own audio levels still overrides them. |
|
||||
| `PUNKTFUNK_PAD_AUDIO_PROFILE` | `0` | **(Linux)** Stop the client from switching a wired DualSense's sound card to **Pro Audio** while it streams [controller audio](/docs/controller-audio) to it. The switch exists because a controller's voice coils are channels 3 and 4 of its sound card, and a controller almost never presents four channels on its own — on any other profile the haptics are folded into the speaker pair and felt as nothing. Punktfunk restores the card's profile when the session ends and never saves it. Set this if you'd rather select the card's profile yourself. |
|
||||
| `PUNKTFUNK_OSD_SCALE` | multiplier, e.g. `1.5` *(default `1`)* | Size of the in-stream overlay — the stats OSD, the capture hint and the start banner. They already follow your display's scaling setting (200 % display → twice the pixels), so set this only to nudge that: bigger for a TV across the room, smaller if your compositor reports an aggressive scale. Clamped to 0.5×–4×, and a line that would run off the screen is shrunk to fit. |
|
||||
| `PUNKTFUNK_NO_AEC` | `1` | Turn the microphone's echo cancellation off for this run, whatever **Echo cancellation** says in [client settings](/docs/client-settings#audio). One-way: it can only switch the processing off, never back on, and the setting is the normal way to control it. Linux and Windows clients. |
|
||||
| `PUNKTFUNK_PRESENT_MODE` | `mailbox` *(default)* · `fifo` · `immediate` · `fifo_relaxed` | How decoded frames meet the display (the Vulkan present mode). The default prefers MAILBOX — tear-free without queueing behind the vertical refresh — and falls back to FIFO (classic vsync) where the driver doesn't offer it. **AMD's Windows driver offers no MAILBOX**, so those clients run FIFO, which adds a standing frame-pacing wait (up to one refresh interval). `immediate` removes that wait but can tear; `fifo_relaxed` only tears when a frame is late. If your latency floor matters more than tearing, try `immediate` and judge by eye. |
|
||||
|
||||
@@ -17,8 +17,9 @@ pad's speaker, channels 3–4 are the voice coils.
|
||||
- **A DualSense or DualSense Edge plugged in over USB** on the client. Bluetooth pads expose no
|
||||
audio interface at all, so they fall back to ordinary rumble — this is a limit of the
|
||||
controller, not of Punktfunk.
|
||||
- On the client, **Controller haptics** is on by default. **Controller speaker** is opt-in: turn
|
||||
it on if you want game audio coming out of the pad as well as your speakers.
|
||||
- On the client, **Controller haptics** is on by default. So is **Controller speaker** on the Linux
|
||||
and Windows apps — turn it off in [client settings](/docs/client-settings#input) if you would
|
||||
rather all game audio came out of your speakers. On Android the speaker is opt-in.
|
||||
- On a **Linux host**, a game that speaks DualSense — which in practice means running it under
|
||||
**GE-Proton 11-5 or newer**. Stock Proton does not route controller audio.
|
||||
- On the host, controller audio is on by default (`PUNKTFUNK_PAD_AUDIO`).
|
||||
@@ -109,6 +110,65 @@ PROTON_DUALSENSE_SPLIT_AUDIO=1 %command%
|
||||
To see which route GE took, launch the game with `WINEDEBUG=+pulse` and look for a line beginning
|
||||
`Routing DualSense`. It names the device it chose and how it opened it.
|
||||
|
||||
## On a Linux client, the pad's own profile matters too
|
||||
|
||||
Everything above is about the host, where the controller-audio device is one Punktfunk mints. On a
|
||||
Linux **client** the pad is real, and the same channel-layout problem shows up from the other side:
|
||||
the voice coils are physically channels 3 and 4 of the controller's USB sound card, and a
|
||||
controller almost never presents as a four-channel device on its own. Depending on your distribution
|
||||
it appears as a stereo output, or as a mono *Speaker* plus a stereo *Headphones* pair. Playing into
|
||||
any of those puts the haptics in the headphone jack and folds the coil channels away — audio that
|
||||
looks perfectly healthy, felt as nothing at all.
|
||||
|
||||
**Punktfunk handles this for you.** When it needs the coils and the pad is not already presenting
|
||||
four channels, it switches the controller's card to **Pro Audio** for the length of the session and
|
||||
puts your setting back afterwards. You will see the profile change in your sound settings while you
|
||||
are streaming; that is expected. It is never saved as the card's remembered profile.
|
||||
|
||||
If you would rather manage the card yourself, set `PUNKTFUNK_PAD_AUDIO_PROFILE=0` on the client. Then
|
||||
Punktfunk uses a four-channel profile if you have already selected one and logs what it needs if you
|
||||
have not.
|
||||
|
||||
Most systems never reach the switch at all. Where your distribution ships a recent `alsa-ucm-conf` —
|
||||
Bazzite and SteamOS among them — a DualSense already exposes its four channels behind its split
|
||||
speaker and headphone outputs, and Punktfunk finds them there. The switch is the fallback for
|
||||
systems that only offer the older stereo profile. **If you run the client as a Flatpak**, your audio
|
||||
manager may not let a sandboxed app change a card's profile; if the log says so, switch the
|
||||
controller to Pro Audio yourself, which is the same fix.
|
||||
|
||||
### Checking the client side without a host
|
||||
|
||||
The client can test the whole path on its own — no host, no game, no pairing. Plug in the
|
||||
DualSense and run:
|
||||
|
||||
```sh
|
||||
punktfunk-session --pad-audio-test
|
||||
```
|
||||
|
||||
It prints every DualSense object it can see in your audio graph, says which one it chose, and then
|
||||
plays a tone into the voice coils for three seconds. **If the pad buzzes, the client side is
|
||||
working** and any remaining silence is coming from the host or the game. Add `--speaker` to test
|
||||
the pad's speaker instead, and `--seconds N` for a longer run.
|
||||
|
||||
On the Steam Deck and other flatpak installs, run it inside the sandbox:
|
||||
|
||||
```sh
|
||||
flatpak run --command=punktfunk-session io.unom.Punktfunk --pad-audio-test
|
||||
```
|
||||
|
||||
### Why the speaker needs more than routing
|
||||
|
||||
The controller's speaker and its headphone jack **share a channel**. Channel 1 of the pad's audio
|
||||
device is the headphone jack's right channel *and* the built-in speaker, and the controller decides
|
||||
which one actually sounds. It powers up pointing at the jack — so with nothing plugged in, a
|
||||
perfectly routed speaker stream is heard by nobody.
|
||||
|
||||
Punktfunk points the pad at its own speaker when **Controller speaker** is on. The voice coils are
|
||||
different channels and are not affected by that choice, which is why haptics work as soon as the
|
||||
audio is routed correctly and the speaker needs this extra step. A game that drives the pad's audio
|
||||
settings itself still overrides it. If your pad's speaker stays quiet, `PUNKTFUNK_PAD_SPEAKER_PATH`
|
||||
and `PUNKTFUNK_PAD_SPEAKER_VOLUME` let you bisect it without a rebuild.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **Bluetooth client pads get rumble, not haptics.** No audio interface exists over BT.
|
||||
@@ -119,3 +179,11 @@ To see which route GE took, launch the game with `WINEDEBUG=+pulse` and look for
|
||||
- **A pad plugged into the host itself can steal the audio.** If a real DualSense is connected to
|
||||
the host while you are streaming to a different one, some titles will find the local pad's sound
|
||||
card first. Unplug it, or stream from a host that has no pad attached.
|
||||
- **The Pro Audio switch on a Linux client renames the pad's microphone too.** Switching a sound
|
||||
card's profile re-creates all of its inputs and outputs, so if you had picked the DualSense's own
|
||||
microphone as your [mic](/docs/client-settings#audio), that session falls back to your default
|
||||
one. Pick a different microphone, or set `PUNKTFUNK_PAD_AUDIO_PROFILE=0` and select a
|
||||
four-channel profile on the card yourself.
|
||||
- **A client killed mid-stream leaves the pad on Pro Audio.** The profile is restored when a
|
||||
session ends normally and is never written to your saved settings, so anything that reloads the
|
||||
card — unplugging it, logging out, a reboot — brings your own profile back.
|
||||
|
||||
@@ -304,6 +304,36 @@ were holding down is released on the host, so nothing sticks. The rest of the in
|
||||
switch mouse mode, disconnect, fullscreen — are in
|
||||
[Getting your input back](/docs/input#getting-your-input-back).
|
||||
|
||||
## My keyboard types the wrong characters (`#` comes out as `\`)
|
||||
|
||||
A German keyboard giving `\` for `#`, `'` for `ä` and `/` for `-`, or `z` and `y` swapped, is a
|
||||
**host** layout mismatch — the client is fine.
|
||||
|
||||
Punktfunk sends the *physical key you pressed*, not the character, exactly as a keyboard plugged
|
||||
into the host would. What that key finally types is decided by the layout the **host session** is
|
||||
running, so the host has to be set to the same layout as the keyboard you're typing on. When it
|
||||
isn't, every key whose position differs between the two layouts comes out as its neighbour.
|
||||
|
||||
On Linux, set the layout the normal way and reconnect:
|
||||
|
||||
```sh
|
||||
sudo localectl set-x11-keymap de pc105 nodeadkeys # your layout, model, variant
|
||||
```
|
||||
|
||||
Punktfunk reads that setting and hands it to the session on the next connect. Two things are worth
|
||||
knowing:
|
||||
|
||||
- **Wayland desktops don't read it by themselves.** `localectl` writes a file only Xorg opens, so
|
||||
before this release a correctly-configured box could still run a US session. If your compositor
|
||||
is already set to the right layout in its own settings, nothing changes.
|
||||
- **Game Mode needs a current `punktfunk-gamescope`.** Gamescope publishes no keyboard layout at
|
||||
all to the apps it runs, so Steam and games saw US whatever the box was set to. Our build fixes
|
||||
that from `+pfhdr8` on — check with `punktfunk-gamescope --version`, and
|
||||
[update](/docs/updating) if it's older.
|
||||
|
||||
Nothing here changes which physical key does what in a game: `WASD` stays under the same fingers on
|
||||
every layout.
|
||||
|
||||
## A controller is detected but games don't see it
|
||||
|
||||
- **Linux.** The host user needs to be in the `input` group. On Bazzite:
|
||||
@@ -350,6 +380,46 @@ It is not only the pad, though: the same group authorizes the helper that stops
|
||||
for a managed **Gaming Mode** takeover, so on a box that autologins into Game Mode, skipping it also
|
||||
costs you [the takeover](#game-mode-black-screen-on-connect-or-the-stream-is-stuck-at-the-boxs-resolution).
|
||||
|
||||
## A Steam Controller 2 is captured, but Steam's controller list stays empty
|
||||
|
||||
The client says everything is fine — the Controllers screen shows **Steam Controller 2, captured,
|
||||
streams as-is** — and on the host Steam's **Settings → Controller → Connected Controllers** has
|
||||
nothing in it. Buttons do nothing in games, and the trackpads don't move the pointer.
|
||||
|
||||
Unlike every other pad Punktfunk presents, the Steam Controller 2 has exactly one consumer:
|
||||
**Steam**. No kernel driver claims its product id — mainline `hid-steam` stops at the Deck — and its
|
||||
state reports ride a vendor collection, so the pad produces no evdev node for anything else to read.
|
||||
If Steam can't open its `hidraw` node, you don't get a degraded controller, you get no controller.
|
||||
|
||||
The node is root-only until a udev rule says otherwise, and distro `steam-devices` rule sets are
|
||||
per-product-id: a host whose copy predates the SC2 (it shipped in 2026) never grants it. Punktfunk
|
||||
ships the rule itself from 0.30.0 on. On an older host, add it by hand:
|
||||
|
||||
```sh
|
||||
sudo tee /etc/udev/rules.d/61-punktfunk-sc2.rules >/dev/null <<'EOF'
|
||||
KERNEL=="hidraw*", KERNELS=="*28DE:1302*", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||
KERNEL=="hidraw*", KERNELS=="*28DE:1304*", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||
KERNEL=="hidraw*", ATTRS{idVendor}=="28de", ATTRS{idProduct}=="1302", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||
KERNEL=="hidraw*", ATTRS{idVendor}=="28de", ATTRS{idProduct}=="1304", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||
EOF
|
||||
sudo udevadm control --reload-rules && sudo udevadm trigger
|
||||
```
|
||||
|
||||
Then end the session and reconnect, so the pad re-enumerates under the new rule. `1302` is the wired
|
||||
controller and `1304` the Puck dongle — the two identities the host presents.
|
||||
|
||||
To confirm this is what you're hitting, look at the host log for one line and one absence: the pad
|
||||
attaching (`attached via usbip`), and **no** `answering feature GET` afterwards. That pair means the
|
||||
kernel enumerated the controller and Steam never opened it. The everything-else checks — the
|
||||
`punktfunk` group, `vhci_hcd`, the `attach` node — are in
|
||||
[the virtual Steam Deck section](#the-pad-works-but-arrives-as-an-xbox-360-controller-instead-of-a-steam-deck)
|
||||
above; if `attached via usbip` is missing from the log entirely, start there instead.
|
||||
|
||||
One more thing that is *not* a bug: with Punktfunk capturing, the trackpads stop working as a mouse
|
||||
whenever Steam isn't running. Punktfunk turns the controller's built-in mouse-and-keyboard emulation
|
||||
("lizard mode") off so it can read the full report stream, so on this pad Steam is what makes the
|
||||
trackpads a pointer — exactly as on a Steam Deck in desktop mode.
|
||||
|
||||
## Copy and paste between host and client does nothing
|
||||
|
||||
The shared clipboard needs **two** separate switches on, and turning on only one looks exactly like
|
||||
|
||||
@@ -3774,6 +3774,15 @@ void punktfunk_reanchor_gate_arm_expecting_drops(ReanchorGate *g, uint64_t expec
|
||||
// `USER_FLAG_RECOVERY_POINT`. Pass `decoder_keyframe = false` where the platform decoder doesn't flag
|
||||
// IDRs (VideoToolbox/MediaCodec) — the wire `FLAG_SOF` covers it.
|
||||
//
|
||||
// This is the uncorroborated entry point and deliberately stays that way. Rust embedders whose
|
||||
// decoder parses the bitstream call [`ReanchorGate::on_decoded_corroborated`] to let their own
|
||||
// parser refute a `USER_FLAG_RECOVERY_ANCHOR` that names a picture they had to conceal; every
|
||||
// client reachable through THIS surface (Apple VideoToolbox, Android MediaCodec) uses a platform
|
||||
// decoder that surfaces no such fact, so it would have nothing to pass but
|
||||
// `AnchorEvidence::Unavailable` — which is exactly what this wrapper already means. Growing a
|
||||
// second export for a corroboration no C caller can supply would spend an ABI version bump on
|
||||
// dead surface.
|
||||
//
|
||||
// # Safety
|
||||
// `g` is a valid gate handle; `out_present` is writable or NULL.
|
||||
PunktfunkStatus punktfunk_reanchor_gate_on_decoded(ReanchorGate *g,
|
||||
|
||||
@@ -20,6 +20,7 @@ The patches here add the missing half, and nothing else. See
|
||||
| `0007-pipewire-never-leave-pw_buffer-user_data-pointing-at.patch` | Associate `pw_buffer->user_data` with its `pipewire_buffer` for every path out of `add_buffer`, clear it in `remove_buffer` (the last point both halves are known), and null-check the consumers — killing the use-after-free that aborted the session on every capture renegotiation | **Yes** — a plain use-after-free in the PipeWire buffer lifecycle |
|
||||
| `0008-steamcompmgr-honor-GAMESCOPE_NO_FOCUS-never-a-focus-.patch` | Honor `GAMESCOPE_NO_FOCUS` (set by hhd-ui and MangoHud, consumed by nobody): such windows are skipped by both focus-candidate collectors, so a mapped-but-unpainted overlay app can no longer win focus and turn the composite black. Compositing is untouched — only focus SELECTION is barred | **Yes** — the atom's setters already exist in the wild; some compositor has to keep the promise |
|
||||
| `0009-pipewire-destroy-capture-textures-on-the-compositor-.patch` | Move capture-buffer destruction off the PipeWire thread: `remove_buffer`/stale-push queue the corpse (`bury_buffer`), steamcompmgr reaps on every vblank — including while the stream is paused, which is exactly the linger window. Without it, dropping the last `CVulkanTexture` ref on the PW thread races `vulkan_screenshot` on the same device and SIGSEGVs (NVIDIA `insertBarrier`), so a lingered display is dead and reconnect loses the session. Reported + written by luxus (punktfunk-overlay#9) | **Yes** — the race is upstream's `paint_pipewire` vs `destroy_buffer`; our patches only make the paint path heavier |
|
||||
| `0010-wlserver-give-the-seat-s-stub-keyboard-the-compiled-.patch` | Set the compiled keymap on `wlserver.wlr.virtual_keyboard_device` too. gamescope builds a keymap from `XKB_DEFAULT_*` but only puts it on `keyboard_group`, while the SEAT carries the keymap-less stub that `wlserver_keyboardfocus()` re-binds on every focus change — so clients get no keymap and fall back to their own `us`, and a headless session (no libinput devices) never recovers | **Yes** — the stub's own comment says it exists "only to set the keymap"; it just never did |
|
||||
|
||||
### Why the headless patch matters
|
||||
|
||||
@@ -103,6 +104,7 @@ The number is a **monotonic patch-set revision**, so one probe answers every cap
|
||||
| `+pfhdr5` | …and the PipeWire buffer use-after-free is fixed (no new capability) |
|
||||
| `+pfhdr6` | …and `GAMESCOPE_NO_FOCUS` windows are never focus candidates (no new capability) |
|
||||
| `+pfhdr7` | …and PipeWire teardown cannot SIGSEGV a lingering compositor (no new capability) |
|
||||
| `+pfhdr8` | …and the seat's keyboard carries the `XKB_DEFAULT_*` keymap, so the session follows the box's configured layout |
|
||||
|
||||
Bump it whenever a patch adds or changes something the host must know about before it spawns.
|
||||
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
From d958d5451d0df3678cdccbb5d7306c15b504c877 Mon Sep 17 00:00:00 2001
|
||||
From: enricobuehler <enrico.buehler@unom.io>
|
||||
Date: Sat, 15 Aug 2026 20:15:12 +0200
|
||||
Subject: [PATCH] wlserver: give the seat's stub keyboard the compiled keymap
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
gamescope reads XKB_DEFAULT_RULES/MODEL/LAYOUT/VARIANT/OPTIONS and compiles a keymap from them,
|
||||
but that keymap only ever lands on wlserver.keyboard_group. The seat carries a DIFFERENT object:
|
||||
wlserver_keyboardfocus() calls wlr_seat_set_keyboard(seat, wlserver.wlr.virtual_keyboard_device)
|
||||
on every focus change, and that stub — created a few lines above with wlr_keyboard_init(kbd,
|
||||
nullptr, "virtual") and the comment "only used to set the keymap" — never has one set.
|
||||
|
||||
With a NULL keymap wlroots advertises none, so every client falls back to its own compiled-in
|
||||
default and gamescope's session is us/pc105 whatever XKB_DEFAULT_LAYOUT says. The group does
|
||||
reach the seat, but only from wlserver_handle_key/_modifiers, i.e. from a real libinput key
|
||||
event — and the next focus change swaps the stub back in anyway. A HEADLESS session (--backend
|
||||
headless, no libinput devices at all) therefore never gets a keymap onto the seat at all.
|
||||
|
||||
Observed on a Bazzite gaming-mode host configured de/nodeadkeys via localectl: both gamescope
|
||||
Xwayland servers report evdev/pc105/us, and every ISO key types its US neighbour (# -> backslash,
|
||||
adiaeresis -> apostrophe, minus -> slash). Injected input (libei/EIS) hits exactly the same path,
|
||||
which is how a remote client with a German keyboard ends up typing US characters.
|
||||
|
||||
Set the keymap on the stub too.
|
||||
---
|
||||
src/meson.build | 3 ++-
|
||||
src/wlserver.cpp | 8 ++++++++
|
||||
2 files changed, 10 insertions(+), 1 deletion(-)
|
||||
|
||||
diff --git a/src/meson.build b/src/meson.build
|
||||
index fe854af..91bba6b 100644
|
||||
--- a/src/meson.build
|
||||
+++ b/src/meson.build
|
||||
@@ -187,7 +187,8 @@ vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()
|
||||
# +pfhdr5 — …and the PipeWire buffer use-after-free is fixed (no new capability)
|
||||
# +pfhdr6 — …and GAMESCOPE_NO_FOCUS windows are never focus candidates (no new capability)
|
||||
# +pfhdr7 — …and PipeWire teardown cannot SIGSEGV a lingering compositor (no new capability)
|
||||
-version_tag = vcs_tag + '+pfhdr7' + ' (' + compiler_name + ' ' + compiler_version + ')'
|
||||
+# +pfhdr8 — …and the seat's keyboard carries the XKB_DEFAULT_* keymap (honours the box layout)
|
||||
+version_tag = vcs_tag + '+pfhdr8' + ' (' + compiler_name + ' ' + compiler_version + ')'
|
||||
|
||||
gamescope_version_conf = configuration_data()
|
||||
gamescope_version_conf.set('VCS_TAG', version_tag)
|
||||
diff --git a/src/wlserver.cpp b/src/wlserver.cpp
|
||||
index 92f2807..3354da7 100644
|
||||
--- a/src/wlserver.cpp
|
||||
+++ b/src/wlserver.cpp
|
||||
@@ -2042,6 +2042,14 @@ bool wlserver_init( void ) {
|
||||
struct wlr_keyboard *keyboard = &wlserver.keyboard_group->keyboard;
|
||||
wlr_keyboard_set_repeat_info(keyboard, 25, 600);
|
||||
wlr_keyboard_set_keymap(keyboard, keymap);
|
||||
+ // The seat carries the STUB keyboard, not the group: wlserver_keyboardfocus() binds
|
||||
+ // wlserver.wlr.virtual_keyboard_device on every focus change, and the group only reaches the
|
||||
+ // seat from wlserver_handle_key/_modifiers — i.e. from a real libinput key event. Left with a
|
||||
+ // NULL keymap the stub makes wlroots advertise no keymap at all, so every client (Xwayland
|
||||
+ // included) keeps its own built-in "us" and XKB_DEFAULT_* is silently ignored. A headless
|
||||
+ // session has no libinput devices, so it never recovers: the very next focus change puts the
|
||||
+ // keymap-less stub back. Give the stub the keymap its own comment above says it exists for.
|
||||
+ wlr_keyboard_set_keymap(wlserver.wlr.virtual_keyboard_device, keymap);
|
||||
wlserver.keyboard_group_modifiers.notify = wlserver_handle_modifiers;
|
||||
wl_signal_add(&keyboard->events.modifiers, &wlserver.keyboard_group_modifiers);
|
||||
wlserver.keyboard_group_key.notify = wlserver_handle_key;
|
||||
--
|
||||
2.50.1 (Apple Git-155)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/plugin-kit",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
|
||||
@@ -54,21 +54,44 @@ export const steamCdnUrl = (
|
||||
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/${file}`;
|
||||
};
|
||||
|
||||
/** Filenames Steam's local `librarycache` uses per kind, in preference order (2x is sharper). */
|
||||
/**
|
||||
* Filenames Steam's local `librarycache` uses per kind, in preference order (2x is sharper).
|
||||
*
|
||||
* Two spellings per kind, because Steam renamed these assets and one cache holds both eras side by
|
||||
* side — on a 779-app cache, 594 appids carry `header.jpg` and 122 carry `library_header.jpg`, and
|
||||
* NO appid carries both. Same story for the cover: 46 appids have only `library_capsule.jpg`. The
|
||||
* renamed files are the same assets, byte-for-byte the same shapes (cover 300×450, header 460×215),
|
||||
* so which name wins is cosmetic — but knowing only one name loses the art outright.
|
||||
*
|
||||
* Missing the cover is the one that shows: the fallback is then the flat CDN URL, which 404s for
|
||||
* anything Valve has re-hashed, so the client walks on to the header and draws a BANNER in a 2:3
|
||||
* poster slot (Forza Horizon 6 / appid 2483190 is the reference case).
|
||||
*/
|
||||
const localFilenames = (kind: ArtKind): string[] =>
|
||||
kind === "portrait"
|
||||
? ["library_600x900_2x.jpg", "library_600x900.jpg"]
|
||||
? ["library_600x900_2x.jpg", "library_600x900.jpg", "library_capsule.jpg"]
|
||||
: kind === "hero"
|
||||
? ["library_hero.jpg"]
|
||||
: kind === "logo"
|
||||
? ["logo.png"]
|
||||
: // Steam's local cache names the header asset differently from the store CDN's
|
||||
// `header.jpg` — this trips everyone once.
|
||||
["library_header.jpg"];
|
||||
// `header.jpg` — this trips everyone once. Newer entries use the CDN's name, so
|
||||
// both belong here.
|
||||
["library_header.jpg", "header.jpg"];
|
||||
|
||||
/**
|
||||
* This kind's file under one Steam root's `appcache/librarycache/<appid>/<hash>/`, or `undefined`.
|
||||
* Steam reuses one hash dir per asset version, so there is normally exactly one candidate.
|
||||
* This kind's file under one Steam root's `appcache/librarycache/`, or `undefined`.
|
||||
*
|
||||
* Three layouts, all of them live in the same cache at the same time — a title's art is in exactly
|
||||
* one of them, so all three have to be checked or its cover is simply not found:
|
||||
*
|
||||
* 1. `<appid>/<hash>/<name>` — per-asset-version hash dir. Steam reuses one hash dir per version,
|
||||
* so there is normally exactly one candidate. Checked first: where a title has been re-fetched
|
||||
* into this layout, this is the copy Steam itself is displaying.
|
||||
* 2. `<appid>/<name>` — straight in the appid dir, and the MAJORITY case (623 of 779 appids on the
|
||||
* reference cache). A hash-dir-only walk misses every one of them, which stayed invisible only
|
||||
* because the flat CDN URL those titles fall back to still resolves for older appids.
|
||||
* 3. `<appid>_<name>` flat in `librarycache/` — the oldest layout.
|
||||
*/
|
||||
export const findLocalArtFile = (
|
||||
root: string,
|
||||
@@ -82,6 +105,11 @@ export const findLocalArtFile = (
|
||||
if (isFile(p)) return p;
|
||||
}
|
||||
}
|
||||
// Layout 2: no hash dir, the asset sits directly in the appid dir.
|
||||
for (const name of localFilenames(kind)) {
|
||||
const p = path.join(base, name);
|
||||
if (isFile(p)) return p;
|
||||
}
|
||||
// Older Steam wrote the files directly under `librarycache/` with the appid in the name.
|
||||
for (const name of localFilenames(kind)) {
|
||||
const flat = path.join(
|
||||
|
||||
@@ -280,6 +280,62 @@ describe("art locations", () => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("finds a cover cached under Steam's newer `library_capsule` name", () => {
|
||||
// The bug this pins: appid 2483190 (Forza Horizon 6) caches its 300×450 cover as
|
||||
// `library_capsule.jpg`, the flat CDN URL for its `library_600x900.jpg` 404s, and the client
|
||||
// therefore fell through to the header and drew a banner in a 2:3 poster slot.
|
||||
const dir = tmp("art-capsule");
|
||||
const hashDir = path.join(
|
||||
dir,
|
||||
"appcache",
|
||||
"librarycache",
|
||||
"2483190",
|
||||
"711e",
|
||||
);
|
||||
fs.mkdirSync(hashDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(hashDir, "library_capsule.jpg"), "x");
|
||||
expect(findLocalArtFile(dir, 2483190, "portrait")).toBe(
|
||||
path.join(hashDir, "library_capsule.jpg"),
|
||||
);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("finds art stored straight in the appid dir, with no hash dir", () => {
|
||||
// The majority layout — 623 of 779 appids on the reference cache. A hash-dir-only walk finds
|
||||
// none of it and silently falls back to a CDN URL that 404s for anything re-hashed.
|
||||
const dir = tmp("art-flat");
|
||||
const appDir = path.join(dir, "appcache", "librarycache", "813230");
|
||||
fs.mkdirSync(appDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(appDir, "library_600x900.jpg"), "x");
|
||||
fs.writeFileSync(path.join(appDir, "header.jpg"), "x");
|
||||
expect(findLocalArtFile(dir, 813230, "portrait")).toBe(
|
||||
path.join(appDir, "library_600x900.jpg"),
|
||||
);
|
||||
// `header.jpg` is the CDN's name, but the local cache uses it too for newer entries — the
|
||||
// two spellings are the same 460×215 asset and never appear together for one appid.
|
||||
expect(findLocalArtFile(dir, 813230, "header")).toBe(
|
||||
path.join(appDir, "header.jpg"),
|
||||
);
|
||||
expect(findLocalArtFile(dir, 813230, "hero")).toBeUndefined();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("a hash dir wins over a loose file of the same kind", () => {
|
||||
// No appid on the reference cache carries both, so this is only about which copy is the
|
||||
// current one if Steam ever leaves the old layout behind: the hash dir is what it re-fetches
|
||||
// into, so that is the copy it is itself displaying.
|
||||
const dir = tmp("art-both");
|
||||
const appDir = path.join(dir, "appcache", "librarycache", "570");
|
||||
const hashDir = path.join(appDir, "abc123");
|
||||
fs.mkdirSync(hashDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(appDir, "library_600x900.jpg"), "x");
|
||||
fs.writeFileSync(path.join(hashDir, "library_600x900.jpg"), "x");
|
||||
expect(findLocalArtFile(dir, 570, "portrait")).toBe(
|
||||
path.join(hashDir, "library_600x900.jpg"),
|
||||
);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("fileUrl produces the host's local-art contract shape", () => {
|
||||
const u = fileUrl(path.join(path.sep, "home", "u", "My Games", "c.jpg"));
|
||||
expect(u.startsWith("file:///")).toBe(true);
|
||||
|
||||
@@ -48,3 +48,16 @@ KERNEL=="hidraw*", KERNELS=="*28DE:1205*", GROUP="input", MODE="0660", TAG+="uac
|
||||
KERNEL=="hidraw*", KERNELS=="*28DE:1102*", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||
KERNEL=="hidraw*", ATTRS{idVendor}=="28de", ATTRS{idProduct}=="1205", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||
KERNEL=="hidraw*", ATTRS{idVendor}=="28de", ATTRS{idProduct}=="1102", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||
# Steam Controller 2 (Triton): wired 28DE:1302 and the Puck dongle 28DE:1304 — the two identities
|
||||
# `steam_backend_product` mints. Steam is the ONLY consumer of this pad: no kernel driver claims
|
||||
# the PID (mainline hid-steam stops at the Deck), so its state reports ride a vendor collection
|
||||
# that yields no evdev node at all. A hidraw node Steam cannot open is therefore not a degraded
|
||||
# controller, it is no controller — the pad enumerates, the kernel reads its report descriptor,
|
||||
# and Steam's own device list stays empty (field report 2026-08-15: usbip attached cleanly, the
|
||||
# log showed every kernel control transfer and not one SET_REPORT from Steam).
|
||||
# Relying on the distro's steam-devices rules is not enough for a pad that shipped in 2026: those
|
||||
# lists are per-PID and a host whose copy predates the SC2 leaves the node root-only.
|
||||
KERNEL=="hidraw*", KERNELS=="*28DE:1302*", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||
KERNEL=="hidraw*", KERNELS=="*28DE:1304*", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||
KERNEL=="hidraw*", ATTRS{idVendor}=="28de", ATTRS{idProduct}=="1302", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||
KERNEL=="hidraw*", ATTRS{idVendor}=="28de", ATTRS{idProduct}=="1304", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||
|
||||
Reference in New Issue
Block a user