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; }