Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 47 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/open-controller-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
140 changes: 132 additions & 8 deletions crates/open-controller-core/examples/windows/loopback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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 { .. }) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand All @@ -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));
Expand Down Expand Up @@ -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()));

Expand All @@ -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,
Expand Down
88 changes: 88 additions & 0 deletions crates/open-controller-core/src/desktop.rs
Original file line number Diff line number Diff line change
@@ -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::<INPUT>() as i32) };
};
let mut owners: HashMap<u32, (u8, Instant)> = 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;
}
}
}
11 changes: 11 additions & 0 deletions crates/open-controller-core/src/dsu.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}
Loading
Loading