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.

16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,22 @@ 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.

## 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
21 changes: 21 additions & 0 deletions crates/open-controller-core/examples/windows/loopback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,13 @@ 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 +235,21 @@ 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));

// A profile for a program: in front, its assignments; away, the chosen profile's again.
profiles.apply(Edit::Select(0));
Expand Down
37 changes: 36 additions & 1 deletion crates/open-controller-core/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ pub enum Role {

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PadView {
/// Actual mute flag of Windows' default input endpoint, not a latched button press.
#[serde(default)]
pub microphone_muted: Option<bool>,
pub key: PadKey,
pub name: String,
pub brand: Brand,
Expand Down Expand Up @@ -541,6 +544,8 @@ impl Target {
}

struct Core {
microphone: crate::microphone::Monitor,
microphone_seen: Option<bool>,
bus: Option<Arc<Bus>>,
/// Read once on connecting: the device tree is slow to query, and this is the input thread.
bus_version: Option<String>,
Expand Down Expand Up @@ -638,6 +643,8 @@ fn run(config: Config, rx: Receiver<Msg>, shared: Arc<Mutex<Snapshot>>, on_chang
HANDHELD_STOP.store(false, Ordering::Relaxed);
let handheld_reader = handheld::start(handheld_tx, &HANDHELD_STOP);
let mut core = Core {
microphone: crate::microphone::Monitor::start(),
microphone_seen: None,
bus: None,
bus_version: None,
bus_error: None,
Expand Down Expand Up @@ -715,6 +722,11 @@ fn run(config: Config, rx: Receiver<Msg>, shared: Arc<Mutex<Snapshot>>, on_chang
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);
Expand Down Expand Up @@ -1699,7 +1711,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,
Expand Down Expand Up @@ -1777,6 +1795,22 @@ impl Core {
let aim_before = p.aim_out;
let (rate_before, accel_before) = (p.rate, p.accel);
p.pad.read(&mut p.state);
let mic_owner = match p.kind {
Kind::Slot(s) => self.roster.get(s).and_then(|s| s.source) == Some(id),
Kind::Native => true,
_ => false,
};
if mic_owner
&& matches!(p.model.family, Family::DualSense | Family::DualSenseEdge)
&& self.profiles.get(&p.model.store).is_some_and(|x| x.mic_control)
&& !self
.profiles
.get(&p.model.store)
.is_some_and(|x| x.for_program(&self.foreground).bindings.contains_key(&(crate::mapping::button::MISC1 as u8)))
&& crate::microphone::pressed(before.buttons, p.state.buttons)
{
self.microphone.toggle();
}
if p.model.family == Family::Handheld && p.kind == Kind::Native {
p.state.buttons |= self.handheld_held;
}
Expand Down Expand Up @@ -2228,6 +2262,7 @@ 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 {
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),
Expand Down
27 changes: 27 additions & 0 deletions crates/open-controller-core/src/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ pub struct Text {
pub touchpad_click: &'static str,
pub capture: &'static str,
pub mic: &'static str,
pub native_light: &'static str,
pub native_light_hint: &'static str,
pub mic_sync: &'static str,
pub mic_sync_hint: &'static str,
pub mic_muted: &'static str,
pub mic_live: &'static str,
pub mic_unavailable: &'static str,
pub rgb_hex: &'static str,
pub rgb_hex_hint: &'static str,
pub share: &'static str,
pub extra_n: &'static str,

Expand Down Expand Up @@ -584,6 +593,15 @@ pub const EN: Text = Text {
touchpad_click: "Touchpad click",
capture: "Capture",
mic: "Mic",
native_light: "Control lighting in native mode",
native_light_hint: "Keeps native input. Lighting may conflict with a game that also controls it.",
mic_sync: "Mic button controls Windows default microphone",
mic_sync_hint: "Press to toggle actual mute. Other assignments take priority. Bluetooth may use the PC microphone.",
mic_muted: "Windows microphone: muted · active",
mic_live: "Windows microphone: unmuted · inactive",
mic_unavailable: "Windows microphone: unavailable",
rgb_hex: "RGB colour · #RRGGBB",
rgb_hex_hint: "Enter six hexadecimal digits to apply.",
share: "Share",
extra_n: "Extra {}",

Expand Down Expand Up @@ -1052,6 +1070,15 @@ pub const PT: Text = Text {
touchpad_click: "Clique do touchpad",
capture: "Captura",
mic: "Microfone",
native_light: "Controlar luz no modo nativo",
native_light_hint: "Mantém a entrada nativa. A luz pode entrar em conflito com o jogo.",
mic_sync: "Botão de microfone controla a entrada padrão do Windows",
mic_sync_hint: "Aperte para alternar o mudo real. Outros mapeamentos têm prioridade. Bluetooth pode usar o microfone do PC.",
mic_muted: "Microfone do Windows: mudo · ativo",
mic_live: "Microfone do Windows: aberto · inativo",
mic_unavailable: "Microfone do Windows: indisponível",
rgb_hex: "Cor RGB · #RRGGBB",
rgb_hex_hint: "Digite seis dígitos hexadecimais para aplicar.",
share: "Compartilhar",
extra_n: "Extra {}",

Expand Down
9 changes: 9 additions & 0 deletions crates/open-controller-core/src/i18n/zh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ pub const ZH: Text = Text {
touchpad_click: "触摸板按下",
capture: "截图键",
mic: "麦克风键",
native_light: "原生模式下控制灯光",
native_light_hint: "保留原始输入。若游戏也控制灯光,双方可能互相覆盖。",
mic_sync: "麦克风键控制 Windows 默认麦克风",
mic_sync_hint: "按一下切换实际静音;此键的其他映射优先。蓝牙下可能使用电脑麦克风。",
mic_muted: "Windows 麦克风:已静音 · active",
mic_live: "Windows 麦克风:未静音 · inactive",
mic_unavailable: "Windows 麦克风:不可用",
rgb_hex: "RGB 颜色 · #RRGGBB",
rgb_hex_hint: "输入完整的六位十六进制颜色即可应用。",
share: "分享键",
extra_n: "额外按键 {}",
tab_xbox: "Xbox 按键",
Expand Down
1 change: 1 addition & 0 deletions crates/open-controller-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod ds4;
pub mod extras;
pub mod i18n;
pub mod mapping;
mod microphone;
pub mod models;
pub mod motion;
pub mod profile;
Expand Down
33 changes: 32 additions & 1 deletion crates/open-controller-core/src/mapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,23 @@ pub mod xusb {
pub const Y: u16 = 0x8000;
}

/// A finger on a touchpad, in normalised 0..=65535 coordinates. Released fingers are zeroed.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Touch {
pub down: bool,
pub x: u16,
pub y: u16,
}

impl Touch {
pub fn from_position(down: bool, x: f32, y: f32) -> Self {
if !down || !x.is_finite() || !y.is_finite() {
return Self::default();
}
Self { down, x: (x.clamp(0., 1.) * 65535.).round() as u16, y: (y.clamp(0., 1.) * 65535.).round() as u16 }
}
}

/// One gamepad's input as SDL reports it: a bit per button, sticks in -32768..=32767 with Y
/// growing downwards, triggers in 0..=32767.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
Expand All @@ -65,6 +82,8 @@ pub struct PadState {
/// (`extras::TOUCH_LEFT` and up).
pub buttons: u64,
pub axes: [i16; axis::COUNT],
#[serde(default)]
pub touches: [Touch; 2],
/// How hard each pressure-sensitive button is pressed, 0 to 255, in [`PRESSURE`]'s order; all
/// 0 on a controller without them.
#[serde(default)]
Expand Down Expand Up @@ -178,7 +197,9 @@ const NOISE: i32 = 2048;

/// A change a person made: a button, or an axis moved further than stick noise.
pub fn significant(before: &PadState, after: &PadState) -> bool {
before.buttons != after.buttons || before.axes.iter().zip(after.axes).any(|(&a, b)| (a as i32 - b as i32).abs() > NOISE)
before.buttons != after.buttons
|| before.touches != after.touches
|| before.axes.iter().zip(after.axes).any(|(&a, b)| (a as i32 - b as i32).abs() > NOISE)
}

/// XInput motor speed (0..=255) to SDL's rumble intensity (0..=65535).
Expand All @@ -190,6 +211,16 @@ pub fn motor(v: u8) -> u16 {
mod tests {
use super::*;

#[test]
fn touch_contacts_are_bounded_and_release_clears_stale_coordinates() {
assert_eq!(Touch::from_position(true, 0., 1.), Touch { down: true, x: 0, y: 65535 });
assert_eq!(Touch::from_position(true, -1., 2.), Touch { down: true, x: 0, y: 65535 });
assert_eq!(Touch::from_position(false, 0.8, 0.5), Touch::default());
assert_eq!(Touch::from_position(true, f32::NAN, 0.), Touch::default());
let old = r#"{"buttons":0,"axes":[0,0,0,0,0,0]}"#;
assert_eq!(serde_json::from_str::<PadState>(old).unwrap().touches, [Touch::default(); 2]);
}

fn state(buttons: &[u32], axes: [i16; 6]) -> PadState {
PadState { buttons: buttons.iter().fold(0, |b, &i| b | 1 << i), axes, ..PadState::default() }
}
Expand Down
Loading
Loading