fix(inject/windows): map absolute input over the streamed output's rect, not the whole desktop
audit / cargo-audit (push) Successful in 2m13s
audit / bun-audit (push) Successful in 15s
arch / build-publish (push) Successful in 12m46s
windows-host / package (push) Successful in 12m53s
android / android (push) Successful in 13m48s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m9s
apple / swift (push) Successful in 15m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m9s
ci / bench (push) Successful in 5m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m10s
ci / rust (push) Successful in 19m38s
decky / build-publish (push) Successful in 27s
deb / build-publish (push) Successful in 8m37s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 1m36s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m39s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 1m15s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m54s
docker / deploy-docs (push) Skipped
deb / build-publish-host (push) Successful in 10m7s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m26s
flatpak / build-publish (push) Successful in 6m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m42s
release / apple (push) Successful in 56m19s
apple / screenshots (push) Successful in 23m11s

Pen, touch, and absolute mouse arrive normalized to the STREAMED display's
frame, but pointer_windows::to_screen and sendinput's MouseMoveAbs mapped
them over the whole virtual desktop — correct only when the virtual display
is the sole active display (Exclusive topology). In Extend — a physical
monitor kept on beside the virtual output, or an Exclusive isolate degraded
to the 0x57 keep-physicals fallback — the streamed output sits at a non-zero
origin, so every sample landed shifted and mis-scaled. The pen exposed it
first (field report 2026-07-24): a stylus is strictly absolute, with no
closed-loop correction onto the target like a cursor.

New pf-inject::stream_target (Windows): the host publishes the streamed
output's CCD target id at capture bring-up (one central site —
capture_virtual_output covers the native and GameStream planes); mapping
sites resolve its current desktop rect through pf-win-display's
source_desktop_rect — the same resolver the cursor-readback poller uses, so
inject and readback always agree — TTL-cached (250 ms) because a
group-layout re-arrange moves a live output's origin mid-session. No target
set / never resolved falls back to the whole virtual desktop (the historical
mapping, still right for Exclusive topology and devtest).

