mirror of
https://github.com/ddeverill/SteamlessController.git
synced 2026-10-21 22:22:16 -05:00
Add simulated dual-stage triggers (#75)
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>
This commit is contained in:
1 parent
781fe3624a
commit
e599201683
11 files changed
+885
-16
No files matched your search
@@ -67,8 +67,9 @@ struct ControllerManager::Slot {
|
||||
// Indices 0-3 are the paddles (L4, L5, R4, R5); everything after them
|
||||
// belongs to the trackpads, which are ordinary bindings dispatched down
|
||||
// this same path. 4-5 are the pad clicks, 6-7 the pad touches, and 8-15
|
||||
// the four directions of each pad in turn.
|
||||
static constexpr size_t kBindableCount = 16;
|
||||
// the four directions of each pad in turn. 16-17 are the full presses of
|
||||
// the left and right triggers when they are dual-stage.
|
||||
static constexpr size_t kBindableCount = 18;
|
||||
BackButtonBinding paddleHeld[kBindableCount];
|
||||
|
||||
// What each pad's directions were last frame. Unlike every other edge
|
||||
@@ -159,6 +160,20 @@ struct ControllerManager::Slot {
|
||||
bool prevLeftPressed = false;
|
||||
bool prevRightPressed = false;
|
||||
|
||||
// Whether each trigger is past its full-press point (#75), with the
|
||||
// hysteresis that keeps a finger resting near the point from chattering. The
|
||||
// previous answer is carried here for the same reason as the pads' — it is
|
||||
// resolved from an analog value, not a bit in the previous report — and
|
||||
// doubles as the edge both the binding and the click haptic act on.
|
||||
TriggerFullPress leftTrigger;
|
||||
TriggerFullPress rightTrigger;
|
||||
bool prevLeftTriggerFull = false;
|
||||
bool prevRightTriggerFull = false;
|
||||
// Where each trigger is right now (raw 0..0x7FFF), published for the
|
||||
// settings window's live meter. Per slot and combined by TriggerLevels, so
|
||||
// a second controller sitting idle cannot overwrite the one being pulled.
|
||||
std::atomic<int> triggerRaw[2] = { 0, 0 };
|
||||
|
||||
// Auto-repeat for held key bindings. When the next repeat is due, and the
|
||||
// gap to use after that — both captured at press time from the user's
|
||||
// keyboard settings, so the rate cannot shift mid-hold.
|
||||
@@ -362,6 +377,14 @@ bool ControllerManager::IsGameModeActive() const {
|
||||
[](const auto& s) { return s->gameModeActive; });
|
||||
}
|
||||
|
||||
void ControllerManager::TriggerLevels(int& left, int& right) const {
|
||||
left = right = 0;
|
||||
for (const auto& slot : m_slots) {
|
||||
left = (std::max)(left, slot->triggerRaw[0].load(std::memory_order_relaxed));
|
||||
right = (std::max)(right, slot->triggerRaw[1].load(std::memory_order_relaxed));
|
||||
}
|
||||
}
|
||||
|
||||
bool ControllerManager::IsGameModeShared() const {
|
||||
return std::any_of(m_slots.begin(), m_slots.end(),
|
||||
[](const auto& s) { return s->gameModeActive && s->sharedHandle; });
|
||||
@@ -590,9 +613,28 @@ static std::string DescribePad(const TrackpadSettings& pad) {
|
||||
}
|
||||
}
|
||||
|
||||
// One trigger, in the terms a bug report is written in: where it presses, where
|
||||
// it lets go, what it holds and whether it clicks. A binding is its wire id
|
||||
// ("key:75", "leftMouse") because that is unambiguous and the log is read by
|
||||
// people comparing it against a profile.
|
||||
static std::string DescribeTrigger(const TriggerSettings& t) {
|
||||
if (!t.IsDualStage()) return "standard";
|
||||
const char* clicks = t.haptic == TriggerHaptic::Off ? "no click"
|
||||
: t.haptic == TriggerHaptic::Press ? "click on press"
|
||||
: "click on press and release";
|
||||
char buf[160];
|
||||
snprintf(buf, sizeof(buf), "dual-stage (press %u%%, release %u%%, full=%s, %s)",
|
||||
ClampTriggerPress(t.press),
|
||||
ClampTriggerRelease(t.release, ClampTriggerPress(t.press)),
|
||||
t.full.Id().c_str(), clicks);
|
||||
return buf;
|
||||
}
|
||||
|
||||
void ControllerManager::LogPadSettings() {
|
||||
std::string line = "PADS: left=" + DescribePad(m_profile.leftPad)
|
||||
+ " right=" + DescribePad(m_profile.rightPad);
|
||||
+ " right=" + DescribePad(m_profile.rightPad)
|
||||
+ " | TRIGGERS: left=" + DescribeTrigger(m_profile.leftTrigger)
|
||||
+ " right=" + DescribeTrigger(m_profile.rightTrigger);
|
||||
|
||||
// The failure this exists to make visible: settings changed with game mode
|
||||
// off do nothing at all, and until now nothing said so. Two #95 sessions
|
||||
@@ -730,6 +772,13 @@ void ControllerManager::ReleaseHeldPaddleInputs(Slot& slot) {
|
||||
SendPaddleInput(held, false);
|
||||
held = BackButtonBinding{};
|
||||
}
|
||||
// The triggers' own state goes with what they were holding. Left set, a
|
||||
// trigger already past its press point when the loop restarts would carry
|
||||
// that into the first frame as if it had just been pressed.
|
||||
slot.leftTrigger.Reset();
|
||||
slot.rightTrigger.Reset();
|
||||
slot.prevLeftTriggerFull = false;
|
||||
slot.prevRightTriggerFull = false;
|
||||
}
|
||||
|
||||
void ControllerManager::StartButtonCapture(std::function<void(const BackButtonBinding&)> callback) {
|
||||
@@ -1136,6 +1185,10 @@ void ControllerManager::DisableGameModeSlot(Slot& slot) {
|
||||
slot.leftPad.Reset();
|
||||
slot.rightPad.Reset();
|
||||
ReleaseHeldPaddleInputs(slot);
|
||||
// Nothing is reading the triggers any more, and a meter left at its last
|
||||
// value would show a pull that ended with the read loop.
|
||||
slot.triggerRaw[0].store(0, std::memory_order_relaxed);
|
||||
slot.triggerRaw[1].store(0, std::memory_order_relaxed);
|
||||
slot.vc.reset();
|
||||
slot.sc->EnableLizardMode();
|
||||
// Reopen shared so Steam can obtain write access — game mode is no longer active.
|
||||
@@ -1293,6 +1346,20 @@ void ControllerManager::ReadLoop(Slot* slot) {
|
||||
memcpy(&try_, buf + 26, 2);
|
||||
}
|
||||
|
||||
// Where each trigger is, as a raw 0..0x7FFF. A report too short to carry
|
||||
// them reads as released, which is also what a Standard trigger does
|
||||
// with whatever it is given.
|
||||
int leftTriggerRaw = 0, rightTriggerRaw = 0;
|
||||
if (n >= 10) {
|
||||
int16_t l = 0, r = 0;
|
||||
memcpy(&l, buf + 6, 2);
|
||||
memcpy(&r, buf + 8, 2);
|
||||
leftTriggerRaw = (std::max<int>)(l, 0);
|
||||
rightTriggerRaw = (std::max<int>)(r, 0);
|
||||
}
|
||||
slot->triggerRaw[0].store(leftTriggerRaw, std::memory_order_relaxed);
|
||||
slot->triggerRaw[1].store(rightTriggerRaw, std::memory_order_relaxed);
|
||||
|
||||
const PadDigital resolved{
|
||||
slot->leftPad.Directions(),
|
||||
slot->rightPad.Directions(),
|
||||
@@ -1310,9 +1377,28 @@ void ControllerManager::ReadLoop(Slot* slot) {
|
||||
slot->rightPress.pressed, trx, try_),
|
||||
slot->leftPress.pressed,
|
||||
slot->rightPress.pressed,
|
||||
slot->leftTrigger.Update(leftTriggerRaw, m_profile.leftTrigger),
|
||||
slot->rightTrigger.Update(rightTriggerRaw, m_profile.rightTrigger),
|
||||
};
|
||||
if (slot->vc) slot->vc->Update(buf, n, m_profile, resolved);
|
||||
|
||||
// The simulated click for a dual-stage trigger. Edge-triggered off the
|
||||
// same answer the binding uses, so what is felt and what is sent cannot
|
||||
// disagree about when the press happened. Nothing on the first frame:
|
||||
// that one is a baseline, not an event. The send itself is queued to the
|
||||
// rumble thread and never blocks here.
|
||||
if (hasPrev) {
|
||||
auto triggerClick = [&](bool left, const TriggerSettings& t, bool now, bool prev) {
|
||||
if (now == prev) return;
|
||||
if (now ? t.ClicksOnPress() : t.ClicksOnRelease())
|
||||
slot->sc->QueueTriggerClick(left);
|
||||
};
|
||||
triggerClick(true, m_profile.leftTrigger,
|
||||
resolved.leftTriggerFull, slot->prevLeftTriggerFull);
|
||||
triggerClick(false, m_profile.rightTrigger,
|
||||
resolved.rightTriggerFull, slot->prevRightTriggerFull);
|
||||
}
|
||||
|
||||
// Trackpad haptics.
|
||||
{
|
||||
// Distance of deliberate travel between movement ticks. A full
|
||||
@@ -1601,6 +1687,16 @@ void ControllerManager::ReadLoop(Slot* slot) {
|
||||
{ (rDirs&DirDown)!=0, (rPrevDirs&DirDown)!=0, rPad.EffectiveDirection(DirDown) },
|
||||
{ (rDirs&DirLeft)!=0, (rPrevDirs&DirLeft)!=0, rPad.EffectiveDirection(DirLeft) },
|
||||
{ (rDirs&DirRight)!=0, (rPrevDirs&DirRight)!=0, rPad.EffectiveDirection(DirRight) },
|
||||
// A dual-stage trigger's full press. Resolved from the analog
|
||||
// value with hysteresis, so like the directions its previous
|
||||
// answer is carried on the slot. EffectiveFull is empty outside
|
||||
// dual-stage mode, and the detector reports false there too,
|
||||
// which is what releases a press still held when the mode is
|
||||
// changed.
|
||||
{ resolved.leftTriggerFull, slot->prevLeftTriggerFull,
|
||||
m_profile.leftTrigger.EffectiveFull() },
|
||||
{ resolved.rightTriggerFull, slot->prevRightTriggerFull,
|
||||
m_profile.rightTrigger.EffectiveFull() },
|
||||
};
|
||||
static_assert(std::size(edges) == Slot::kBindableCount,
|
||||
"paddleHeld must have one entry per edge-dispatched binding");
|
||||
@@ -1665,6 +1761,8 @@ void ControllerManager::ReadLoop(Slot* slot) {
|
||||
slot->prevRightTap = resolved.rightTap;
|
||||
slot->prevLeftPressed = resolved.leftPressed;
|
||||
slot->prevRightPressed = resolved.rightPressed;
|
||||
slot->prevLeftTriggerFull = resolved.leftTriggerFull;
|
||||
slot->prevRightTriggerFull = resolved.rightTriggerFull;
|
||||
|
||||
memcpy(prevBuf, buf, n);
|
||||
hasPrev = true;
|
||||
|
||||
@@ -85,6 +85,12 @@ public:
|
||||
// it rebuilds the virtual controller, see the definition.
|
||||
void SetProfile(const ControllerProfile& profile);
|
||||
|
||||
// Where each trigger is right now, raw 0..0x7FFF, for the settings window's
|
||||
// live meter. The larger of every controller's, and 0 while game mode is
|
||||
// off — nothing is reading the controller then. Safe to call from the UI
|
||||
// thread while the read loops run.
|
||||
void TriggerLevels(int& left, int& right) const;
|
||||
|
||||
bool IsConnected() const { return !m_slots.empty(); }
|
||||
bool IsGameModeActive() const;
|
||||
// Whether any slot running game mode is doing so on a handle it shares with
|
||||
|
||||
@@ -120,6 +120,13 @@ std::map<std::wstring, ControllerProfile> Load() {
|
||||
// exactly how those profiles behaved.
|
||||
ReadPad(child, L"LeftPad", p.leftPad);
|
||||
ReadPad(child, L"RightPad", p.rightPad);
|
||||
// Absent before dual-stage triggers existed, which reads back as
|
||||
// Standard: analog only, as those profiles always were.
|
||||
auto readDw = [&](const wchar_t* name, uint32_t def) -> uint32_t {
|
||||
return ReadDw(child, name, def);
|
||||
};
|
||||
p.leftTrigger = ReadTriggerSettings(L"LeftTrigger", readDw);
|
||||
p.rightTrigger = ReadTriggerSettings(L"RightTrigger", readDw);
|
||||
profiles[id] = p;
|
||||
}
|
||||
RegCloseKey(child);
|
||||
@@ -156,6 +163,9 @@ void Save(const std::map<std::wstring, ControllerProfile>& profiles) {
|
||||
WriteDw(child, L"R5", p.back.r5.Pack());
|
||||
WritePad(child, L"LeftPad", p.leftPad);
|
||||
WritePad(child, L"RightPad", p.rightPad);
|
||||
auto writeDw = [&](const wchar_t* name, uint32_t v) { WriteDw(child, name, v); };
|
||||
WriteTriggerSettings(L"LeftTrigger", p.leftTrigger, writeDw);
|
||||
WriteTriggerSettings(L"RightTrigger", p.rightTrigger, writeDw);
|
||||
RegCloseKey(child);
|
||||
}
|
||||
++i;
|
||||
|
||||
+375
-2
@@ -180,6 +180,34 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back
|
||||
.btn-save:hover{opacity:.9;}
|
||||
</style>
|
||||
)HTML"
|
||||
// A second style block rather than more of the one above: that literal is within
|
||||
// a kilobyte of the compiler's limit on a single string. Rules here layer on the
|
||||
// same page exactly as if they were written up there.
|
||||
R"HTML(
|
||||
<style>
|
||||
/* The dual-stage trigger meter: a track showing where the trigger is right now,
|
||||
with a bright marker where the press happens and a dim one where it lets go.
|
||||
The band between them is the hysteresis, shaded so it reads as a zone rather
|
||||
than as two unrelated numbers. */
|
||||
.meter-wrap{display:flex;align-items:center;gap:12px;flex:none;}
|
||||
.meter{position:relative;width:250px;height:12px;border-radius:6px;background:rgba(255,255,255,.1);cursor:pointer;touch-action:none;user-select:none;}
|
||||
.meter-band{position:absolute;top:0;bottom:0;background:rgba(102,192,244,.2);}
|
||||
.meter-fill{position:absolute;left:0;top:0;bottom:0;width:0;border-radius:6px 0 0 6px;background:#4a6a85;}
|
||||
.meter.down .meter-fill{background:#5ba32b;}
|
||||
.meter-mark{position:absolute;top:-6px;width:4px;height:24px;margin-left:-2px;border-radius:2px;cursor:ew-resize;}
|
||||
.meter-mark::after{content:'';position:absolute;top:-2px;bottom:-2px;left:-8px;right:-8px;}
|
||||
.meter-mark.press{background:#66c0f4;}
|
||||
.meter-mark.rel{background:#8f98a0;}
|
||||
.meter-mark:focus{outline:2px solid #fff;outline-offset:2px;}
|
||||
.meter-val{font-family:'JetBrains Mono',monospace;font-size:12px;color:#8f98a0;font-weight:600;min-width:84px;text-align:right;}
|
||||
/* The help line also holds Reset: in the meter row itself it pushed the row wider
|
||||
than the window's minimum width and the page grew a horizontal scrollbar. */
|
||||
.meter-help{display:flex;align-items:flex-start;gap:14px;font-size:12px;color:#7d8b96;line-height:1.45;padding-top:11px;}
|
||||
.meter-help span{flex:1;}
|
||||
.meter-help .btn-row-reset{flex:none;}
|
||||
.meter-help b{color:#66c0f4;font-weight:700;}
|
||||
</style>
|
||||
)HTML"
|
||||
// Styles above, markup below.
|
||||
R"HTML(
|
||||
</head>
|
||||
@@ -325,6 +353,74 @@ R"HTML(
|
||||
<div id="row-RPAD" class="row"></div>
|
||||
<div id="row-RPADtouch" class="row"></div>
|
||||
</div>
|
||||
<div class="group">
|
||||
<div class="group-label">LEFT TRIGGER</div>
|
||||
<div class="row mode-row">
|
||||
<div class="row-top">
|
||||
<span class="pos-label">Trigger Mode</span>
|
||||
<div class="connector"></div>
|
||||
<select id="tmode-LT" class="mode-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mode-row" id="meter-row-LT">
|
||||
<div class="row-top">
|
||||
<span class="pos-label">Full-Press Point</span>
|
||||
<div class="connector"></div>
|
||||
<div class="meter-wrap">
|
||||
<div class="meter" id="meter-LT">
|
||||
<div class="meter-band" id="band-LT"></div>
|
||||
<div class="meter-fill" id="fill-LT"></div>
|
||||
<div class="meter-mark press" id="mark-press-LT" tabindex="0" role="slider" aria-label="Left trigger full-press point"></div>
|
||||
<div class="meter-mark rel" id="mark-rel-LT" tabindex="0" role="slider" aria-label="Left trigger release point"></div>
|
||||
</div>
|
||||
<span class="meter-val" id="meter-val-LT"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meter-help" id="meter-help-LT"><span>Pull the trigger to watch it. Drag the <b>bright marker</b> to set where the full press happens and the dim one to set where it lets go. The analog pull still reaches the game while the full press is held.</span><button class="btn-row-reset" id="meter-reset-LT">Reset</button></div>
|
||||
</div>
|
||||
<div id="row-LTfull" class="row"></div>
|
||||
<div class="row mode-row" id="haptic-row-LT">
|
||||
<div class="row-top">
|
||||
<span class="pos-label">Click Feel</span>
|
||||
<div class="connector"></div>
|
||||
<select id="thaptic-LT" class="mode-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group">
|
||||
<div class="group-label">RIGHT TRIGGER</div>
|
||||
<div class="row mode-row">
|
||||
<div class="row-top">
|
||||
<span class="pos-label">Trigger Mode</span>
|
||||
<div class="connector"></div>
|
||||
<select id="tmode-RT" class="mode-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mode-row" id="meter-row-RT">
|
||||
<div class="row-top">
|
||||
<span class="pos-label">Full-Press Point</span>
|
||||
<div class="connector"></div>
|
||||
<div class="meter-wrap">
|
||||
<div class="meter" id="meter-RT">
|
||||
<div class="meter-band" id="band-RT"></div>
|
||||
<div class="meter-fill" id="fill-RT"></div>
|
||||
<div class="meter-mark press" id="mark-press-RT" tabindex="0" role="slider" aria-label="Right trigger full-press point"></div>
|
||||
<div class="meter-mark rel" id="mark-rel-RT" tabindex="0" role="slider" aria-label="Right trigger release point"></div>
|
||||
</div>
|
||||
<span class="meter-val" id="meter-val-RT"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meter-help" id="meter-help-RT"><span>Pull the trigger to watch it. Drag the <b>bright marker</b> to set where the full press happens and the dim one to set where it lets go. The analog pull still reaches the game while the full press is held.</span><button class="btn-row-reset" id="meter-reset-RT">Reset</button></div>
|
||||
</div>
|
||||
<div id="row-RTfull" class="row"></div>
|
||||
<div class="row mode-row" id="haptic-row-RT">
|
||||
<div class="row-top">
|
||||
<span class="pos-label">Click Feel</span>
|
||||
<div class="connector"></div>
|
||||
<select id="thaptic-RT" class="mode-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group">
|
||||
<div class="group-label">LEFT GRIP</div>
|
||||
<div id="row-L4" class="row"></div>
|
||||
@@ -761,6 +857,7 @@ function resetDefaults(){
|
||||
diags[p]=DEFAULT_DIAGS[p];
|
||||
speeds[p]=DEFAULT_SPEEDS[p];
|
||||
});
|
||||
triggerReset();
|
||||
flash=null;
|
||||
clearTimeout(flashTimer);
|
||||
renderModeSelects();
|
||||
@@ -784,6 +881,7 @@ function currentProfile(){
|
||||
p[x+'diag']=diags[x];
|
||||
p[x+'speed']=String(speeds[x]);
|
||||
});
|
||||
triggerToProfile(p);
|
||||
return p;
|
||||
}
|
||||
// Inverse of currentProfile: adopt a stored profile as the live state, filling
|
||||
@@ -813,6 +911,7 @@ function loadProfileInto(p){
|
||||
// hasOwnProperty guard of its own.
|
||||
speeds[x]=clampSpeed(x,p[x+'speed']);
|
||||
});
|
||||
triggerFromProfile(p);
|
||||
savedProfile=currentProfile();
|
||||
}
|
||||
// Commit the current state to the selected profile. Split out from the
|
||||
@@ -1139,6 +1238,7 @@ function renderModeSelects(){
|
||||
setSpeed(padId,speeds[padId]);
|
||||
});
|
||||
renderPadRows();
|
||||
renderTriggerSelects();
|
||||
}
|
||||
// Show only the rows the current mode actually has settings for.
|
||||
function renderPadRows(){
|
||||
@@ -1395,7 +1495,220 @@ document.addEventListener('keyup',function(e){
|
||||
e.stopPropagation();
|
||||
sendCapture(e.code, pendingMods&~bit);
|
||||
});
|
||||
|
||||
)HTML"
|
||||
// Dual-stage triggers (#75), in a literal of their own: the ones either side are
|
||||
// each within reach of the compiler's single-string limit. It sits ahead of the
|
||||
// page's first render so the two Full Press rows are in ROWS by then. The hooks
|
||||
// that bring it into the profile cycle — triggerToProfile, triggerFromProfile,
|
||||
// triggerReset and renderTriggerSelects — are called from the functions above.
|
||||
R"HTML(
|
||||
// ---- Dual-stage triggers ----
|
||||
var TRIGGERS=['LT','RT'];
|
||||
var TRIGGER_RAW_MAX=32767;
|
||||
// The same defaults and bounds as TriggerConfig.h, because C++ clamps whatever
|
||||
// this sends: a marker that stopped somewhere C++ would not has shown a value
|
||||
// that was never saved.
|
||||
var TRIGGER_DEFAULT={mode:'standard',press:95,release:80,haptic:'both'};
|
||||
var TRIGGER_PRESS_MIN=20, TRIGGER_PRESS_MAX=100, TRIGGER_RELEASE_MIN=5, TRIGGER_GAP=3;
|
||||
var TRIGGER_MODE_OPTIONS=[
|
||||
{id:'standard', label:'Standard (analog only)'},
|
||||
{id:'dual', label:'Dual Stage (adds a full-press button)'},
|
||||
];
|
||||
var TRIGGER_HAPTIC_OPTIONS=[
|
||||
{id:'off', label:'Off'},
|
||||
{id:'press', label:'Click on press'},
|
||||
{id:'both', label:'Click on press and release'},
|
||||
];
|
||||
// The full press is an ordinary rebindable row, so it rides the same capture
|
||||
// flow, picker and profile plumbing as a paddle.
|
||||
ROWS.push({id:'LTfull',badge:'L',posTag:'FULL',posLabel:'Full Press'},
|
||||
{id:'RTfull',badge:'R',posTag:'FULL',posLabel:'Full Press'});
|
||||
DEFAULTS.LTfull='none';
|
||||
DEFAULTS.RTfull='none';
|
||||
var tmodes={}, tpress={}, trelease={}, thaptic={};
|
||||
// Where each trigger is right now, and whether the meter should read it as past
|
||||
// its press point. The latter is the same hysteresis the app applies, so the
|
||||
// meter shows what the binding will do.
|
||||
var tlevel={LT:0,RT:0}, tdown={LT:false,RT:false};
|
||||
function trigEl(id){ return document.getElementById(id); }
|
||||
function trigNum(v,def){ var n=parseInt(v,10); return (isNaN(n)||n===0)?def:n; }
|
||||
function clampPress(v){
|
||||
return Math.min(TRIGGER_PRESS_MAX,Math.max(TRIGGER_PRESS_MIN,v));
|
||||
}
|
||||
// Always strictly below the press point, as TriggerConfig.h does it.
|
||||
function clampRelease(v,press){
|
||||
var ceil=Math.max(TRIGGER_RELEASE_MIN,press-TRIGGER_GAP);
|
||||
return Math.min(ceil,Math.max(TRIGGER_RELEASE_MIN,v));
|
||||
}
|
||||
function triggerReset(){
|
||||
TRIGGERS.forEach(function(t){
|
||||
tmodes[t]=TRIGGER_DEFAULT.mode;
|
||||
tpress[t]=TRIGGER_DEFAULT.press;
|
||||
trelease[t]=TRIGGER_DEFAULT.release;
|
||||
thaptic[t]=TRIGGER_DEFAULT.haptic;
|
||||
});
|
||||
}
|
||||
function triggerToProfile(p){
|
||||
TRIGGERS.forEach(function(t){
|
||||
p[t+'mode']=tmodes[t];
|
||||
p[t+'press']=String(tpress[t]);
|
||||
p[t+'release']=String(trelease[t]);
|
||||
p[t+'haptic']=thaptic[t];
|
||||
});
|
||||
}
|
||||
// Anything missing or unrecognised lands on the default, which is what a profile
|
||||
// saved before these settings existed has to read as: a plain analog trigger.
|
||||
function triggerFromProfile(p){
|
||||
TRIGGERS.forEach(function(t){
|
||||
tmodes[t]=(p[t+'mode']==='dual')?'dual':'standard';
|
||||
tpress[t]=clampPress(trigNum(p[t+'press'],TRIGGER_DEFAULT.press));
|
||||
trelease[t]=clampRelease(trigNum(p[t+'release'],TRIGGER_DEFAULT.release),tpress[t]);
|
||||
var h=p[t+'haptic'];
|
||||
thaptic[t]=(h==='off'||h==='press')?h:'both';
|
||||
});
|
||||
}
|
||||
// Rebuilds the dropdowns, which only needs doing when a profile is loaded —
|
||||
// doing it on every change would reset the control under the user's hand.
|
||||
function renderTriggerSelects(){
|
||||
TRIGGERS.forEach(function(t){
|
||||
fillSelect('tmode-'+t,TRIGGER_MODE_OPTIONS,tmodes[t]);
|
||||
fillSelect('thaptic-'+t,TRIGGER_HAPTIC_OPTIONS,thaptic[t]);
|
||||
});
|
||||
renderTriggerRows();
|
||||
}
|
||||
// Everything below the mode dropdown is about the full press, so it only exists
|
||||
// in Dual Stage.
|
||||
function renderTriggerRows(){
|
||||
TRIGGERS.forEach(function(t){
|
||||
var dual=(tmodes[t]==='dual');
|
||||
['meter-row-'+t,'haptic-row-'+t,'row-'+t+'full'].forEach(function(id){
|
||||
var el=trigEl(id);
|
||||
if(el) el.style.display=dual?'':'none';
|
||||
});
|
||||
// A capture still running on a row that has just been hidden would bind
|
||||
// something nobody can see.
|
||||
if(!dual&&listening===t+'full') cancelListening();
|
||||
renderMeter(t);
|
||||
});
|
||||
}
|
||||
function renderMeter(t){
|
||||
var press=tpress[t], rel=trelease[t];
|
||||
var mp=trigEl('mark-press-'+t), mr=trigEl('mark-rel-'+t);
|
||||
mp.style.left=press+'%';
|
||||
mr.style.left=rel+'%';
|
||||
var band=trigEl('band-'+t);
|
||||
band.style.left=rel+'%';
|
||||
band.style.width=(press-rel)+'%';
|
||||
trigEl('meter-val-'+t).textContent=press+'% / '+rel+'%';
|
||||
mp.setAttribute('aria-valuemin',TRIGGER_PRESS_MIN);
|
||||
mp.setAttribute('aria-valuemax',TRIGGER_PRESS_MAX);
|
||||
mp.setAttribute('aria-valuenow',press);
|
||||
mr.setAttribute('aria-valuemin',TRIGGER_RELEASE_MIN);
|
||||
mr.setAttribute('aria-valuemax',press-TRIGGER_GAP);
|
||||
mr.setAttribute('aria-valuenow',rel);
|
||||
renderLevel(t);
|
||||
}
|
||||
function renderLevel(t){
|
||||
var raw=tlevel[t];
|
||||
// Rounded up like the app's own threshold, so the meter never reads as pressed
|
||||
// a hair before the binding would.
|
||||
var pressAt=Math.ceil(tpress[t]*TRIGGER_RAW_MAX/100);
|
||||
var relAt=Math.ceil(trelease[t]*TRIGGER_RAW_MAX/100);
|
||||
if(!tdown[t]&&raw>=pressAt) tdown[t]=true;
|
||||
else if(tdown[t]&&raw<=relAt) tdown[t]=false;
|
||||
trigEl('fill-'+t).style.width=(raw/TRIGGER_RAW_MAX*100)+'%';
|
||||
trigEl('meter-'+t).className='meter'+(tdown[t]?' down':'');
|
||||
}
|
||||
function setTriggerLevel(t,raw){
|
||||
tlevel[t]=Math.max(0,Math.min(TRIGGER_RAW_MAX,raw));
|
||||
renderLevel(t);
|
||||
}
|
||||
// Moving the press point drags the release point down with it rather than
|
||||
// refusing to move, so the two can never cross; moving the release point stops
|
||||
// at the press point instead.
|
||||
function setTriggerPoint(t,which,pct){
|
||||
if(which==='press'){
|
||||
tpress[t]=clampPress(pct);
|
||||
trelease[t]=clampRelease(trelease[t],tpress[t]);
|
||||
}else{
|
||||
trelease[t]=clampRelease(pct,tpress[t]);
|
||||
}
|
||||
renderMeter(t);
|
||||
}
|
||||
var tdrag=null;
|
||||
function pctFromPointer(t,e){
|
||||
var r=trigEl('meter-'+t).getBoundingClientRect();
|
||||
return Math.round((e.clientX-r.left)/r.width*100);
|
||||
}
|
||||
TRIGGERS.forEach(function(t){
|
||||
var track=trigEl('meter-'+t);
|
||||
// One set of pointer handlers on the track, captured to it, so a drag keeps
|
||||
// working when the pointer wanders off the 12px bar. A press on a marker grabs
|
||||
// that marker; a press anywhere else jumps the nearer one there and drags it.
|
||||
track.addEventListener('pointerdown',function(e){
|
||||
if(e.button!==0) return;
|
||||
var which;
|
||||
if(e.target.id==='mark-press-'+t) which='press';
|
||||
else if(e.target.id==='mark-rel-'+t) which='rel';
|
||||
else{
|
||||
var pct=pctFromPointer(t,e);
|
||||
which=(Math.abs(pct-tpress[t])<=Math.abs(pct-trelease[t]))?'press':'rel';
|
||||
setTriggerPoint(t,which,pct);
|
||||
}
|
||||
tdrag={t:t,which:which};
|
||||
track.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
});
|
||||
track.addEventListener('pointermove',function(e){
|
||||
if(tdrag&&tdrag.t===t) setTriggerPoint(t,tdrag.which,pctFromPointer(t,e));
|
||||
});
|
||||
function endDrag(e){
|
||||
if(!tdrag||tdrag.t!==t) return;
|
||||
tdrag=null;
|
||||
try{ track.releasePointerCapture(e.pointerId); }catch(x){}
|
||||
}
|
||||
track.addEventListener('pointerup',endDrag);
|
||||
track.addEventListener('pointercancel',endDrag);
|
||||
[['press','mark-press-'],['rel','mark-rel-']].forEach(function(m){
|
||||
trigEl(m[1]+t).addEventListener('keydown',function(e){
|
||||
var step=e.shiftKey?5:1, d=0;
|
||||
if(e.key==='ArrowLeft'||e.key==='ArrowDown') d=-step;
|
||||
else if(e.key==='ArrowRight'||e.key==='ArrowUp') d=step;
|
||||
else return;
|
||||
e.preventDefault();
|
||||
setTriggerPoint(t,m[0],(m[0]==='press'?tpress[t]:trelease[t])+d);
|
||||
});
|
||||
});
|
||||
trigEl('tmode-'+t).addEventListener('change',function(e){
|
||||
tmodes[t]=(e.target.value==='dual')?'dual':'standard';
|
||||
renderTriggerRows();
|
||||
});
|
||||
trigEl('thaptic-'+t).addEventListener('change',function(e){
|
||||
thaptic[t]=e.target.value;
|
||||
});
|
||||
trigEl('meter-reset-'+t).onclick=function(){
|
||||
tpress[t]=TRIGGER_DEFAULT.press;
|
||||
trelease[t]=TRIGGER_DEFAULT.release;
|
||||
renderMeter(t);
|
||||
};
|
||||
});
|
||||
// Live position from the app. A second listener rather than another branch in
|
||||
// the one above, which is in the literal that cannot take any more.
|
||||
if(window.chrome&&window.chrome.webview){
|
||||
window.chrome.webview.addEventListener('message',function(e){
|
||||
var msg;
|
||||
try{ msg=JSON.parse(e.data); }catch(x){ return; }
|
||||
if(msg.type!=='triggers') return;
|
||||
setTriggerLevel('LT',parseInt(msg.l,10)||0);
|
||||
setTriggerLevel('RT',parseInt(msg.r,10)||0);
|
||||
});
|
||||
}
|
||||
triggerReset();
|
||||
// Hide the Dual Stage rows now rather than when the first profile arrives, so
|
||||
// they are never on screen for the moment before it does.
|
||||
renderTriggerRows();
|
||||
)HTML"
|
||||
R"HTML(
|
||||
renderAll();
|
||||
</script>
|
||||
</body>
|
||||
@@ -1569,11 +1882,13 @@ LRESULT RemapWindow::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
||||
return 0;
|
||||
}
|
||||
ShowWindow(hwnd, SW_HIDE);
|
||||
KillTimer(hwnd, IDT_TRIGGER_LEVELS);
|
||||
if (m_onClose) m_onClose();
|
||||
return 0;
|
||||
|
||||
case WM_CLOSE_CONFIRMED:
|
||||
ShowWindow(hwnd, SW_HIDE);
|
||||
KillTimer(hwnd, IDT_TRIGGER_LEVELS);
|
||||
if (m_onClose) m_onClose();
|
||||
return 0;
|
||||
|
||||
@@ -1608,7 +1923,25 @@ LRESULT RemapWindow::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
case WM_TIMER:
|
||||
if (wp != IDT_TRIGGER_LEVELS) break;
|
||||
// Pushed only when something moved. A trigger at rest is the usual
|
||||
// state, and 30 identical messages a second to a page nobody is
|
||||
// looking at is not free.
|
||||
if (m_webview && m_mgr && IsWindowVisible(hwnd)) {
|
||||
int l = 0, r = 0;
|
||||
m_mgr->TriggerLevels(l, r);
|
||||
if (l != m_lastLeftTrigger || r != m_lastRightTrigger) {
|
||||
m_lastLeftTrigger = l;
|
||||
m_lastRightTrigger = r;
|
||||
PostToWebView(L"{\"type\":\"triggers\",\"l\":\"" + std::to_wstring(l)
|
||||
+ L"\",\"r\":\"" + std::to_wstring(r) + L"\"}");
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
|
||||
case WM_DESTROY:
|
||||
KillTimer(hwnd, IDT_TRIGGER_LEVELS);
|
||||
if (m_mgr) m_mgr->StopButtonCapture();
|
||||
m_webview.Reset();
|
||||
m_controller.Reset();
|
||||
@@ -2166,6 +2499,23 @@ void RemapWindow::OnWebMessage(const std::wstring& raw) {
|
||||
readPad("LPAD", cfg.leftPad);
|
||||
readPad("RPAD", cfg.rightPad);
|
||||
|
||||
// Same flat keys as the pads, under the trigger's own id. The bare id
|
||||
// is never sent — only "LTmode", "LTfull" and the rest — so the reader's
|
||||
// "key": match cannot confuse one trigger's values for another's.
|
||||
auto readTrigger = [&](const std::string& id, TriggerSettings& t) {
|
||||
auto num = [&](const std::string& key) {
|
||||
return static_cast<uint32_t>(
|
||||
std::strtoul(JsonStr(msg, key).c_str(), nullptr, 10));
|
||||
};
|
||||
t.mode = TriggerModeFromId(JsonStr(msg, id + "mode"));
|
||||
t.press = TriggerPressFromWire(num(id + "press"));
|
||||
t.release = TriggerReleaseFromWire(num(id + "release"), t.press);
|
||||
t.haptic = TriggerHapticFromId(JsonStr(msg, id + "haptic"));
|
||||
t.full = BackButtonBinding::FromId(JsonStr(msg, id + "full"));
|
||||
};
|
||||
readTrigger("LT", cfg.leftTrigger);
|
||||
readTrigger("RT", cfg.rightTrigger);
|
||||
|
||||
const std::string token = JsonStr(msg, "game");
|
||||
if (PickerEntry* entry = EntryForToken(token)) {
|
||||
const std::wstring gameId = entry->game.id;
|
||||
@@ -2274,6 +2624,16 @@ std::wstring RemapWindow::ProfileJson(const ControllerProfile& p) {
|
||||
L"\"" + k + L"left\":\"" + wid(s.left) + L"\","
|
||||
L"\"" + k + L"right\":\"" + wid(s.right) + L"\"";
|
||||
};
|
||||
// One trigger's values under the id the page keys them by. Numbers go as
|
||||
// strings like every other value on this channel.
|
||||
auto trigger = [&](const wchar_t* id, const TriggerSettings& t) {
|
||||
const std::wstring k = id;
|
||||
return L"\"" + k + L"mode\":\"" + narrow(TriggerModeId(t.mode)) + L"\","
|
||||
L"\"" + k + L"press\":\"" + std::to_wstring(t.press) + L"\","
|
||||
L"\"" + k + L"release\":\"" + std::to_wstring(t.release) + L"\","
|
||||
L"\"" + k + L"haptic\":\"" + narrow(TriggerHapticId(t.haptic)) + L"\","
|
||||
L"\"" + k + L"full\":\"" + wid(t.full) + L"\"";
|
||||
};
|
||||
return L"{\"useDefault\":\""
|
||||
+ std::wstring(p.useDefaultMappings ? L"1" : L"0")
|
||||
+ L"\","
|
||||
@@ -2285,7 +2645,9 @@ std::wstring RemapWindow::ProfileJson(const ControllerProfile& p) {
|
||||
L"\"R4\":\"" + wid(p.back.r4) + L"\","
|
||||
L"\"R5\":\"" + wid(p.back.r5) + L"\","
|
||||
+ pad(L"LPAD", p.leftPad) + L","
|
||||
+ pad(L"RPAD", p.rightPad) + L"}";
|
||||
+ pad(L"RPAD", p.rightPad) + L","
|
||||
+ trigger(L"LT", p.leftTrigger) + L","
|
||||
+ trigger(L"RT", p.rightTrigger) + L"}";
|
||||
}
|
||||
|
||||
void RemapWindow::SendInitState() {
|
||||
@@ -2318,6 +2680,10 @@ void RemapWindow::SendInitState() {
|
||||
addLabel(*b);
|
||||
for (const auto* b : { &p.back.l4, &p.back.l5, &p.back.r4, &p.back.r5 })
|
||||
addLabel(*b);
|
||||
// Stored under a trigger that is currently Standard still needs a name
|
||||
// ready for when it is switched to Dual Stage.
|
||||
addLabel(p.leftTrigger.full);
|
||||
addLabel(p.rightTrigger.full);
|
||||
};
|
||||
addProfileLabels(m_config);
|
||||
for (const auto& [id, cfg] : m_gameProfiles)
|
||||
@@ -2341,6 +2707,13 @@ void RemapWindow::SendInitState() {
|
||||
// arrives later whenever it changes, so the page needs one handler for it
|
||||
// either way, and having two ways to learn it is how they drift.
|
||||
SetControlState(m_controlEnabled, m_controlManual);
|
||||
|
||||
// The live trigger meters. Forgetting what was last sent makes the first
|
||||
// tick send the current levels even if they have not moved, which is what
|
||||
// a page that has just loaded needs. SetTimer on an existing id replaces
|
||||
// it, so this is safe to run on every init.
|
||||
m_lastLeftTrigger = m_lastRightTrigger = -1;
|
||||
SetTimer(m_hwnd, IDT_TRIGGER_LEVELS, 33, nullptr);
|
||||
}
|
||||
|
||||
// Token-based picker ids so the raw game id — possibly non-ASCII, sometimes
|
||||
|
||||
@@ -147,6 +147,11 @@ private:
|
||||
// The background enumeration finished. LPARAM owns a
|
||||
// std::vector<InstalledGame> the handler takes and deletes.
|
||||
static constexpr UINT WM_GAMES_READY = WM_APP + 3;
|
||||
// Pushes the triggers' live position to the page's meters while the window
|
||||
// is shown (~30 Hz, and only when a value changed).
|
||||
static constexpr UINT_PTR IDT_TRIGGER_LEVELS = 1;
|
||||
int m_lastLeftTrigger = -1;
|
||||
int m_lastRightTrigger = -1;
|
||||
|
||||
HWND m_hwnd = nullptr;
|
||||
HINSTANCE m_hInst = nullptr;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <string>
|
||||
#include "BackButtonConfig.h"
|
||||
#include "ControllerPlatform.h"
|
||||
#include "TriggerConfig.h"
|
||||
|
||||
// What a trackpad's finger movement drives.
|
||||
//
|
||||
@@ -156,6 +157,14 @@ struct PadDigital {
|
||||
// See kPadPressArea.
|
||||
bool leftPressed = false;
|
||||
bool rightPressed = false;
|
||||
// Each trigger is past its full-press point, with hysteresis already
|
||||
// applied (TriggerFullPress). Not a trackpad, but resolved by the same
|
||||
// loop and delivered by the same two paths, for the same reason the pad
|
||||
// state is: one answer shared by the virtual pad, SendInput and the
|
||||
// haptic, rather than three derivations of it. False in any mode but
|
||||
// dual-stage.
|
||||
bool leftTriggerFull = false;
|
||||
bool rightTriggerFull = false;
|
||||
};
|
||||
|
||||
// Where a click landed on a pad, which is what keeps a directional pad's three
|
||||
@@ -357,12 +366,18 @@ struct ControllerProfile {
|
||||
.click = BackButtonBinding::FromAction(BackButtonAction::LeftMouseButton),
|
||||
};
|
||||
|
||||
// Both default to Standard — analog passthrough and nothing else — which
|
||||
// is what every profile written before dual-stage triggers existed did.
|
||||
TriggerSettings leftTrigger;
|
||||
TriggerSettings rightTrigger;
|
||||
|
||||
bool operator==(const ControllerProfile& o) const {
|
||||
return useDefaultMappings == o.useDefaultMappings
|
||||
&& platform == o.platform
|
||||
&& back.l4 == o.back.l4 && back.l5 == o.back.l5
|
||||
&& back.r4 == o.back.r4 && back.r5 == o.back.r5
|
||||
&& leftPad == o.leftPad && rightPad == o.rightPad;
|
||||
&& leftPad == o.leftPad && rightPad == o.rightPad
|
||||
&& leftTrigger == o.leftTrigger && rightTrigger == o.rightTrigger;
|
||||
}
|
||||
bool operator!=(const ControllerProfile& o) const { return !(*this == o); }
|
||||
};
|
||||
@@ -2008,6 +2008,17 @@ void TrayApp::LoadSettings() {
|
||||
readPad(L"RightPad", profile.rightPad);
|
||||
}
|
||||
|
||||
// Outside the migration branch above: the triggers have nothing to migrate,
|
||||
// and an install that predates them has no such values, which reads back as
|
||||
// Standard — analog only, as it always was.
|
||||
{
|
||||
auto readTriggerDw = [&](const wchar_t* name, uint32_t def) -> uint32_t {
|
||||
return readDw(name, def);
|
||||
};
|
||||
profile.leftTrigger = ReadTriggerSettings(L"LeftTrigger", readTriggerDw);
|
||||
profile.rightTrigger = ReadTriggerSettings(L"RightTrigger", readTriggerDw);
|
||||
}
|
||||
|
||||
m_defaultProfile = profile;
|
||||
m_activeGameId.clear();
|
||||
m_controller->SetProfile(m_defaultProfile);
|
||||
@@ -2091,6 +2102,8 @@ void TrayApp::SaveSettings() {
|
||||
};
|
||||
writePad(L"LeftPad", profile.leftPad);
|
||||
writePad(L"RightPad", profile.rightPad);
|
||||
WriteTriggerSettings(L"LeftTrigger", profile.leftTrigger, writeDw);
|
||||
WriteTriggerSettings(L"RightTrigger", profile.rightTrigger, writeDw);
|
||||
// Written last and unconditionally: its presence is what tells the next
|
||||
// launch the per-pad values above are authoritative.
|
||||
writeDw(L"PadsMigrated", 1);
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
#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());
|
||||
}
|
||||
@@ -288,7 +288,8 @@ void VirtualController::Update(const uint8_t* buf, size_t n,
|
||||
const BackButtonConfig& backCfg = profile.back;
|
||||
|
||||
// Calls `apply` once for every trackpad binding the pads are currently
|
||||
// pressing, so the two report paths below only have to say how one binding
|
||||
// pressing — and every dual-stage trigger's full press — so the two report
|
||||
// paths below only have to say how one binding
|
||||
// reaches their own report format. Nothing here is read from the report:
|
||||
// the tap, the press and its zone, and the directions were all resolved by
|
||||
// ControllerManager and arrive in `resolved`, so both delivery paths and
|
||||
@@ -315,6 +316,14 @@ void VirtualController::Update(const uint8_t* buf, size_t n,
|
||||
for (uint8_t d : { DirUp, DirDown, DirLeft, DirRight })
|
||||
if (p.dirs & d) apply(p.pad.EffectiveDirection(static_cast<PadDir>(d)));
|
||||
}
|
||||
|
||||
// A dual-stage trigger's full press rides the same path. It is added
|
||||
// on top of the trigger's own analog value, which still passes through
|
||||
// untouched, so a game sees the pull and the extra input together —
|
||||
// and a full press bound to a trigger just pins that trigger at its
|
||||
// maximum, which is where it already is.
|
||||
if (resolved.leftTriggerFull) apply(profile.leftTrigger.EffectiveFull());
|
||||
if (resolved.rightTriggerFull) apply(profile.rightTrigger.EffectiveFull());
|
||||
};
|
||||
|
||||
if (m_platform == ControllerPlatform::PlayStation) {
|
||||
|
||||
@@ -48,6 +48,15 @@ static uint16_t UnitToHapticSpeed(double value, uint16_t minimumSpeed) {
|
||||
static constexpr uint8_t HAPTIC_COMMAND_TICK = 1;
|
||||
static constexpr uint8_t HAPTIC_COMMAND_CLICK = 2;
|
||||
|
||||
// The grip half of a trigger click. 0x5000 for 60 ms is candidate 4 in
|
||||
// TriggerProbe, rated among the most click-like and chosen together with the
|
||||
// pad click (candidate 6). The minimum gap is just under the pad's own
|
||||
// movement-tick spacing, which exists for the same reason: a waveform still
|
||||
// playing when the next arrives turns into a burst.
|
||||
static constexpr uint16_t kTriggerClickBuzzSpeed = 0x5000;
|
||||
static constexpr auto kTriggerClickBuzzDuration = std::chrono::milliseconds(60);
|
||||
static constexpr auto kTriggerClickMinGap = std::chrono::milliseconds(40);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Open / Close
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -132,9 +141,19 @@ bool SteamController::AdoptHandle(void* handle, const std::wstring& path) {
|
||||
return m_device.Adopt(static_cast<HANDLE>(handle), path);
|
||||
}
|
||||
|
||||
void SteamController::Close() {
|
||||
if (m_running.exchange(false) && m_rumbleThread.joinable())
|
||||
void SteamController::StopRumbleThread() {
|
||||
if (m_running.exchange(false) && m_rumbleThread.joinable()) {
|
||||
m_rumbleWake.notify_all();
|
||||
m_rumbleThread.join();
|
||||
}
|
||||
// A click queued but not yet played must not fire the next time the thread
|
||||
// starts, long after the pull that asked for it.
|
||||
std::lock_guard<std::mutex> lock(m_rumbleMutex);
|
||||
for (TriggerClick& c : m_triggerClick) c = TriggerClick{};
|
||||
}
|
||||
|
||||
void SteamController::Close() {
|
||||
StopRumbleThread();
|
||||
ClearTrackpadHaptics();
|
||||
SetRumble(0, 0);
|
||||
m_device.Close();
|
||||
@@ -260,8 +279,7 @@ void SteamController::EmergencyLizardRestore() noexcept {
|
||||
}
|
||||
|
||||
bool SteamController::EnableLizardMode() {
|
||||
if (m_running.exchange(false) && m_rumbleThread.joinable())
|
||||
m_rumbleThread.join();
|
||||
StopRumbleThread();
|
||||
|
||||
ClearTrackpadHaptics();
|
||||
SetRumble(0, 0);
|
||||
@@ -380,9 +398,30 @@ SteamController::RumbleFrame SteamController::CurrentRumbleFrameLocked(
|
||||
if (m_rumbleBoostLeft > frame.left) frame.left = m_rumbleBoostLeft;
|
||||
if (m_rumbleBoostRight > frame.right) frame.right = m_rumbleBoostRight;
|
||||
}
|
||||
// A trigger click's buzz, merged the same way as the attack boost: the
|
||||
// higher of it and whatever the game is asking for wins, and the game's
|
||||
// level is what is left when the buzz ends.
|
||||
if (m_triggerClick[0].buzzUntil > now && kTriggerClickBuzzSpeed > frame.left)
|
||||
frame.left = kTriggerClickBuzzSpeed;
|
||||
if (m_triggerClick[1].buzzUntil > now && kTriggerClickBuzzSpeed > frame.right)
|
||||
frame.right = kTriggerClickBuzzSpeed;
|
||||
return frame;
|
||||
}
|
||||
|
||||
void SteamController::QueueTriggerClick(bool left) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_rumbleMutex);
|
||||
TriggerClick& c = m_triggerClick[left ? 0 : 1];
|
||||
if (now - c.lastAt < kTriggerClickMinGap)
|
||||
return; // the last one is still playing — drop, don't queue
|
||||
c.lastAt = now;
|
||||
c.pending = true;
|
||||
c.buzzUntil = now + kTriggerClickBuzzDuration;
|
||||
}
|
||||
m_rumbleWake.notify_one();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trackpad haptics
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -496,15 +535,50 @@ bool SteamController::SendTrackpadCommandOutput(uint8_t side, uint8_t command, i
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void SteamController::RumbleLoop() {
|
||||
constexpr auto kPeriod = std::chrono::milliseconds(40);
|
||||
// Whether the last thing sent was non-zero. The actuators play on until told
|
||||
// otherwise, and a trigger click's buzz can end with nothing else driving
|
||||
// them — so the loop has to be the one to send the zero, once, when the
|
||||
// frame goes quiet.
|
||||
bool driving = false;
|
||||
|
||||
while (m_running.load()) {
|
||||
RumbleFrame frame{};
|
||||
bool clickLeft = false, clickRight = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_rumbleMutex);
|
||||
std::unique_lock<std::mutex> lock(m_rumbleMutex);
|
||||
// Normally one period. Sooner for a click that has just been
|
||||
// queued, and sooner again for the moment a click's buzz is due to
|
||||
// stop — otherwise it would run on for up to a whole period past its
|
||||
// 60 ms and feel like a smear rather than a click.
|
||||
const auto now0 = std::chrono::steady_clock::now();
|
||||
auto wakeAt = now0 + kPeriod;
|
||||
for (const TriggerClick& c : m_triggerClick)
|
||||
if (c.buzzUntil > now0 && c.buzzUntil < wakeAt) wakeAt = c.buzzUntil;
|
||||
m_rumbleWake.wait_until(lock, wakeAt, [this] {
|
||||
return !m_running.load()
|
||||
|| m_triggerClick[0].pending || m_triggerClick[1].pending;
|
||||
});
|
||||
if (!m_running.load()) break;
|
||||
|
||||
clickLeft = m_triggerClick[0].pending;
|
||||
clickRight = m_triggerClick[1].pending;
|
||||
m_triggerClick[0].pending = false;
|
||||
m_triggerClick[1].pending = false;
|
||||
frame = CurrentRumbleFrameLocked(std::chrono::steady_clock::now());
|
||||
}
|
||||
if (frame.left || frame.right)
|
||||
SendRumbleOutput(frame.left, frame.right);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(40));
|
||||
// The pad half of a click goes out before the buzz so the sharper of
|
||||
// the two leads. Off the read loop, which is the point of queuing it.
|
||||
if (clickLeft) SendTrackpadCommandOutput(HAPTIC_SIDE_LEFT, HAPTIC_COMMAND_CLICK, 0);
|
||||
if (clickRight) SendTrackpadCommandOutput(HAPTIC_SIDE_RIGHT, HAPTIC_COMMAND_CLICK, 0);
|
||||
|
||||
if (frame.left || frame.right) {
|
||||
SendRumbleOutput(frame.left, frame.right);
|
||||
driving = true;
|
||||
} else if (driving) {
|
||||
SendRumbleOutput(0, 0);
|
||||
driving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "hid/HidDevice.h"
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
@@ -113,7 +114,7 @@ public:
|
||||
static constexpr uint8_t BTN_LS_TOUCH = 0x01; // bit 0 — left stick capacitive touch
|
||||
static constexpr uint8_t BTN_TP_LT = 0x02; // bit 1 — left trackpad active (touch or click)
|
||||
static constexpr uint8_t BTN_TP_LT_CLICK = 0x04; // bit 2 — left trackpad hard press
|
||||
// bit 3 (0x08): TBD
|
||||
static constexpr uint8_t BTN_LT_FULL = 0x08; // bit 3 — left trigger fully pressed (digital threshold)
|
||||
static constexpr uint8_t FLAG_GRIP_RT = 0x10; // bit 4 — right grip sensor active
|
||||
static constexpr uint8_t FLAG_GRIP_LT = 0x20; // bit 5 — left grip sensor active
|
||||
// other bits TBD
|
||||
@@ -223,6 +224,28 @@ public:
|
||||
// strongClick = true → physical-click sensation; false → light touch-down tick.
|
||||
void PulseTrackpadHaptic(bool left, bool strongClick);
|
||||
|
||||
// The simulated click for a dual-stage trigger (#75): a firmware click on
|
||||
// the pad under the thumb on that trigger's side, plus a short buzz in the
|
||||
// grip on the same side. There is no actuator in a trigger, so this comes
|
||||
// from the controller body — which hardware testing found the most
|
||||
// click-like of the candidates, with the pad click and the buzz together
|
||||
// the strongest.
|
||||
//
|
||||
// Never writes to the device itself: it queues the click for the rumble
|
||||
// thread and returns. This is called from the read loop, and a write that
|
||||
// blocks (a Bluetooth link in trouble can hold one for seconds) must not
|
||||
// stall the loop that is reading the trigger.
|
||||
//
|
||||
// Dropped rather than queued when the previous click on that side is still
|
||||
// playing, for the reason TickTrackpadMovement is rate limited: the firmware
|
||||
// plays the waveform to its end, and a backlog of them is a crunchy burst.
|
||||
//
|
||||
// The buzz joins the rumble a game is already asking for rather than
|
||||
// replacing it — the higher of the two is what the grip plays, and the game's
|
||||
// level resumes when the buzz ends. Sending an explicit zero when it ended,
|
||||
// as the probe does, would cut an explosion short on every trigger pull.
|
||||
void QueueTriggerClick(bool left);
|
||||
|
||||
// Fire a very light tick while the thumb moves on the trackpad.
|
||||
// Returns false when the tick was dropped by the send rate limiter.
|
||||
bool TickTrackpadMovement(bool left);
|
||||
@@ -237,6 +260,9 @@ private:
|
||||
};
|
||||
|
||||
void RumbleLoop();
|
||||
// Stops and joins the rumble thread if it is running, waking it from its
|
||||
// wait first and discarding any trigger click it had not yet played.
|
||||
void StopRumbleThread();
|
||||
RumbleFrame CurrentRumbleFrameLocked(std::chrono::steady_clock::time_point now) const;
|
||||
bool SendRumbleOutput(uint16_t leftSpeed, uint16_t rightSpeed);
|
||||
bool SendTrackpadPulseOutput(uint8_t side, uint16_t onUs, uint16_t offUs,
|
||||
@@ -252,6 +278,17 @@ private:
|
||||
|
||||
// Rumble state — protect with m_rumbleMutex.
|
||||
std::mutex m_rumbleMutex;
|
||||
// Wakes RumbleLoop ahead of its 40 ms period, for a queued trigger click or
|
||||
// for the moment a click's buzz is due to end. Waits on m_rumbleMutex.
|
||||
std::condition_variable m_rumbleWake;
|
||||
|
||||
// One per trigger, indexed 0 = left, 1 = right. Protected by m_rumbleMutex.
|
||||
struct TriggerClick {
|
||||
bool pending = false; // pad click not yet sent by the rumble thread
|
||||
std::chrono::steady_clock::time_point lastAt{}; // last accepted click
|
||||
std::chrono::steady_clock::time_point buzzUntil{}; // grip buzz ends here
|
||||
};
|
||||
TriggerClick m_triggerClick[2];
|
||||
uint16_t m_rumbleBaseLeft = 0;
|
||||
uint16_t m_rumbleBaseRight = 0;
|
||||
uint16_t m_rumbleBoostLeft = 0;
|
||||
|
||||
Reference in new issue
Block a user