mirror of
https://github.com/ddeverill/SteamlessController.git
synced 2026-10-22 14:42:16 -05:00
The 2026 controller's triggers are smooth analog travel with no physical second stage, so a trigger set to Dual Stage holds a full-press binding once it is pulled past a threshold. The analog value still reaches the game untouched, so a game sees the pull and the extra input together. The threshold is software on the analog value, with hysteresis: it presses at 95% and lets go at 80% by default, which is what measurement showed the firmware's own full-pull bit does. Both triggers share one code path and the point is adjustable. The left trigger's firmware bit turned up at buf[5] bit 3 and is named BTN_LT_FULL; nothing here reads it, but it is now documented where the right one is. The binding is an ordinary BackButtonBinding, so it can be a gamepad button, a key combo or a mouse button, and goes out through the same paddle dispatch and virtual-pad paths. It is released if the mode is switched off mid-hold. There is no actuator in a trigger, so the click is a pad click plus a short grip buzz on the trigger's side. It is queued to the rumble thread rather than written from the read loop, because a Bluetooth write can block for seconds. The buzz merges with whatever rumble a game is already asking for instead of cancelling it, and a click is dropped if the previous one on that side is still playing. Customize Controls gains a group per trigger: a mode select, a live meter with draggable press and release markers, a Full Press rebind row and a click-feel select. Settings persist per profile, and profiles written before this existed read back as a plain analog trigger. The settings page's script and styles each go in a literal of their own, because the neighbouring ones are within a kilobyte of the compiler's limit on a single string. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
230 lines
10 KiB
C++
230 lines
10 KiB
C++
#pragma once
|
|
#include <algorithm>
|
|
#include <cstdint>
|
|
#include <string>
|
|
#include "BackButtonConfig.h"
|
|
|
|
// Simulated dual-stage triggers (#75).
|
|
//
|
|
// The 2026 controller's triggers are smooth analog travel with no physical
|
|
// second stage, so a "full press" is a software threshold on the analog value.
|
|
// TriggerProbe measured what that threshold has to live with:
|
|
//
|
|
// - rest reads exactly 0, so there is no noise floor to clear;
|
|
// - every deliberate pull peaks at exactly 100% and sits flat there, so the
|
|
// top of travel is a firm place to put a threshold;
|
|
// - the firmware's own full-pull bit (BTN_RT_FULL / BTN_LT_FULL) fires at
|
|
// 95-100% and lets go at about 80%, which is the click a hand would expect.
|
|
//
|
|
// The defaults below copy that, and the analog value is thresholded in software
|
|
// rather than reading the firmware bit so both triggers share one code path and
|
|
// the point stays adjustable.
|
|
|
|
// What a trigger does besides being analog. Values are stable — persisted as
|
|
// DWORDs, and Standard is 0 so a missing or zeroed value is exactly how every
|
|
// profile written before this existed behaved.
|
|
enum class TriggerMode : uint8_t {
|
|
Standard = 0, // analog passthrough only
|
|
DualStage = 1, // analog passthrough, plus a binding held past the press point
|
|
COUNT
|
|
};
|
|
|
|
inline TriggerMode TriggerModeFromDword(uint32_t v) {
|
|
return v < static_cast<uint32_t>(TriggerMode::COUNT)
|
|
? static_cast<TriggerMode>(v)
|
|
: TriggerMode::Standard;
|
|
}
|
|
|
|
// When the simulated click is felt. There is no actuator in the trigger, so the
|
|
// click comes from the controller body — a pad click and a grip buzz on the
|
|
// trigger's side, which hardware testing found the most click-like of the
|
|
// candidates (see SteamController::QueueTriggerClick).
|
|
enum class TriggerHaptic : uint8_t {
|
|
Off = 0,
|
|
Press = 1,
|
|
PressAndRelease = 2, // like a physical click: once down, once up
|
|
COUNT
|
|
};
|
|
|
|
inline TriggerHaptic TriggerHapticFromDword(uint32_t v) {
|
|
return v < static_cast<uint32_t>(TriggerHaptic::COUNT)
|
|
? static_cast<TriggerHaptic>(v)
|
|
: TriggerHaptic::PressAndRelease;
|
|
}
|
|
|
|
// Wire ids for the settings page, which speaks in strings. Anything unknown —
|
|
// including an empty or missing value — lands on the safe default, so a page
|
|
// that predates a setting cannot turn dual-stage on by omission.
|
|
inline const char* TriggerModeId(TriggerMode m) {
|
|
return m == TriggerMode::DualStage ? "dual" : "standard";
|
|
}
|
|
inline TriggerMode TriggerModeFromId(const std::string& id) {
|
|
return id == "dual" ? TriggerMode::DualStage : TriggerMode::Standard;
|
|
}
|
|
|
|
inline const char* TriggerHapticId(TriggerHaptic h) {
|
|
switch (h) {
|
|
case TriggerHaptic::Off: return "off";
|
|
case TriggerHaptic::Press: return "press";
|
|
default: return "both";
|
|
}
|
|
}
|
|
inline TriggerHaptic TriggerHapticFromId(const std::string& id) {
|
|
if (id == "off") return TriggerHaptic::Off;
|
|
if (id == "press") return TriggerHaptic::Press;
|
|
return TriggerHaptic::PressAndRelease;
|
|
}
|
|
|
|
// Raw trigger range in the state report: 0x0000 released to 0x7FFF fully pulled.
|
|
inline constexpr int kTriggerRawMax = 0x7FFF;
|
|
|
|
// Percent of travel. A threshold below ~20% would fire on a trigger resting
|
|
// under a finger, and the release has to leave the press room above it.
|
|
inline constexpr uint32_t kTriggerPressDefault = 95;
|
|
inline constexpr uint32_t kTriggerReleaseDefault = 80;
|
|
inline constexpr uint32_t kTriggerPressMin = 20;
|
|
inline constexpr uint32_t kTriggerPressMax = 100;
|
|
inline constexpr uint32_t kTriggerReleaseMin = 5;
|
|
// Closest the release may sit to the press. With none, a trigger resting right
|
|
// on the press point would chatter on every report.
|
|
inline constexpr uint32_t kTriggerHysteresisMin = 3;
|
|
|
|
inline uint32_t ClampTriggerPress(uint32_t v) {
|
|
return std::clamp(v, kTriggerPressMin, kTriggerPressMax);
|
|
}
|
|
|
|
// Always strictly below the (already clamped) press point.
|
|
inline uint32_t ClampTriggerRelease(uint32_t release, uint32_t press) {
|
|
const uint32_t ceiling = press > kTriggerHysteresisMin ? press - kTriggerHysteresisMin
|
|
: kTriggerReleaseMin;
|
|
// (std::max) in parentheses: this header is included from files that pull in
|
|
// Windows.h without NOMINMAX, whose max macro would eat the call.
|
|
return std::clamp(release, kTriggerReleaseMin, (std::max)(ceiling, kTriggerReleaseMin));
|
|
}
|
|
|
|
// A percentage that arrived as text, where zero or nothing parseable means "not
|
|
// sent" rather than "zero percent". The same convention as the scroll speed: a
|
|
// missing value has to land on the default, not be clamped up to the minimum.
|
|
inline uint32_t TriggerPressFromWire(uint32_t v) {
|
|
return v == 0 ? kTriggerPressDefault : ClampTriggerPress(v);
|
|
}
|
|
inline uint32_t TriggerReleaseFromWire(uint32_t v, uint32_t press) {
|
|
return v == 0 ? ClampTriggerRelease(kTriggerReleaseDefault, press)
|
|
: ClampTriggerRelease(v, press);
|
|
}
|
|
|
|
// One trigger's configuration. The full press is a BackButtonBinding rather
|
|
// than a type of its own for the same reason a trackpad click is: it is the
|
|
// same question ("what should this input do"), answered by the same catalog and
|
|
// dispatched down the same path as the paddles.
|
|
struct TriggerSettings {
|
|
TriggerMode mode = TriggerMode::Standard;
|
|
uint32_t press = kTriggerPressDefault; // percent of travel
|
|
uint32_t release = kTriggerReleaseDefault; // percent of travel
|
|
TriggerHaptic haptic = TriggerHaptic::PressAndRelease;
|
|
BackButtonBinding full = BackButtonBinding::FromAction(BackButtonAction::None);
|
|
|
|
bool operator==(const TriggerSettings& o) const {
|
|
return mode == o.mode && press == o.press && release == o.release
|
|
&& haptic == o.haptic && full == o.full;
|
|
}
|
|
bool operator!=(const TriggerSettings& o) const { return !(*this == o); }
|
|
|
|
bool IsDualStage() const { return mode == TriggerMode::DualStage; }
|
|
|
|
// The binding to hold while the trigger is past its press point, or nothing
|
|
// at all outside dual-stage mode. Reading it through here rather than the
|
|
// field is what stops a binding left over from an earlier mode firing from
|
|
// behind hidden UI.
|
|
BackButtonBinding EffectiveFull() const {
|
|
return IsDualStage() ? full : BackButtonBinding{};
|
|
}
|
|
|
|
bool ClicksOnPress() const {
|
|
return IsDualStage() && haptic != TriggerHaptic::Off;
|
|
}
|
|
bool ClicksOnRelease() const {
|
|
return IsDualStage() && haptic == TriggerHaptic::PressAndRelease;
|
|
}
|
|
};
|
|
|
|
// Whether one trigger is past its press point, with hysteresis: it presses at
|
|
// `press` and does not let go until it has come back down to `release`.
|
|
//
|
|
// Hysteresis is not optional. The firmware's own bit has about 20 points of it
|
|
// for exactly this reason — a finger resting near a single threshold would
|
|
// otherwise press and release the binding on every report.
|
|
class TriggerFullPress {
|
|
public:
|
|
// raw is 0..kTriggerRawMax. Returns whether the full press is held.
|
|
bool Update(int raw, const TriggerSettings& s) {
|
|
if (!s.IsDualStage()) {
|
|
// Leaving dual-stage mode while held reports a release this frame,
|
|
// which is what lets the dispatcher let go of what the press sent.
|
|
held_ = false;
|
|
return false;
|
|
}
|
|
const uint32_t press = ClampTriggerPress(s.press);
|
|
const uint32_t release = ClampTriggerRelease(s.release, press);
|
|
const int pressAt = RawAt(press);
|
|
const int releaseAt = RawAt(release);
|
|
if (!held_ && raw >= pressAt) held_ = true;
|
|
else if (held_ && raw <= releaseAt) held_ = false;
|
|
return held_;
|
|
}
|
|
|
|
void Reset() { held_ = false; }
|
|
bool Held() const { return held_; }
|
|
|
|
// Percent of travel as a raw value, rounded up so a threshold never fires
|
|
// a hair early.
|
|
static int RawAt(uint32_t percent) {
|
|
return static_cast<int>((static_cast<uint64_t>(percent) * kTriggerRawMax + 99) / 100);
|
|
}
|
|
|
|
private:
|
|
bool held_ = false;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Persistence
|
|
//
|
|
// The registry is read from two places that cannot share a helper — the default
|
|
// profile in TrayApp and every game profile in GameProfiles — and each already
|
|
// carries its own copy of the per-pad names. Written once here, so a trigger's
|
|
// values cannot drift apart between the two.
|
|
//
|
|
// readDw(name, default) -> value; writeDw(name, value). Names are the prefix
|
|
// plus Mode / Press / Release / Haptic / Full, e.g. "LeftTriggerMode".
|
|
//
|
|
// Every value is absent from a profile written before this existed, and the
|
|
// defaults make that read back as Standard — a trigger that is just analog,
|
|
// which is exactly what those profiles did.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
template <class ReadDw>
|
|
inline TriggerSettings ReadTriggerSettings(const wchar_t* prefix, ReadDw&& readDw) {
|
|
auto name = [&](const wchar_t* suffix) { return std::wstring(prefix) + suffix; };
|
|
TriggerSettings t;
|
|
t.mode = TriggerModeFromDword(readDw(name(L"Mode").c_str(), 0u));
|
|
t.press = ClampTriggerPress(readDw(name(L"Press").c_str(), kTriggerPressDefault));
|
|
t.release = ClampTriggerRelease(readDw(name(L"Release").c_str(), kTriggerReleaseDefault),
|
|
t.press);
|
|
t.haptic = TriggerHapticFromDword(readDw(name(L"Haptic").c_str(),
|
|
static_cast<uint32_t>(TriggerHaptic::PressAndRelease)));
|
|
t.full = BackButtonBinding::Unpack(readDw(name(L"Full").c_str(),
|
|
BackButtonBinding::FromAction(BackButtonAction::None).Pack()));
|
|
return t;
|
|
}
|
|
|
|
template <class WriteDw>
|
|
inline void WriteTriggerSettings(const wchar_t* prefix, const TriggerSettings& t,
|
|
WriteDw&& writeDw) {
|
|
auto name = [&](const wchar_t* suffix) { return std::wstring(prefix) + suffix; };
|
|
writeDw(name(L"Mode").c_str(), static_cast<uint32_t>(t.mode));
|
|
writeDw(name(L"Press").c_str(), t.press);
|
|
writeDw(name(L"Release").c_str(), t.release);
|
|
writeDw(name(L"Haptic").c_str(), static_cast<uint32_t>(t.haptic));
|
|
writeDw(name(L"Full").c_str(), t.full.Pack());
|
|
}
|