One change in to_screen covers pen + touch; MouseMoveAbs converts the same
desktop pixel into the 0..65535 MOUSEEVENTF_VIRTUALDESK coordinate, closing
the identical latent absolute-mouse bug (apollo-comparison open item #14/#30).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 17:38:36 +02:00
co-authored by Claude Fable 5
parent 64511af4d3
commit 087e7d0405
7 changed files with 220 additions and 43 deletions
Generated
+1
View File
@@ -2981,6 +2981,7 @@ dependencies = [
"pf-driver-proto",
"pf-host-config",
"pf-paths",
"pf-win-display",
"punktfunk-core",
"reis",
"tokio",
+3
View File
@@ -48,6 +48,9 @@ xkbcommon = "0.8"
usbip-sim = { path = "../punktfunk-host/vendor/usbip-sim" }
[target.'cfg(target_os = "windows")'.dependencies]
# The streamed-output desktop rect (CCD source rect) absolute input maps into — the same
# resolver the cursor-readback poller uses, so inject and readback always agree.
pf-win-display = { path = "../pf-win-display" }
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_Security",
@@ -41,9 +41,8 @@ use windows::Win32::UI::Input::Pointer::{
POINTER_FLAG_UPDATE, POINTER_INFO, POINTER_PEN_INFO, POINTER_TOUCH_INFO,
};
use windows::Win32::UI::WindowsAndMessaging::{
GetSystemMetrics, PEN_FLAG_BARREL, PEN_FLAG_ERASER, PEN_FLAG_INVERTED, PEN_FLAG_NONE,
PEN_MASK_PRESSURE, PEN_MASK_ROTATION, PEN_MASK_TILT_X, PEN_MASK_TILT_Y, PT_PEN, PT_TOUCH,
SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, TOUCH_FLAG_NONE,
PEN_FLAG_BARREL, PEN_FLAG_ERASER, PEN_FLAG_INVERTED, PEN_FLAG_NONE, PEN_MASK_PRESSURE,
PEN_MASK_ROTATION, PEN_MASK_TILT_X, PEN_MASK_TILT_Y, PT_PEN, PT_TOUCH, TOUCH_FLAG_NONE,
TOUCH_MASK_NONE,
};
@@ -149,22 +148,14 @@ unsafe fn inject_following_desktop(
/// Windows pen pressure is 0..1024 (vs the wire's full-scale u16).
const WIN_PEN_PRESSURE_MAX: u32 = 1024;
/// Map a normalized [0,1] coordinate pair onto virtual-desktop pixels the same surface the
/// SendInput absolute mouse targets, so pen, touch, and pointer all land identically.
/// Map a normalized [0,1] coordinate pair onto desktop pixels over the STREAMED output's rect
/// ([`crate::stream_target`]) — the surface the SendInput absolute mouse targets too, so pen,
/// touch, and pointer all land identically. The wire normalizes over the streamed display's
/// frame, not the desktop: mapping over the whole virtual desktop is only right when they
/// coincide (Exclusive topology — the fallback when no stream target is live).
fn to_screen(x: f32, y: f32) -> POINT {
// SAFETY: `GetSystemMetrics` takes a constant index and reads global metrics; no memory in.
let (vx, vy, vw, vh) = unsafe {
(
GetSystemMetrics(SM_XVIRTUALSCREEN),
GetSystemMetrics(SM_YVIRTUALSCREEN),
GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1),
GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1),
)
};
POINT {
x: vx + (x.clamp(0.0, 1.0) * (vw - 1) as f32) as i32,
y: vy + (y.clamp(0.0, 1.0) * (vh - 1) as f32) as i32,
}
let (px, py) = crate::stream_target::map_normalized(x as f64, y as f64);
POINT { x: px, y: py }
}
/// An owned `HSYNTHETICPOINTERDEVICE` (destroyed exactly once on drop).
@@ -1,6 +1,6 @@
//! Windows input injection via `SendInput` (Win32 KeyboardAndMouse) — the Windows analogue of
//! [`super::wlr`]: absolute mouse normalized to the virtual desktop, relative mouse for games,
//! scancode keyboard, scroll, buttons. Survives UAC/lock desktop switches with Sunshine's
//! [`super::wlr`]: absolute mouse mapped over the streamed output's rect
//! ([`crate::stream_target`]), relative mouse for games, scancode keyboard, scroll, buttons. Survives UAC/lock desktop switches with Sunshine's
//! retry-on-failure model: the thread stays bound to its desktop and only reattaches
//! (`OpenInputDesktop`/`SetThreadDesktop`) when `SendInput` reports a short write (the input
//! desktop switched) — no per-event reattach overhead.
@@ -32,14 +32,10 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{
MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK,
MOUSEEVENTF_WHEEL, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, VIRTUAL_KEY,
};
use windows::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetSystemMetrics, GetWindowThreadProcessId, SM_CXVIRTUALSCREEN,
SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
};
use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId};
use super::InputInjector;
const ABS_MAX: f64 = 65535.0; // SendInput absolute coords are 0..65535 over the chosen surface.
const GENERIC_ALL: u32 = 0x1000_0000;
const XBUTTON1: u32 = 0x0001;
const XBUTTON2: u32 = 0x0002;
@@ -165,13 +161,16 @@ impl InputInjector for SendInputInjector {
if w == 0 || h == 0 {
return Ok(()); // contract: drop zero extent
}
let (_vx, _vy, vw, vh) = virtual_desktop_rect();
// One virtual output spanning the virtual desktop: map client (0..w,0..h) -> 0..65535.
// Client (0..w,0..h) → the STREAMED output's desktop rect
// ([`crate::stream_target`]; the whole virtual desktop only as fallback) →
// 0..65535 over the virtual desktop for MOUSEEVENTF_VIRTUALDESK. Mapping over
// the desktop alone is the Extend-topology offset bug the pen plane exposed
// (design/pen-tablet-input.md): correct only when the streamed display IS the
// whole desktop.
let cx = (event.x.clamp(0, w as i32)) as f64 / w as f64;
let cy = (event.y.clamp(0, h as i32)) as f64 / h as f64;
let ax = (cx * ABS_MAX).round() as i32;
let ay = (cy * ABS_MAX).round() as i32;
let _ = (vw, vh); // virtual-desktop rect reserved for multi-output mapping
let px = crate::stream_target::map_normalized(cx, cy);
let (ax, ay) = crate::stream_target::desktop_px_to_virtualdesk(px);
let mi = MOUSEINPUT {
dx: ax,
dy: ay,
@@ -378,19 +377,6 @@ fn key(ki: KEYBDINPUT) -> INPUT {
}
}
fn virtual_desktop_rect() -> (i32, i32, i32, i32) {
// SAFETY: each `GetSystemMetrics` takes a single by-value `SYSTEM_METRICS_INDEX` constant and
// returns an `i32`; it dereferences no pointer and has no side effects — FFI-`unsafe` only.
unsafe {
(
GetSystemMetrics(SM_XVIRTUALSCREEN),
GetSystemMetrics(SM_YVIRTUALSCREEN),
GetSystemMetrics(SM_CXVIRTUALSCREEN),
GetSystemMetrics(SM_CYVIRTUALSCREEN),
)
}
}
// VKs Windows wants flagged extended even when the scancode high bits aren't set: the editing
// cluster (Ins/Del/Home/End/PgUp/PgDn = 0x21..0x28, 0x2D, 0x2E), the Win keys (0x5B/0x5C/0x5D),
// RCtrl (0xA3), RAlt (0xA5), Pause (0x90). MAPVK_VK_TO_VSC_EX already encodes E0 for most; this is a
@@ -0,0 +1,181 @@
//! The streamed display every absolute coordinate maps into (design/pen-tablet-input.md field
//! fix). Pen, touch, and absolute-mouse positions arrive normalized to the STREAMED output's
//! frame, but the injectors historically mapped them over the whole virtual desktop — correct
//! only when the virtual display is the sole active display (Exclusive topology, normalized to
//! origin). In Extend — a physical monitor kept on beside the virtual output, or an Exclusive
//! isolate degraded to the keep-physicals fallback — the streamed output sits at a non-zero
//! origin, so every sample landed shifted and mis-scaled (the pen exposed it first: a stylus is
//! strictly absolute, with no closed-loop correction onto the target like a cursor).
//!
//! The host publishes the streamed output's CCD target id at capture bring-up
//! ([`set_stream_target`]); the mapping sites resolve its CURRENT desktop rect through
//! [`pf_win_display::win_display::source_desktop_rect`] — the same resolver the cursor-readback
//! poller maps frames with, so the two directions always agree — TTL-cached because a
//! group-layout re-arrange moves a live output's origin mid-session. With no target set, or none
//! resolved yet, mapping falls back to the whole virtual desktop: the historical behavior, still
//! correct for Exclusive topology and the client-less devtest paths.
use std::sync::Mutex;
use std::time::{Duration, Instant};
use windows::Win32::UI::WindowsAndMessaging::{
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
};
/// `(x, y, w, h)` in desktop coordinates, physical pixels (`source_desktop_rect` order).
type Rect = (i32, i32, i32, i32);
/// How long a resolved rect stays fresh: long enough that the CCD query cost vanishes at input
/// rates (pen samples + the 40 ms refresh threads), short enough that a mid-session layout move
/// (a parallel session joining the auto-row) is picked up within a blink.
const RECT_TTL: Duration = Duration::from_millis(250);
struct State {
target_id: Option<u32>,
rect: Option<Rect>,
queried: Option<Instant>,
}
static STATE: Mutex<State> = Mutex::new(State {
target_id: None,
rect: None,
queried: None,
});
/// Publish the streamed output (its CCD target id) that absolute input maps into. The host calls
/// this at capture bring-up; it is never cleared at teardown — a deactivated target simply stops
/// resolving (the last-known rect is kept, and nothing injects between sessions), and the next
/// session's bring-up re-targets. One slot per process: with parallel sessions the LAST bring-up
/// wins for every session's absolute input — per-session routing needs source-tagged input
/// events (parallel-displays plan) and the single slot is never worse than the historical
/// whole-desktop mapping.
pub fn set_stream_target(target_id: Option<u32>) {
let mut st = STATE.lock().unwrap();
if st.target_id != target_id {
tracing::info!(?target_id, "absolute-input stream target set");
st.target_id = target_id;
st.rect = None;
st.queried = None;
}
}
/// The streamed output's current desktop rect, TTL-cached. `None` = no target set / never
/// resolved (callers fall back to the whole virtual desktop).
fn stream_rect() -> Option<Rect> {
let mut st = STATE.lock().unwrap();
let target_id = st.target_id?;
let fresh = st.queried.is_some_and(|at| at.elapsed() < RECT_TTL);
if !fresh {
st.queried = Some(Instant::now());
// SAFETY: read-only QueryDisplayConfig over owned locals (`source_desktop_rect`'s
// contract — the same call the cursor-readback poller makes at spawn).
match unsafe { pf_win_display::win_display::source_desktop_rect(target_id) } {
Some(r) => {
if st.rect != Some(r) {
tracing::info!(target_id, rect = ?r, "stream-target desktop rect resolved");
}
st.rect = Some(r);
}
// Not an active path right now (teardown, or a topology commit in flight): keep the
// last-known rect — snapping mid-stroke to the whole-desktop mapping would visibly
// jump, and after teardown nothing injects until the next session re-targets.
None => {
if st.rect.is_some() {
tracing::debug!(
target_id,
"stream target not an active path — keeping last rect"
);
}
}
}
}
st.rect
}
/// Desktop-space pixel for a normalized `[0,1]²` coordinate over the streamed output's rect,
/// falling back to the whole virtual desktop when no stream target is live.
pub(crate) fn map_normalized(nx: f64, ny: f64) -> (i32, i32) {
map_into(stream_rect().unwrap_or_else(virtual_desktop_rect), nx, ny)
}
/// Pure mapping: `[0,1]²` over `(x, y, w, h)`, inclusive edges (1.0 lands on the last pixel).
fn map_into((x, y, w, h): Rect, nx: f64, ny: f64) -> (i32, i32) {
(
x + (nx.clamp(0.0, 1.0) * (w - 1).max(0) as f64).round() as i32,
y + (ny.clamp(0.0, 1.0) * (h - 1).max(0) as f64).round() as i32,
)
}
/// The virtual-desktop bounds `(x, y, w, h)` — the mapping fallback, and the surface
/// `MOUSEEVENTF_VIRTUALDESK` absolute coordinates normalize over.
pub(crate) fn virtual_desktop_rect() -> Rect {
// SAFETY: each `GetSystemMetrics` takes a single by-value `SYSTEM_METRICS_INDEX` constant and
// returns an `i32`; it dereferences no pointer and has no side effects — FFI-`unsafe` only.
unsafe {
(
GetSystemMetrics(SM_XVIRTUALSCREEN),
GetSystemMetrics(SM_YVIRTUALSCREEN),
GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1),
GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1),
)
}
}
/// A desktop-space pixel as the `MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK` 0..65535
/// coordinate pair `SendInput` wants.
pub(crate) fn desktop_px_to_virtualdesk(px: (i32, i32)) -> (i32, i32) {
px_to_abs(virtual_desktop_rect(), px)
}
/// SendInput absolute coordinates span 0..65535 over the chosen surface.
const ABS_MAX: f64 = 65535.0;
/// Pure normalization: a desktop pixel inside `(x, y, w, h)` → 0..65535 over that surface.
fn px_to_abs((vx, vy, vw, vh): Rect, (px, py): (i32, i32)) -> (i32, i32) {
(
((px - vx) as f64 * ABS_MAX / (vw - 1).max(1) as f64).round() as i32,
((py - vy) as f64 * ABS_MAX / (vh - 1).max(1) as f64).round() as i32,
)
}
#[cfg(test)]
mod tests {
use super::*;
/// The Extend-topology field bug: physical 1920x1080 at (0,0), streamed virtual 2560x1440
/// beside it at (1920,0) — samples must land inside the VIRTUAL output, not at the desktop
/// origin.
#[test]
fn maps_over_the_streamed_rect_not_the_desktop() {
let r = (1920, 0, 2560, 1440);
assert_eq!(map_into(r, 0.0, 0.0), (1920, 0));
assert_eq!(map_into(r, 1.0, 1.0), (1920 + 2559, 1439));
assert_eq!(map_into(r, 0.5, 0.5), (1920 + 1280, 720));
}
#[test]
fn clamps_out_of_range_and_handles_negative_origins() {
// An output placed LEFT of / ABOVE the primary has a negative desktop origin.
let r = (-2560, -100, 2560, 1440);
assert_eq!(map_into(r, 0.0, 0.0), (-2560, -100));
assert_eq!(map_into(r, 2.0, -1.0), (-2560 + 2559, -100));
}
#[test]
fn degenerate_rect_pins_to_its_origin() {
assert_eq!(map_into((10, 20, 0, 0), 0.7, 0.7), (10, 20));
}
/// The VIRTUALDESK round trip: win32k maps an absolute coordinate back to a pixel roughly as
/// `px = ax * vw / 65536` (floor) — edge pixels and the streamed output's origin must survive.
#[test]
fn virtualdesk_normalization_round_trips() {
let v = (0, 0, 4480, 1080);
assert_eq!(px_to_abs(v, (0, 0)), (0, 0));
assert_eq!(px_to_abs(v, (4479, 1079)), (65535, 65535));
let (ax, _) = px_to_abs(v, (1920, 0));
assert_eq!((ax as i64 * 4480 / 65536) as i32, 1920);
// Negative-origin desktops (a monitor left of the primary) still normalize from 0.
let v = (-2560, 0, 4480, 1440);
assert_eq!(px_to_abs(v, (-2560, 0)), (0, 0));
}
}
+10
View File
@@ -417,6 +417,16 @@ pub mod pen;
#[cfg(target_os = "windows")]
#[path = "inject/windows/pointer_windows.rs"]
pub mod pen;
/// Windows: the streamed output's desktop rect that every absolute coordinate (pen, touch,
/// absolute mouse) maps into — published by the host at capture bring-up, resolved through the
/// CCD source rect (the cursor-readback poller's resolver, so both directions agree). Mapping
/// over the whole virtual desktop instead is the Extend-topology offset bug the pen exposed
/// (design/pen-tablet-input.md).
#[cfg(target_os = "windows")]
#[path = "inject/windows/stream_target.rs"]
pub mod stream_target;
#[cfg(target_os = "windows")]
pub use stream_target::set_stream_target;
/// Stub — pen injection needs the Linux uinput tablet or Windows synthetic pointers;
/// `pen_supported()` is false here, so no host advertises the cap and no batches arrive.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
+5
View File
@@ -124,6 +124,11 @@ pub fn capture_virtual_output(
virtual-display warnings above)"
)
})?;
// Aim the injectors' absolute mapping (pen/touch/abs-mouse) at THIS display: the wire
// normalizes over the streamed frame, and mapping it over the whole virtual desktop is wrong
// the moment a physical monitor shares the desktop (Extend topology, or an Exclusive isolate
// degraded to the keep-physicals fallback) — the pen-offset field bug.
crate::inject::set_stream_target(Some(target.target_id));
let pref = vout.preferred_mode;
let keep = vout.keepalive;
// The sealed-channel delivery seam: resolve the pf-vdisplay control device ONCE (it is