feat(windows): parallel virtual displays — proto v3 ring binding, manager slot map, group topology (W0–W3)
windows-drivers / probe-and-proto (push) Successful in 41s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m7s
apple / swift (push) Successful in 1m11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
decky / build-publish (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 38s
windows-drivers / driver-build (push) Successful in 1m46s
ci / bench (push) Successful in 6m35s
docker / deploy-docs (push) Successful in 20s
windows-host / package (push) Successful in 9m37s
deb / build-publish (push) Successful in 13m48s
arch / build-publish (push) Successful in 14m21s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m34s
android / android (push) Successful in 15m45s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m20s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m18s
ci / rust (push) Successful in 18m48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m41s
release / apple (push) Successful in 19m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m49s
flatpak / build-publish (push) Failing after 2m9s
apple / screenshots (push) Successful in 19m3s
windows-drivers / probe-and-proto (push) Successful in 41s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m7s
apple / swift (push) Successful in 1m11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
decky / build-publish (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 38s
windows-drivers / driver-build (push) Successful in 1m46s
ci / bench (push) Successful in 6m35s
docker / deploy-docs (push) Successful in 20s
windows-host / package (push) Successful in 9m37s
deb / build-publish (push) Successful in 13m48s
arch / build-publish (push) Successful in 14m21s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m34s
android / android (push) Successful in 15m45s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m20s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m18s
ci / rust (push) Successful in 18m48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m41s
release / apple (push) Successful in 19m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m49s
flatpak / build-publish (push) Failing after 2m9s
apple / screenshots (push) Successful in 19m3s
design/windows-parallel-virtual-displays.md (display-management Stage 7 / §6.6): N simultaneously-live pf-vdisplay monitors, one sealed ring each, every idd-push-security invariant preserved per-ring. - proto v3: SharedHeader._pad → target_id — the ring NAMES its monitor, host-stamped before the magic; the driver publisher refuses a cross-bound ring via the shared, unit-tested frame::check_attach (new DRV_STATUS_BIND_FAIL — the gamepad pad_index validation applied to frames, invariant #10); the host's wait_for_attach surfaces the refusal loudly and self-checks its own stamp. - manager: the one-monitor MgrState becomes a slot map keyed by the client's identity slot (0 = anonymous/GameStream); per-slot reconnect + dead-WUDFHost preempts, slot-scoped begin_idd_setup (a different identity is an admission question, never a preempt), ONE device-level watchdog pinger, per-slot /display/state + /display/release. - group topology: isolate_displays_ccd takes the managed target SET (a sibling slot is never deactivated); SavedConfig + the DDC/PnP axes move to the group record (first-in captures, last-out restores); desktop layout via CCD source origins from the pure layout::arrange (auto-row default, manual pins win), re-applied on create + reconfigure. - admission: the Windows separate→reject override now sits behind the PUNKTFUNK_WIN_SEPARATE=1 validation hatch (the wedge it guarded is structurally gone — a second identity gets its own monitor + ring; default flips in W5 after soak); max_displays and NVENC session-unit budgets decline an unaffordable display AT admission; kick_dwm_compose is process-globally throttled and per-display — cursor jump + 35 ms dwell (a sub-tick jump composes nothing; DWM reads dirties from current state at the next vsync tick). On-glass on the RTX box: V1/V2/V4/V5/V6/V9 green — two paired clients on two monitors streaming ~60 fps each with zero mismatches and zero bind failures, churn-hammer clean (no 0x80070490), per-ring mode-change recreate leaves the sibling untouched, typed budget rejection, fault-injected cross-bind refused loudly with the sibling undisturbed. V7: WUDFHost-kill shared fate is clean; in-process device recovery is a known follow-up (the retired-never-closed control handles block the adapter cycle — reset-pf-vdisplay.ps1 recovers). DWM composes two IDD monitors concurrently at 60 fps — the plan's load-bearing unknown, answered yes. Also carries the client-HDR EDID forwarding that shared this working tree (Hello::display_hdr → AddRequest luminance tail → the monitor's CTA-861.3 HDR block, PUNKTFUNK_CLIENT_PEAK_NITS hatch) and the Deck client fixes (40 ms rumble keep-alive with 1-LSB jitter, HDR self-diagnosing presenter warn, flatpak HDR env). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,16 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir
|
||||
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
|
||||
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// Steam Deck built-in haptic keep-alive interval. The Deck's actuator decays inside SDL's
|
||||
/// ~2 s internal rumble resend (`SDL_RUMBLE_RESEND_MS`), and SDL short-circuits a repeated
|
||||
/// identical `set_rumble` value to a no-op device write — so a STEADY host value (which the
|
||||
/// host delivers only as unchanging 500 ms refreshes) never re-kicks the motor and is felt as
|
||||
/// a periodic pulse. We re-issue below the decay so the bursts fuse into a continuous buzz;
|
||||
/// 40 ms mirrors SDL's sibling Steam-Controller driver keep-alive. Deck-only (see
|
||||
/// [`Worker::issue_rumble`]); every other pad sustains rumble at the hardware level and is
|
||||
/// left untouched.
|
||||
const DECK_RUMBLE_KEEPALIVE_MS: u64 = 40;
|
||||
|
||||
/// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
|
||||
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
|
||||
const MENU_DEADZONE: u16 = 16384;
|
||||
@@ -641,6 +651,13 @@ struct Worker {
|
||||
menu_mode: bool,
|
||||
menu_nav: MenuNav,
|
||||
menu_tx: async_channel::Sender<MenuEvent>,
|
||||
/// Last rumble value handed to the active pad (the logical host value, pre-jitter) and
|
||||
/// when — drives the Steam Deck haptic keep-alive in [`Worker::render_feedback`].
|
||||
rumble_last: (u16, u16),
|
||||
rumble_last_at: Option<Instant>,
|
||||
/// Toggles the 1-LSB low-motor nudge that forces SDL past its identical-value dedupe on a
|
||||
/// Deck keep-alive re-issue (see [`Worker::issue_rumble`]).
|
||||
rumble_jitter: bool,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
@@ -1225,6 +1242,36 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand a rumble value to SDL on the active pad, remembering it for the Deck keep-alive.
|
||||
/// SDL short-circuits an identical `(low, high)` with NO device write (it only re-arms its
|
||||
/// expiration), so on a Deck keep-alive re-issue of the same non-zero value we flip a single
|
||||
/// low-motor LSB — an imperceptible amplitude nudge — to force the write through and keep the
|
||||
/// actuator physically fed. The 1500 ms SDL duration is kept on every issue so SDL's logical
|
||||
/// expiration is continuously refreshed and a genuine sustained rumble never dies at 1.5 s.
|
||||
fn issue_rumble(&mut self, low: u16, high: u16, deck: bool) {
|
||||
let (out_low, out_high) =
|
||||
if deck && (low, high) == self.rumble_last && (low, high) != (0, 0) {
|
||||
self.rumble_jitter = !self.rumble_jitter;
|
||||
(low ^ self.rumble_jitter as u16, high)
|
||||
} else {
|
||||
(low, high)
|
||||
};
|
||||
match self
|
||||
.open
|
||||
.as_mut()
|
||||
.map(|(_, p)| p.set_rumble(out_low, out_high, 1_500))
|
||||
{
|
||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in the
|
||||
// right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The host logs
|
||||
// the send side on 0xCA, so the two together pinpoint host-game vs client-render.
|
||||
Some(Err(e)) => tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed"),
|
||||
Some(Ok(())) => tracing::debug!(low, high, "rumble: rendered"),
|
||||
None => tracing::debug!(low, high, "rumble: received but no active pad to render"),
|
||||
}
|
||||
self.rumble_last = (low, high);
|
||||
self.rumble_last_at = Some(Instant::now());
|
||||
}
|
||||
|
||||
/// Drain and render the feedback planes — rumble plus HID output (lightbar /
|
||||
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single
|
||||
/// consumer. The host re-sends rumble state every ~500 ms, so the SDL duration only
|
||||
@@ -1236,23 +1283,37 @@ impl Worker {
|
||||
let Some(connector) = self.attached.clone() else {
|
||||
return;
|
||||
};
|
||||
// The Steam Deck's built-in haptic actuator decays inside SDL's ~2 s internal rumble
|
||||
// resend, and SDL dedupes an unchanged `set_rumble` value to a no-op device write — so a
|
||||
// steady host value (delivered only as identical 500 ms refreshes) is felt as a periodic
|
||||
// pulse rather than a continuous buzz. Detect the Deck pad here and keep it fed below the
|
||||
// decay (`DECK_RUMBLE_KEEPALIVE_MS`); every other pad sustains at the hardware level.
|
||||
let deck = self
|
||||
.open
|
||||
.as_ref()
|
||||
.and_then(|(id, _)| self.pad_info(*id))
|
||||
.is_some_and(|p| matches!(p.pref, GamepadPref::SteamDeck));
|
||||
let mut fresh = false;
|
||||
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
|
||||
if pad == 0 {
|
||||
if let Some((_, p)) = self.open.as_mut() {
|
||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
|
||||
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
||||
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
||||
// client-render.
|
||||
if let Err(e) = p.set_rumble(low, high, 1_500) {
|
||||
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
|
||||
} else {
|
||||
tracing::debug!(low, high, "rumble: rendered");
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(low, high, "rumble: received but no active pad to render");
|
||||
}
|
||||
fresh = true;
|
||||
self.issue_rumble(low, high, deck);
|
||||
}
|
||||
}
|
||||
// Deck keep-alive: no fresh datagram this tick but a non-zero value is latched — re-kick
|
||||
// the actuator so its discrete haptic bursts fuse into a continuous buzz instead of a
|
||||
// ~2 s pulse. Zero is left alone (a real stop must stay stopped); non-Deck pads never
|
||||
// enter here (`deck` is false), so their behaviour is byte-for-byte unchanged.
|
||||
if deck
|
||||
&& !fresh
|
||||
&& self.rumble_last != (0, 0)
|
||||
&& self
|
||||
.rumble_last_at
|
||||
.is_none_or(|t| t.elapsed() >= Duration::from_millis(DECK_RUMBLE_KEEPALIVE_MS))
|
||||
{
|
||||
let (low, high) = self.rumble_last;
|
||||
self.issue_rumble(low, high, deck);
|
||||
}
|
||||
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
|
||||
let is_ds = self
|
||||
.open
|
||||
@@ -1318,6 +1379,9 @@ impl Worker {
|
||||
menu_mode: false,
|
||||
menu_nav: MenuNav::new(),
|
||||
menu_tx,
|
||||
rumble_last: (0, 0),
|
||||
rumble_last_at: None,
|
||||
rumble_jitter: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,11 @@ pub struct SessionParams {
|
||||
/// the host still gates the upgrade behind its own PUNKTFUNK_10BIT policy) — `0`
|
||||
/// when the user turned HDR off in Settings ("never send me 10-bit").
|
||||
pub video_caps: u8,
|
||||
/// This display's HDR colour volume (primaries/white/luminance), when the embedder can read
|
||||
/// it from the OS. Rides `Hello::display_hdr` → the host's virtual-display EDID, so host apps
|
||||
/// tone-map to THIS panel. `None` = unknown/SDR (host EDID defaults). Overridable for testing
|
||||
/// via `PUNKTFUNK_CLIENT_PEAK_NITS` (synthesizes a BT.2020 volume at that peak).
|
||||
pub display_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||
/// Stream the default microphone to the host's virtual mic source.
|
||||
pub mic_enabled: bool,
|
||||
/// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see
|
||||
@@ -221,6 +226,9 @@ fn pump(
|
||||
params.audio_channels,
|
||||
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
|
||||
params.preferred_codec, // the user's soft codec preference (0 = auto)
|
||||
// This display's HDR volume → the host's virtual-display EDID. The env hatch wins so an
|
||||
// A/B run can pin an exact peak (PUNKTFUNK_CLIENT_PEAK_NITS=600).
|
||||
punktfunk_core::client::display_hdr_env_override().or(params.display_hdr),
|
||||
params.launch.clone(),
|
||||
params.pin,
|
||||
Some(params.identity),
|
||||
|
||||
Reference in New Issue
Block a user