diff --git a/Cargo.lock b/Cargo.lock
index 4d69855..0ce5aa1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4308,6 +4308,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
+ "windows 0.62.2",
"windows-sys 0.61.2",
"x11rb",
]
@@ -4321,6 +4322,7 @@ dependencies = [
"gpui-pre",
"gpui-pre-platform",
"open-controller-core",
+ "raw-window-handle",
"serde",
"serde_json",
"unicode-segmentation",
diff --git a/README.md b/README.md
index 4609f0a..e44dc47 100644
--- a/README.md
+++ b/README.md
@@ -216,8 +216,8 @@ the left trigger is pulled (aiming down sights) or while a chosen button is held
also work as a switch: one press turns the gyro on, the next turns it off. The game still gets the
button. A half turn a second is the stick all the way at 100 % sensitivity; a small deadzone keeps
a still hand still, a filter smooths slow movement without delaying fast turns, and the stick
-keeps working. Each friend aims with their own controller, which a gyro mapped to the mouse would
-not allow.
+keeps working. Each friend aims with their own controller; the separate Flick Stick option
+instead uses the shared desktop mouse.
The aim can be tuned further: a sensitivity of its own while the left trigger is pulled, a slower
or faster up and down, and a button that pauses the gyro while held, to put the controller back
@@ -279,6 +279,51 @@ already opened it keeps it after it goes back to Xbox, so it would read it twice
without a serial number shares its settings with every controller of its model, and so does this
choice. With "Hide original controllers" off, native only removes the Xbox controller.
+The Light page can explicitly opt into light-bar control while input stays native. This is off
+by default; a game that also sets the light can compete with it. Custom colours accept a full
+`#RRGGBB` value as well as the colour swatches.
+
+The preview follows both touchpad contacts, including movement without clicking, with
+frame-timed smoothing and a short fading trail in the focused detailed preview. Unfocused
+windows and reduced-motion mode keep direct feedback instead; losing focus clears old trails.
+Both contacts and trails follow the configured LED colour, with the theme accent as a fallback
+when the LED colour is black or off. This only affects drawing, never controller input. On Windows,
+the DualSense microphone indicator shows the actual mute state of the default recording device.
+Its separate, opt-in mic-button switch toggles that device; another binding for the button takes
+priority. Bluetooth may use the PC microphone, since it need not expose a DualSense recording
+device. This reads the endpoint's mute flag and never records audio.
+
+Windows uses one application-drawn title bar with the usual window controls.
+
+### Controller Lab
+
+The controller's Lab page has optional tools, saved with each profile and off by default:
+
+- **Trigger Lab:** independent DualSense/Edge L2 and R2 resistance, vibration and trigger-break
+ effects. Adjust the start/end zones, force and frequency, try them for five seconds, stop
+ them immediately or opt into persistent profile effects. A trial restores the profile after
+ its timeout; disconnect and normal exit clear effects owned by OpenController. Output goes
+ through SDL, including USB/Bluetooth framing and checksums.
+- **Touchpad mouse (Windows):** relative movement, tap-to-click, physical left/right clicks,
+ two-finger scrolling and a trackball mode with adjustable inertia decay. This is shared
+ desktop input; disable it in the Lab when a game should handle the touchpad itself.
+- **Flick Stick (Windows, mapping mode):** the right stick's direction starts a turn, rotation
+ around its rim continues it, and the gyro can provide fine aim. Counts per full turn, flick
+ duration, activation threshold and gyro sensitivity are adjustable. Calibrate counts against
+ the game's mouse sensitivity. The virtual right stick is consumed while this is enabled.
+- **Trigger mapping (mapping mode):** Hair Trigger uses short pull/release reversals; two-stage
+ mapping assigns buttons to soft/full pulls; Hip Fire delays the soft action so a quick full
+ pull can bypass it. The chosen buttons also use their existing keyboard/gamepad assignments.
+
+Native input stays native. Flick Stick and input remapping require mapping mode.
+
+Desktop mouse output uses a worker and a watchdog to release abandoned clicks.
+
+Use matching resident/UI binaries (IPC protocol 2). Trials restore the saved profile settings.
+
+Protocol references: [SDL's gamepad effect example](https://github.com/libsdl-org/SDL/blob/main/test/testcontroller.c),
+and [DualSense trigger encoding](https://gist.github.com/Nielk1/6d54cc2c00d2201ccb8c2720ad7538db).
+
## Supported controllers
Controllers are read through [SDL 3](https://libsdl.org), which speaks each one's own protocol,
diff --git a/crates/open-controller-core/Cargo.toml b/crates/open-controller-core/Cargo.toml
index a9cdce2..53e5017 100644
--- a/crates/open-controller-core/Cargo.toml
+++ b/crates/open-controller-core/Cargo.toml
@@ -26,6 +26,7 @@ sdl3-sys = { version = "0.7.2", default-features = false, features = [
] }
[target.'cfg(windows)'.dependencies]
+windows = { version = "0.62.2", features = ["Win32_Media_Audio", "Win32_Media_Audio_Endpoints", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_Variant"] }
# Checks a downloaded driver installer against the hash it had when this version was made.
sha2 = "0.10"
windows-sys = { workspace = true, features = [
diff --git a/crates/open-controller-core/examples/windows/loopback.rs b/crates/open-controller-core/examples/windows/loopback.rs
index 485c116..b03058e 100644
--- a/crates/open-controller-core/examples/windows/loopback.rs
+++ b/crates/open-controller-core/examples/windows/loopback.rs
@@ -9,7 +9,8 @@ use windows_sys::Win32::Foundation::FILETIME;
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetProcessTimes};
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_F24};
use windows_sys::Win32::UI::Input::XboxController::{
- XINPUT_GAMEPAD_A, XINPUT_GAMEPAD_LEFT_SHOULDER, XINPUT_GAMEPAD_X, XINPUT_GAMEPAD_Y, XINPUT_STATE, XINPUT_VIBRATION, XInputGetState, XInputSetState,
+ XINPUT_GAMEPAD_A, XINPUT_GAMEPAD_LEFT_SHOULDER, XINPUT_GAMEPAD_X, XINPUT_GAMEPAD_Y, XINPUT_STATE, XINPUT_VIBRATION, XInputGetState,
+ XInputSetState,
};
/// The Xbox set, two back paddles and the touchpad click.
@@ -50,7 +51,7 @@ fn attach_one(first: bool) -> (SDL_JoystickID, *mut SDL_Joystick) {
naxes: 6,
nbuttons: BUTTON_MASK.count_ones() as u16,
ntouchpads: 1,
- nsensors: 1,
+ nsensors: sensors.len() as u16,
button_mask: BUTTON_MASK,
axis_mask: 0x3F,
name: c"Loopback Controller".as_ptr(),
@@ -90,13 +91,103 @@ fn check(ok: bool, what: &str) {
}
}
+fn lab_inputs(engine: &Engine, joy: *mut SDL_Joystick, slot: u32, original: &Profiles) {
+ use open_controller_core::lab::{Config as Lab, TriggerInput, TriggerMode};
+ use open_controller_core::mapping::button;
+ let mut profiles = original.clone();
+ for b in 0..64 {
+ profiles.apply(Edit::Bind { button: b, action: None });
+ }
+ let mut cfg = Lab::default();
+ cfg.triggers[0] = TriggerInput { mode: TriggerMode::Hair, soft: 5, ..Default::default() };
+ profiles.apply(Edit::Lab(cfg));
+ engine.send(Command::SetProfiles(PROFILE.into(), profiles.clone()));
+ wait_for("Hair Trigger profile", Duration::from_secs(1), || {
+ engine
+ .snapshot()
+ .pads
+ .into_iter()
+ .find(|p| p.store.as_deref() == Some(PROFILE) && p.profile().lab.triggers[0].mode == TriggerMode::Hair)
+ });
+ let pull = |percent: i32| {
+ unsafe { SDL_SetJoystickVirtualAxis(joy, SDL_GAMEPAD_AXIS_LEFT_TRIGGER.0, (percent * 65535 / 100 - 32768) as i16) };
+ };
+ pull(50);
+ wait_for("hair press", Duration::from_secs(1), || xinput(slot).filter(|s| s.Gamepad.bLeftTrigger == 255));
+ pull(40);
+ wait_for("hair release by reversal", Duration::from_secs(1), || xinput(slot).filter(|s| s.Gamepad.bLeftTrigger == 0));
+ pull(45);
+ wait_for("hair rearm before returning to zero", Duration::from_secs(1), || xinput(slot).filter(|s| s.Gamepad.bLeftTrigger == 255));
+ pull(0);
+ wait_for("hair released", Duration::from_secs(1), || xinput(slot).filter(|s| s.Gamepad.bLeftTrigger == 0));
+ check(true, "Hair Trigger presses, reverses and rearms through real XInput");
+ cfg.triggers[0] = TriggerInput {
+ mode: TriggerMode::TwoStage,
+ soft_button: button::WEST as u8,
+ full_button: button::SOUTH as u8,
+ ..Default::default()
+ };
+ profiles.apply(Edit::Lab(cfg));
+ engine.send(Command::SetProfiles(PROFILE.into(), profiles.clone()));
+ wait_for("two-stage profile", Duration::from_secs(1), || {
+ engine
+ .snapshot()
+ .pads
+ .into_iter()
+ .find(|p| p.store.as_deref() == Some(PROFILE) && p.profile().lab.triggers[0].mode == TriggerMode::TwoStage)
+ });
+ pull(50);
+ wait_for("soft-pull X", Duration::from_secs(1), || {
+ xinput(slot).filter(|s| {
+ s.Gamepad.wButtons & XINPUT_GAMEPAD_X != 0 && s.Gamepad.wButtons & XINPUT_GAMEPAD_A == 0 && s.Gamepad.bLeftTrigger == 0
+ })
+ });
+ pull(100);
+ wait_for("full-pull A replaces X", Duration::from_secs(1), || {
+ xinput(slot).filter(|s| s.Gamepad.wButtons & XINPUT_GAMEPAD_A != 0 && s.Gamepad.wButtons & XINPUT_GAMEPAD_X == 0)
+ });
+ pull(0);
+ wait_for("both stage actions released", Duration::from_secs(1), || xinput(slot).filter(|s| s.Gamepad.wButtons == 0));
+ check(true, "two-stage actions reach XInput independently and release cleanly");
+ cfg.triggers[0].mode = TriggerMode::HipFire;
+ cfg.triggers[0].delay_ms = 100;
+ profiles.apply(Edit::Lab(cfg));
+ engine.send(Command::SetProfiles(PROFILE.into(), profiles.clone()));
+ wait_for("Hip Fire profile", Duration::from_secs(1), || {
+ engine
+ .snapshot()
+ .pads
+ .into_iter()
+ .find(|p| p.store.as_deref() == Some(PROFILE) && p.profile().lab.triggers[0].mode == TriggerMode::HipFire)
+ });
+ let start = Instant::now();
+ pull(50);
+ wait_for("timed soft action without another report", Duration::from_secs(1), || {
+ xinput(slot).filter(|s| s.Gamepad.wButtons & XINPUT_GAMEPAD_X != 0)
+ });
+ check(start.elapsed() >= Duration::from_millis(90), "Hip Fire waits before its soft action without blocking input");
+ pull(0);
+ wait_for("Hip Fire released", Duration::from_secs(1), || xinput(slot).filter(|s| s.Gamepad.wButtons == 0));
+ engine.send(Command::SetProfiles(PROFILE.into(), original.clone()));
+ wait_for("original profile restored", Duration::from_secs(1), || {
+ engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) && p.profile().lab == Lab::default())
+ });
+ engine.send(Command::Watch(false));
+}
+
fn main() {
let dir = std::env::temp_dir().join("open-controller-loopback");
let mut profiles = Profiles::default();
profiles.apply(Edit::Bind { button: RIGHT_PADDLE1, action: Some(Action::Xbox(XboxButton::Y)) });
profiles.apply(Edit::Bind { button: LEFT_PADDLE1, action: Some(Action::Keys(Chord { mods: 0, key: VK_F24 })) });
- let config =
- Config { data_dir: dir, hide: false, profiles: [(PROFILE.to_string(), profiles.clone())].into(), driver: Default::default(), split_joycons: false, mappings: Default::default() };
+ let config = Config {
+ data_dir: dir,
+ hide: false,
+ profiles: [(PROFILE.to_string(), profiles.clone())].into(),
+ driver: Default::default(),
+ split_joycons: false,
+ mappings: Default::default(),
+ };
let engine = Engine::start(config, || {});
wait_for("the engine", Duration::from_secs(10), || engine.snapshot().running.then_some(()));
if !matches!(engine.snapshot().vigem, open_controller_core::Driver::Ready { .. }) {
@@ -168,7 +259,9 @@ fn main() {
unsafe { SDL_SetJoystickVirtualButton(joy, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER.0, false) };
check(s.Gamepad.wButtons & XINPUT_GAMEPAD_LEFT_SHOULDER == 0, "the left bumper made into the left trigger pulls it, and is not LB");
engine.send(Command::SetProfiles(PROFILE.into(), profiles.clone()));
- wait_for("everything let go", Duration::from_millis(500), || xinput(slot).filter(|s| s.Gamepad.wButtons == 0 && s.Gamepad.bLeftTrigger == 0));
+ wait_for("everything let go", Duration::from_millis(500), || {
+ xinput(slot).filter(|s| s.Gamepad.wButtons == 0 && s.Gamepad.bLeftTrigger == 0)
+ });
let pad = engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE));
check(
@@ -217,7 +310,14 @@ fn main() {
engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) && p.extras.contains(&TOUCH_LEFT))
});
check(pad.features.touchpad && pad.features.motion, "a touchpad and a gyro are seen, and the touchpad's halves listed");
+ engine.send(Command::Watch(true));
unsafe { SDL_SetJoystickVirtualTouchpad(joy, 0, 0, true, 0.2, 0.5, 1.0) };
+ wait_for("touch coordinates in the preview", Duration::from_secs(1), || {
+ engine.snapshot().pads.into_iter().find(|p| {
+ p.store.as_deref() == Some(PROFILE) && p.input.touches[0] == open_controller_core::mapping::Touch::from_position(true, 0.2, 0.5)
+ })
+ });
+ check(true, "touching without clicking reaches the preview with its position");
button(TOUCHPAD, true);
wait_for("a click on the left half as X", Duration::from_millis(500), x(true));
button(TOUCHPAD, false);
@@ -229,6 +329,25 @@ fn main() {
button(TOUCHPAD, false);
unsafe { SDL_SetJoystickVirtualTouchpad(joy, 0, 0, false, 0.8, 0.5, 0.0) };
check(right_is_not_x, "a click on the left half of the touchpad is a button of its own, the right half another");
+ wait_for("released touch clears the preview", Duration::from_secs(1), || {
+ engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) && !p.input.touches[0].down)
+ });
+ unsafe { SDL_SetJoystickVirtualTouchpad(joy, 0, 1, true, 0.7, 0.2, 1.0) };
+ wait_for("the second finger independently reaches the preview", Duration::from_secs(1), || {
+ engine.snapshot().pads.into_iter().find(|p| {
+ p.store.as_deref() == Some(PROFILE)
+ && !p.input.touches[0].down
+ && p.input.touches[1] == open_controller_core::mapping::Touch::from_position(true, 0.7, 0.2)
+ })
+ });
+ unsafe { SDL_SetJoystickVirtualTouchpad(joy, 0, 1, false, 0.7, 0.2, 0.0) };
+ wait_for("both fingers release", Duration::from_secs(1), || {
+ engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) && p.input.touches.iter().all(|t| !t.down))
+ });
+ check(true, "both touchpad fingers and their independent releases reach the preview");
+ engine.send(Command::Watch(false));
+
+ lab_inputs(&engine, joy, slot, &profiles);
// A profile for a program: in front, its assignments; away, the chosen profile's again.
profiles.apply(Edit::Select(0));
@@ -303,7 +422,9 @@ fn main() {
gyro(0.0, 100);
check(
untoggled == 0 && toggled < -20000 && paused == 0 && resumed < -20000 && toggled_off == 0,
- &format!("a toggle turns the gyro on and off ({untoggled}, {toggled}, {toggled_off}), and the off button stops it while held ({paused})"),
+ &format!(
+ "a toggle turns the gyro on and off ({untoggled}, {toggled}, {toggled_off}), and the off button stops it while held ({paused})"
+ ),
);
profiles.apply(Edit::Gyro(Gyro::default()));
@@ -324,9 +445,12 @@ fn main() {
profiles.apply(Edit::Sticks(Sticks { left: Shape { deadzone: 20, ..Shape::default() }, right: Some(own), ..Sticks::default() }));
engine.send(Command::SetProfiles(PROFILE.into(), profiles.clone()));
unsafe { SDL_SetJoystickVirtualAxis(joy, SDL_GAMEPAD_AXIS_RIGHTX.0, 26500) };
- let tilted = wait_for("the right stick at full tilt", Duration::from_millis(500), || xinput(slot).filter(|s| s.Gamepad.sThumbRX == 32767));
+ let tilted =
+ wait_for("the right stick at full tilt", Duration::from_millis(500), || xinput(slot).filter(|s| s.Gamepad.sThumbRX == 32767));
unsafe { SDL_SetJoystickVirtualAxis(joy, SDL_GAMEPAD_AXIS_RIGHTX.0, 5000) };
- let right_small = wait_for("a small right stick", Duration::from_millis(500), || xinput(slot).filter(|s| s.Gamepad.sThumbRX > 0 && s.Gamepad.sThumbRX < 10000));
+ let right_small = wait_for("a small right stick", Duration::from_millis(500), || {
+ xinput(slot).filter(|s| s.Gamepad.sThumbRX > 0 && s.Gamepad.sThumbRX < 10000)
+ });
unsafe { SDL_SetJoystickVirtualAxis(joy, SDL_GAMEPAD_AXIS_RIGHTX.0, 0) };
check(
tilted.Gamepad.sThumbRX == 32767 && right_small.Gamepad.sThumbRX > 5000,
diff --git a/crates/open-controller-core/src/desktop.rs b/crates/open-controller-core/src/desktop.rs
new file mode 100644
index 0000000..9f557d2
--- /dev/null
+++ b/crates/open-controller-core/src/desktop.rs
@@ -0,0 +1,88 @@
+//! Desktop mouse output is isolated from the input loop. A watchdog releases abandoned clicks.
+use crate::lab::Mouse;
+
+pub struct Desktop {
+ #[cfg(windows)]
+ tx: crossbeam_channel::Sender<(u32, Mouse)>,
+}
+impl Desktop {
+ pub fn start() -> Self {
+ #[cfg(windows)]
+ {
+ let (tx, rx) = crossbeam_channel::bounded(256);
+ let _ = std::thread::Builder::new().name("open-controller-mouse".into()).spawn(move || worker(rx));
+ Self { tx }
+ }
+ #[cfg(not(windows))]
+ Self {}
+ }
+ pub fn send(&self, id: u32, mouse: Mouse) {
+ #[cfg(windows)]
+ let _ = self.tx.try_send((id, mouse));
+ #[cfg(not(windows))]
+ let _ = (id, mouse);
+ }
+}
+
+#[cfg(windows)]
+fn worker(rx: crossbeam_channel::Receiver<(u32, Mouse)>) {
+ use std::collections::HashMap;
+ use std::time::{Duration, Instant};
+ use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
+ INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN,
+ MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_WHEEL, MOUSEINPUT, SendInput,
+ };
+ let emit = |dx: i32, dy: i32, data: u32, flags: u32| {
+ let input = INPUT {
+ r#type: INPUT_MOUSE,
+ Anonymous: INPUT_0 { mi: MOUSEINPUT { dx, dy, mouseData: data, dwFlags: flags, time: 0, dwExtraInfo: 0 } },
+ };
+ unsafe { SendInput(1, &input, size_of::() as i32) };
+ };
+ let mut owners: HashMap = HashMap::new();
+ let mut held = 0u8;
+ let mut residual = (0f32, 0f32, 0f32);
+ loop {
+ let mut tap = false;
+ let mut disconnected = false;
+ match rx.recv_timeout(Duration::from_millis(20)) {
+ Ok((id, m)) => {
+ owners.insert(id, (m.buttons & 3, Instant::now()));
+ residual.0 += m.dx.clamp(-20000., 20000.);
+ residual.1 += m.dy.clamp(-20000., 20000.);
+ residual.2 += m.wheel.clamp(-2400., 2400.);
+ tap = m.tap;
+ }
+ Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
+ owners.clear();
+ disconnected = true;
+ }
+ Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
+ }
+ owners.retain(|_, (_, at)| at.elapsed() < Duration::from_millis(250));
+ let buttons = owners.values().fold(0, |bits, (b, _)| bits | b);
+ for (mask, down, up) in [(1, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP), (2, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP)] {
+ if (held ^ buttons) & mask != 0 {
+ emit(0, 0, 0, if buttons & mask != 0 { down } else { up });
+ }
+ }
+ held = buttons;
+ let (dx, dy, wheel) = (residual.0.trunc() as i32, residual.1.trunc() as i32, residual.2.trunc() as i32);
+ residual.0 -= dx as f32;
+ residual.1 -= dy as f32;
+ residual.2 -= wheel as f32;
+ if dx != 0 || dy != 0 {
+ emit(dx, dy, 0, MOUSEEVENTF_MOVE);
+ }
+ if wheel != 0 {
+ emit(0, 0, wheel as u32, MOUSEEVENTF_WHEEL);
+ }
+ if tap && held & 1 == 0 {
+ emit(0, 0, 0, MOUSEEVENTF_LEFTDOWN);
+ emit(0, 0, 0, MOUSEEVENTF_LEFTUP);
+ }
+ if disconnected {
+ break;
+ }
+ }
+}
diff --git a/crates/open-controller-core/src/dsu.rs b/crates/open-controller-core/src/dsu.rs
new file mode 100644
index 0000000..89606c1
--- /dev/null
+++ b/crates/open-controller-core/src/dsu.rs
@@ -0,0 +1,11 @@
+//! DSU status shared by the Lab snapshot.
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Status {
+ pub running: bool,
+ pub port: u16,
+ pub clients: usize,
+ pub error: Option,
+}
diff --git a/crates/open-controller-core/src/engine.rs b/crates/open-controller-core/src/engine.rs
index 4c95d24..75e9025 100644
--- a/crates/open-controller-core/src/engine.rs
+++ b/crates/open-controller-core/src/engine.rs
@@ -133,6 +133,7 @@ pub enum PadKey {
}
pub enum Command {
+ TriggerPreview(PadKey, Option<[crate::lab::TriggerEffect; 2]>),
SetHiding(bool),
/// Rumble a controller briefly, to tell which one is which.
Identify(PadKey),
@@ -185,6 +186,11 @@ pub enum Role {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PadView {
+ #[serde(default)]
+ pub lab_status: crate::lab::Status,
+ /// Actual mute flag of Windows' default input endpoint, not a latched button press.
+ #[serde(default)]
+ pub microphone_muted: Option,
pub key: PadKey,
pub name: String,
pub brand: Brand,
@@ -393,6 +399,7 @@ struct Model {
/// A connected device, as SDL sees it.
struct Phys {
+ tools: Tools,
pad: Gamepad,
model: Model,
link: Link,
@@ -430,6 +437,31 @@ struct Phys {
tilt_at: Option,
}
+struct Tools {
+ runtime: crate::lab::Runtime,
+ trial: Option<(Instant, [crate::lab::TriggerEffect; 2])>,
+ last_effect: Option<([crate::lab::TriggerEffect; 2], Instant)>,
+ error: Option,
+ mouse_on: bool,
+}
+impl Tools {
+ fn wanted(&mut self, cfg: crate::lab::Config, owned: bool, now: Instant) -> ([crate::lab::TriggerEffect; 2], bool) {
+ let expired = self.trial.is_some_and(|(until, _)| now >= until);
+ if expired || !owned {
+ self.trial = None;
+ }
+ let effects = if owned {
+ self.trial.map(|(_, e)| e).unwrap_or(if cfg.adaptive_enabled { cfg.adaptive } else { Default::default() })
+ } else {
+ Default::default()
+ };
+ (effects, expired)
+ }
+ fn new(_now: Instant) -> Self {
+ Self { runtime: Default::default(), trial: None, last_effect: None, error: None, mouse_on: false }
+ }
+}
+
impl Phys {
/// What its motion sensors read, for a virtual DualShock 4: at rest when they are off.
fn motion(&self) -> Motion {
@@ -541,6 +573,9 @@ impl Target {
}
struct Core {
+ desktop: crate::desktop::Desktop,
+ microphone: crate::microphone::Monitor,
+ microphone_seen: Option,
bus: Option>,
/// Read once on connecting: the device tree is slow to query, and this is the input thread.
bus_version: Option,
@@ -638,6 +673,9 @@ fn run(config: Config, rx: Receiver, shared: Arc>, on_chang
HANDHELD_STOP.store(false, Ordering::Relaxed);
let handheld_reader = handheld::start(handheld_tx, &HANDHELD_STOP);
let mut core = Core {
+ desktop: crate::desktop::Desktop::start(),
+ microphone: crate::microphone::Monitor::start(),
+ microphone_seen: None,
bus: None,
bus_version: None,
bus_error: None,
@@ -712,9 +750,15 @@ fn run(config: Config, rx: Receiver, shared: Arc>, on_chang
core.handheld_buttons();
core.poll_sixaxis(now);
core.forward(now);
+ core.update_tools(now);
core.macro_buttons();
core.feedback(now);
core.housekeeping(now);
+ let muted = core.microphone.muted();
+ if core.microphone_seen != muted {
+ core.microphone_seen = muted;
+ core.changed = true;
+ }
let waiting = core.roster.slots().iter().any(|s| s.lost_since.is_some());
let countdown = waiting && now.duration_since(last_countdown) >= Duration::from_millis(500);
@@ -814,6 +858,15 @@ impl Core {
fn command(&mut self, c: Command, now: Instant) {
match c {
+ Command::TriggerPreview(key, effects) => {
+ if let Some(id) = self.device_for(key)
+ && let Some(p) = self.phys.get_mut(&id)
+ && matches!(p.model.family, Family::DualSense | Family::DualSenseEdge)
+ {
+ p.tools.trial = effects.map(|e| (now + Duration::from_secs(5), e.map(crate::lab::TriggerEffect::clean)));
+ self.changed = true;
+ }
+ }
Command::SetHiding(on) => {
self.hiding = on;
if on {
@@ -1142,7 +1195,10 @@ impl Core {
let ds4 = self.output_of(&p.model.store) == Output::DualShock4;
let gyro = matches!(p.kind, Kind::Slot(s) if !self.is_native(s))
&& p.model.features.motion
- && (ds4 || self.profile_of(&p.model.store).is_some_and(|pr| pr.gyro.mode != GyroMode::Off));
+ && (ds4
+ || cfg!(windows)
+ && self.profile_of(&p.model.store).is_some_and(|pr| pr.lab.flick.enabled && pr.lab.flick.gyro_mouse)
+ || self.profile_of(&p.model.store).is_some_and(|pr| pr.gyro.mode != GyroMode::Off));
(gyro, gyro && ds4)
};
let p = self.phys.get_mut(&id).unwrap();
@@ -1344,6 +1400,7 @@ impl Core {
model.store = format!("handheld:{}", m.name);
}
let mut p = Phys {
+ tools: Tools::new(Instant::now()),
model,
link,
identity,
@@ -1369,6 +1426,7 @@ impl Core {
pad,
};
p.pad.read(&mut p.state);
+ p.tools.runtime.output = p.state;
let xbox = matches!(p.pad.pad_type(), device::PadType::Xbox360 | device::PadType::XboxOne);
p.kind = if devnode::is_virtual(&p.path) {
Kind::Own
@@ -1513,6 +1571,10 @@ impl Core {
self.gone_halves.push((p.pad.pad_type() == device::PadType::JoyConRight, p.path.clone(), now));
}
self.updated.retain(|&d| d != id);
+ self.desktop.send(id, Default::default());
+ if p.tools.last_effect.is_some() {
+ let _ = p.pad.trigger_effect(Default::default());
+ }
self.macro_held.remove(&id);
if let Kind::Slot(s) = p.kind {
self.roster.detach(id, now);
@@ -1599,7 +1661,7 @@ impl Core {
fn report_of(&self, dev: DeviceId) -> XusbReport {
let Some(p) = self.phys.get(&dev) else { return XusbReport::default() };
let profile = self.profile_of(&p.model.store);
- let mut state = p.state;
+ let mut state = p.tools.runtime.output;
if let Some(pr) = profile {
state.buttons &= !binding::taken(&pr.bindings);
// The real triggers, before pressure can move them too.
@@ -1617,12 +1679,12 @@ impl Core {
}
let mut r = mapping::to_xusb(&state);
// The gyro adds to the right stick, so the stick still turns and the gyro fine-tunes.
- if p.aim_out != (0, 0) {
+ if p.aim_out != (0, 0) && !profile.is_some_and(|pr| cfg!(windows) && pr.lab.flick.enabled) {
r.thumb_rx = (i32::from(r.thumb_rx) + i32::from(p.aim_out.0)).clamp(-32768, 32767) as i16;
r.thumb_ry = (i32::from(r.thumb_ry) + i32::from(p.aim_out.1)).clamp(-32768, 32767) as i16;
}
if let Some(pr) = profile {
- binding::overlay(&pr.bindings, p.state.buttons, &mut r);
+ binding::overlay(&pr.bindings, p.tools.runtime.output.buttons, &mut r);
}
if let Some(b) = self.macro_held.get(&dev) {
b.press(&mut r);
@@ -1699,7 +1761,13 @@ impl Core {
for p in self.phys.values_mut() {
// Where controllers stay as they are, their lights are still OpenController's to set.
let player = match p.kind {
- Kind::Slot(_) if native(&self.profiles, &p.model.store, p.model.form) => continue,
+ Kind::Slot(_)
+ if native(&self.profiles, &p.model.store, p.model.form)
+ && !self.profiles.get(&p.model.store).is_some_and(|x| x.native_light) =>
+ {
+ p.led = None;
+ continue;
+ }
Kind::Slot(s) => self.targets.get(&s).and_then(|t| t.player),
Kind::Native if !VIRTUAL_PADS => None,
_ => continue,
@@ -1812,9 +1880,6 @@ impl Core {
}
let (kind, state) = (p.kind, p.state);
// Any button can type keys, not only the extra ones.
- if kind == Kind::Native && before.buttons != state.buttons {
- self.type_keys(Owner::Device(id), id, before.buttons, state.buttons);
- }
if let Kind::Slot(s) = kind {
// Stick noise on the idle connection of a controller connected twice must not
// pull the slot over to it.
@@ -1824,9 +1889,6 @@ impl Core {
}
if self.roster.get(s).and_then(|x| x.source) == Some(id) && !self.is_native(s) {
self.resend(s);
- if before.buttons != state.buttons || !self.held.is_empty() {
- self.type_keys(Owner::Slot(s), id, before.buttons, state.buttons);
- }
}
}
}
@@ -1834,6 +1896,82 @@ impl Core {
self.updated.clear();
}
+ fn update_tools(&mut self, now: Instant) {
+ let ids: Vec = self.phys.keys().copied().collect();
+ for id in ids {
+ let (owned, mapped) = match self.phys[&id].kind {
+ Kind::Slot(s) => (self.roster.get(s).and_then(|slot| slot.source) == Some(id), !self.is_native(s)),
+ Kind::Native => (true, false),
+ _ => (false, false),
+ };
+ let cfg = self.profile_of(&self.phys[&id].model.store).map(|p| p.lab).unwrap_or_default();
+ let p = self.phys.get_mut(&id).unwrap();
+ let before = p.tools.runtime.output;
+ if owned {
+ if !p.tools.runtime.active {
+ p.tools.runtime = Default::default();
+ }
+ let mouse = p.tools.runtime.update(p.state, cfg, mapped, p.rate, now);
+ p.tools.runtime.active = true;
+ let mouse_on = cfg!(windows) && (cfg.touch.mode != crate::lab::TouchMode::Off || mapped && cfg.flick.enabled);
+ if mouse_on || p.tools.mouse_on {
+ self.desktop.send(id, mouse);
+ }
+ p.tools.mouse_on = mouse_on;
+ } else {
+ if p.tools.mouse_on {
+ self.desktop.send(id, Default::default());
+ p.tools.mouse_on = false;
+ }
+ p.tools.runtime.active = false;
+ p.tools.trial = None;
+ }
+ let (effects, expired) = p.tools.wanted(cfg, owned, now);
+ self.changed |= expired;
+ if matches!(p.model.family, Family::DualSense | Family::DualSenseEdge) {
+ let active = effects.iter().any(|e| e.mode != crate::lab::EffectMode::Off && e.strength > 0);
+ let due = match p.tools.last_effect {
+ None => active || p.tools.trial.is_some(),
+ Some((last, at)) => {
+ last != effects || (active || p.tools.error.is_some()) && now.duration_since(at) >= Duration::from_secs(1)
+ }
+ };
+ if due {
+ let error = p.pad.trigger_effect(effects).err();
+ self.changed |= error != p.tools.error;
+ p.tools.error = error;
+ p.tools.last_effect = Some((effects, now));
+ }
+ }
+ let after = p.tools.runtime.output;
+ let kind = p.kind;
+ let mic_press = owned
+ && crate::microphone::pressed(before.buttons, after.buttons)
+ && matches!(p.model.family, Family::DualSense | Family::DualSenseEdge)
+ && self.profiles.get(&p.model.store).is_some_and(|x| {
+ x.mic_control && !x.for_program(&self.foreground).bindings.contains_key(&(crate::mapping::button::MISC1 as u8))
+ });
+ if mic_press {
+ self.microphone.toggle();
+ }
+ if owned && before != after {
+ self.input_changed = true;
+ if let Kind::Slot(s) = kind
+ && mapped
+ {
+ self.resend(s);
+ }
+ }
+ if owned && (before.buttons != after.buttons || !self.held.is_empty()) {
+ match kind {
+ Kind::Slot(s) if mapped => self.type_keys(Owner::Slot(s), id, before.buttons, after.buttons),
+ Kind::Native => self.type_keys(Owner::Device(id), id, before.buttons, after.buttons),
+ _ => {}
+ }
+ }
+ }
+ }
+
fn feedback(&mut self, now: Instant) {
while let Ok(fb) = self.feedback_rx.try_recv() {
let Some((&s, t)) = self.targets.iter_mut().find(|(_, t)| t.serial == fb.serial) else { continue };
@@ -2149,6 +2287,15 @@ impl Core {
view.role = role;
view.hidden = slot.devices.iter().any(|d| self.is_hidden(*d));
view.input = source.map(|p| p.state).unwrap_or_default();
+ view.lab_status = source
+ .map(|p| crate::lab::Status {
+ testing: p.tools.trial.is_some(),
+ trigger_error: p.tools.error.clone(),
+ dsu: Default::default(),
+ output: p.tools.runtime.output,
+ led: p.led,
+ })
+ .unwrap_or_default();
view.gyro = source.and_then(|p| p.rate);
view.aim = source.map_or((0, 0), |p| p.aim_out);
view.can_power_off = source.is_some_and(|p| p.link == Link::Bluetooth && device::bluetooth_address(&p.identity).is_some());
@@ -2166,6 +2313,13 @@ impl Core {
let player = device::xinput_slot(&p.path).or_else(|| if VIRTUAL_PADS { None } else { p.pad.player() });
view.role = Role::Native { player };
view.input = p.state;
+ view.lab_status = crate::lab::Status {
+ testing: p.tools.trial.is_some(),
+ trigger_error: p.tools.error.clone(),
+ dsu: Default::default(),
+ output: p.tools.runtime.output,
+ led: p.led,
+ };
pads.push(view);
}
// In player order, whoever holds the slot: games number them that way. The sort is
@@ -2228,6 +2382,8 @@ impl Core {
// A controller that copies another's ids goes by the name of the model its owner chose.
let alias = model.and_then(|m| models::alias(m.vendor, m.product, chosen));
PadView {
+ lab_status: Default::default(),
+ microphone_muted: model.filter(|m| matches!(m.family, Family::DualSense | Family::DualSenseEdge)).and(self.microphone_seen),
key,
name: alias.map(|a| a.4.to_string()).or_else(|| model.map(|m| m.name.clone())).unwrap_or_default(),
brand: model.map(|m| m.brand).unwrap_or(Brand::Other),
@@ -2262,6 +2418,12 @@ impl Core {
}
fn shutdown(&mut self) {
+ for (&id, p) in &self.phys {
+ self.desktop.send(id, Default::default());
+ if p.tools.last_effect.is_some() {
+ let _ = p.pad.trigger_effect(Default::default());
+ }
+ }
for p in self.phys.values().filter(|p| !(matches!(p.kind, Kind::Slot(_)) && native(&self.profiles, &p.model.store, p.model.form))) {
p.pad.rumble(0, 0, 0);
}
@@ -2316,6 +2478,21 @@ fn slot_role(native: bool, remaining: Option, target: Option