diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index 32e2a37..46f2185 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -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 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 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)(l, 0); + rightTriggerRaw = (std::max)(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; diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index 3206ab3..6252fcb 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -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 diff --git a/src/app/GameProfiles.cpp b/src/app/GameProfiles.cpp index 5828022..eeccc22 100644 --- a/src/app/GameProfiles.cpp +++ b/src/app/GameProfiles.cpp @@ -120,6 +120,13 @@ std::map 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& 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; diff --git a/src/app/RemapWindow.cpp b/src/app/RemapWindow.cpp index 4f14146..6d15c65 100644 --- a/src/app/RemapWindow.cpp +++ b/src/app/RemapWindow.cpp @@ -180,6 +180,34 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back .btn-save:hover{opacity:.9;} )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( + +)HTML" // Styles above, markup below. R"HTML( @@ -325,6 +353,74 @@ R"HTML(
+
+
LEFT TRIGGER
+
+
+ Trigger Mode +
+ +
+
+
+
+ Full-Press Point +
+
+
+
+
+
+
+
+ +
+
+
Pull the trigger to watch it. Drag the bright marker 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.
+
+
+
+
+ Click Feel +
+ +
+
+
+
+
RIGHT TRIGGER
+
+
+ Trigger Mode +
+ +
+
+
+
+ Full-Press Point +
+
+
+
+
+
+
+
+ +
+
+
Pull the trigger to watch it. Drag the bright marker 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.
+
+
+
+
+ Click Feel +
+ +
+
+
LEFT GRIP
@@ -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(); @@ -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( + 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 diff --git a/src/app/RemapWindow.h b/src/app/RemapWindow.h index 3707508..c29ccc4 100644 --- a/src/app/RemapWindow.h +++ b/src/app/RemapWindow.h @@ -147,6 +147,11 @@ private: // The background enumeration finished. LPARAM owns a // std::vector 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; diff --git a/src/app/TrackpadConfig.h b/src/app/TrackpadConfig.h index 217cf43..0155759 100644 --- a/src/app/TrackpadConfig.h +++ b/src/app/TrackpadConfig.h @@ -3,6 +3,7 @@ #include #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); } }; diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 3f22813..f730017 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -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); diff --git a/src/app/TriggerConfig.h b/src/app/TriggerConfig.h new file mode 100644 index 0000000..6698110 --- /dev/null +++ b/src/app/TriggerConfig.h @@ -0,0 +1,229 @@ +#pragma once +#include +#include +#include +#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(TriggerMode::COUNT) + ? static_cast(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(TriggerHaptic::COUNT) + ? static_cast(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((static_cast(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 +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(TriggerHaptic::PressAndRelease))); + t.full = BackButtonBinding::Unpack(readDw(name(L"Full").c_str(), + BackButtonBinding::FromAction(BackButtonAction::None).Pack())); + return t; +} + +template +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(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(t.haptic)); + writeDw(name(L"Full").c_str(), t.full.Pack()); +} diff --git a/src/app/VirtualController.cpp b/src/app/VirtualController.cpp index 71dc004..66d713b 100644 --- a/src/app/VirtualController.cpp +++ b/src/app/VirtualController.cpp @@ -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(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) { diff --git a/src/steam/SteamController.cpp b/src/steam/SteamController.cpp index b82ebe6..07d4a23 100644 --- a/src/steam/SteamController.cpp +++ b/src/steam/SteamController.cpp @@ -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), 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 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 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 lock(m_rumbleMutex); + std::unique_lock 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; + } } } diff --git a/src/steam/SteamController.h b/src/steam/SteamController.h index c254aa2..f80a4fa 100644 --- a/src/steam/SteamController.h +++ b/src/steam/SteamController.h @@ -2,6 +2,7 @@ #include "hid/HidDevice.h" #include #include +#include #include #include #include @@ -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;