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:
Dylan DeverillandClaude Sonnet 5.5 committed 2026-09-30 22:26:51 -07:00
1 parent 781fe3624a
commit e599201683
11 files changed
+885 -16

No files matched your search

+101 -3
View File
@@ -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;
+6
View File
@@ -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
+10
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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;
+16 -1
View File
@@ -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); }
};
+13
View File
@@ -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);
+229
View File
@@ -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());
}
+10 -1
View File
@@ -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) {
+82 -8
View File
@@ -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;
}
}
}
+38 -1
View File
@@ -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;