diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp
index 1349855..c140fd5 100644
--- a/src/app/ControllerManager.cpp
+++ b/src/app/ControllerManager.cpp
@@ -79,6 +79,32 @@ struct ControllerManager::Slot {
uint8_t prevLeftDirs = DirNone;
uint8_t prevRightDirs = DirNone;
+ // Touch confirmation, and the previous answer for the same reason as the
+ // directions above.
+ //
+ // The report's touch bit means "pad active", which a press sets on its way
+ // down — the finger lands, force builds, and only then does the click bit
+ // follow. So a touch binding dispatched off the raw bit fired on every
+ // press, before the press itself, in every mode. Touch is not an
+ // independent signal: a click always implies one.
+ //
+ // What makes touch its own thing is a contact that does NOT become a
+ // press. So a finger has to be down this long without clicking before
+ // touch is reported at all, and a contact that clicks first reports none —
+ // nor any later in that same contact, since it has stopped being a tap.
+ //
+ // Nothing distinguishes the two at the moment of contact: contact area
+ // does not separate them (a thumb loading for a press sits around 2500,
+ // above a light rest and overlapping a grinding one), and the click that
+ // would settle it has not happened yet. Only waiting does.
+ static constexpr int kTouchConfirmFrames = 12; // ~48ms at 250Hz
+ int touchFramesLeft = 0;
+ int touchFramesRight = 0;
+ bool touchClickedLeft = false;
+ bool touchClickedRight = false;
+ bool prevLeftTouch = false;
+ bool prevRightTouch = false;
+
// 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.
@@ -1000,10 +1026,30 @@ void ControllerManager::ReadLoop(Slot* slot) {
// 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() };
+ // Is this contact a tap, or the beginning of a press? See
+ // kTouchConfirmFrames — nothing but time tells the two apart.
+ auto confirmTouch = [](bool touching, bool clicked,
+ int& framesDown, bool& sawClick) -> bool {
+ if (!touching) { framesDown = 0; sawClick = false; return false; }
+ if (clicked) sawClick = true;
+ ++framesDown;
+ return !sawClick && framesDown >= Slot::kTouchConfirmFrames;
+ };
+ const uint8_t tb2 = n > 4 ? buf[4] : 0;
+ const uint8_t tb3 = n > 5 ? buf[5] : 0;
+
+ const PadDigital resolved{
+ slot->leftPad.Directions(),
+ slot->rightPad.Directions(),
+ slot->leftPad.ClickInCentre(),
+ slot->rightPad.ClickInCentre(),
+ confirmTouch((tb3 & SteamController::BTN_TP_LT) != 0,
+ (tb3 & SteamController::BTN_TP_LT_CLICK) != 0,
+ slot->touchFramesLeft, slot->touchClickedLeft),
+ confirmTouch((tb2 & SteamController::BTN_TP_RT) != 0,
+ (tb2 & SteamController::BTN_TP_RT_CLICK) != 0,
+ slot->touchFramesRight, slot->touchClickedRight),
+ };
if (slot->vc) slot->vc->Update(buf, n, m_profile, resolved);
// Trackpad haptics.
@@ -1270,11 +1316,14 @@ void ControllerManager::ReadLoop(Slot* slot) {
{ 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 && 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,
+ // 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() },
+ // constantly while sliding. It is confirmed rather than read
+ // from the report, though: the raw bit is also set by a press
+ // on its way down, so the previous answer is carried on the
+ // slot the same way the directions are.
+ { resolved.leftTouch, slot->prevLeftTouch, lPad.EffectiveTouch() },
+ { resolved.rightTouch, slot->prevRightTouch, 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) },
@@ -1345,6 +1394,8 @@ void ControllerManager::ReadLoop(Slot* slot) {
// it on any frame would stand a direction up or down for good.
slot->prevLeftDirs = resolved.leftDirs;
slot->prevRightDirs = resolved.rightDirs;
+ slot->prevLeftTouch = resolved.leftTouch;
+ slot->prevRightTouch = resolved.rightTouch;
memcpy(prevBuf, buf, n);
hasPrev = true;
diff --git a/src/app/RemapWindow.cpp b/src/app/RemapWindow.cpp
index 2d3c33d..39b8082 100644
--- a/src/app/RemapWindow.cpp
+++ b/src/app/RemapWindow.cpp
@@ -249,7 +249,7 @@ R"HTML(
-
Press the outer part of the pad for a direction, or the middle for the trackpad click. Touch works anywhere.
+
Press the outer part of the pad for a direction, or the middle for the trackpad click.
@@ -280,7 +280,7 @@ R"HTML(
-
Press the outer part of the pad for a direction, or the middle for the trackpad click. Touch works anywhere.
+
Press the outer part of the pad for a direction, or the middle for the trackpad click.
@@ -517,8 +517,8 @@ var ROWS = [
{id:'R5',posTag:'LOWER',posLabel:'Lower grip'},
{id:'LPAD',badge:'L',posTag:'PAD',posLabel:'Trackpad Click'},
{id:'RPAD',badge:'R',posTag:'PAD',posLabel:'Trackpad Click'},
- {id:'LPADtouch',badge:'L',posTag:'TOUCH',posLabel:'Trackpad Touch'},
- {id:'RPADtouch',badge:'R',posTag:'TOUCH',posLabel:'Trackpad Touch'},
+ {id:'LPADtouch',badge:'L',posTag:'TAP',posLabel:'Trackpad Tap'},
+ {id:'RPADtouch',badge:'R',posTag:'TAP',posLabel:'Trackpad Tap'},
{id:'LPADup', badge:'L',posTag:'UP', posLabel:'Up'},
{id:'LPADdown', badge:'L',posTag:'DOWN', posLabel:'Down'},
{id:'LPADleft', badge:'L',posTag:'LEFT', posLabel:'Left'},
diff --git a/src/app/TrackpadConfig.h b/src/app/TrackpadConfig.h
index ee83d8c..5c2201e 100644
--- a/src/app/TrackpadConfig.h
+++ b/src/app/TrackpadConfig.h
@@ -122,6 +122,11 @@ struct PadDigital {
// click binding on this without asking what mode the pad is in.
bool leftClickInCentre = true;
bool rightClickInCentre = true;
+ // A finger resting on the pad without pressing it. Not the report's touch
+ // bit, which a press also sets on its way down — see kTouchConfirmFrames
+ // in ControllerManager for why a touch binding cannot use the raw bit.
+ bool leftTouch = false;
+ bool rightTouch = false;
};
// 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 23198ad..187d6f3 100644
--- a/src/app/VirtualController.cpp
+++ b/src/app/VirtualController.cpp
@@ -298,15 +298,15 @@ void VirtualController::Update(const uint8_t* buf, size_t n,
struct PadState {
const TrackpadSettings& pad;
- bool touching;
+ bool touching; // already narrowed to a tap, not a press
bool clicked; // already narrowed to a centre press
uint8_t dirs;
};
const PadState pads[] = {
- { profile.leftPad, (b3 & SteamController::BTN_TP_LT) != 0,
+ { profile.leftPad, resolved.leftTouch,
(b3 & SteamController::BTN_TP_LT_CLICK) != 0
&& resolved.leftClickInCentre, resolved.leftDirs },
- { profile.rightPad, (b2 & SteamController::BTN_TP_RT) != 0,
+ { profile.rightPad, resolved.rightTouch,
(b2 & SteamController::BTN_TP_RT_CLICK) != 0
&& resolved.rightClickInCentre, resolved.rightDirs },
};
diff --git a/src/probe/TrackpadZoneProbe.cpp b/src/probe/TrackpadZoneProbe.cpp
index f65679e..b227464 100644
--- a/src/probe/TrackpadZoneProbe.cpp
+++ b/src/probe/TrackpadZoneProbe.cpp
@@ -27,6 +27,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -309,6 +310,222 @@ void PrintSummary() {
}
// ---------------------------------------------------------------------------
+// ---------------------------------------------------------------------------
+// Tap tracking — why a haptic does not fire on every press
+// ---------------------------------------------------------------------------
+
+// The live constants from ControllerManager's click haptic latch, mirrored so
+// the session can be replayed through the real state machine rather than
+// through a description of it. Keep in step with Slot.
+constexpr int kPressConfirmFrames = 2;
+constexpr float kReleaseIdleArea = 1000.0f;
+constexpr int kAreaLowConfirmFrames = 8;
+
+struct Tap {
+ uint16_t areaAtPress = 0;
+ // Frames between the finger landing and the click bit going high. A press
+ // sets the touch bit on its way down, so a touch binding fired off the raw
+ // bit fires on every press — this is the window a tap has to outlast
+ // before it counts as one, and the shortest of these is the ceiling on
+ // ControllerManager's kTouchConfirmFrames. -1 when the finger was already
+ // down before this session started.
+ int touchToClick = -1;
+ // Frames the click bit stayed low after this press before going high
+ // again, and the lowest contact area reached in that gap. Together these
+ // say whether a release is distinguishable from threshold chatter, and
+ // whether the thumb ever came near the idle area the latch waits for.
+ int lowFrames = 0;
+ uint16_t minGapArea = 0xFFFF;
+ bool gapClosed = false; // the next press arrived, so the gap is final
+ bool haptic = false; // the latch would have fired for this press
+};
+
+struct TapTracker {
+ std::vector taps;
+ bool prevClick = false;
+
+ // Frames actually seen, and over how long. A session that captures fewer
+ // presses than the thumb made is worthless until this says why: at the
+ // report rate the stream is healthy and the presses genuinely were not
+ // there, and far below it the reports are being dropped before this sees
+ // them. Two processes reading one device each get their own queue, and an
+ // overflowing queue drops silently.
+ long long frames = 0;
+ std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now();
+ bool haveStart = false;
+
+ // A replay of the real latch, frame for frame.
+ enum class State { WaitingForPress, WaitingForRelease };
+ State state = State::WaitingForPress;
+ int clickTrue = 0;
+ int areaLow = 0;
+ int hapticsFired = 0; // presses and releases both pulse
+
+ // Frames the finger has been down for, or -1 when it is not down. Starts
+ // at -1 so a finger already resting when the session opens is not credited
+ // with a landing nobody saw.
+ int framesDown = -1;
+ bool prevTouch = false;
+
+ void Frame(bool touching, bool click, uint16_t area) {
+ if (!haveStart) { started = std::chrono::steady_clock::now(); haveStart = true; }
+ ++frames;
+
+ if (!touching) framesDown = -1;
+ else if (!prevTouch) framesDown = 0;
+ else if (framesDown >= 0) ++framesDown;
+ prevTouch = touching;
+
+ // --- what the user did ---
+ if (click && !prevClick) {
+ if (!taps.empty() && !taps.back().gapClosed) taps.back().gapClosed = true;
+ // Echoed as it happens, so a session that is missing presses says
+ // so while there is still a thumb on the pad to check it with,
+ // rather than at the summary once the evidence is gone.
+ if (taps.empty()) {
+ printf("\n tap 1 area %5u\n", area);
+ } else {
+ printf(" tap %2zu area %5u (gap %d frames, area down to %u)\n",
+ taps.size() + 1, area, taps.back().lowFrames,
+ taps.back().minGapArea == 0xFFFF ? 0 : taps.back().minGapArea);
+ }
+ fflush(stdout);
+ Tap t;
+ t.areaAtPress = area;
+ t.touchToClick = framesDown;
+ taps.push_back(t);
+ }
+ if (!click && !taps.empty() && !taps.back().gapClosed) {
+ Tap& t = taps.back();
+ ++t.lowFrames;
+ t.minGapArea = std::min(t.minGapArea, area);
+ }
+
+ // --- what the latch would have done ---
+ clickTrue = click ? clickTrue + 1 : 0;
+ if (state == State::WaitingForPress) {
+ if (clickTrue >= kPressConfirmFrames) {
+ state = State::WaitingForRelease;
+ areaLow = 0;
+ ++hapticsFired;
+ if (!taps.empty()) taps.back().haptic = true;
+ }
+ } else {
+ if (!click && static_cast(area) <= kReleaseIdleArea) {
+ if (++areaLow >= kAreaLowConfirmFrames) {
+ state = State::WaitingForPress;
+ ++hapticsFired;
+ }
+ } else {
+ areaLow = 0;
+ }
+ }
+
+ prevClick = click;
+ }
+};
+
+std::mutex g_tapMutex;
+TapTracker g_tap;
+std::atomic g_tapMode{false};
+
+void PrintTapSummary() {
+ std::lock_guard lk(g_tapMutex);
+ const auto& taps = g_tap.taps;
+
+ printf("\n===========================================================\n");
+ printf(" Tap session — %s pad, %zu presses\n",
+ g_useLeftPad.load() ? "LEFT" : "RIGHT", taps.size());
+ printf("===========================================================\n\n");
+
+ // First, and before any of the numbers below mean anything: was the stream
+ // healthy? Printed even when nothing was captured, which is exactly the
+ // case where the answer decides whether the session says anything at all.
+ const double secs = std::chrono::duration(
+ std::chrono::steady_clock::now() - g_tap.started).count();
+ const double hz = secs > 0.0 ? static_cast(g_tap.frames) / secs : 0.0;
+ printf("STREAM: %lld frames over %.1fs = %.0f Hz\n", g_tap.frames, secs, hz);
+ if (g_tap.frames == 0) {
+ printf(" Nothing arrived at all. The opened interface is not the one\n"
+ " carrying this controller's state.\n\n");
+ return;
+ }
+ if (hz < 100.0) {
+ printf(" Well under the controller's report rate — frames are being\n"
+ " dropped before this tool sees them, so presses can go missing\n"
+ " from this session that your thumb definitely made. Close the\n"
+ " tray app (two readers, two queues) and run the session again.\n");
+ }
+ printf("\n");
+
+ if (taps.empty()) {
+ printf("No presses seen. With the stream rate above in mind: at the full\n"
+ "report rate the click bit genuinely never went high on this\n"
+ "interface, which points at the wrong pad or the wrong slot.\n\n");
+ return;
+ }
+
+ int fired = 0;
+ for (const auto& t : taps) if (t.haptic) ++fired;
+ printf("PRESS HAPTICS: %d of %zu presses would fire\n\n",
+ fired, taps.size());
+
+ printf(" %-4s %-10s %-9s %-10s %s\n",
+ "#", "area@press", "lowFrames", "minGapArea", "haptic");
+ int i = 0;
+ for (const auto& t : taps) {
+ printf(" %-4d %-10u %-9d %-10s %s\n",
+ ++i, t.areaAtPress, t.lowFrames,
+ t.minGapArea == 0xFFFF ? "-" : std::to_string(t.minGapArea).c_str(),
+ t.haptic ? "yes" : "NO");
+ }
+
+ // The two questions the fix turns on: does the thumb ever get near the
+ // idle area the latch waits for, and is a real release long enough to be
+ // told apart from threshold chatter by duration alone?
+ std::vector gapAreas, lowRuns;
+ for (const auto& t : taps) {
+ if (t.minGapArea != 0xFFFF) gapAreas.push_back(t.minGapArea);
+ if (t.gapClosed) lowRuns.push_back(t.lowFrames);
+ }
+
+ if (!gapAreas.empty()) {
+ printf("\nCONTACT AREA BETWEEN PRESSES (the latch re-arms at <= %.0f)\n",
+ kReleaseIdleArea);
+ printf(" min %.0f p05 %.0f p50 %.0f max %.0f\n",
+ Percentile(gapAreas, 0.0), Percentile(gapAreas, 0.05),
+ Percentile(gapAreas, 0.50), Percentile(gapAreas, 1.0));
+ int under = 0;
+ for (double a : gapAreas) if (a <= kReleaseIdleArea) ++under;
+ printf(" %d of %zu gaps reach it\n", under, gapAreas.size());
+ }
+
+ // The other window that matters: how long a press spends touching before
+ // it clicks. A tap has to outlast that to be told apart from one.
+ std::vector toClick;
+ for (const auto& t : taps) if (t.touchToClick >= 0) toClick.push_back(t.touchToClick);
+ if (!toClick.empty()) {
+ printf("\nFINGER DOWN BEFORE THE CLICK, in frames (~4ms each)\n");
+ printf(" min %.0f p05 %.0f p50 %.0f max %.0f (n=%zu)\n",
+ Percentile(toClick, 0.0), Percentile(toClick, 0.05),
+ Percentile(toClick, 0.50), Percentile(toClick, 1.0), toClick.size());
+ printf(" kTouchConfirmFrames must exceed the shortest of these (%.0f) or a\n"
+ " quick press still fires the tap binding on its way down. It is\n"
+ " currently 12.\n", Percentile(toClick, 0.0));
+ }
+
+ if (!lowRuns.empty()) {
+ printf("\nCLICK BIT LOW between presses, in frames (~4ms each)\n");
+ printf(" min %.0f p05 %.0f p50 %.0f max %.0f\n",
+ Percentile(lowRuns, 0.0), Percentile(lowRuns, 0.05),
+ Percentile(lowRuns, 0.50), Percentile(lowRuns, 1.0));
+ printf(" A release confirmed on the click bit alone would need a\n"
+ " window under %.0f frames to catch every one of these.\n",
+ Percentile(lowRuns, 0.0));
+ }
+ printf("\n");
+}
+
// Reader thread
// ---------------------------------------------------------------------------
@@ -421,8 +638,17 @@ void ReaderThread(HidDevice* dev) {
const bool rightClick = (b2 & SteamController::BTN_TP_RT_CLICK) != 0;
const bool wantLeft = g_useLeftPad.load();
- if (wantLeft && leftClick && !prevLeftClick) RecordPress(left, lf.area);
- if (!wantLeft && rightClick && !prevRightClick) RecordPress(right, rf.area);
+ // Tap mode replays the latch frame by frame, so it needs every frame,
+ // not just the click edges the zone recorder cares about.
+ if (g_tapMode.load()) {
+ std::lock_guard lk(g_tapMutex);
+ g_tap.Frame(wantLeft ? lf.touching : rf.touching,
+ wantLeft ? leftClick : rightClick,
+ wantLeft ? lf.area : rf.area);
+ } else {
+ if (wantLeft && leftClick && !prevLeftClick) RecordPress(left, lf.area);
+ if (!wantLeft && rightClick && !prevRightClick) RecordPress(right, rf.area);
+ }
prevLeftClick = leftClick;
prevRightClick = rightClick;
@@ -442,6 +668,8 @@ void PrintHelp() {
"\n"
"Commands:\n"
" cal start the guided calibration (%d stages)\n"
+ " tap record rapid presses and replay the haptic latch over\n"
+ " them, to show which presses would fire a haptic\n"
" stop abandon a calibration in progress\n"
" pad l|r choose which pad to watch (currently %s)\n"
" sum print the summary for what has been collected so far\n"
@@ -474,6 +702,15 @@ int main() {
return 1;
}
printf("Opened %ls\n", paths[0].c_str());
+ // A puck publishes an interface per slot and only one has a controller in
+ // it, so the first path is not always the live one. Say when there was a
+ // choice, since "no presses recorded" and "watching an empty slot" look
+ // identical from the outside.
+ if (paths.size() > 1) {
+ printf("NOTE: %zu interfaces found; this is the first. If presses do not\n"
+ " register, the controller is on one of the others.\n",
+ paths.size());
+ }
PrintHelp();
@@ -512,7 +749,8 @@ int main() {
if (cmd == "stop") {
g_stage.store(-1);
g_stageDone.store(0);
- puts("Calibration abandoned. Samples so far are kept — 'sum' to see them.");
+ g_tapMode.store(false);
+ puts("Stopped. Samples so far are kept — 'sum' to see them.");
continue;
}
@@ -526,8 +764,20 @@ int main() {
continue;
}
+ if (cmd == "tap") {
+ {
+ std::lock_guard lk(g_tapMutex);
+ g_tap = TapTracker{};
+ }
+ g_tapMode.store(true);
+ printf("\nTap the pad repeatedly, the way you would to walk down a\n"
+ "menu — thumb staying on the pad between presses. Then 'sum'.\n");
+ continue;
+ }
+
if (cmd == "sum" || cmd == "summary") {
- PrintSummary();
+ if (g_tapMode.load()) PrintTapSummary();
+ else PrintSummary();
continue;
}