From e14c0a799d9b00e64f6173e2df1441ade4c3179f Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Sun, 6 Sep 2026 12:55:23 -0700 Subject: [PATCH] Dispatch trackpad directions, touches and zoned clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the v1.20 trackpad work, and the point at which a directional pad does something: its bindings now reach both the virtual gamepad and SendInput. Neither mode is selectable from the UI yet, so this is only reachable by setting LeftPadMode or RightPadMode to 4 by hand. The edge table grows from 6 bindings to 16 — the four paddles, then each pad's click, touch and four directions. Every entry still has the same shape, so auto-repeat and the release-on-teardown sweep pick the new ones up for free, both being sized off kBindableCount. Directions are the first edge here that is not a bit in the report. They are resolved from a position, so the previous report cannot be re-read for them and the slot carries the answer instead. That write sits outside the hasPrev guard, alongside the report copy it is the equivalent of: the first frame of a read loop has to establish a baseline rather than dispatch against a stale one, and skipping it on any frame would stand a direction up or down for good. A directional pad's click only reaches its click binding when the press landed inside the centre circle. Out in the ring the same press is a direction, and delivering both is the one-press-two-meanings collision the zone split exists to prevent. Every other mode answers "centre", so they gate to exactly what they did before. The virtual controller is now updated after the pads rather than before. It is handed what they resolved, so the old order would have reported last frame's directions — a frame of lag on every press, and a direction still held for a frame after release. Both report paths walk the pad bindings through one shared helper rather than a copy each; on the DS4 side that walk happens before the hat is computed, so a direction bound to the d-pad folds into it and diagonals come out as diagonals. Co-Authored-By: Claude Opus 5 --- src/app/ControllerManager.cpp | 73 +++++++++++++++++++++++++++++++---- src/app/TrackpadConfig.h | 17 ++++++-- src/app/VirtualController.cpp | 65 ++++++++++++++++++++++++------- src/app/VirtualController.h | 6 ++- 4 files changed, 135 insertions(+), 26 deletions(-) diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index ca0e9c9..8e44ec9 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -64,11 +64,21 @@ struct ControllerManager::Slot { // what the press actually sent rather than reading the binding again on // release, so rebinding mid-hold still releases cleanly. // - // Indices 0-3 are the paddles (L4, L5, R4, R5); 4-5 are the left and right - // pad clicks, which are ordinary bindings dispatched down this same path. - static constexpr size_t kBindableCount = 6; + // 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; BackButtonBinding paddleHeld[kBindableCount]; + // What each pad's directions were last frame. Unlike every other edge + // dispatched here these are not bits in the report — they are resolved + // from a position, so the previous report cannot be re-read for them and + // the answer has to be carried. Written every frame, including the first, + // so a direction cannot be stranded down across a mode change. + uint8_t prevLeftDirs = DirNone; + uint8_t prevRightDirs = DirNone; + // 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. @@ -972,9 +982,17 @@ void ControllerManager::ReadLoop(Slot* slot) { "dropped before it reaches SendInput", n); } - if (slot->vc) slot->vc->Update(buf, n, m_profile); + // The pads first: a directional pad's directions are resolved here and + // the virtual controller is handed them, so updating it first would + // report last frame's directions — a frame of lag on every press and, + // worse, a direction still held for a frame after release. slot->leftPad.Update(buf, n); slot->rightPad.Update(buf, n); + const PadDigital resolved{ slot->leftPad.Directions(), + slot->rightPad.Directions(), + slot->leftPad.ClickInCentre(), + slot->rightPad.ClickInCentre() }; + if (slot->vc) slot->vc->Update(buf, n, m_profile, resolved); // Trackpad haptics. { @@ -1189,14 +1207,47 @@ void ControllerManager::ReadLoop(Slot* slot) { const BackButtonBinding leftPadClick = m_profile.leftPad.EffectiveClick(); const BackButtonBinding rightPadClick = m_profile.rightPad.EffectiveClick(); - struct PaddleEdge { bool cur; bool prev; const BackButtonBinding& binding; }; + // A directional pad's click is only its click binding when the + // press landed in the middle — out in the ring the same press is a + // direction, and letting both through is the one physical press + // meaning two things that the zone split exists to prevent. The + // zone is latched for the press's whole life, so this cannot flip + // mid-hold and strand a binding down. Every other mode answers + // "centre", so they gate to exactly what they did before. + const bool leftClickInCentre = resolved.leftClickInCentre; + const bool rightClickInCentre = resolved.rightClickInCentre; + + const uint8_t lDirs = resolved.leftDirs, lPrevDirs = slot->prevLeftDirs; + const uint8_t rDirs = resolved.rightDirs, rPrevDirs = slot->prevRightDirs; + const auto& lPad = m_profile.leftPad; + const auto& rPad = m_profile.rightPad; + + // Held by value rather than by reference: half of these bindings + // are returned by value from the Effective* accessors, and a + // reference member would be bound to a temporary. + struct PaddleEdge { bool cur; bool prev; BackButtonBinding binding; }; const PaddleEdge edges[] = { { n>4 && (buf[4]&SteamController::BTN_L4)!=0, (prevBuf[4]&SteamController::BTN_L4)!=0, m_profile.back.l4 }, { n>4 && (buf[4]&SteamController::BTN_L5)!=0, (prevBuf[4]&SteamController::BTN_L5)!=0, m_profile.back.l5 }, { n>2 && (buf[2]&SteamController::BTN_R4)!=0, (prevBuf[2]&SteamController::BTN_R4)!=0, m_profile.back.r4 }, { n>3 && (buf[3]&SteamController::BTN_R5)!=0, (prevBuf[3]&SteamController::BTN_R5)!=0, m_profile.back.r5 }, - { n>5 && (buf[5]&SteamController::BTN_TP_LT_CLICK)!=0, (prevBuf[5]&SteamController::BTN_TP_LT_CLICK)!=0, leftPadClick }, - { n>4 && (buf[4]&SteamController::BTN_TP_RT_CLICK)!=0, (prevBuf[4]&SteamController::BTN_TP_RT_CLICK)!=0, rightPadClick }, + { n>5 && (buf[5]&SteamController::BTN_TP_LT_CLICK)!=0 && leftClickInCentre, (prevBuf[5]&SteamController::BTN_TP_LT_CLICK)!=0 && leftClickInCentre, leftPadClick }, + { n>4 && (buf[4]&SteamController::BTN_TP_RT_CLICK)!=0 && rightClickInCentre, (prevBuf[4]&SteamController::BTN_TP_RT_CLICK)!=0 && rightClickInCentre, rightPadClick }, + // Touch is not zoned: it cannot collide with a direction, + // which comes from the click, and a thumb crosses zones + // constantly while sliding. + { n>5 && (buf[5]&SteamController::BTN_TP_LT)!=0, (prevBuf[5]&SteamController::BTN_TP_LT)!=0, lPad.EffectiveTouch() }, + { n>4 && (buf[4]&SteamController::BTN_TP_RT)!=0, (prevBuf[4]&SteamController::BTN_TP_RT)!=0, rPad.EffectiveTouch() }, + // Directions are resolved rather than read, so their previous + // state is the one carried on the slot, not a bit in prevBuf. + { (lDirs&DirUp)!=0, (lPrevDirs&DirUp)!=0, lPad.EffectiveDirection(DirUp) }, + { (lDirs&DirDown)!=0, (lPrevDirs&DirDown)!=0, lPad.EffectiveDirection(DirDown) }, + { (lDirs&DirLeft)!=0, (lPrevDirs&DirLeft)!=0, lPad.EffectiveDirection(DirLeft) }, + { (lDirs&DirRight)!=0, (lPrevDirs&DirRight)!=0, lPad.EffectiveDirection(DirRight) }, + { (rDirs&DirUp)!=0, (rPrevDirs&DirUp)!=0, rPad.EffectiveDirection(DirUp) }, + { (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) }, }; static_assert(std::size(edges) == Slot::kBindableCount, "paddleHeld must have one entry per edge-dispatched binding"); @@ -1250,6 +1301,14 @@ void ControllerManager::ReadLoop(Slot* slot) { } } + // Deliberately outside the hasPrev guard above, alongside the report + // it is the equivalent of: the first frame of a read loop establishes + // a baseline rather than dispatching against a stale one, and every + // later frame leaves behind what the next will compare with. Skipping + // it on any frame would stand a direction up or down for good. + slot->prevLeftDirs = resolved.leftDirs; + slot->prevRightDirs = resolved.rightDirs; + memcpy(prevBuf, buf, n); hasPrev = true; } diff --git a/src/app/TrackpadConfig.h b/src/app/TrackpadConfig.h index 9e26ab4..ee83d8c 100644 --- a/src/app/TrackpadConfig.h +++ b/src/app/TrackpadConfig.h @@ -105,12 +105,23 @@ enum PadDir : uint8_t { DirRight = 1 << 3, }; -// What both pads' directions resolved to this frame. Passed to the virtual -// controller, which cannot work them out for itself — resolving a direction -// needs the hysteresis state that lives per-pad in TrackpadInput. +// What both pads resolved to this frame, for the two places a pad binding is +// delivered — the virtual controller and SendInput. Neither can work these out +// for itself: a direction and a click's zone both need the hysteresis and +// latch state that lives per-pad in TrackpadInput. +// +// Carrying the zone here rather than re-deriving it is what keeps the two +// delivery paths agreeing. It is inferrable — a directional pad only presses +// directions from a ring click, so a click with nothing under it is a centre +// one — but an inference that holds "except for one frame after the diagonals +// setting changes" is exactly the kind that gets found the hard way. struct PadDigital { uint8_t leftDirs = DirNone; uint8_t rightDirs = DirNone; + // True for any pad that is not a directional pad, so a caller can gate the + // click binding on this without asking what mode the pad is in. + bool leftClickInCentre = true; + bool rightClickInCentre = true; }; // Where a click landed on a pad, which is what keeps a directional pad's three diff --git a/src/app/VirtualController.cpp b/src/app/VirtualController.cpp index 52f7e9e..23198ad 100644 --- a/src/app/VirtualController.cpp +++ b/src/app/VirtualController.cpp @@ -282,10 +282,43 @@ void VirtualController::SetBatteryState(uint8_t levelPercent, uint8_t chargeStat } void VirtualController::Update(const uint8_t* buf, size_t n, - const ControllerProfile& profile) { + const ControllerProfile& profile, + PadDigital resolved) { if (!m_valid) return; 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 + // reaches their own report format. Directions and the click's zone were + // both resolved by TrackpadInput and arrive in `dirs`; touch and the click + // bit itself are read from the report here. + auto forEachPadBinding = [&](const auto& apply) { + const uint8_t b2 = n > 4 ? buf[4] : 0; + const uint8_t b3 = n > 5 ? buf[5] : 0; + + struct PadState { + const TrackpadSettings& pad; + bool touching; + bool clicked; // already narrowed to a centre press + uint8_t dirs; + }; + const PadState pads[] = { + { profile.leftPad, (b3 & SteamController::BTN_TP_LT) != 0, + (b3 & SteamController::BTN_TP_LT_CLICK) != 0 + && resolved.leftClickInCentre, resolved.leftDirs }, + { profile.rightPad, (b2 & SteamController::BTN_TP_RT) != 0, + (b2 & SteamController::BTN_TP_RT_CLICK) != 0 + && resolved.rightClickInCentre, resolved.rightDirs }, + }; + + for (const PadState& p : pads) { + if (p.touching) apply(p.pad.EffectiveTouch()); + if (p.clicked) apply(p.pad.EffectiveClick()); + for (uint8_t d : { DirUp, DirDown, DirLeft, DirRight }) + if (p.dirs & d) apply(p.pad.EffectiveDirection(static_cast(d))); + } + }; + if (m_platform == ControllerPlatform::PlayStation) { // A pad reaches the DS4 touchpad only when that is what it was set // to. Decided per pad, so one pad can drive the desktop while the @@ -373,13 +406,15 @@ void VirtualController::Update(const uint8_t* buf, size_t n, if (n > 3) { if (buf[3] & SteamController::BTN_R5) applyBack(backCfg.r5); } - // Pad clicks bound to a gamepad action reach the virtual pad the - // same way the paddles do. EffectiveClick yields nothing for a - // pad feeding the touchpad, whose press is reported below instead. - if (n > 5 && (buf[5] & SteamController::BTN_TP_LT_CLICK)) - applyBack(profile.leftPad.EffectiveClick()); - if (n > 4 && (buf[4] & SteamController::BTN_TP_RT_CLICK)) - applyBack(profile.rightPad.EffectiveClick()); + // Trackpad clicks, touches and directions bound to a gamepad + // action reach the virtual pad the same way the paddles do. The + // Effective* accessors yield nothing for a pad feeding the + // touchpad, whose press is reported below instead. + // + // Applied before the hat is worked out below, so a direction bound + // to the d-pad — which is the default — folds into it and its + // diagonals come out as diagonals rather than as two cardinals. + forEachPadBinding(applyBack); DS4_DPAD_DIRECTIONS hat = DS4_BUTTON_DPAD_NONE; if (dUp && dRt) hat = DS4_BUTTON_DPAD_NORTHEAST; @@ -492,13 +527,13 @@ void VirtualController::Update(const uint8_t* buf, size_t n, if (n > 3) { if (buf[3] & SteamController::BTN_R5) ApplyBackActionX360(backCfg.r5, report); } - // Pad clicks bound to a gamepad action. EffectiveClick still applies: - // a pad set to DS4 Touchpad has no binding to deliver, and on this - // platform no touchpad to deliver it to either. - if (n > 5 && (buf[5] & SteamController::BTN_TP_LT_CLICK)) - ApplyBackActionX360(profile.leftPad.EffectiveClick(), report); - if (n > 4 && (buf[4] & SteamController::BTN_TP_RT_CLICK)) - ApplyBackActionX360(profile.rightPad.EffectiveClick(), report); + // Trackpad clicks, touches and directions bound to a gamepad action. + // The Effective* accessors still apply: a pad set to DS4 Touchpad has + // no binding to deliver, and on this platform no touchpad to deliver + // it to either. + forEachPadBinding([&](const BackButtonBinding& b) { + ApplyBackActionX360(b, report); + }); vigem_target_x360_update(static_cast(m_client), static_cast(m_target), diff --git a/src/app/VirtualController.h b/src/app/VirtualController.h index 7d8afad..0488ae0 100644 --- a/src/app/VirtualController.h +++ b/src/app/VirtualController.h @@ -35,7 +35,11 @@ public: // for choosing what to tell the user. Empty unless a connect failed. const std::wstring& BusReport() const { return m_busReport; } - void Update(const uint8_t* buf, size_t n, const ControllerProfile& profile); + // `resolved` carries what each pad's directions and click zone came out as + // this frame. Passed in rather than worked out here because resolving them + // needs the per-pad hysteresis and latch state that lives in TrackpadInput. + void Update(const uint8_t* buf, size_t n, const ControllerProfile& profile, + PadDigital resolved); void OnRumble(uint8_t largeMotor, uint8_t smallMotor); // DS4-only: update battery level shown in the DS4 report.