./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
1146 / 1320
86.8%
Branches:
757 / 1009
75.0%
Functions:
94 / 94
100.0%

src/internal/input_poller.cpp
Line Branch Exec Source
1 /**
2 * @file input_poller.cpp
3 * @brief This TU implements the internal input poll engine from input_poller.hpp.
4 *
5 * This engine drives the public input::Input facade. A background poll thread reads keyboard, mouse, gamepad, and
6 * mouse-wheel state. It detects press and hold edges under strict modifier rules and feeds the opt-in interception
7 * layer.
8 */
9
10 #include "input_poller.hpp"
11 #include "drain_backoff.hpp"
12 #include "input_delivery_scope.hpp"
13 #include "input_intercept.hpp"
14 #include "input_key_cache.hpp"
15 #include "lifecycle_context.hpp"
16 #include "platform.hpp"
17 #include "xinput_raw_scope.hpp"
18
19 #include "DetourModKit/diagnostics.hpp"
20 #include "DetourModKit/logger.hpp"
21
22 #include <windows.h>
23 #include <xinput.h>
24
25 #include <algorithm>
26 #include <array>
27 #include <atomic>
28 #include <cstdint>
29 #include <exception>
30 #include <limits>
31 #include <shared_mutex>
32 #include <system_error>
33 #include <thread>
34 #include <type_traits>
35 #include <unordered_set>
36
37 namespace DetourModKit
38 {
39 namespace detail
40 {
41 namespace
42 {
43 /**
44 * @brief Checks whether a single InputCode is currently pressed.
45 * @param wheel_pulse Per-cycle wheel pulse mask (bit 0 = WheelUp .. bit 3 = WheelRight), latched once
46 * per cycle so repeated reads within a cycle stay consistent.
47 */
48 4701 bool is_code_pressed(
49 const InputCode &code,
50 KeyStateCache &key_cache,
51 const XINPUT_STATE &gamepad_state,
52 bool gamepad_connected,
53 int trigger_threshold,
54 int stick_threshold,
55 uint8_t wheel_pulse
56 ) noexcept
57 {
58
3/4
✓ Branch 2 → 3 taken 913 times.
✓ Branch 2 → 9 taken 1307 times.
✓ Branch 2 → 13 taken 2481 times.
✗ Branch 2 → 29 not taken.
4701 switch (code.source)
59 {
60 913 case InputSource::Keyboard:
61 case InputSource::Mouse:
62 // The per-cycle cache makes a VK referenced by many bindings cost one GetAsyncKeyState call per
63 // cycle and gives the whole cycle one coherent sample.
64
3/4
✓ Branch 3 → 4 taken 913 times.
✗ Branch 3 → 7 not taken.
✓ Branch 5 → 6 taken 814 times.
✓ Branch 5 → 7 taken 99 times.
1826 return code.code != 0 && key_cache.pressed(
65 913 code.code,
66 1816 [](int vk) noexcept
67 {
68 #ifdef DMK_ENABLE_TEST_SEAMS
69
2/2
✓ Branch 3 → 4 taken 884 times.
✓ Branch 3 → 6 taken 19 times.
903 if (g_input_key_state_probe)
70 {
71 884 return g_input_key_state_probe(vk);
72 }
73 #endif
74 19 return (GetAsyncKeyState(vk) & 0x8000) != 0;
75 }
76 913 );
77 1307 case InputSource::MouseWheel:
78 {
79 // The wheel has no held state. The poll loop latches each notch into wheel_pulse. WheelCode values
80 // are 1-based and dense, so the direction index is code - WheelCode::Up.
81 1307 const int dir = code.code - WheelCode::Up;
82
2/4
✓ Branch 9 → 10 taken 1307 times.
✗ Branch 9 → 11 not taken.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 1307 times.
1307 if (dir < 0 || dir > 3)
83 {
84 ✗ return false;
85 }
86 1307 return (wheel_pulse & (1u << dir)) != 0;
87 }
88 2481 case InputSource::Gamepad:
89 {
90
2/2
✓ Branch 13 → 14 taken 140 times.
✓ Branch 13 → 15 taken 2341 times.
2481 if (!gamepad_connected)
91 {
92 140 return false;
93 }
94
1/2
✓ Branch 15 → 16 taken 2341 times.
✗ Branch 15 → 17 not taken.
2341 if (code.code < GamepadCode::LeftTrigger)
95 {
96 2341 return (gamepad_state.Gamepad.wButtons & static_cast<WORD>(code.code)) != 0;
97 }
98
0/11
✗ Branch 17 → 18 not taken.
✗ Branch 17 → 19 not taken.
✗ Branch 17 → 20 not taken.
✗ Branch 17 → 21 not taken.
✗ Branch 17 → 22 not taken.
✗ Branch 17 → 23 not taken.
✗ Branch 17 → 24 not taken.
✗ Branch 17 → 25 not taken.
✗ Branch 17 → 26 not taken.
✗ Branch 17 → 27 not taken.
✗ Branch 17 → 28 not taken.
✗ switch (code.code)
99 {
100 ✗ case GamepadCode::LeftTrigger:
101 ✗ return gamepad_state.Gamepad.bLeftTrigger > trigger_threshold;
102 ✗ case GamepadCode::RightTrigger:
103 ✗ return gamepad_state.Gamepad.bRightTrigger > trigger_threshold;
104 ✗ case GamepadCode::LeftStickUp:
105 ✗ return gamepad_state.Gamepad.sThumbLY > stick_threshold;
106 ✗ case GamepadCode::LeftStickDown:
107 ✗ return gamepad_state.Gamepad.sThumbLY < -stick_threshold;
108 ✗ case GamepadCode::LeftStickLeft:
109 ✗ return gamepad_state.Gamepad.sThumbLX < -stick_threshold;
110 ✗ case GamepadCode::LeftStickRight:
111 ✗ return gamepad_state.Gamepad.sThumbLX > stick_threshold;
112 ✗ case GamepadCode::RightStickUp:
113 ✗ return gamepad_state.Gamepad.sThumbRY > stick_threshold;
114 ✗ case GamepadCode::RightStickDown:
115 ✗ return gamepad_state.Gamepad.sThumbRY < -stick_threshold;
116 ✗ case GamepadCode::RightStickLeft:
117 ✗ return gamepad_state.Gamepad.sThumbRX < -stick_threshold;
118 ✗ case GamepadCode::RightStickRight:
119 ✗ return gamepad_state.Gamepad.sThumbRX > stick_threshold;
120 ✗ default:
121 ✗ return false;
122 }
123 }
124 }
125 ✗ return false;
126 }
127
128 /**
129 * @brief Checks if a held input satisfies a required modifier.
130 * @details Returns true when the codes match exactly. It also returns true for keyboard modifiers in the
131 * same family. For example, LShift satisfies generic Shift, and generic Shift satisfies LShift.
132 */
133 348 bool modifier_satisfies(const InputCode &required, const InputCode &held) noexcept
134 {
135
1/2
✓ Branch 3 → 4 taken 348 times.
✗ Branch 3 → 5 not taken.
348 if (required == held)
136 {
137 348 return true;
138 }
139
0/4
✗ Branch 5 → 6 not taken.
✗ Branch 5 → 7 not taken.
✗ Branch 6 → 7 not taken.
✗ Branch 6 → 8 not taken.
✗ if (required.source != InputSource::Keyboard || held.source != InputSource::Keyboard)
140 {
141 ✗ return false;
142 }
143 // Each row groups the generic, left, and right modifier codes.
144 ✗ constexpr int families[][3] = {
145 {0x11, 0xA2, 0xA3}, // Ctrl, LCtrl, RCtrl
146 {0x10, 0xA0, 0xA1}, // Shift, LShift, RShift
147 {0x12, 0xA4, 0xA5}, // Alt, LAlt, RAlt
148 };
149
0/2
✗ Branch 20 → 9 not taken.
✗ Branch 20 → 21 not taken.
✗ for (const auto &family : families)
150 {
151 ✗ bool req_in = false;
152 ✗ bool held_in = false;
153
0/2
✗ Branch 15 → 10 not taken.
✗ Branch 15 → 16 not taken.
✗ for (int vk : family)
154 {
155
0/2
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
✗ if (required.code == vk)
156 {
157 ✗ req_in = true;
158 }
159
0/2
✗ Branch 12 → 13 not taken.
✗ Branch 12 → 14 not taken.
✗ if (held.code == vk)
160 {
161 ✗ held_in = true;
162 }
163 }
164
0/4
✗ Branch 16 → 17 not taken.
✗ Branch 16 → 19 not taken.
✗ Branch 17 → 18 not taken.
✗ Branch 17 → 19 not taken.
✗ if (req_in && held_in)
165 {
166 ✗ return true;
167 }
168 }
169 ✗ return false;
170 }
171
172 /**
173 * @brief Reports whether any binding uses a gamepad InputCode.
174 * @param bindings Bindings to inspect.
175 * @return true when at least one binding contains a gamepad InputCode.
176 */
177 3749 bool scan_for_gamepad_bindings(const std::vector<InputBinding> &bindings) noexcept
178 {
179
2/2
✓ Branch 47 → 4 taken 49312 times.
✓ Branch 47 → 48 taken 3607 times.
56668 for (const auto &binding : bindings)
180 {
181
2/2
✓ Branch 21 → 8 taken 48981 times.
✓ Branch 21 → 22 taken 49170 times.
147463 for (const auto &key : binding.keys)
182 {
183
2/2
✓ Branch 10 → 11 taken 142 times.
✓ Branch 10 → 12 taken 48839 times.
48981 if (key.source == InputSource::Gamepad)
184 {
185 142 return true;
186 }
187 }
188
2/2
✓ Branch 37 → 24 taken 14 times.
✓ Branch 37 → 38 taken 49170 times.
98354 for (const auto &mod : binding.modifiers)
189 {
190
1/2
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 28 taken 14 times.
14 if (mod.source == InputSource::Gamepad)
191 {
192 ✗ return true;
193 }
194 }
195 }
196 3607 return false;
197 }
198
199 /**
200 * @brief Reports whether any binding uses a mouse-wheel trigger.
201 * @details Wheel codes only appear as trigger keys (never modifiers), so modifiers are not scanned.
202 */
203 3749 bool scan_for_wheel_bindings(const std::vector<InputBinding> &bindings) noexcept
204 {
205
2/2
✓ Branch 31 → 4 taken 49463 times.
✓ Branch 31 → 32 taken 3597 times.
56809 for (const auto &binding : bindings)
206 {
207
2/2
✓ Branch 21 → 8 taken 49132 times.
✓ Branch 21 → 22 taken 49311 times.
147906 for (const auto &key : binding.keys)
208 {
209
2/2
✓ Branch 10 → 11 taken 152 times.
✓ Branch 10 → 12 taken 48980 times.
49132 if (key.source == InputSource::MouseWheel)
210 {
211 152 return true;
212 }
213 }
214 }
215 3597 return false;
216 }
217
218 /**
219 * @brief Reports whether any consume binding carries a suppressible gamepad button (gates the XInput hook).
220 * @details Only digital buttons gate it: the detour masks wButtons, so analog codes can never be
221 * cleared. Analog codes do not justify hook installation because the hook cannot mask them.
222 */
223 3749 bool scan_for_consume_gamepad_bindings(const std::vector<InputBinding> &bindings) noexcept
224 {
225
2/2
✓ Branch 36 → 4 taken 49325 times.
✓ Branch 36 → 37 taken 3642 times.
56716 for (const auto &binding : bindings)
226 {
227
2/2
✓ Branch 6 → 7 taken 49093 times.
✓ Branch 6 → 8 taken 232 times.
49325 if (!binding.consume)
228 {
229 49093 continue;
230 }
231
2/2
✓ Branch 25 → 10 taken 232 times.
✓ Branch 25 → 26 taken 125 times.
589 for (const auto &key : binding.keys)
232 {
233
5/6
✓ Branch 12 → 13 taken 109 times.
✓ Branch 12 → 16 taken 123 times.
✓ Branch 13 → 14 taken 109 times.
✗ Branch 13 → 16 not taken.
✓ Branch 14 → 15 taken 107 times.
✓ Branch 14 → 16 taken 2 times.
232 if (key.source == InputSource::Gamepad && key.code > 0 && key.code < GamepadCode::LeftTrigger)
234 {
235 107 return true;
236 }
237 }
238 }
239 3642 return false;
240 }
241
242 /**
243 * @brief Builds the detour-evaluable consume rule list from the current bindings.
244 * @details Rules exist only when every known modifier is a digital gamepad button available in wButtons.
245 * Otherwise, the detour cannot reproduce the poll loop's strict-match decision. The whole list
246 * is dropped. The reactive mask still covers the held-modifier case. A rule
247 * carries the chord's modifier bits, its digital trigger bits to clear, and a forbidden mask
248 * of every other known modifier bit. Exact-duplicate triples are emitted once, so the rule
249 * budget is a budget of distinct chord shapes.
250 */
251 3749 std::vector<GamepadConsumeRule> build_gamepad_consume_rules(
252 const std::vector<InputBinding> &bindings,
253 const std::vector<InputCode> &known_modifiers
254 )
255 {
256 521 const auto is_digital_gamepad = [](const InputCode &code) noexcept
257 {
258
5/6
✓ Branch 2 → 3 taken 387 times.
✓ Branch 2 → 6 taken 134 times.
✓ Branch 3 → 4 taken 387 times.
✗ Branch 3 → 6 not taken.
✓ Branch 4 → 5 taken 385 times.
✓ Branch 4 → 6 taken 2 times.
521 return code.source == InputSource::Gamepad && code.code > 0 && code.code < GamepadCode::LeftTrigger;
259 };
260
261 3749 uint16_t known_mod_mask = 0;
262
2/2
✓ Branch 19 → 4 taken 127 times.
✓ Branch 19 → 20 taken 3738 times.
7614 for (const auto &mod : known_modifiers)
263 {
264
2/2
✓ Branch 7 → 8 taken 11 times.
✓ Branch 7 → 10 taken 116 times.
127 if (!is_digital_gamepad(mod))
265 {
266 11 return {};
267 }
268 116 known_mod_mask = static_cast<uint16_t>(known_mod_mask | static_cast<uint16_t>(mod.code));
269 }
270
271 3738 std::vector<GamepadConsumeRule> rules;
272
2/2
✓ Branch 83 → 22 taken 49469 times.
✓ Branch 83 → 84 taken 3738 times.
56945 for (const auto &binding : bindings)
273 {
274
2/2
✓ Branch 24 → 25 taken 49075 times.
✓ Branch 24 → 26 taken 394 times.
49469 if (!binding.consume)
275 {
276 49200 continue;
277 }
278 394 uint16_t trigger_mask = 0;
279
2/2
✓ Branch 42 → 28 taken 394 times.
✓ Branch 42 → 43 taken 394 times.
1182 for (const auto &key : binding.keys)
280 {
281
2/2
✓ Branch 31 → 32 taken 269 times.
✓ Branch 31 → 33 taken 125 times.
394 if (is_digital_gamepad(key))
282 {
283 269 trigger_mask = static_cast<uint16_t>(trigger_mask | static_cast<uint16_t>(key.code));
284 }
285 }
286
2/2
✓ Branch 43 → 44 taken 125 times.
✓ Branch 43 → 45 taken 269 times.
394 if (trigger_mask == 0)
287 {
288 // No digital gamepad trigger exists to clear. The detour has nothing to mask here.
289 125 continue;
290 }
291 // Every modifier is digital here: the gate above returned an empty list otherwise.
292 269 uint16_t modifier_mask = 0;
293
2/2
✓ Branch 58 → 47 taken 401 times.
✓ Branch 58 → 59 taken 269 times.
939 for (const auto &mod : binding.modifiers)
294 {
295 401 modifier_mask = static_cast<uint16_t>(modifier_mask | static_cast<uint16_t>(mod.code));
296 }
297 269 const uint16_t forbidden_mask =
298 269 static_cast<uint16_t>(known_mod_mask & static_cast<uint16_t>(~modifier_mask));
299
1/2
✓ Branch 61 → 62 taken 269 times.
✗ Branch 61 → 89 not taken.
269 const auto duplicate = std::find_if(
300 rules.begin(),
301 rules.end(),
302 2150 [&](const GamepadConsumeRule &rule) noexcept
303 {
304
3/4
✓ Branch 2 → 3 taken 34 times.
✓ Branch 2 → 6 taken 2116 times.
✓ Branch 3 → 4 taken 34 times.
✗ Branch 3 → 6 not taken.
2184 return rule.modifier_mask == modifier_mask && rule.forbidden_mask == forbidden_mask &&
305
2/2
✓ Branch 4 → 5 taken 32 times.
✓ Branch 4 → 6 taken 2 times.
2184 rule.trigger_mask == trigger_mask;
306 }
307 );
308
2/2
✓ Branch 69 → 70 taken 237 times.
✓ Branch 69 → 72 taken 32 times.
538 if (duplicate == rules.end())
309 {
310
1/2
✓ Branch 70 → 71 taken 237 times.
✗ Branch 70 → 88 not taken.
237 rules.push_back(GamepadConsumeRule{modifier_mask, forbidden_mask, trigger_mask});
311 }
312 }
313 3738 return rules;
314 3738 }
315
316 // The release grace absorbs the modifier-before-trigger release window without a noticeable delay for a
317 // deliberate tap.
318 constexpr uint64_t GAMEPAD_SUPPRESS_GRACE_MS = 80;
319
320 // Process-wide monotonic source for BindingToken generations, so a token minted by one poller can never
321 // alias a different poller's state. The source starts at 1. The value 0 remains reserved for an invalid
322 // token.
323 std::atomic<std::uint64_t> s_next_binding_generation{1};
324
325 /// Draws a unique binding generation with a relaxed operation that does not publish state.
326 6061 std::uint64_t next_binding_generation() noexcept
327 {
328 6061 return s_next_binding_generation.fetch_add(1, std::memory_order_relaxed);
329 }
330
331 /// Ensures @p binding carries a lifecycle control block. Allocates only for directly seeded entries.
332 1506 void ensure_lifecycle(InputBinding &binding)
333 {
334
2/2
✓ Branch 3 → 4 taken 314 times.
✓ Branch 3 → 8 taken 1192 times.
1506 if (!binding.lifecycle)
335 {
336
2/2
✓ Branch 4 → 5 taken 306 times.
✓ Branch 4 → 9 taken 8 times.
314 binding.lifecycle = make_binding_lifecycle();
337 }
338 1498 }
339
340 struct BindingRundown
341 {
342 std::shared_ptr<BindingLifecycle> lifecycle;
343 std::uint64_t generation{0};
344 };
345
346 // HoldRelease stores one action per release. Separate containers can diverge when only one copy succeeds,
347 // which leaves dispatch with a name that was never staged. One object prevents that split.
348 struct HoldRelease
349 {
350 std::function<void(bool)> callback;
351 std::string name;
352 };
353
354 3099 void add_rundown(std::vector<BindingRundown> &rundowns, const std::shared_ptr<BindingLifecycle> &lifecycle)
355 {
356
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 3099 times.
3099 if (!lifecycle)
357 {
358 ✗ return;
359 }
360
1/2
✓ Branch 7 → 8 taken 3099 times.
✗ Branch 7 → 30 not taken.
3099 const auto duplicate = std::find_if(
361 rundowns.begin(),
362 rundowns.end(),
363 356 [&lifecycle](const BindingRundown &rundown) { return rundown.lifecycle == lifecycle; }
364 );
365
2/2
✓ Branch 15 → 16 taken 2747 times.
✓ Branch 15 → 22 taken 352 times.
6198 if (duplicate == rundowns.end())
366 {
367 5494 rundowns.push_back({lifecycle, 0});
368 }
369
2/6
✓ Branch 17 → 18 taken 2747 times.
✗ Branch 17 → 24 not taken.
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 2747 times.
✗ Branch 26 → 27 not taken.
✗ Branch 26 → 28 not taken.
5494 }
370
371 2439 void drain_rundowns(const std::vector<BindingRundown> &rundowns) noexcept
372 {
373
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 2439 times.
2439 if (current_thread_in_delivery())
374 {
375 ✗ return;
376 }
377
2/2
✓ Branch 31 → 7 taken 2442 times.
✓ Branch 31 → 32 taken 2439 times.
7320 for (const auto &rundown : rundowns)
378 {
379 // A tombstoned registration admits nothing further, so wait for every in-flight callback
380 // regardless of parity slot. An advanced live registration still admits new-generation
381 // callbacks, so it drains only the retired slot.
382 2442 DrainBackoff backoff;
383
2/2
✓ Branch 11 → 12 taken 418 times.
✓ Branch 11 → 17 taken 2024 times.
2442 if (rundown.lifecycle->tombstoned())
384 {
385
1/2
✗ Branch 16 → 13 not taken.
✓ Branch 16 → 22 taken 418 times.
418 while (rundown.lifecycle->in_flight_total() != 0)
386 {
387 ✗ backoff.pause();
388 }
389 }
390 else
391 {
392
2/2
✓ Branch 21 → 18 taken 65 times.
✓ Branch 21 → 22 taken 2024 times.
2089 while (rundown.lifecycle->in_flight(rundown.generation) != 0)
393 {
394 65 backoff.pause();
395 }
396 }
397 }
398 }
399
400 /**
401 * @brief Adds one rundown for each distinct lifecycle of the bindings at @p indices.
402 * @details The caller holds the binding lock exclusive.
403 */
404 2447 void collect_rundowns(
405 std::vector<BindingRundown> &rundowns,
406 const std::vector<InputBinding> &bindings,
407 const std::vector<std::size_t> &indices
408 )
409 {
410 2447 rundowns.reserve(indices.size());
411
2/2
✓ Branch 19 → 6 taken 2782 times.
✓ Branch 19 → 20 taken 2446 times.
7674 for (size_t idx : indices)
412 {
413
1/2
✓ Branch 9 → 10 taken 2782 times.
✗ Branch 9 → 21 not taken.
2782 add_rundown(rundowns, bindings[idx].lifecycle);
414 }
415 2446 }
416
417 /**
418 * @brief Stages the release callback of each Hold binding at @p indices that a reshape drops.
419 * @details The caller holds the binding lock exclusive. An idempotent release always stages. Any other
420 * release stages only while its binding is active.
421 */
422 1094 void stage_hold_releases(
423 std::vector<HoldRelease> &hold_releases,
424 const std::vector<InputBinding> &bindings,
425 const std::unique_ptr<std::atomic<uint8_t>[]> &active_states,
426 const std::vector<std::size_t> &indices
427 )
428 {
429 1094 hold_releases.reserve(indices.size());
430
2/2
✓ Branch 39 → 6 taken 1429 times.
✓ Branch 39 → 40 taken 1093 times.
3615 for (size_t idx : indices)
431 {
432
5/6
✓ Branch 9 → 10 taken 410 times.
✓ Branch 9 → 25 taken 1019 times.
✓ Branch 12 → 13 taken 410 times.
✗ Branch 12 → 25 not taken.
✓ Branch 26 → 27 taken 410 times.
✓ Branch 26 → 30 taken 1019 times.
1839 if (bindings[idx].trigger == input::Trigger::Hold && bindings[idx].on_state_change &&
433
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 24 taken 410 times.
410 (bindings[idx].release_is_idempotent ||
434
0/2
✗ Branch 23 → 24 not taken.
✗ Branch 23 → 25 not taken.
✗ active_states[idx].load(std::memory_order_relaxed) != 0))
435 {
436
1/2
✓ Branch 29 → 30 taken 410 times.
✗ Branch 29 → 41 not taken.
410 hold_releases.emplace_back(bindings[idx].on_state_change, bindings[idx].name);
437 }
438 }
439 1093 }
440 } // anonymous namespace
441
442 #ifdef DMK_ENABLE_TEST_SEAMS
443 std::function<bool(int)> g_input_key_state_probe;
444 std::function<void(std::size_t)> g_input_post_stage_probe;
445 std::function<void()> g_input_pre_dispatch_probe;
446 void (*g_input_join_fail_seam)() = nullptr;
447 std::function<void(const std::array<int, 4> &)> g_input_external_wheel_post_drain_probe;
448 #endif
449
450 2007 std::shared_ptr<BindingLifecycle> make_binding_lifecycle()
451 {
452
2/2
✓ Branch 3 → 4 taken 1998 times.
✓ Branch 3 → 7 taken 9 times.
2007 return std::make_shared<BindingLifecycle>(next_binding_generation());
453 }
454
455 static_assert(
456 std::is_nothrow_move_assignable_v<InputBinding>,
457 "Input reshape commits rely on noexcept InputBinding move assignment"
458 );
459 static_assert(
460 std::is_nothrow_move_constructible_v<InputBinding>,
461 "Input reshape commits rely on noexcept InputBinding move construction"
462 );
463
464 570 InputPoller::InputPoller(
465 std::vector<InputBinding> bindings,
466 std::chrono::milliseconds poll_interval,
467 bool require_focus,
468 int gamepad_index,
469 int trigger_threshold,
470 int stick_threshold,
471 input::Input::WheelBackend wheel_backend,
472 const WheelHostTable *wheel_host,
473 std::uint32_t wheel_target_thread_id
474 570 )
475 1140 : m_bindings(std::move(bindings)),
476 1140 m_poll_interval(std::clamp(poll_interval, input::MIN_POLL_INTERVAL, input::MAX_POLL_INTERVAL)),
477 570 m_require_focus(require_focus),
478
1/2
✓ Branch 18 → 19 taken 570 times.
✗ Branch 18 → 66 not taken.
570 m_active_states(std::make_unique<std::atomic<uint8_t>[]>(m_bindings.size())),
479
1/2
✓ Branch 19 → 20 taken 570 times.
✗ Branch 19 → 54 not taken.
570 m_gamepad_index(std::clamp(gamepad_index, 0, 3)),
480
1/2
✓ Branch 20 → 21 taken 570 times.
✗ Branch 20 → 56 not taken.
570 m_trigger_threshold(std::clamp(trigger_threshold, 0, 255)),
481
1/2
✓ Branch 21 → 22 taken 570 times.
✗ Branch 21 → 58 not taken.
570 m_stick_threshold(std::clamp(stick_threshold, 0, 32767)), m_intercept_owner(next_intercept_owner()),
482
2/4
✓ Branch 8 → 9 taken 570 times.
✗ Branch 8 → 74 not taken.
✓ Branch 16 → 17 taken 570 times.
✗ Branch 16 → 68 not taken.
2280 m_wheel_backend(wheel_backend), m_wheel_host(wheel_host), m_wheel_target_thread_id(wheel_target_thread_id)
483 {
484
1/2
✓ Branch 35 → 36 taken 570 times.
✗ Branch 35 → 61 not taken.
570 m_name_index.reserve(m_bindings.size());
485 // Stamp a lifecycle on any binding seeded without one, so the poll loop's generation-safety check has
486 // an identity to compare against.
487
2/2
✓ Branch 50 → 38 taken 760 times.
✓ Branch 50 → 51 taken 570 times.
1900 for (auto &binding : m_bindings)
488 {
489
1/2
✓ Branch 40 → 41 taken 760 times.
✗ Branch 40 → 60 not taken.
760 ensure_lifecycle(binding);
490 }
491 // Construction is single-owner, so no lock is held; emit directly after the rebuild.
492 570 DeferredDiagnostics diagnostics;
493 570 recompute_modifier_caches_locked(diagnostics);
494 570 diagnostics.emit();
495 570 }
496
497 // The poll loop drives the wheel through these helpers so it never names a specific backend. MessageHook
498 // installs a local source and reuses the interception layer's owner, epoch, and drain path.
499 // ExternalHost opens one lease on the loader's resident host and drives it through the C ABI. The host table
500 // was validated in Input::start() before this poller was built, so the pointer and its function pointers are
501 // known good here.
502
503 137 int32_t InputPoller::prepare_wheel_source() noexcept
504 {
505
2/4
✓ Branch 2 → 3 taken 137 times.
✗ Branch 2 → 4 not taken.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 137 times.
137 if (m_wheel_backend != input::Input::WheelBackend::ExternalHost || m_wheel_lease != 0)
506 {
507 ✗ return DMK_WHEELHOST_OK;
508 }
509 137 WheelHostLease lease = 0;
510 137 const std::uint64_t generation = m_binding_generation;
511 const int32_t status =
512 137 m_wheel_host->open_lease(m_wheel_host->host_context, m_intercept_owner, generation, &lease);
513
2/2
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 8 taken 135 times.
137 if (status != DMK_WHEELHOST_OK)
514 {
515 2 return status;
516 }
517
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 135 times.
135 if (lease == 0)
518 {
519 ✗ return DMK_WHEELHOST_ERR_INVALID;
520 }
521 135 m_wheel_lease = lease;
522 135 m_wheel_lease_generation = generation;
523 135 m_external_lease_active.store(true, std::memory_order_release);
524 135 return DMK_WHEELHOST_OK;
525 }
526
527 1400 std::uint32_t InputPoller::resolve_wheel_target() const noexcept
528 {
529
2/2
✓ Branch 2 → 3 taken 34 times.
✓ Branch 2 → 4 taken 1366 times.
1400 if (m_wheel_target_thread_id != 0)
530 {
531 34 return m_wheel_target_thread_id;
532 }
533 // Automatic discovery: the current foreground window, only when this process owns it. There is no
534 // enumeration fallback; without a process-owned foreground window the route waits and retries.
535 1366 const HWND foreground = GetForegroundWindow();
536
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 1366 times.
1366 if (foreground == nullptr)
537 {
538 ✗ return 0;
539 }
540 1366 DWORD pid = 0;
541 1366 const DWORD thread_id = GetWindowThreadProcessId(foreground, &pid);
542
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 1366 times.
1366 return pid == GetCurrentProcessId() ? thread_id : 0;
543 }
544
545 2882 void InputPoller::note_wheel_host_status(std::int32_t status, const char *operation) noexcept
546 {
547
2/2
✓ Branch 2 → 3 taken 2874 times.
✓ Branch 2 → 4 taken 8 times.
2882 if (status == DMK_WHEELHOST_OK)
548 {
549 2874 return;
550 }
551 // One log line per distinct latched status, so a persistent failure does not spam every cycle but a
552 // changed failure is never silent. Health carries the live state; the latch only gates the log.
553
2/2
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 10 taken 6 times.
16 if (m_wheel_host_logged_status.exchange(status, std::memory_order_relaxed) != status)
554 {
555 2 (void)log().try_log(
556 LogLevel::Warning,
557 "InputPoller: wheel host {} failed with status {}; wheel capture stays disabled "
558 "until the route recovers",
559 operation,
560 status
561 );
562 }
563 }
564
565 namespace
566 {
567 /// Projects one host route snapshot onto the public health enum.
568 [[nodiscard]] input::Input::WheelSourceHealth
569 1382 derive_external_health(const WheelHostRouteStatus &status) noexcept
570 {
571 using Health = input::Input::WheelSourceHealth;
572 // A blocked cleanup is the most specific fact, so it takes precedence over its transaction.
573
2/2
✓ Branch 2 → 3 taken 4 times.
✓ Branch 2 → 4 taken 1378 times.
1382 if (status.route_state == DMK_WHEELHOST_ROUTE_CLEANUP_BLOCKED)
574 {
575 4 return Health::CleanupBlocked;
576 }
577
2/2
✓ Branch 4 → 5 taken 10 times.
✓ Branch 4 → 6 taken 1368 times.
1378 if (status.control_state != DMK_WHEELHOST_CONTROL_IDLE)
578 {
579 10 return Health::Retryable;
580 }
581
2/2
✓ Branch 6 → 7 taken 1364 times.
✓ Branch 6 → 11 taken 4 times.
1368 if (status.route_state == DMK_WHEELHOST_ROUTE_READY)
582 {
583
2/2
✓ Branch 7 → 8 taken 1361 times.
✓ Branch 7 → 9 taken 3 times.
1364 return status.capture_armable != 0 ? Health::Ready : Health::Retryable;
584 }
585
1/2
✓ Branch 11 → 12 taken 4 times.
✗ Branch 11 → 13 not taken.
4 return status.route_state == DMK_WHEELHOST_ROUTE_RETRYABLE ? Health::Retryable : Health::TargetWait;
586 }
587 } // anonymous namespace
588
589 1400 void InputPoller::wheel_source_maintain() noexcept
590 {
591 1400 const std::uint32_t desired = resolve_wheel_target();
592
2/2
✓ Branch 3 → 4 taken 23 times.
✓ Branch 3 → 14 taken 1377 times.
1400 if (m_wheel_backend == input::Input::WheelBackend::MessageHook)
593 {
594 23 const WheelRouteState state = message_hook_route_state();
595 23 const std::uint32_t mounted = message_hook_thread_id();
596
2/2
✓ Branch 6 → 7 taken 15 times.
✓ Branch 6 → 11 taken 8 times.
23 if (state == WheelRouteState::Ready)
597 {
598 // Keep a healthy mounted route through temporary focus loss. Migrate only when discovery
599 // resolves a different thread of this process.
600
2/4
✓ Branch 7 → 8 taken 15 times.
✗ Branch 7 → 10 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 15 times.
15 if (desired != 0 && desired != mounted)
601 {
602 ✗ (void)install_message_hook(m_intercept_owner, desired);
603 }
604 1395 return;
605 }
606
1/2
✓ Branch 11 → 12 taken 8 times.
✗ Branch 11 → 13 not taken.
8 if (desired != 0)
607 {
608 8 (void)install_message_hook(m_intercept_owner, desired);
609 }
610 8 return;
611 }
612
613
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 1377 times.
1377 if (m_wheel_lease == 0)
614 {
615 ✗ return;
616 }
617 1382 const auto read_status = [this](WheelHostRouteStatus &out) noexcept
618 {
619 1382 out = WheelHostRouteStatus{};
620 1382 return m_wheel_host->route_status(
621 1382 m_wheel_host->host_context,
622 m_wheel_lease,
623 static_cast<std::uint32_t>(sizeof(out)),
624 &out
625 1382 );
626 1377 };
627 1377 WheelHostRouteStatus status{};
628
1/2
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 21 taken 1377 times.
1377 if (const std::int32_t status_code = read_status(status); status_code != DMK_WHEELHOST_OK)
629 {
630 ✗ note_wheel_host_status(status_code, "route_status");
631 ✗ m_external_health.store(input::Input::WheelSourceHealth::Retryable, std::memory_order_relaxed);
632 ✗ return;
633 }
634 1377 m_external_health.store(derive_external_health(status), std::memory_order_relaxed);
635
636
2/2
✓ Branch 23 → 24 taken 9 times.
✓ Branch 23 → 26 taken 1368 times.
1377 if (status.control_state != DMK_WHEELHOST_CONTROL_IDLE &&
637
2/2
✓ Branch 24 → 25 taken 6 times.
✓ Branch 24 → 26 taken 3 times.
9 status.control_state != DMK_WHEELHOST_CONTROL_RETARGET_PENDING)
638 {
639 6 return;
640 }
641 // A pending retarget requires a retry even when the desired thread still owns the mount.
642 4110 const bool needs_retarget = status.control_state == DMK_WHEELHOST_CONTROL_RETARGET_PENDING ||
643
6/6
✓ Branch 26 → 27 taken 1368 times.
✓ Branch 26 → 30 taken 3 times.
✓ Branch 27 → 28 taken 1364 times.
✓ Branch 27 → 30 taken 4 times.
✓ Branch 28 → 29 taken 3 times.
✓ Branch 28 → 31 taken 1361 times.
1374 status.route_state != DMK_WHEELHOST_ROUTE_READY ||
644
2/2
✓ Branch 29 → 30 taken 1 time.
✓ Branch 29 → 31 taken 2 times.
3 (desired != 0 && desired != status.mounted_thread_id);
645
2/2
✓ Branch 32 → 33 taken 1363 times.
✓ Branch 32 → 34 taken 8 times.
1371 if (!needs_retarget)
646 {
647 1363 return;
648 }
649 // The retry uses current intent. Without current intent, it preserves the mounted thread.
650
2/2
✓ Branch 34 → 35 taken 5 times.
✓ Branch 34 → 36 taken 3 times.
8 const std::uint32_t target = desired != 0 ? desired : status.mounted_thread_id;
651
1/2
✗ Branch 37 → 38 not taken.
✓ Branch 37 → 39 taken 8 times.
8 if (target == 0)
652 {
653 ✗ return;
654 }
655 const std::int32_t retarget_status =
656 8 m_wheel_host->retarget(m_wheel_host->host_context, m_wheel_lease, target);
657
2/2
✓ Branch 40 → 41 taken 3 times.
✓ Branch 40 → 43 taken 5 times.
8 if (retarget_status == DMK_WHEELHOST_OK)
658 {
659 3 m_external_health.store(input::Input::WheelSourceHealth::Ready, std::memory_order_relaxed);
660 3 return;
661 }
662 5 note_wheel_host_status(retarget_status, "retarget");
663 5 WheelHostRouteStatus failed{};
664
1/2
✓ Branch 45 → 46 taken 5 times.
✗ Branch 45 → 47 not taken.
10 m_external_health.store(
665 10 read_status(failed) == DMK_WHEELHOST_OK ? derive_external_health(failed)
666 : input::Input::WheelSourceHealth::Retryable,
667 std::memory_order_relaxed
668 );
669 }
670
671 27 input::Input::WheelSourceHealth InputPoller::wheel_source_health() const noexcept
672 {
673 using Health = input::Input::WheelSourceHealth;
674
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 27 times.
27 if (!m_has_wheel_bindings.load(std::memory_order_acquire))
675 {
676 ✗ return Health::Inactive;
677 }
678
1/2
✓ Branch 5 → 6 taken 27 times.
✗ Branch 5 → 10 not taken.
27 if (m_wheel_backend == input::Input::WheelBackend::ExternalHost)
679 {
680
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 27 times.
27 if (!m_external_lease_active.load(std::memory_order_acquire))
681 {
682 ✗ return Health::Inactive;
683 }
684 27 return m_external_health.load(std::memory_order_relaxed);
685 }
686 ✗ const WheelRouteState state = message_hook_route_state();
687
0/6
✗ Branch 11 → 12 not taken.
✗ Branch 11 → 15 not taken.
✗ Branch 13 → 14 not taken.
✗ Branch 13 → 15 not taken.
✗ Branch 16 → 17 not taken.
✗ Branch 16 → 18 not taken.
✗ if (state == WheelRouteState::Ready && !intercept_owned_by(m_intercept_owner))
688 {
689 // The layer moved to another owner; this poller's route is effectively waiting.
690 ✗ return Health::TargetWait;
691 }
692
0/5
✗ Branch 18 → 19 not taken.
✗ Branch 18 → 20 not taken.
✗ Branch 18 → 21 not taken.
✗ Branch 18 → 22 not taken.
✗ Branch 18 → 23 not taken.
✗ switch (state)
693 {
694 ✗ case WheelRouteState::Ready:
695 ✗ return Health::Ready;
696 ✗ case WheelRouteState::Retryable:
697 ✗ return Health::Retryable;
698 ✗ case WheelRouteState::CleanupBlocked:
699 ✗ return Health::CleanupBlocked;
700 ✗ case WheelRouteState::TargetWait:
701 ✗ break;
702 }
703 ✗ return Health::TargetWait;
704 }
705
706 1371 std::array<int, 4> InputPoller::wheel_source_take_counts() noexcept
707 {
708
2/2
✓ Branch 2 → 3 taken 1337 times.
✓ Branch 2 → 16 taken 34 times.
1371 if (m_wheel_backend == input::Input::WheelBackend::ExternalHost)
709 {
710 1337 std::array<int, 4> out{};
711
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1337 times.
1337 if (m_wheel_lease == 0)
712 {
713 ✗ return out;
714 }
715 1337 std::uint32_t counts[DMK_WHEEL_DIRECTIONS] = {0, 0, 0, 0};
716 const std::int32_t status =
717 1337 m_wheel_host->drain_counts(m_wheel_host->host_context, m_wheel_lease, counts);
718 1337 note_wheel_host_status(status, "drain_counts");
719
1/2
✓ Branch 7 → 8 taken 1337 times.
✗ Branch 7 → 14 not taken.
1337 if (status == DMK_WHEELHOST_OK)
720 {
721 // The C ABI direction order (Up, Down, Left, Right) matches WheelPulseState indexing exactly.
722
2/2
✓ Branch 13 → 9 taken 5348 times.
✓ Branch 13 → 14 taken 1337 times.
6685 for (int dir = 0; dir < 4; ++dir)
723 {
724 10696 out[static_cast<std::size_t>(dir)] = static_cast<int>(
725 5348 std::min(counts[dir], static_cast<std::uint32_t>(std::numeric_limits<int>::max()))
726 );
727 }
728 }
729 1337 return out;
730 }
731 34 return take_wheel_counts(m_intercept_owner);
732 }
733
734 2843 void InputPoller::wheel_source_publish_consume(std::uint8_t direction_mask, bool capture_enabled) noexcept
735 {
736
2/2
✓ Branch 2 → 3 taken 1406 times.
✓ Branch 2 → 14 taken 1437 times.
2843 if (m_wheel_backend == input::Input::WheelBackend::ExternalHost)
737 {
738
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1406 times.
1406 if (m_wheel_lease == 0)
739 {
740 ✗ return;
741 }
742 // The WheelDirection consume bits match the C ABI DMK_WHEEL_CONSUME_* bit order exactly. The TTL is
743 // twice the poll interval, matching the local suppression self-heal, so a stalled poller stops the
744 // host swallowing after a bounded delay.
745 const std::uint32_t ttl_ms =
746 1406 static_cast<std::uint32_t>(std::clamp<std::int64_t>(m_poll_interval.count() * 2, 1, 60000));
747 1406 std::uint32_t capture_flags = 0;
748
2/2
✓ Branch 7 → 8 taken 1377 times.
✓ Branch 7 → 11 taken 29 times.
1406 if (capture_enabled)
749 {
750 1377 capture_flags = DMK_WHEEL_CAPTURE_ENABLED;
751
2/2
✓ Branch 9 → 10 taken 23 times.
✓ Branch 9 → 11 taken 1354 times.
1377 if (m_require_focus.load(std::memory_order_relaxed))
752 {
753 23 capture_flags |= DMK_WHEEL_CAPTURE_REQUIRE_FOCUS;
754 }
755 }
756 1406 const std::int32_t status = m_wheel_host->publish_capture(
757 1406 m_wheel_host->host_context,
758 m_wheel_lease,
759 capture_flags,
760 direction_mask,
761 ttl_ms
762 );
763 1406 note_wheel_host_status(status, "publish_capture");
764 1406 return;
765 }
766 (
767 void
768 1437 )publish_wheel_consume(direction_mask, m_require_focus.load(std::memory_order_relaxed), m_intercept_owner);
769 }
770
771 1073 void InputPoller::wheel_source_close() noexcept
772 {
773
4/4
✓ Branch 2 → 3 taken 266 times.
✓ Branch 2 → 8 taken 807 times.
✓ Branch 3 → 4 taken 134 times.
✓ Branch 3 → 8 taken 132 times.
1073 if (m_wheel_backend == input::Input::WheelBackend::ExternalHost && m_wheel_lease != 0)
774 {
775 134 const int32_t status = m_wheel_host->close_lease(
776 134 m_wheel_host->host_context,
777 m_wheel_lease,
778 134 m_intercept_owner,
779 m_wheel_lease_generation
780 );
781 134 note_wheel_host_status(status, "close_lease");
782 // After a refused close, retain the lease identity for a later shutdown retry.
783
2/2
✓ Branch 6 → 7 taken 131 times.
✓ Branch 6 → 8 taken 3 times.
134 if (status == DMK_WHEELHOST_OK)
784 {
785 131 m_wheel_lease = 0;
786 131 m_wheel_lease_generation = 0;
787 131 m_external_lease_active.store(false, std::memory_order_release);
788 }
789 }
790 1073 }
791
792 std::optional<InputPoller::ModifierCaches>
793 3763 InputPoller::build_modifier_caches(const std::vector<InputBinding> &bindings) noexcept
794 {
795 try
796 {
797 3763 ModifierCaches caches;
798 3763 std::unordered_set<InputCode, InputCodeHash> modifier_set;
799
2/2
✓ Branch 29 → 5 taken 49507 times.
✓ Branch 29 → 30 taken 3749 times.
53256 for (size_t i = 0; i < bindings.size(); ++i)
800 {
801
2/2
✓ Branch 7 → 8 taken 49497 times.
✓ Branch 7 → 11 taken 10 times.
49507 if (!bindings[i].name.empty())
802 {
803
4/4
✓ Branch 9 → 10 taken 49485 times.
✓ Branch 9 → 46 taken 12 times.
✓ Branch 10 → 11 taken 49483 times.
✓ Branch 10 → 46 taken 2 times.
49497 caches.name_index[bindings[i].name].push_back(i);
804 }
805
2/2
✓ Branch 26 → 14 taken 421 times.
✓ Branch 26 → 27 taken 49493 times.
99407 for (const auto &mod : bindings[i].modifiers)
806 {
807
1/2
✓ Branch 16 → 17 taken 421 times.
✗ Branch 16 → 44 not taken.
421 modifier_set.insert(mod);
808 }
809 }
810
1/2
✓ Branch 32 → 33 taken 3749 times.
✗ Branch 32 → 48 not taken.
3749 caches.known_modifiers.assign(modifier_set.begin(), modifier_set.end());
811
812 // Built from the same bindings and modifier set as the reactive path so the two never disagree.
813
1/2
✓ Branch 33 → 34 taken 3749 times.
✗ Branch 33 → 47 not taken.
3749 caches.consume_rules = build_gamepad_consume_rules(bindings, caches.known_modifiers);
814 3749 caches.has_gamepad_bindings = scan_for_gamepad_bindings(bindings);
815 3749 caches.has_wheel_bindings = scan_for_wheel_bindings(bindings);
816 3749 caches.has_consume_gamepad_bindings = scan_for_consume_gamepad_bindings(bindings);
817 3749 return caches;
818 3777 }
819 14 catch (...)
820 {
821 14 return std::nullopt;
822 14 }
823 }
824
825 void
826 3749 InputPoller::commit_modifier_caches_locked(ModifierCaches &caches, DeferredDiagnostics &diagnostics) noexcept
827 {
828 // Snapshot wheel ownership before this reshape. The installed detour continues to latch notches across an
829 // unbind -> rebind while the poll loop skips the drain. A stale backlog accumulates in the unowned window.
830 // The no-wheel -> wheel transition below must discard it.
831 3749 const bool had_wheel_bindings = m_has_wheel_bindings.load(std::memory_order_relaxed);
832
833 3749 m_consume_rules.swap(caches.consume_rules);
834 3749 m_name_index.swap(caches.name_index);
835 3749 m_name_index_authoritative = true;
836 3749 m_known_modifiers.swap(caches.known_modifiers);
837 3749 m_has_gamepad_bindings.store(caches.has_gamepad_bindings, std::memory_order_relaxed);
838 3749 m_has_consume_gamepad_bindings.store(caches.has_consume_gamepad_bindings, std::memory_order_relaxed);
839
840 // Offer the detour-side consume rule list. A poller that does not hold the layer keeps the rules cached and
841 // does not overwrite the owner's list.
842 3749 publish_consume_rules_locked(diagnostics);
843
844 // The published flag stays false until after this drain. Otherwise, the poll thread can consume a stale
845 // backlog before this thread clears it. A non-owner drains nothing because that backlog belongs to its
846 // owner's window.
847
4/4
✓ Branch 9 → 10 taken 3743 times.
✓ Branch 9 → 15 taken 6 times.
✓ Branch 10 → 11 taken 146 times.
✓ Branch 10 → 15 taken 3597 times.
3749 if (!had_wheel_bindings && caches.has_wheel_bindings)
848 {
849
2/2
✓ Branch 11 → 12 taken 135 times.
✓ Branch 11 → 13 taken 11 times.
146 if (m_wheel_backend == input::Input::WheelBackend::ExternalHost)
850 {
851 135 m_external_wheel_discard_pending.store(true, std::memory_order_release);
852 }
853 else
854 {
855 11 (void)wheel_source_take_counts();
856 }
857 }
858 // Release pairs with the poll cycle's acquire snapshot and publishes the drain before consumption.
859 3749 m_has_wheel_bindings.store(caches.has_wheel_bindings, std::memory_order_release);
860 3749 }
861
862 void
863 1734 InputPoller::recompute_modifier_caches_locked(DeferredDiagnostics &diagnostics, CacheFailPolicy policy) noexcept
864 {
865 // A caller already changed member state. Advance the generation even when the derived-cache build fails.
866 1734 m_binding_generation = next_binding_generation();
867
868 1734 std::optional<ModifierCaches> caches = build_modifier_caches(m_bindings);
869
2/2
✓ Branch 5 → 6 taken 1725 times.
✓ Branch 5 → 9 taken 9 times.
1734 if (caches)
870 {
871 1725 commit_modifier_caches_locked(*caches, diagnostics);
872 1725 return;
873 }
874
875
2/2
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 14 taken 7 times.
9 if (policy == CacheFailPolicy::Retain)
876 {
877 // Keep the lookup caches (the caller changed a flag, not the binding set) but disarm the suppression. A
878 // retained rule list has no independent expiry and masks a revoked chord for the rest of the process.
879 2 m_has_consume_gamepad_bindings.store(false, std::memory_order_relaxed);
880 2 m_consume_rules.clear();
881 2 publish_consume_rules_locked(diagnostics);
882 2 diagnostics.cache_rebuild_retained = true;
883 2 return;
884 }
885
886 // Keep every derived cache conservative and index-safe rather than leave a stale name map whose old indices
887 // can address past the new binding array.
888 7 m_name_index.clear();
889 7 m_name_index_authoritative = false;
890 7 m_known_modifiers.clear();
891 7 m_has_gamepad_bindings.store(false, std::memory_order_relaxed);
892 7 m_has_wheel_bindings.store(false, std::memory_order_relaxed);
893 7 m_has_consume_gamepad_bindings.store(false, std::memory_order_relaxed);
894 7 m_consume_rules.clear();
895 7 publish_consume_rules_locked(diagnostics);
896 7 diagnostics.cache_rebuild_cleared = true;
897
2/2
✓ Branch 23 → 24 taken 7 times.
✓ Branch 23 → 26 taken 1727 times.
1734 }
898
899 52216 template <typename Visit> bool InputPoller::for_each_named_locked(std::string_view name, Visit &&visit) const
900 {
901
12/14
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::is_binding_active(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::is_binding_active(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 47071 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::has_bindings_by_name(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::has_bindings_by_name(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 6 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#2}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#2}&&) const:
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 2536 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 2538 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::set_consume(std::basic_string_view<char, std::char_traits<char> >, bool)::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::set_consume(std::basic_string_view<char, std::char_traits<char> >, bool)::{lambda(unsigned long long)#1}&&) const:
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 13 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#2}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#2}&&) const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 5 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#1}&&) const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 5 times.
52216 if (name.empty())
902 {
903 5 return false;
904 }
905
13/14
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::is_binding_active(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::is_binding_active(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 5 → 6 taken 47075 times.
✗ Branch 5 → 31 not taken.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::has_bindings_by_name(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::has_bindings_by_name(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 5 → 6 taken 5 times.
✓ Branch 5 → 31 taken 1 time.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#2}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#2}&&) const:
✓ Branch 5 → 6 taken 2532 times.
✓ Branch 5 → 31 taken 4 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 5 → 6 taken 2534 times.
✓ Branch 5 → 31 taken 4 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::set_consume(std::basic_string_view<char, std::char_traits<char> >, bool)::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::set_consume(std::basic_string_view<char, std::char_traits<char> >, bool)::{lambda(unsigned long long)#1}&&) const:
✓ Branch 5 → 6 taken 12 times.
✓ Branch 5 → 31 taken 1 time.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#2}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#2}&&) const:
✓ Branch 5 → 6 taken 4 times.
✓ Branch 5 → 31 taken 1 time.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#1}&&) const:
✓ Branch 5 → 6 taken 4 times.
✓ Branch 5 → 31 taken 1 time.
52174 if (m_name_index_authoritative)
906 {
907
7/14
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::is_binding_active(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::is_binding_active(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 6 → 7 taken 46373 times.
✗ Branch 6 → 43 not taken.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::has_bindings_by_name(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::has_bindings_by_name(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 6 → 7 taken 5 times.
✗ Branch 6 → 43 not taken.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#2}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#2}&&) const:
✓ Branch 6 → 7 taken 2532 times.
✗ Branch 6 → 44 not taken.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 6 → 7 taken 2534 times.
✗ Branch 6 → 43 not taken.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::set_consume(std::basic_string_view<char, std::char_traits<char> >, bool)::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::set_consume(std::basic_string_view<char, std::char_traits<char> >, bool)::{lambda(unsigned long long)#1}&&) const:
✓ Branch 6 → 7 taken 12 times.
✗ Branch 6 → 43 not taken.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#2}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#2}&&) const:
✓ Branch 6 → 7 taken 4 times.
✗ Branch 6 → 44 not taken.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#1}&&) const:
✓ Branch 6 → 7 taken 4 times.
✗ Branch 6 → 43 not taken.
52166 const auto it = m_name_index.find(name);
908
10/14
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::is_binding_active(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::is_binding_active(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 9 → 10 taken 3 times.
✓ Branch 9 → 11 taken 46461 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::has_bindings_by_name(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::has_bindings_by_name(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 9 → 10 taken 5 times.
✗ Branch 9 → 11 not taken.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#2}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#2}&&) const:
✓ Branch 9 → 10 taken 7 times.
✓ Branch 9 → 11 taken 2525 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 9 → 10 taken 7 times.
✓ Branch 9 → 11 taken 2527 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::set_consume(std::basic_string_view<char, std::char_traits<char> >, bool)::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::set_consume(std::basic_string_view<char, std::char_traits<char> >, bool)::{lambda(unsigned long long)#1}&&) const:
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 12 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#2}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#2}&&) const:
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 4 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#1}&&) const:
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 4 times.
51464 if (it == m_name_index.end())
909 {
910 22 return false;
911 }
912
12/14
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::is_binding_active(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::is_binding_active(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 26 → 14 taken 59305 times.
✓ Branch 26 → 27 taken 45468 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::has_bindings_by_name(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::has_bindings_by_name(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✗ Branch 26 → 14 not taken.
✗ Branch 26 → 27 not taken.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#2}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#2}&&) const:
✓ Branch 26 → 14 taken 2864 times.
✓ Branch 26 → 27 taken 2525 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 26 → 14 taken 2866 times.
✓ Branch 26 → 27 taken 2527 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::set_consume(std::basic_string_view<char, std::char_traits<char> >, bool)::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::set_consume(std::basic_string_view<char, std::char_traits<char> >, bool)::{lambda(unsigned long long)#1}&&) const:
✓ Branch 26 → 14 taken 12 times.
✓ Branch 26 → 27 taken 12 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#2}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#2}&&) const:
✓ Branch 26 → 14 taken 4 times.
✓ Branch 26 → 27 taken 4 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#1}&&) const:
✓ Branch 26 → 14 taken 4 times.
✓ Branch 26 → 27 taken 4 times.
167128 for (const std::size_t idx : it->second)
913 {
914
2/4
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#2}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#2}&&) const:
✓ Branch 16 → 17 taken 2864 times.
✗ Branch 16 → 43 not taken.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#2}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#2}&&) const:
✓ Branch 16 → 17 taken 4 times.
✗ Branch 16 → 43 not taken.
65055 visit(idx);
915 }
916 50540 return !it->second.empty();
917 }
918 12 bool matched = false;
919
14/14
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::is_binding_active(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::is_binding_active(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 40 → 32 taken 4 times.
✓ Branch 40 → 41 taken 1 time.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::has_bindings_by_name(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::has_bindings_by_name(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 40 → 32 taken 2 times.
✓ Branch 40 → 41 taken 1 time.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#2}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#2}&&) const:
✓ Branch 40 → 32 taken 10 times.
✓ Branch 40 → 41 taken 4 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 40 → 32 taken 10 times.
✓ Branch 40 → 41 taken 4 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::set_consume(std::basic_string_view<char, std::char_traits<char> >, bool)::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::set_consume(std::basic_string_view<char, std::char_traits<char> >, bool)::{lambda(unsigned long long)#1}&&) const:
✓ Branch 40 → 32 taken 4 times.
✓ Branch 40 → 41 taken 1 time.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#2}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#2}&&) const:
✓ Branch 40 → 32 taken 2 times.
✓ Branch 40 → 41 taken 1 time.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#1}&&) const:
✓ Branch 40 → 32 taken 2 times.
✓ Branch 40 → 41 taken 1 time.
42 for (std::size_t idx = 0; idx < m_bindings.size(); ++idx)
920 {
921
14/14
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::is_binding_active(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::is_binding_active(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 35 → 36 taken 1 time.
✓ Branch 35 → 38 taken 3 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::has_bindings_by_name(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::has_bindings_by_name(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 35 → 36 taken 1 time.
✓ Branch 35 → 38 taken 1 time.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#2}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#2}&&) const:
✓ Branch 35 → 36 taken 4 times.
✓ Branch 35 → 38 taken 6 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::named_indices_locked(std::basic_string_view<char, std::char_traits<char> >) const::{lambda(unsigned long long)#1}&&) const:
✓ Branch 35 → 36 taken 4 times.
✓ Branch 35 → 38 taken 6 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::set_consume(std::basic_string_view<char, std::char_traits<char> >, bool)::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::set_consume(std::basic_string_view<char, std::char_traits<char> >, bool)::{lambda(unsigned long long)#1}&&) const:
✓ Branch 35 → 36 taken 1 time.
✓ Branch 35 → 38 taken 3 times.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#2}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#2}&&) const:
✓ Branch 35 → 36 taken 1 time.
✓ Branch 35 → 38 taken 1 time.
bool DetourModKit::detail::InputPoller::for_each_named_locked<DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#1}>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::InputPoller::retire_gates_by_name(std::basic_string_view<char, std::char_traits<char> >, std::chrono::time_point<std::chrono::_V2::steady_clock, std::chrono::duration<long long, std::ratio<1ll, 1000000000ll> > >)::{lambda(unsigned long long)#1}&&) const:
✓ Branch 35 → 36 taken 1 time.
✓ Branch 35 → 38 taken 1 time.
34 if (m_bindings[idx].name == name)
922 {
923 13 visit(idx);
924 13 matched = true;
925 }
926 }
927 13 return matched;
928 }
929
930 2539 std::vector<std::size_t> InputPoller::named_indices_locked(std::string_view name) const
931 {
932 2539 std::size_t matched = 0;
933
1/2
✓ Branch 2 → 3 taken 2539 times.
✗ Branch 2 → 7 not taken.
2539 (void)for_each_named_locked(name, [&matched](std::size_t) noexcept { ++matched; });
934 2539 std::vector<std::size_t> indices;
935
2/2
✓ Branch 3 → 4 taken 2537 times.
✓ Branch 3 → 9 taken 2 times.
2539 indices.reserve(matched);
936
1/2
✓ Branch 4 → 5 taken 2537 times.
✗ Branch 4 → 8 not taken.
5405 (void)for_each_named_locked(name, [&indices](std::size_t idx) { indices.push_back(idx); });
937 2537 return indices;
938 2 }
939
940 4164 void InputPoller::publish_consume_rules_locked(DeferredDiagnostics &diagnostics) noexcept
941 {
942 const ConsumePublish result =
943 4164 publish_gamepad_consume_rules(m_consume_rules.data(), m_consume_rules.size(), m_intercept_owner);
944
2/2
✓ Branch 5 → 6 taken 3984 times.
✓ Branch 5 → 9 taken 180 times.
4164 if (!result.authorized)
945 {
946 // Not this poller's layer: report zero occupancy and keep the rules cached for the retry.
947 3984 m_consume_rules_unpublished.store(true, std::memory_order_release);
948 3984 record_consume_capacity(0, 0, diagnostics);
949 3984 return;
950 }
951 180 m_consume_rules_unpublished.store(false, std::memory_order_release);
952 180 record_consume_capacity(result.published, m_consume_rules.size() - result.published, diagnostics);
953 }
954
955 #ifdef DMK_ENABLE_TEST_SEAMS
956 19 void InputPoller::publish_consume_rules_for_test() noexcept
957 {
958 19 DeferredDiagnostics diagnostics;
959 {
960 19 std::unique_lock lock(m_bindings_rw_mutex);
961 19 publish_consume_rules_locked(diagnostics);
962 19 }
963 19 diagnostics.emit();
964 19 }
965
966 12 bool InputPoller::name_index_authoritative_for_test() const noexcept
967 {
968 12 std::shared_lock lock(m_bindings_rw_mutex);
969 24 return m_name_index_authoritative;
970 12 }
971 #endif
972
973 4164 void InputPoller::record_consume_capacity(
974 std::size_t active,
975 std::size_t rejected,
976 DeferredDiagnostics &diagnostics
977 ) noexcept
978 {
979 4164 m_consume_rules_total.store(active + rejected, std::memory_order_relaxed);
980
2/2
✓ Branch 10 → 11 taken 4162 times.
✓ Branch 10 → 12 taken 2 times.
4164 if (rejected == 0)
981 {
982 4162 return;
983 }
984 // Latch per engine. An unlatched log repeats the same condition on every publish. A process-wide latch
985 // silences a later engine's overflow.
986
1/2
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 2 times.
2 if (m_consume_bound_reported.exchange(true, std::memory_order_relaxed))
987 {
988 ✗ return;
989 }
990 2 diagnostics.consume_bound = true;
991 2 diagnostics.consume_rejected = rejected;
992 2 diagnostics.consume_total = active + rejected;
993 }
994
995 5208 void InputPoller::DeferredDiagnostics::emit() const noexcept
996 {
997
2/2
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 6 taken 5206 times.
5208 if (cache_rebuild_retained)
998 {
999 2 (void)log().try_log(
1000 LogLevel::Error,
1001 "InputPoller: out of memory rebuilding modifier caches; name lookup is "
1002 "retained and gamepad consume suppression is disarmed until the next "
1003 "successful rebuild"
1004 );
1005 }
1006
2/2
✓ Branch 6 → 7 taken 7 times.
✓ Branch 6 → 10 taken 5201 times.
5208 if (cache_rebuild_cleared)
1007 {
1008 7 (void)log().try_log(
1009 LogLevel::Error,
1010 "InputPoller: out of memory rebuilding modifier caches; name lookup scans the "
1011 "binding set and input interception is disabled until the next successful rebuild"
1012 );
1013 }
1014
2/2
✓ Branch 10 → 11 taken 17 times.
✓ Branch 10 → 14 taken 5191 times.
5208 if (add_binding_oom)
1015 {
1016 17 (void)log().try_log(LogLevel::Error, "InputPoller: out of memory in add_binding; binding not added");
1017 }
1018
2/2
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 18 taken 5207 times.
5208 if (add_bindings_oom)
1019 {
1020 1 (void)log().try_log(LogLevel::Error, "InputPoller: out of memory in add_bindings; bindings not added");
1021 }
1022
2/2
✓ Branch 18 → 19 taken 2 times.
✓ Branch 18 → 22 taken 5206 times.
5208 if (consume_bound)
1023 {
1024 4 (void)log().try_log(
1025 LogLevel::Warning,
1026 "InputPoller: {} of {} gamepad consume chords exceed the interception table; "
1027 "they keep the reactive mask but lose same-frame suppression",
1028 2 consume_rejected,
1029 2 consume_total
1030 );
1031 }
1032 5208 }
1033
1034 11 input::ConsumeCapacity InputPoller::consume_capacity() const noexcept
1035 {
1036 11 const std::size_t total = m_consume_rules_total.load(std::memory_order_relaxed);
1037 11 const std::size_t active = std::min(total, MAX_GAMEPAD_CONSUME_RULES);
1038 11 return input::ConsumeCapacity{MAX_GAMEPAD_CONSUME_RULES, active, total - active};
1039 }
1040
1041 564 InputPoller::~InputPoller() noexcept
1042 {
1043 564 shutdown();
1044 564 }
1045
1046 509 void InputPoller::start()
1047 {
1048
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 7 taken 508 times.
509 if (m_poll_thread.joinable())
1049 {
1050
1/2
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 20 not taken.
1 log().debug("InputPoller: start() called while already running; no-op.");
1051 1 return;
1052 }
1053
1054 // Acquire before poll-thread creation because execution can start immediately. Its module reference must
1055 // already be part of the count.
1056 508 const HMODULE self_ref = acquire_module_ref(diagnostics::ModulePinReason::InputPoller);
1057
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 13 taken 508 times.
508 if (self_ref == nullptr)
1058 {
1059 throw std::system_error(
1060 ✗ static_cast<int>(GetLastError()),
1061 std::system_category(),
1062 "InputPoller: acquire_module_ref failed"
1063
0/4
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 21 not taken.
✗ Branch 11 → 12 not taken.
✗ Branch 11 → 21 not taken.
✗ );
1064 }
1065
1066 508 m_xinput_raw_owner = acquire_xinput_raw_scope_owner();
1067 508 m_running.store(true, std::memory_order_release);
1068 try
1069 {
1070
2/4
✓ Branch 5 → 6 taken 506 times.
✗ Branch 5 → 8 not taken.
✓ Branch 15 → 16 taken 508 times.
✗ Branch 15 → 23 not taken.
1524 m_poll_thread = std::jthread([this](std::stop_token token) { poll_loop(std::move(token)); });
1071 }
1072 ✗ catch (...)
1073 {
1074 ✗ m_running.store(false, std::memory_order_release);
1075
0/2
✗ Branch 28 → 29 not taken.
✗ Branch 28 → 31 not taken.
✗ if (m_xinput_raw_owner)
1076 {
1077 ✗ release_xinput_raw_scope_owner();
1078 ✗ m_xinput_raw_owner = false;
1079 }
1080 ✗ release_module_ref(self_ref, diagnostics::ModulePinReason::InputPoller);
1081 ✗ throw;
1082 ✗ }
1083 508 m_self_ref = self_ref;
1084 }
1085
1086 48 bool InputPoller::is_running() const noexcept
1087 {
1088 48 return m_running.load(std::memory_order_acquire);
1089 }
1090
1091 42394 size_t InputPoller::binding_count() const noexcept
1092 {
1093 42394 std::shared_lock lock(m_bindings_rw_mutex);
1094 42450 return m_bindings.size();
1095 42401 }
1096
1097 7 bool InputPoller::has_bindings_by_name(std::string_view name) const noexcept
1098 {
1099 7 std::shared_lock lock(m_bindings_rw_mutex);
1100 7 return for_each_named_locked(name, [](std::size_t) noexcept {});
1101 7 }
1102
1103 4 std::chrono::milliseconds InputPoller::poll_interval() const noexcept
1104 {
1105 4 return m_poll_interval;
1106 }
1107
1108 2 int InputPoller::gamepad_index() const noexcept
1109 {
1110 2 return m_gamepad_index;
1111 }
1112
1113 8 bool InputPoller::is_binding_active(size_t index) const noexcept
1114 {
1115 // The shared lock keeps the index and array consistent across a reshape. The unique_ptr<atomic[]> ownership
1116 // swap needs synchronization, not the relaxed element load.
1117 8 std::shared_lock lock(m_bindings_rw_mutex);
1118
2/2
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 6 times.
8 if (index >= m_bindings.size())
1119 {
1120 2 return false;
1121 }
1122 12 return m_active_states[index].load(std::memory_order_relaxed) != 0;
1123 8 }
1124
1125 47231 bool InputPoller::is_binding_active(std::string_view name) const noexcept
1126 {
1127 47231 std::shared_lock lock(m_bindings_rw_mutex);
1128 47208 bool active = false;
1129 47208 (void)for_each_named_locked(
1130 name,
1131 47208 [this, &active](std::size_t idx) noexcept
1132 {
1133 // The shared lock holds idx in bounds. The explicit check is defense in depth against a future
1134 // reshape that repopulates m_name_index without a corresponding m_active_states resize.
1135
6/6
✓ Branch 3 → 4 taken 59242 times.
✓ Branch 3 → 14 taken 69 times.
✓ Branch 12 → 13 taken 153 times.
✓ Branch 12 → 14 taken 59027 times.
✓ Branch 15 → 16 taken 153 times.
✓ Branch 15 → 17 taken 59096 times.
118180 if (idx < m_bindings.size() && m_active_states[idx].load(std::memory_order_relaxed) != 0)
1136 {
1137 153 active = true;
1138 }
1139 59249 }
1140 );
1141 93115 return active;
1142 46216 }
1143
1144 75 input::BindingToken InputPoller::acquire_binding_token(std::string_view name) const noexcept
1145 {
1146 75 input::BindingToken token;
1147 try
1148 {
1149 75 std::shared_lock lock(m_bindings_rw_mutex);
1150 // Collect the indices first because only this step can throw. Then stamp the generation. An allocation
1151 // failure leaves the token invalid instead of valid but empty. An unknown name leaves generation zero.
1152
1/2
✓ Branch 3 → 4 taken 75 times.
✗ Branch 3 → 19 not taken.
75 token.m_indices = named_indices_locked(name);
1153
2/2
✓ Branch 7 → 8 taken 4 times.
✓ Branch 7 → 10 taken 71 times.
75 if (token.m_indices.empty())
1154 {
1155 4 return input::BindingToken{};
1156 }
1157 71 token.m_generation = m_binding_generation;
1158
2/2
✓ Branch 12 → 13 taken 71 times.
✓ Branch 12 → 15 taken 4 times.
75 }
1159 ✗ catch (...)
1160 {
1161 // If allocation fails, return an invalid token so the consumer uses the name-based query.
1162 ✗ return input::BindingToken{};
1163 ✗ }
1164 71 return token;
1165 75 }
1166
1167 13 bool InputPoller::is_binding_active(const input::BindingToken &token) const noexcept
1168 {
1169
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 11 times.
13 if (!token.valid())
1170 {
1171 2 return false;
1172 }
1173 11 std::shared_lock lock(m_bindings_rw_mutex);
1174 // A generation mismatch means the cached indices can refer to different bindings. Fail closed.
1175
2/2
✓ Branch 6 → 7 taken 7 times.
✓ Branch 6 → 8 taken 4 times.
11 if (token.m_generation != m_binding_generation)
1176 {
1177 7 return false;
1178 }
1179
2/2
✓ Branch 36 → 10 taken 5 times.
✓ Branch 36 → 37 taken 4 times.
13 for (const size_t idx : token.m_indices)
1180 {
1181 // The generation match proves idx in bounds. The explicit check is defense in depth against a
1182 // future reshape path that forgets to advance the generation.
1183
3/6
✓ Branch 13 → 14 taken 5 times.
✗ Branch 13 → 24 not taken.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 5 times.
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 27 taken 5 times.
10 if (idx < m_bindings.size() && m_active_states[idx].load(std::memory_order_relaxed) != 0)
1184 {
1185 ✗ return true;
1186 }
1187 }
1188 4 return false;
1189 11 }
1190
1191 80 bool InputPoller::binding_token_current(const input::BindingToken &token) const noexcept
1192 {
1193
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 78 times.
80 if (!token.valid())
1194 {
1195 2 return false;
1196 }
1197 78 std::shared_lock lock(m_bindings_rw_mutex);
1198 78 return token.m_generation == m_binding_generation;
1199 78 }
1200
1201 31 void InputPoller::set_require_focus(bool require_focus) noexcept
1202 {
1203 31 m_require_focus.store(require_focus, std::memory_order_relaxed);
1204 31 }
1205
1206 14 void InputPoller::set_consume(std::string_view name, bool consume) noexcept
1207 {
1208 14 DeferredDiagnostics diagnostics;
1209 {
1210 14 std::unique_lock lock(m_bindings_rw_mutex);
1211 14 bool changed = false;
1212 14 (void)for_each_named_locked(
1213 name,
1214 14 [this, consume, &changed](std::size_t idx) noexcept
1215 {
1216
2/2
✓ Branch 3 → 4 taken 10 times.
✓ Branch 3 → 6 taken 3 times.
13 if (m_bindings[idx].consume != consume)
1217 {
1218 10 m_bindings[idx].consume = consume;
1219 10 changed = true;
1220 }
1221 13 }
1222 );
1223 // Refresh the interception gates only on a real transition, as set_consume_by_owner does. A redundant
1224 // rebuild advances the generation and makes every live BindingToken stale despite no state change.
1225 // InputTest.BindingTokenStaysCurrentAfterRedundantConsumeSet pins both no-op flag values.
1226
2/2
✓ Branch 4 → 5 taken 10 times.
✓ Branch 4 → 6 taken 4 times.
14 if (changed)
1227 {
1228 10 recompute_modifier_caches_locked(diagnostics, CacheFailPolicy::Retain);
1229 }
1230 14 }
1231 14 diagnostics.emit();
1232 14 }
1233
1234 1033 void InputPoller::set_consume_by_owner(std::uint64_t owner, bool consume) noexcept
1235 {
1236 // The value 0 is the "no owner" sentinel. Skip the scan so an unstamped call cannot clear all owner-0
1237 // bindings.
1238
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 1033 times.
1033 if (owner == 0)
1239 {
1240 ✗ return;
1241 }
1242 1033 DeferredDiagnostics diagnostics;
1243 {
1244 1033 std::unique_lock lock(m_bindings_rw_mutex);
1245 1033 bool changed = false;
1246
2/2
✓ Branch 21 → 7 taken 45521 times.
✓ Branch 21 → 22 taken 1033 times.
47587 for (auto &binding : m_bindings)
1247 {
1248
4/4
✓ Branch 9 → 10 taken 337 times.
✓ Branch 9 → 12 taken 45184 times.
✓ Branch 10 → 11 taken 11 times.
✓ Branch 10 → 12 taken 326 times.
45521 if (binding.consume_owner == owner && binding.consume != consume)
1249 {
1250 11 binding.consume = consume;
1251 11 changed = true;
1252 }
1253 }
1254 // Rebuild only on a real transition. A redundant rebuild advances the generation and makes every live
1255 // BindingToken stale despite no state change.
1256
2/2
✓ Branch 22 → 23 taken 11 times.
✓ Branch 22 → 24 taken 1022 times.
1033 if (changed)
1257 {
1258 11 recompute_modifier_caches_locked(diagnostics, CacheFailPolicy::Retain);
1259 }
1260 1033 }
1261 1033 diagnostics.emit();
1262 }
1263
1264 1087 void InputPoller::shutdown() noexcept
1265 {
1266
2/2
✓ Branch 3 → 4 taken 574 times.
✓ Branch 3 → 12 taken 513 times.
1087 if (!m_poll_thread.joinable())
1267 {
1268 // Preserve the keepalive and the open external-host lease after a detach because that thread can
1269 // still read these members. An unstarted or drained poller has no such reader, so close the lease
1270 // and release the precommitted keepalive so it does not remain for the process lifetime.
1271
2/2
✓ Branch 5 → 6 taken 571 times.
✓ Branch 5 → 11 taken 3 times.
574 if (!m_requires_abandonment.load(std::memory_order_acquire))
1272 {
1273 571 wheel_source_close();
1274
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 10 taken 571 times.
571 if (m_xinput_raw_owner)
1275 {
1276 ✗ release_xinput_raw_scope_owner();
1277 ✗ m_xinput_raw_owner = false;
1278 }
1279 571 m_owner_keepalive.reset();
1280 }
1281 574 return;
1282 }
1283
1284 513 m_poll_thread.request_stop();
1285 513 m_cv.notify_all();
1286
1287
2/2
✓ Branch 15 → 16 taken 3 times.
✓ Branch 15 → 21 taken 510 times.
513 if (!blocking_teardown_permitted())
1288 {
1289 // No authorization exists to block. A join can deadlock the loader, so detach the thread and leak its
1290 // module reference. The detached thread still executes, so shared binding state and hold-release
1291 // callbacks must not be touched here (mirrors clear_bindings(invoke_callbacks=false)).
1292 3 m_requires_abandonment.store(true, std::memory_order_release);
1293 try
1294 {
1295
1/2
✓ Branch 17 → 18 taken 3 times.
✗ Branch 17 → 46 not taken.
3 m_poll_thread.detach();
1296 }
1297 ✗ catch (...)
1298 {
1299 // The abandonment flag pins the keepalive, so the poller (and its jthread member) is never
1300 // destroyed and ~jthread's loader-lock join is never reached.
1301 ✗ }
1302 3 DetourModKit::diagnostics::record_intentional_leak(DetourModKit::diagnostics::LeakSubsystem::Input);
1303 3 m_running.store(false, std::memory_order_release);
1304 3 return;
1305 }
1306
1307
6/6
✓ Branch 24 → 25 taken 507 times.
✓ Branch 24 → 27 taken 3 times.
✓ Branch 26 → 27 taken 2 times.
✓ Branch 26 → 28 taken 505 times.
✓ Branch 29 → 30 taken 5 times.
✓ Branch 29 → 33 taken 505 times.
510 if (m_poll_thread.get_id() == std::this_thread::get_id() || current_thread_in_delivery())
1308 {
1309 // A callback reached this path. On the poll thread a self-join raises. On a control thread inside a
1310 // synchronous delivery, the poll thread can park on that delivery's gate, so a join can deadlock. The
1311 // owner hands this poller to the reaper, which re-enters shutdown() off-thread. See self_retiring().
1312 5 m_running.store(false, std::memory_order_release);
1313 5 m_self_retiring.store(true, std::memory_order_release);
1314 5 return;
1315 }
1316
1317 try
1318 {
1319 #if defined(DMK_ENABLE_TEST_SEAMS)
1320
2/2
✓ Branch 33 → 34 taken 3 times.
✓ Branch 33 → 35 taken 502 times.
505 if (auto *seam = g_input_join_fail_seam)
1321 {
1322
1/2
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 49 taken 3 times.
3 seam();
1323 }
1324 #endif
1325
1/2
✓ Branch 35 → 36 taken 502 times.
✗ Branch 35 → 49 not taken.
502 m_poll_thread.join();
1326 }
1327 3 catch (...)
1328 {
1329 // Poll-thread completion is now uncertain. Detach it, which also prevents a ~jthread rejoin. Keep the
1330 // module reference and detours because the thread can still read their state.
1331 3 m_requires_abandonment.store(true, std::memory_order_release);
1332 try
1333 {
1334
1/2
✓ Branch 52 → 53 taken 3 times.
✗ Branch 52 → 59 not taken.
3 m_poll_thread.detach();
1335 }
1336 ✗ catch (...)
1337 {
1338 ✗ }
1339 3 (void)log().try_log(
1340 LogLevel::Error,
1341 "InputPoller: poll-thread join failed; abandoning its module reference and "
1342 "leaving the interception detours installed to stay memory-safe."
1343 );
1344 3 DetourModKit::diagnostics::record_intentional_leak(DetourModKit::diagnostics::LeakSubsystem::Input);
1345 3 m_self_ref = nullptr;
1346 3 m_running.store(false, std::memory_order_release);
1347 3 return;
1348 3 }
1349
1350 // The join completed off the loader lock. Drop the reference taken before thread creation. The active
1351 // caller for this teardown still holds its own, so this is never the terminal release.
1352 502 release_module_ref(static_cast<HMODULE>(m_self_ref), diagnostics::ModulePinReason::InputPoller);
1353 502 m_self_ref = nullptr;
1354
1355 // The poll thread is provably stopped here. Release of active holds and dispatch of their
1356 // on_state_change(false) callbacks is race-free.
1357 502 m_running.store(false, std::memory_order_release);
1358
1359 // The stopped poll thread cannot publish masks or enter a raw sample during hook teardown.
1360 // Skipped on the loader-lock path above: hook removal must not run under the loader lock, so the detours
1361 // stay installed there. The owner id makes a superseded poller's teardown a no-op. Close the external-host
1362 // lease before the local uninstall. A close proves the host holds no pointer from this generation; it is a
1363 // no-op for the local backends. uninstall() then tears down XInput and the local wheel source and revokes
1364 // the layer owner.
1365 502 wheel_source_close();
1366 502 uninstall(m_intercept_owner);
1367
1/2
✓ Branch 40 → 41 taken 502 times.
✗ Branch 40 → 43 not taken.
502 if (m_xinput_raw_owner)
1368 {
1369 502 release_xinput_raw_scope_owner();
1370 502 m_xinput_raw_owner = false;
1371 }
1372
1373 502 release_active_holds();
1374
1375 // This call still has an external owner. Clear the cycle last so that owner destroys the poller only
1376 // after the worker body and every rundown step can no longer touch it.
1377 502 m_owner_keepalive.reset();
1378 }
1379
1380 namespace
1381 {
1382 struct PendingCallback
1383 {
1384 // The lease appears first, so destruction occurs after both std::function members and their capture
1385 // managers.
1386 StagedCallbackLease lease;
1387 std::string name;
1388 std::function<void()> on_press;
1389 std::function<void(bool)> on_state_change;
1390 bool hold_value;
1391 // The edge's state transition commits only after the whole pass stages every edge. This deferral
1392 // makes a failed pass leave no edge behind. m_active_states still holds the pre-pass value. The next
1393 // cycle derives this edge again from unchanged physical input.
1394 std::size_t state_index = 0;
1395 std::uint8_t state_value = 0;
1396
1397 612 PendingCallback(
1398 StagedCallbackLease staged_lease,
1399 std::string binding_name,
1400 std::function<void()> press_callback,
1401 std::function<void(bool)> state_callback,
1402 bool next_hold_value,
1403 std::size_t next_state_index,
1404 std::uint8_t next_state_value
1405 )
1406 1836 : lease(std::move(staged_lease)), name(std::move(binding_name)),
1407 1836 on_press(std::move(press_callback)), on_state_change(std::move(state_callback)),
1408 612 hold_value(next_hold_value), state_index(next_state_index), state_value(next_state_value)
1409 {
1410 612 }
1411 };
1412
1413 /**
1414 * @brief Polls the gamepad state once per connected cycle.
1415 * @details Call only from the poll thread. Throttles reconnection attempts on empty slots. A read through
1416 * the shared raw scope preserves controller state through compatible DMK copies.
1417 * @return true when @p poll_enabled is true and the gamepad is connected this cycle.
1418 */
1419 2843 [[nodiscard]] bool poll_gamepad_state(
1420 bool poll_enabled,
1421 int gamepad_index,
1422 std::uint64_t intercept_owner,
1423 XINPUT_STATE &gamepad_state,
1424 bool &gamepad_was_connected,
1425 std::chrono::steady_clock::time_point &last_gamepad_poll
1426 ) noexcept
1427 {
1428 2843 constexpr auto gamepad_reconnect_interval = std::chrono::seconds{2};
1429 2843 bool gamepad_connected = false;
1430
2/2
✓ Branch 2 → 3 taken 1786 times.
✓ Branch 2 → 15 taken 1057 times.
2843 if (poll_enabled)
1431 {
1432 1786 const auto now = std::chrono::steady_clock::now();
1433
6/6
✓ Branch 4 → 5 taken 225 times.
✓ Branch 4 → 9 taken 1561 times.
✓ Branch 8 → 9 taken 91 times.
✓ Branch 8 → 10 taken 134 times.
✓ Branch 11 → 12 taken 1652 times.
✓ Branch 11 → 14 taken 134 times.
1786 if (gamepad_was_connected || (now - last_gamepad_poll) >= gamepad_reconnect_interval)
1434 {
1435 1652 last_gamepad_poll = now;
1436 1652 gamepad_was_connected =
1437 1652 sample_xinput_raw_state(static_cast<DWORD>(gamepad_index), gamepad_state, intercept_owner);
1438 }
1439 1786 gamepad_connected = gamepad_was_connected;
1440 }
1441 2843 return gamepad_connected;
1442 }
1443
1444 /**
1445 * @brief Returns true when the exact modifier set of @p binding and one of its keys are held this cycle.
1446 * @details While the exact modifier set is held, adds each consume bit of @p binding to @p gamepad_owned
1447 * or @p wheel_owned.
1448 */
1449 4005 [[nodiscard]] bool evaluate_binding_press(
1450 const InputBinding &binding,
1451 const std::vector<InputCode> &known_modifiers,
1452 KeyStateCache &key_cache,
1453 const XINPUT_STATE &gamepad_state,
1454 bool gamepad_connected,
1455 int trigger_threshold,
1456 int stick_threshold,
1457 uint8_t wheel_pulse_mask,
1458 uint16_t &gamepad_owned,
1459 uint8_t &wheel_owned
1460 ) noexcept
1461 {
1462 4005 bool any_pressed = false;
1463 4005 bool modifiers_held = true;
1464
2/2
✓ Branch 18 → 4 taken 515 times.
✓ Branch 18 → 19 taken 3838 times.
8358 for (const auto &mod : binding.modifiers)
1465 {
1466
2/2
✓ Branch 7 → 8 taken 167 times.
✓ Branch 7 → 9 taken 348 times.
515 if (!is_code_pressed(
1467 mod,
1468 key_cache,
1469 gamepad_state,
1470 gamepad_connected,
1471 trigger_threshold,
1472 stick_threshold,
1473 wheel_pulse_mask
1474 ))
1475 {
1476 167 modifiers_held = false;
1477 167 break;
1478 }
1479 }
1480
1481
2/2
✓ Branch 19 → 20 taken 3838 times.
✓ Branch 19 → 57 taken 167 times.
4005 if (modifiers_held)
1482 {
1483 // Enforce an exact modifier set. If a held known modifier is absent from the required set of the
1484 // binding, reject the binding.
1485
2/2
✓ Branch 55 → 22 taken 349 times.
✓ Branch 55 → 56 taken 3837 times.
8024 for (const auto &km : known_modifiers)
1486 {
1487
1/2
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 27 taken 349 times.
349 if (!is_code_pressed(
1488 km,
1489 key_cache,
1490 gamepad_state,
1491 gamepad_connected,
1492 trigger_threshold,
1493 stick_threshold,
1494 wheel_pulse_mask
1495 ))
1496 {
1497 ✗ continue;
1498 }
1499 349 bool is_required = false;
1500
2/2
✓ Branch 43 → 29 taken 348 times.
✓ Branch 43 → 44 taken 1 time.
698 for (const auto &mod : binding.modifiers)
1501 {
1502
1/2
✓ Branch 32 → 33 taken 348 times.
✗ Branch 32 → 34 not taken.
348 if (modifier_satisfies(mod, km))
1503 {
1504 348 is_required = true;
1505 348 break;
1506 }
1507 }
1508
2/2
✓ Branch 44 → 45 taken 1 time.
✓ Branch 44 → 46 taken 348 times.
349 if (!is_required)
1509 {
1510 1 modifiers_held = false;
1511 1 break;
1512 }
1513 }
1514 }
1515
1516
2/2
✓ Branch 57 → 58 taken 3837 times.
✓ Branch 57 → 88 taken 168 times.
4005 if (modifiers_held)
1517 {
1518
2/2
✓ Branch 86 → 60 taken 3837 times.
✓ Branch 86 → 87 taken 2407 times.
10081 for (const auto &key : binding.keys)
1519 {
1520 3837 const bool key_pressed = is_code_pressed(
1521 key,
1522 key_cache,
1523 gamepad_state,
1524 gamepad_connected,
1525 trigger_threshold,
1526 stick_threshold,
1527 wheel_pulse_mask
1528 );
1529
1530 // Pre-arm the consume bit while the modifiers are held, before the trigger is
1531 // pressed. The mask trails physical state by one cycle. A claim only on a pressed
1532 // trigger leaks its initial edge to the faster game poll.
1533 // A mask for a still-up bit is a no-op, and the consume-until-release latch still
1534 // trails the trigger.
1535
5/6
✓ Branch 63 → 64 taken 1646 times.
✓ Branch 63 → 68 taken 2191 times.
✓ Branch 64 → 65 taken 1006 times.
✓ Branch 64 → 68 taken 640 times.
✓ Branch 65 → 66 taken 1006 times.
✗ Branch 65 → 68 not taken.
3837 if (binding.consume && key.source == InputSource::Gamepad && key.code > 0 &&
1536
1/2
✓ Branch 66 → 67 taken 1006 times.
✗ Branch 66 → 68 not taken.
1006 key.code < GamepadCode::LeftTrigger)
1537 {
1538 1006 gamepad_owned = static_cast<uint16_t>(gamepad_owned | static_cast<uint16_t>(key.code));
1539 }
1540
1541 // Pre-arm the wheel-consume bit while the modifiers are held. The queue hook
1542 // decides whether to swallow as soon as a message arrives. The mask must reflect
1543 // "modifiers currently satisfied", not the derived wheel_pulse_mask. This mirrors
1544 // the gamepad pre-arm above.
1545
5/6
✓ Branch 68 → 69 taken 1646 times.
✓ Branch 68 → 73 taken 2191 times.
✓ Branch 69 → 70 taken 640 times.
✓ Branch 69 → 73 taken 1006 times.
✓ Branch 70 → 71 taken 640 times.
✗ Branch 70 → 73 not taken.
3837 if (binding.consume && key.source == InputSource::MouseWheel && key.code >= WheelCode::Up &&
1546
1/2
✓ Branch 71 → 72 taken 640 times.
✗ Branch 71 → 73 not taken.
640 key.code <= WheelCode::Right)
1547 {
1548 640 wheel_owned = static_cast<uint8_t>(
1549 640 wheel_owned | static_cast<uint8_t>(1u << (key.code - WheelCode::Up))
1550 );
1551 }
1552
1553 // Activation still keys off the real press: a non-consume binding fires on the
1554 // first pressed key and stops. A consume binding continues its scan so the
1555 // pre-arm above sees every owned bit.
1556
2/2
✓ Branch 73 → 74 taken 1406 times.
✓ Branch 73 → 75 taken 2431 times.
3837 if (!key_pressed)
1557 {
1558 1406 continue;
1559 }
1560 2431 any_pressed = true;
1561
2/2
✓ Branch 75 → 76 taken 1430 times.
✓ Branch 75 → 77 taken 1001 times.
2431 if (!binding.consume)
1562 {
1563 1430 break;
1564 }
1565 }
1566 }
1567 4005 return any_pressed;
1568 }
1569
1570 /**
1571 * @brief Evaluates every binding against this cycle's input sample and stages each edge callback.
1572 * @details The caller holds the binding lock shared. A binding that stages no callback commits its state
1573 * here, unless callback admission refuses its lease. The caller commits each staged edge after
1574 * the pass.
1575 */
1576 2842 void stage_binding_edges(
1577 const std::vector<InputBinding> &bindings,
1578 const std::unique_ptr<std::atomic<uint8_t>[]> &active_states,
1579 const std::vector<InputCode> &known_modifiers,
1580 bool process_focused,
1581 KeyStateCache &key_cache,
1582 const XINPUT_STATE &gamepad_state,
1583 bool gamepad_connected,
1584 int trigger_threshold,
1585 int stick_threshold,
1586 uint8_t wheel_pulse_mask,
1587 uint16_t &gamepad_owned,
1588 uint8_t &wheel_owned,
1589 std::vector<PendingCallback> &pending
1590 )
1591 {
1592 2842 const size_t count = bindings.size();
1593
2/2
✓ Branch 101 → 4 taken 5797 times.
✓ Branch 101 → 102 taken 2835 times.
8632 for (size_t i = 0; i < count; ++i)
1594 {
1595 5797 const auto &binding = bindings[i];
1596
2/2
✓ Branch 6 → 7 taken 5 times.
✓ Branch 6 → 8 taken 5792 times.
5797 if (binding.keys.empty())
1597 {
1598 5 continue;
1599 }
1600
1601 5792 bool any_pressed = false;
1602
2/2
✓ Branch 8 → 9 taken 4005 times.
✓ Branch 8 → 11 taken 1787 times.
5792 if (process_focused)
1603 {
1604 4005 any_pressed = evaluate_binding_press(
1605 binding,
1606 known_modifiers,
1607 key_cache,
1608 gamepad_state,
1609 gamepad_connected,
1610 trigger_threshold,
1611 stick_threshold,
1612 wheel_pulse_mask,
1613 gamepad_owned,
1614 wheel_owned
1615 );
1616 }
1617
1618 5792 const bool was_active = active_states[i].load(std::memory_order_relaxed) != 0;
1619
2/2
✓ Branch 19 → 20 taken 2431 times.
✓ Branch 19 → 21 taken 3361 times.
5792 const std::uint8_t next_state = any_pressed ? 1 : 0;
1620
1621
2/3
✓ Branch 22 → 23 taken 4497 times.
✓ Branch 22 → 61 taken 1295 times.
✗ Branch 22 → 98 not taken.
5792 switch (binding.trigger)
1622 {
1623 4497 case input::Trigger::Press:
1624 {
1625
8/8
✓ Branch 23 → 24 taken 1330 times.
✓ Branch 23 → 28 taken 3167 times.
✓ Branch 24 → 25 taken 99 times.
✓ Branch 24 → 28 taken 1231 times.
✓ Branch 26 → 27 taken 96 times.
✓ Branch 26 → 28 taken 3 times.
✓ Branch 29 → 30 taken 96 times.
✓ Branch 29 → 51 taken 4401 times.
4497 if (any_pressed && !was_active && binding.on_press)
1626 {
1627
1/2
✓ Branch 31 → 32 taken 96 times.
✗ Branch 31 → 34 not taken.
96 const std::uint64_t generation = binding.lifecycle ? binding.lifecycle->generation() : 0;
1628 96 StagedCallbackLease lease{binding.lifecycle, generation};
1629
1/2
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 41 taken 96 times.
96 if (!lease.engaged())
1630 {
1631 ✗ continue;
1632 }
1633 96 pending.emplace_back(
1634
2/2
✓ Branch 44 → 45 taken 89 times.
✓ Branch 44 → 103 taken 7 times.
96 std::move(lease),
1635 96 binding.name,
1636 96 binding.on_press,
1637 103 std::function<void(bool)>{},
1638 103 false,
1639 i,
1640 next_state
1641 );
1642 89 break;
1643
1/2
✗ Branch 48 → 49 not taken.
✓ Branch 48 → 50 taken 89 times.
96 }
1644 4401 active_states[i].store(next_state, std::memory_order_relaxed);
1645 4401 break;
1646 }
1647 1295 case input::Trigger::Hold:
1648 {
1649
5/6
✓ Branch 61 → 62 taken 523 times.
✓ Branch 61 → 65 taken 772 times.
✓ Branch 63 → 64 taken 523 times.
✗ Branch 63 → 65 not taken.
✓ Branch 66 → 67 taken 523 times.
✓ Branch 66 → 88 taken 772 times.
1295 if (any_pressed != was_active && binding.on_state_change)
1650 {
1651
1/2
✓ Branch 68 → 69 taken 523 times.
✗ Branch 68 → 71 not taken.
523 const std::uint64_t generation = binding.lifecycle ? binding.lifecycle->generation() : 0;
1652 523 StagedCallbackLease lease{binding.lifecycle, generation};
1653
1/2
✗ Branch 76 → 77 not taken.
✓ Branch 76 → 78 taken 523 times.
523 if (!lease.engaged())
1654 {
1655 ✗ continue;
1656 }
1657 523 pending.emplace_back(
1658
1/2
✓ Branch 81 → 82 taken 523 times.
✗ Branch 81 → 110 not taken.
523 std::move(lease),
1659 523 binding.name,
1660 523 std::function<void()>{},
1661 523 binding.on_state_change,
1662 any_pressed,
1663 i,
1664 next_state
1665 );
1666 523 break;
1667
1/2
✗ Branch 85 → 86 not taken.
✓ Branch 85 → 87 taken 523 times.
523 }
1668 772 active_states[i].store(next_state, std::memory_order_relaxed);
1669 772 break;
1670 }
1671 }
1672 }
1673 2835 }
1674
1675 /**
1676 * @brief Steps and publishes the gamepad suppression mask, or disarms it once on exit from the armed state.
1677 * @details The consume-until-release latch keeps a trigger masked until release plus a grace window.
1678 * A modifier release before the trigger release cannot leak a bare trigger.
1679 */
1680 2843 void update_gamepad_suppression(
1681 bool suppression_eligible,
1682 uint16_t gamepad_owned,
1683 const XINPUT_STATE &gamepad_state,
1684 std::uint64_t intercept_owner,
1685 GamepadSuppressState &gamepad_suppress_state,
1686 bool &gamepad_suppress_active
1687 ) noexcept
1688 {
1689
2/2
✓ Branch 2 → 3 taken 1033 times.
✓ Branch 2 → 8 taken 1810 times.
2843 if (suppression_eligible)
1690 {
1691 1033 const uint16_t suppress = step_gamepad_suppress(
1692 gamepad_suppress_state,
1693 gamepad_owned,
1694 1033 gamepad_state.Gamepad.wButtons,
1695 GetTickCount64(),
1696 GAMEPAD_SUPPRESS_GRACE_MS
1697 );
1698 1033 (void)publish_gamepad_suppress(suppress, intercept_owner);
1699 // The rule list and its TTL survive focus changes, so the detour needs this explicit gate to
1700 // stop suppression after the mod enters the background.
1701 1033 (void)set_gamepad_rule_suppress_enabled(true, intercept_owner);
1702 1033 gamepad_suppress_active = true;
1703 }
1704
2/2
✓ Branch 8 → 9 taken 9 times.
✓ Branch 8 → 12 taken 1801 times.
1810 else if (gamepad_suppress_active)
1705 {
1706 // On exit from armed state due to focus loss, disconnect, or removal of the last consume binding,
1707 // disarm once. The game regains the buttons next cycle instead of after the TTL lapses. Publication
1708 // only on this edge keeps the idle path free of a per-cycle clock read.
1709 9 gamepad_suppress_state = GamepadSuppressState{};
1710 9 (void)publish_gamepad_suppress(0, intercept_owner);
1711 9 (void)set_gamepad_rule_suppress_enabled(false, intercept_owner);
1712 9 gamepad_suppress_active = false;
1713 }
1714 2843 }
1715
1716 /**
1717 * @brief Invokes each staged callback that its lifecycle still admits and logs each callback exception.
1718 * @details Call off the binding lock. The staged leases stay alive until the caller clears @p pending.
1719 */
1720 2843 void dispatch_pending_callbacks(const std::vector<PendingCallback> &pending)
1721 {
1722 #ifdef DMK_ENABLE_TEST_SEAMS
1723 // Between the stage pass and dispatch, a test reshapes the binding set here. The check below refuses a
1724 // staged callback after this reshape advances its generation or tombstones its binding.
1725
2/2
✓ Branch 3 → 4 taken 76 times.
✓ Branch 3 → 6 taken 2767 times.
2843 if (g_input_post_stage_probe)
1726 {
1727 76 g_input_post_stage_probe(pending.size());
1728 }
1729 #endif
1730
1731
2/2
✓ Branch 45 → 8 taken 611 times.
✓ Branch 45 → 46 taken 2843 times.
6297 for (auto &callback : pending)
1732 {
1733 // A terminal hold-release (false) edge is admitted even across a generation advance: it only
1734 // ends a held state. A dropped edge strands the consumer in its held state. A press or held(true)
1735 // edge is still refused once its generation advanced or its registration was tombstoned.
1736 const bool admit_across_generation =
1737
4/4
✓ Branch 11 → 12 taken 523 times.
✓ Branch 11 → 14 taken 88 times.
✓ Branch 12 → 13 taken 23 times.
✓ Branch 12 → 14 taken 500 times.
611 static_cast<bool>(callback.on_state_change) && !callback.hold_value;
1738 const BindingInvocation invocation{
1739 callback.lease.lifecycle(),
1740 callback.lease.generation(),
1741 admit_across_generation
1742 611 };
1743
2/2
✓ Branch 19 → 20 taken 6 times.
✓ Branch 19 → 21 taken 605 times.
611 if (!invocation.admitted())
1744 {
1745 6 continue;
1746 }
1747 #ifdef DMK_ENABLE_TEST_SEAMS
1748
2/2
✓ Branch 22 → 23 taken 7 times.
✓ Branch 22 → 24 taken 598 times.
605 if (g_input_pre_dispatch_probe)
1749 {
1750
1/2
✓ Branch 23 → 24 taken 7 times.
✗ Branch 23 → 57 not taken.
7 g_input_pre_dispatch_probe();
1751 }
1752 #endif
1753 try
1754 {
1755
2/2
✓ Branch 25 → 26 taken 84 times.
✓ Branch 25 → 27 taken 521 times.
605 if (callback.on_press)
1756 {
1757
1/2
✓ Branch 26 → 30 taken 84 times.
✗ Branch 26 → 47 not taken.
84 callback.on_press();
1758 }
1759
1/2
✓ Branch 28 → 29 taken 521 times.
✗ Branch 28 → 30 not taken.
521 else if (callback.on_state_change)
1760 {
1761
2/2
✓ Branch 29 → 30 taken 494 times.
✓ Branch 29 → 47 taken 27 times.
521 callback.on_state_change(callback.hold_value);
1762 }
1763 }
1764
1/2
✓ Branch 47 → 48 taken 27 times.
✗ Branch 47 → 53 not taken.
27 catch (const std::exception &e)
1765 {
1766 27 (void)log().try_log(
1767 LogLevel::Error,
1768 "InputPoller: Exception in callback \"{}\": {}",
1769 27 callback.name,
1770 27 e.what()
1771 );
1772 27 }
1773 ✗ catch (...)
1774 {
1775 ✗ (void)log().try_log(
1776 LogLevel::Error,
1777 "InputPoller: Unknown exception in callback \"{}\"",
1778 ✗ callback.name
1779 );
1780
0/2
✗ Branch 56 → 30 not taken.
✗ Branch 56 → 57 not taken.
✗ }
1781
2/2
✓ Branch 32 → 33 taken 605 times.
✓ Branch 32 → 35 taken 6 times.
611 }
1782 2843 }
1783 } // anonymous namespace
1784
1785 2843 bool InputPoller::maintain_interception_sources() noexcept
1786 {
1787 // Each install call is idempotent and fails cheaply until its target appears. The XInput call runs every
1788 // cycle, not only while coverage is absent. An installed pair can still lose an entry point to a rival
1789 // writer. A skip based on the published flag hides that loss.
1790
2/2
✓ Branch 3 → 4 taken 1173 times.
✓ Branch 3 → 5 taken 1670 times.
2843 if (m_has_consume_gamepad_bindings.load(std::memory_order_relaxed))
1791 {
1792 1173 (void)install_xinput(m_gamepad_index, m_intercept_owner);
1793 }
1794 2843 const bool has_wheel_bindings = m_has_wheel_bindings.load(std::memory_order_acquire);
1795
2/2
✓ Branch 6 → 7 taken 1400 times.
✓ Branch 6 → 8 taken 1443 times.
2843 if (has_wheel_bindings)
1796 {
1797 // Maintain the route every cycle: mount an absent route, migrate a moved one, and latch health.
1798 // A ready route on the selected thread is a cheap liveness recheck.
1799 1400 wheel_source_maintain();
1800 }
1801
1802 // An install can publish ownership during this cycle. Check it before the rule publication.
1803
6/6
✓ Branch 9 → 10 taken 1719 times.
✓ Branch 9 → 13 taken 1124 times.
✓ Branch 11 → 12 taken 90 times.
✓ Branch 11 → 13 taken 1629 times.
✓ Branch 14 → 15 taken 90 times.
✓ Branch 14 → 20 taken 2753 times.
2843 if (m_consume_rules_unpublished.load(std::memory_order_acquire) && intercept_owned_by(m_intercept_owner))
1804 {
1805 90 DeferredDiagnostics diagnostics;
1806 {
1807 90 std::unique_lock rules_lock(m_bindings_rw_mutex);
1808 90 publish_consume_rules_locked(diagnostics);
1809 90 }
1810 90 diagnostics.emit();
1811 }
1812 2843 return has_wheel_bindings;
1813 }
1814
1815 1406 bool InputPoller::drain_external_wheel_counts(
1816 std::array<int, 4> &external_wheel_counts,
1817 std::uint64_t &external_wheel_generation,
1818 std::array<int, 4> &external_wheel_carry
1819 )
1820 {
1821 {
1822 1406 std::shared_lock generation_lock(m_bindings_rw_mutex);
1823
2/2
✓ Branch 4 → 5 taken 1306 times.
✓ Branch 4 → 6 taken 100 times.
1406 if (m_has_wheel_bindings.load(std::memory_order_relaxed))
1824 {
1825 1306 external_wheel_generation = m_binding_generation;
1826 }
1827 1406 }
1828
2/2
✓ Branch 7 → 8 taken 100 times.
✓ Branch 7 → 9 taken 1306 times.
1406 if (external_wheel_generation == 0)
1829 {
1830 100 return false;
1831 }
1832
2/2
✓ Branch 10 → 11 taken 31 times.
✓ Branch 10 → 13 taken 1275 times.
1306 if (m_external_wheel_discard_pending.exchange(false, std::memory_order_acq_rel))
1833 {
1834 // The no-wheel -> wheel transition discards the unowned backlog, parked carry included.
1835 31 external_wheel_carry = {};
1836 31 (void)wheel_source_take_counts();
1837 }
1838 1306 external_wheel_counts = wheel_source_take_counts();
1839
2/2
✓ Branch 21 → 15 taken 5224 times.
✓ Branch 21 → 22 taken 1306 times.
7836 for (std::size_t dir = 0; dir < external_wheel_counts.size(); ++dir)
1840 {
1841 5224 external_wheel_counts[dir] += std::exchange(external_wheel_carry[dir], 0);
1842 }
1843 #ifdef DMK_ENABLE_TEST_SEAMS
1844
2/2
✓ Branch 23 → 24 taken 45 times.
✓ Branch 23 → 25 taken 1261 times.
1306 if (g_input_external_wheel_post_drain_probe)
1845 {
1846 45 g_input_external_wheel_post_drain_probe(external_wheel_counts);
1847 }
1848 #endif
1849 1306 return true;
1850 }
1851
1852 508 void InputPoller::poll_loop(std::stop_token stop_token)
1853 {
1854 508 const int trigger_thresh = m_trigger_threshold;
1855 508 const int stick_thresh = m_stick_threshold;
1856
1857 508 bool gamepad_was_connected = false;
1858 508 auto last_gamepad_poll = std::chrono::steady_clock::time_point{};
1859
1860 // Interception state persists across cycles and remains private to the poll thread.
1861 508 WheelPulseState wheel_pulse{};
1862 508 GamepadSuppressState gp_suppress{};
1863
1864 // This carry parks drained external-host counts that the pulse state did not take because the binding
1865 // generation moved or the pass failed. Drained notches have no physical equivalent to repeat, so the next
1866 // drain merges them. The carry stays private to the poll thread.
1867 508 std::array<int, 4> external_wheel_carry{};
1868
1869 // This flag tracks whether the previous cycle published live gamepad suppression. The disarm in
1870 // update_gamepad_suppression runs exactly once on the arm->disarm transition, which includes removal of the
1871 // last consume gamepad binding. A plain flag gate skips that transition.
1872 508 bool gamepad_suppress_active = false;
1873
1874 // This state remains private to the poll thread. is_code_pressed reads it only when
1875 // gamepad_connected is true, which holds only after a successful poll overwrites it.
1876 508 XINPUT_STATE gamepad_state{};
1877
1878 // The per-cycle keyboard and mouse state cache lives for the poll thread's lifetime. See
1879 // input_key_cache.hpp.
1880 508 KeyStateCache key_cache;
1881
1882 508 std::vector<PendingCallback> pending;
1883
1884
2/2
✓ Branch 90 → 3 taken 2843 times.
✓ Branch 90 → 91 taken 506 times.
3349 while (!stop_token.stop_requested())
1885 {
1886 2843 pending.clear();
1887 2843 key_cache.reset();
1888 const bool process_focused =
1889
3/4
✓ Branch 6 → 7 taken 136 times.
✓ Branch 6 → 9 taken 2707 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 136 times.
2843 !m_require_focus.load(std::memory_order_relaxed) || is_process_foreground();
1890 2843 const bool has_wheel_bindings = maintain_interception_sources();
1891
1892 // Accumulate bits that active consume bindings claim this cycle, then publish them after the binding
1893 // pass. A consume binding masks only what it owns. "Ctrl+WheelUp" contributes Up only while Ctrl is
1894 // held. Publication each cycle also disarms the mask after the last binding leaves.
1895 2843 uint16_t gamepad_owned = 0;
1896 2843 uint8_t wheel_owned = 0;
1897
1898 2843 const bool gamepad_connected = poll_gamepad_state(
1899
4/4
✓ Branch 13 → 14 taken 1807 times.
✓ Branch 13 → 16 taken 1036 times.
✓ Branch 14 → 15 taken 1786 times.
✓ Branch 14 → 16 taken 21 times.
2843 m_has_gamepad_bindings.load(std::memory_order_relaxed) && process_focused,
1900 m_gamepad_index,
1901 2843 m_intercept_owner,
1902 gamepad_state,
1903 gamepad_was_connected,
1904 last_gamepad_poll
1905 );
1906
1907 // Stage this cycle's edge callbacks, then dispatch after release of the binding lock so user code can
1908 // re-enter update_combos(). One catch treats the whole pass as a transaction. A failed pass restores
1909 // staged mutations and owes no callback. The next cycle derives the same edges from unchanged physical
1910 // input. The immediate commit in stage_binding_edges lets a release and press before the next cycle
1911 // still produce a press edge.
1912 2843 WheelPulseState wheel_pulse_staged = wheel_pulse;
1913
1914 // Arm the swallow mask only for a cycle that also drains the wheel counters. A rebuild failure stops
1915 // the drain while the consume wheel binding stays in m_bindings. An armed mask then swallows every
1916 // notch without delivery. That state cannot lapse on its own.
1917 2843 bool wheel_drained = false;
1918 2843 std::array<int, 4> external_wheel_counts{};
1919 2843 std::uint64_t external_wheel_generation = 0;
1920 const bool external_wheel_counts_taken =
1921
2/2
✓ Branch 18 → 19 taken 1406 times.
✓ Branch 18 → 22 taken 1437 times.
4249 m_wheel_backend == input::Input::WheelBackend::ExternalHost &&
1922
3/4
✓ Branch 19 → 20 taken 1406 times.
✗ Branch 19 → 109 not taken.
✓ Branch 20 → 21 taken 1306 times.
✓ Branch 20 → 22 taken 100 times.
1406 drain_external_wheel_counts(external_wheel_counts, external_wheel_generation, external_wheel_carry);
1923 // This flag marks drained host counts that neither the pulse rollback point nor the carry holds yet. A
1924 // failed pass parks them, because a drained notch has no physical equivalent to repeat ([B-92]).
1925 2843 bool external_wheel_counts_owed = external_wheel_counts_taken;
1926 try
1927 {
1928 // Re-reserve to the current binding count before acquisition of the evaluation lock. This keeps
1929 // the growth allocation outside the critical section. The catch still covers the residual race
1930 // where a concurrent add_binding grows the set first.
1931 2843 size_t reserve_hint = 0;
1932 {
1933 2843 std::shared_lock count_lock(m_bindings_rw_mutex);
1934 2843 reserve_hint = m_bindings.size();
1935 2843 }
1936
2/2
✓ Branch 26 → 27 taken 2842 times.
✓ Branch 26 → 95 taken 1 time.
2843 pending.reserve(reserve_hint);
1937
1938 2842 std::shared_lock lock(m_bindings_rw_mutex);
1939
1940 // Snapshot the accumulated wheel notches into a per-cycle pulse mask so each notch maps to
1941 // exactly one Press edge. The poll drains it while unfocused, so a background notch is discarded.
1942 // The flag read, the local-backend drain, and the m_bindings snapshot share one shared-lock epoch
1943 // that no reshape can split. The external-host drain precedes the epoch, so the generation check
1944 // below catches a reshape in between.
1945 2842 uint8_t wheel_pulse_mask = 0;
1946
2/2
✓ Branch 29 → 30 taken 1328 times.
✓ Branch 29 → 44 taken 1514 times.
2842 if (m_has_wheel_bindings.load(std::memory_order_relaxed))
1947 {
1948 1328 const bool external_generation_matches =
1949
3/4
✓ Branch 30 → 31 taken 1305 times.
✓ Branch 30 → 33 taken 23 times.
✓ Branch 31 → 32 taken 1305 times.
✗ Branch 31 → 34 not taken.
2633 m_wheel_backend != input::Input::WheelBackend::ExternalHost ||
1950
2/2
✓ Branch 32 → 33 taken 1303 times.
✓ Branch 32 → 34 taken 2 times.
1305 (external_wheel_counts_taken && m_binding_generation == external_wheel_generation);
1951
2/2
✓ Branch 35 → 36 taken 1326 times.
✓ Branch 35 → 42 taken 2 times.
1328 if (external_generation_matches)
1952 {
1953 1326 const auto taken = m_wheel_backend == input::Input::WheelBackend::ExternalHost
1954
2/2
✓ Branch 36 → 37 taken 1303 times.
✓ Branch 36 → 38 taken 23 times.
1326 ? external_wheel_counts
1955 1326 : wheel_source_take_counts();
1956 1326 add_wheel_notches(wheel_pulse, taken);
1957 // Take the rollback point after the drain. Restoration reverses the staged pulse step but
1958 // preserves drained notches, which have no physical equivalent to repeat.
1959 1326 wheel_pulse_staged = wheel_pulse;
1960 1326 wheel_pulse_mask = step_wheel_pulse(wheel_pulse);
1961 1326 wheel_drained = true;
1962 1326 external_wheel_counts_owed = false;
1963 }
1964
1/2
✓ Branch 42 → 43 taken 2 times.
✗ Branch 42 → 44 not taken.
2 else if (external_wheel_counts_taken)
1965 {
1966 // A reshape moved the generation between the drain and this evaluation. Park the drained
1967 // counts so the next cycle's drain merges them.
1968 2 external_wheel_carry = external_wheel_counts;
1969 2 external_wheel_counts_owed = false;
1970 }
1971 }
1972
1973 2842 stage_binding_edges(
1974 2842 m_bindings,
1975 2842 m_active_states,
1976
2/2
✓ Branch 44 → 45 taken 2835 times.
✓ Branch 44 → 93 taken 7 times.
2842 m_known_modifiers,
1977 process_focused,
1978 key_cache,
1979 gamepad_state,
1980 gamepad_connected,
1981 trigger_thresh,
1982 stick_thresh,
1983 wheel_pulse_mask,
1984 gamepad_owned,
1985 wheel_owned,
1986 pending
1987 );
1988
1989 // The commit uses only atomic stores under the shared lock, so it cannot fail.
1990
2/2
✓ Branch 67 → 47 taken 611 times.
✓ Branch 67 → 68 taken 2835 times.
6281 for (const auto &staged : pending)
1991 {
1992 611 m_active_states[staged.state_index].store(staged.state_value, std::memory_order_relaxed);
1993 }
1994 2842 }
1995 8 catch (...)
1996 {
1997 // Roll back every source that a staged edge needs. Return the drained notches to the backlog. Drop
1998 // the partial consume masks so suppression disarms wholly. The drain merge emptied the carry, so
1999 // owed host counts replace it.
2000 8 pending.clear();
2001 8 wheel_pulse = wheel_pulse_staged;
2002
2/2
✓ Branch 99 → 100 taken 1 time.
✓ Branch 99 → 101 taken 7 times.
8 if (external_wheel_counts_owed)
2003 {
2004 1 external_wheel_carry = external_wheel_counts;
2005 }
2006 8 gamepad_owned = 0;
2007 8 wheel_owned = 0;
2008 8 (void)log().try_log(
2009 LogLevel::Error,
2010 "InputPoller: failed staging poll-cycle callbacks; cycle rolled back"
2011 );
2012
1/2
✓ Branch 103 → 70 taken 8 times.
✗ Branch 103 → 109 not taken.
8 }
2013
2014 2843 update_gamepad_suppression(
2015
5/6
✓ Branch 71 → 72 taken 1173 times.
✓ Branch 71 → 75 taken 1670 times.
✓ Branch 72 → 73 taken 1173 times.
✗ Branch 72 → 75 not taken.
✓ Branch 73 → 74 taken 1033 times.
✓ Branch 73 → 75 taken 140 times.
2843 m_has_consume_gamepad_bindings.load(std::memory_order_relaxed) && process_focused &&
2016 gamepad_connected,
2017 gamepad_owned,
2018 gamepad_state,
2019 2843 m_intercept_owner,
2020 gp_suppress,
2021 gamepad_suppress_active
2022 );
2023
2024 // Publish the per-direction wheel-swallow mask every cycle. Tie it to the drain instead of wheel_owned
2025 // alone. The mask cannot outlive the loop's ability to deliver the notches it swallows. While wheel
2026 // bindings exist, capture_enabled stays true, so the external host counts notches between drains. The
2027 // local backend ignores that argument. Its capture follows ownership and wheel_capture_armable_locked.
2028
2/2
✓ Branch 77 → 78 taken 1326 times.
✓ Branch 77 → 79 taken 1517 times.
2843 wheel_source_publish_consume(wheel_drained ? wheel_owned : 0, has_wheel_bindings);
2029
2030
1/2
✓ Branch 81 → 82 taken 2843 times.
✗ Branch 81 → 109 not taken.
2843 dispatch_pending_callbacks(pending);
2031
2032 // Destroy staged callable copies before the poll wait. A teardown then observes lease completion
2033 // immediately instead of one poll interval later.
2034 2843 pending.clear();
2035
2036
1/2
✓ Branch 83 → 84 taken 2843 times.
✗ Branch 83 → 109 not taken.
2843 std::unique_lock lock(m_cv_mutex);
2037
1/2
✓ Branch 85 → 86 taken 2841 times.
✗ Branch 85 → 104 not taken.
2843 m_cv.wait_for(
2038 lock,
2039 stop_token,
2040 2843 m_poll_interval,
2041 5639 [&stop_token]() { return stop_token.stop_requested(); }
2042 );
2043 2841 }
2044 506 }
2045
2046 namespace
2047 {
2048 /**
2049 * @brief Invokes each staged release with false and logs each callback exception.
2050 * @details Call off the binding lock.
2051 */
2052 676 void dispatch_hold_releases(const std::vector<HoldRelease> &hold_releases) noexcept
2053 {
2054
2/2
✓ Branch 16 → 4 taken 7 times.
✓ Branch 16 → 17 taken 676 times.
1359 for (auto &[callback, binding_name] : hold_releases)
2055 {
2056 try
2057 {
2058
1/2
✓ Branch 6 → 7 taken 7 times.
✗ Branch 6 → 18 not taken.
7 callback(false);
2059 }
2060
0/2
✗ Branch 18 → 19 not taken.
✗ Branch 18 → 24 not taken.
✗ catch (const std::exception &e)
2061 {
2062 ✗ (void)log().try_log(
2063 LogLevel::Error,
2064 "InputPoller: Exception in hold release callback \"{}\": {}",
2065 ✗ binding_name,
2066 ✗ e.what()
2067 );
2068 ✗ }
2069 ✗ catch (...)
2070 {
2071 ✗ (void)log().try_log(
2072 LogLevel::Error,
2073 "InputPoller: Unknown exception in hold release callback \"{}\"",
2074 ✗ binding_name
2075 );
2076 ✗ }
2077 }
2078 676 }
2079
2080 /**
2081 * @brief Copies @p bindings into @p staged_bindings and gives each binding at @p indices the keys and
2082 * modifiers of the matching combo.
2083 * @details The caller holds the binding lock exclusive. @p combos holds one combo for each index in
2084 * @p indices.
2085 */
2086 1348 void stage_combo_replacements(
2087 std::vector<InputBinding> &staged_bindings,
2088 const std::vector<InputBinding> &bindings,
2089 const std::vector<std::size_t> &indices,
2090 const input::KeyComboList &combos
2091 )
2092 {
2093 1348 staged_bindings = bindings;
2094
2/2
✓ Branch 13 → 4 taken 1348 times.
✓ Branch 13 → 14 taken 1348 times.
2696 for (size_t i = 0; i < indices.size(); ++i)
2095 {
2096 1348 const size_t idx = indices[i];
2097 1348 staged_bindings[idx].keys = combos[i].keys;
2098 1348 staged_bindings[idx].modifiers = combos[i].modifiers;
2099 }
2100 1348 }
2101
2102 /// Builds the replacement bindings of a cardinality change from @p prototype.
2103 691 void stage_appended_bindings(
2104 std::vector<InputBinding> &appended,
2105 const InputBinding &prototype,
2106 const input::KeyComboList &combos,
2107 std::size_t append_count
2108 )
2109 {
2110 // An empty replacement yields one inert sentinel, so the name stays addressable across a bound ->
2111 // unbound -> bound INI reload cycle.
2112 691 appended.reserve(append_count);
2113
2/2
✓ Branch 4 → 5 taken 333 times.
✓ Branch 4 → 13 taken 357 times.
690 if (combos.empty())
2114 {
2115
1/2
✓ Branch 5 → 6 taken 333 times.
✗ Branch 5 → 38 not taken.
333 InputBinding sentinel = prototype;
2116 333 sentinel.keys.clear();
2117 333 sentinel.modifiers.clear();
2118
1/2
✓ Branch 10 → 11 taken 333 times.
✗ Branch 10 → 36 not taken.
333 appended.push_back(std::move(sentinel));
2119 333 }
2120 else
2121 {
2122
2/2
✓ Branch 33 → 15 taken 709 times.
✓ Branch 33 → 34 taken 353 times.
1419 for (const auto &combo : combos)
2123 {
2124
2/2
✓ Branch 17 → 18 taken 705 times.
✓ Branch 17 → 41 taken 4 times.
709 InputBinding binding = prototype;
2125
1/2
✓ Branch 18 → 19 taken 705 times.
✗ Branch 18 → 39 not taken.
705 binding.keys = combo.keys;
2126
1/2
✓ Branch 19 → 20 taken 705 times.
✗ Branch 19 → 39 not taken.
705 binding.modifiers = combo.modifiers;
2127
1/2
✓ Branch 22 → 23 taken 705 times.
✗ Branch 22 → 39 not taken.
705 appended.push_back(std::move(binding));
2128 705 }
2129 }
2130 686 }
2131
2132 /**
2133 * @brief Stages each retained binding with its prior state, then moves in each @p appended binding at
2134 * state 0.
2135 * @details The caller holds the binding lock exclusive. @p indices names the dropped bindings in ascending
2136 * order. A retained held binding stays active after the commit.
2137 */
2138 681 void stage_rebuilt_bindings(
2139 std::vector<InputBinding> &staged_bindings,
2140 std::vector<uint8_t> &rebuilt_states,
2141 const std::vector<InputBinding> &bindings,
2142 const std::unique_ptr<std::atomic<uint8_t>[]> &active_states,
2143 const std::vector<std::size_t> &indices,
2144 std::vector<InputBinding> &appended
2145 )
2146 {
2147 681 size_t cursor = 0;
2148
2/2
✓ Branch 29 → 4 taken 1017 times.
✓ Branch 29 → 30 taken 681 times.
2379 for (size_t skip : indices)
2149 {
2150
2/2
✓ Branch 19 → 7 taken 1 time.
✓ Branch 19 → 20 taken 1017 times.
1018 for (size_t i = cursor; i < skip; ++i)
2151 {
2152
1/2
✓ Branch 15 → 16 taken 1 time.
✗ Branch 15 → 64 not taken.
2 rebuilt_states.push_back(active_states[i].load(std::memory_order_relaxed));
2153
1/2
✓ Branch 17 → 18 taken 1 time.
✗ Branch 17 → 65 not taken.
1 staged_bindings.push_back(bindings[i]);
2154 }
2155 1017 cursor = skip + 1;
2156 }
2157
2/2
✓ Branch 44 → 31 taken 2 times.
✓ Branch 44 → 45 taken 681 times.
683 for (size_t i = cursor; i < bindings.size(); ++i)
2158 {
2159
1/2
✓ Branch 39 → 40 taken 2 times.
✗ Branch 39 → 66 not taken.
4 rebuilt_states.push_back(active_states[i].load(std::memory_order_relaxed));
2160 2 staged_bindings.push_back(bindings[i]);
2161 }
2162
2/2
✓ Branch 62 → 47 taken 1026 times.
✓ Branch 62 → 63 taken 681 times.
3414 for (auto &binding : appended)
2163 {
2164
1/2
✓ Branch 51 → 52 taken 1026 times.
✗ Branch 51 → 68 not taken.
1026 staged_bindings.push_back(std::move(binding));
2165
1/2
✓ Branch 52 → 53 taken 1026 times.
✗ Branch 52 → 67 not taken.
1026 rebuilt_states.push_back(0);
2166 }
2167 681 }
2168
2169 /// Stores each of @p rebuilt_states into the unpublished @p new_states array.
2170 676 void store_rebuilt_states(
2171 const std::unique_ptr<std::atomic<uint8_t>[]> &new_states,
2172 const std::vector<uint8_t> &rebuilt_states
2173 ) noexcept
2174 {
2175
2/2
✓ Branch 15 → 3 taken 1019 times.
✓ Branch 15 → 16 taken 676 times.
1695 for (size_t i = 0; i < rebuilt_states.size(); ++i)
2176 {
2177 1019 new_states[i].store(rebuilt_states[i], std::memory_order_relaxed);
2178 }
2179 676 }
2180 } // anonymous namespace
2181
2182 InputPoller::ComboUpdate
2183 2046 InputPoller::update_combos(std::string_view name, const input::KeyComboList &combos) noexcept
2184 {
2185 2046 std::vector<HoldRelease> hold_releases;
2186 2046 std::vector<BindingRundown> rundowns;
2187 2046 std::vector<InputBinding> staged_bindings;
2188 2046 DeferredDiagnostics diagnostics;
2189
2190 try
2191 {
2192
1/2
✓ Branch 2 → 3 taken 2046 times.
✗ Branch 2 → 161 not taken.
2046 std::unique_lock lock(m_bindings_rw_mutex);
2193
2/2
✓ Branch 3 → 4 taken 2044 times.
✓ Branch 3 → 159 taken 2 times.
2046 std::vector<size_t> indices = named_indices_locked(name);
2194
2/2
✓ Branch 5 → 6 taken 3 times.
✓ Branch 5 → 10 taken 2041 times.
2044 if (indices.empty())
2195 {
2196 // Release the writer lock before log output under the deferred-log convention.
2197
1/2
✓ Branch 6 → 7 taken 3 times.
✗ Branch 6 → 157 not taken.
3 lock.unlock();
2198 3 (void)log()
2199 3 .try_log(LogLevel::Debug, "InputPoller: update_combos(\"{}\") ignored: name not found", name);
2200 3 return ComboUpdate::NameAbsent;
2201 }
2202
2203 // Stage every fallible binding and cache allocation before any member-state commit.
2204
2/2
✓ Branch 12 → 13 taken 1348 times.
✓ Branch 12 → 48 taken 693 times.
2041 if (indices.size() == combos.size())
2205 {
2206
1/2
✓ Branch 13 → 14 taken 1348 times.
✗ Branch 13 → 144 not taken.
1348 stage_combo_replacements(staged_bindings, m_bindings, indices, combos);
2207
2208 1348 std::optional<ModifierCaches> caches = build_modifier_caches(staged_bindings);
2209
1/2
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 21 taken 1348 times.
1348 if (!caches)
2210 {
2211
0/2
✗ Branch 17 → 18 not taken.
✗ Branch 17 → 142 not taken.
✗ lock.unlock();
2212 ✗ (void)log().try_log(
2213 LogLevel::Error,
2214 "InputPoller: out of memory in update_combos; combos unchanged"
2215 );
2216 ✗ return ComboUpdate::ResourceFailure;
2217 }
2218
2219
1/2
✓ Branch 21 → 22 taken 1348 times.
✗ Branch 21 → 142 not taken.
1348 collect_rundowns(rundowns, m_bindings, indices);
2220
2221 1348 m_bindings.swap(staged_bindings);
2222
2/2
✓ Branch 38 → 25 taken 1348 times.
✓ Branch 38 → 39 taken 1348 times.
4044 for (auto &rundown : rundowns)
2223 {
2224 1348 rundown.generation = rundown.lifecycle->advance_generation();
2225 }
2226 1348 m_binding_generation = next_binding_generation();
2227 1348 commit_modifier_caches_locked(*caches, diagnostics);
2228
1/2
✓ Branch 42 → 43 taken 1348 times.
✗ Branch 42 → 142 not taken.
1348 lock.unlock();
2229 1348 diagnostics.emit();
2230 1348 drain_rundowns(rundowns);
2231 1348 return ComboUpdate::Updated;
2232 1348 }
2233
2234 // A cardinality change rebuilds the bindings vector and the parallel m_active_states array. The
2235 // prototype keeps callback identity, mode, and name stable across the rebuild. Its lifecycle is
2236 // held apart so the retained registration receives a new generation instead of a tombstone.
2237
2/2
✓ Branch 50 → 51 taken 691 times.
✓ Branch 50 → 157 taken 2 times.
693 InputBinding prototype = m_bindings[indices.front()];
2238 691 const std::shared_ptr<BindingLifecycle> prototype_lifecycle = prototype.lifecycle;
2239
2240
2/2
✓ Branch 53 → 54 taken 333 times.
✓ Branch 53 → 55 taken 358 times.
691 const size_t append_count = combos.empty() ? 1 : combos.size();
2241 691 const size_t new_size = m_bindings.size() - indices.size() + append_count;
2242
2243 691 std::vector<InputBinding> appended;
2244
2/2
✓ Branch 58 → 59 taken 686 times.
✓ Branch 58 → 151 taken 5 times.
691 stage_appended_bindings(appended, prototype, combos, append_count);
2245
2246
2/2
✓ Branch 59 → 60 taken 685 times.
✓ Branch 59 → 151 taken 1 time.
686 staged_bindings.reserve(new_size);
2247 685 std::vector<uint8_t> rebuilt_states;
2248
2/2
✓ Branch 60 → 61 taken 684 times.
✓ Branch 60 → 149 taken 1 time.
685 rebuilt_states.reserve(new_size);
2249
2/2
✓ Branch 61 → 62 taken 683 times.
✓ Branch 61 → 149 taken 1 time.
684 auto new_states = std::make_unique<std::atomic<uint8_t>[]>(new_size);
2250
2251 // Capture release callbacks for held entries that this update drops. Otherwise, a Hold consumer remains
2252 // held forever after its entry vanishes. The same-name NON-prototype tombstone rejects its staged
2253 // release. Therefore, a gate-backed hold always synthesizes the compensatory false, as remove and clear
2254 // do. The gate deduplicates, so an unheld drop is a no-op and the prototype's already-admitted release
2255 // is not doubled.
2256
2/2
✓ Branch 62 → 63 taken 682 times.
✓ Branch 62 → 147 taken 1 time.
683 stage_hold_releases(hold_releases, m_bindings, m_active_states, indices);
2257
2258
2/2
✓ Branch 63 → 64 taken 681 times.
✓ Branch 63 → 147 taken 1 time.
682 collect_rundowns(rundowns, m_bindings, indices);
2259
2260
1/2
✓ Branch 64 → 65 taken 681 times.
✗ Branch 64 → 147 not taken.
681 stage_rebuilt_bindings(staged_bindings, rebuilt_states, m_bindings, m_active_states, indices, appended);
2261
2262 681 std::optional<ModifierCaches> caches = build_modifier_caches(staged_bindings);
2263
2/2
✓ Branch 67 → 68 taken 5 times.
✓ Branch 67 → 72 taken 676 times.
681 if (!caches)
2264 {
2265
1/2
✓ Branch 68 → 69 taken 5 times.
✗ Branch 68 → 145 not taken.
5 lock.unlock();
2266 5 (void)log().try_log(
2267 LogLevel::Error,
2268 "InputPoller: out of memory in update_combos; combos unchanged"
2269 );
2270 5 return ComboUpdate::ResourceFailure;
2271 }
2272
2273 676 store_rebuilt_states(new_states, rebuilt_states);
2274
2275 676 m_bindings.swap(staged_bindings);
2276 676 m_active_states = std::move(new_states);
2277
2/2
✓ Branch 96 → 79 taken 678 times.
✓ Branch 96 → 97 taken 676 times.
2030 for (auto &rundown : rundowns)
2278 {
2279 1356 rundown.generation = rundown.lifecycle == prototype_lifecycle
2280
2/2
✓ Branch 82 → 83 taken 676 times.
✓ Branch 82 → 85 taken 2 times.
678 ? rundown.lifecycle->advance_generation()
2281 2 : rundown.lifecycle->tombstone();
2282 }
2283 676 m_binding_generation = next_binding_generation();
2284 676 commit_modifier_caches_locked(*caches, diagnostics);
2285
16/16
✓ Branch 102 → 103 taken 676 times.
✓ Branch 102 → 104 taken 5 times.
✓ Branch 106 → 107 taken 676 times.
✓ Branch 106 → 108 taken 5 times.
✓ Branch 110 → 111 taken 676 times.
✓ Branch 110 → 112 taken 5 times.
✓ Branch 114 → 115 taken 676 times.
✓ Branch 114 → 116 taken 5 times.
✓ Branch 118 → 119 taken 676 times.
✓ Branch 118 → 120 taken 5 times.
✓ Branch 122 → 123 taken 676 times.
✓ Branch 122 → 124 taken 5 times.
✓ Branch 126 → 127 taken 676 times.
✓ Branch 126 → 128 taken 1356 times.
✓ Branch 130 → 131 taken 676 times.
✓ Branch 130 → 136 taken 1356 times.
3480 }
2286 14 catch (...)
2287 {
2288 // The transaction allocates before its member-state commit, so the prior state remains intact.
2289 14 (void)log().try_log(LogLevel::Error, "InputPoller: out of memory in update_combos; combos unchanged");
2290 14 return ComboUpdate::ResourceFailure;
2291 14 }
2292
2293 676 diagnostics.emit();
2294 676 drain_rundowns(rundowns);
2295
2296 // Fire the captured release callbacks outside the writer lock. This path runs from a user-driven INI
2297 // reshape, never a DllMain detach, so synchronous dispatch is safe.
2298 676 dispatch_hold_releases(hold_releases);
2299
2300 676 return ComboUpdate::Updated;
2301 2046 }
2302
2303 741 bool InputPoller::add_binding(InputBinding binding) noexcept
2304 {
2305 741 DeferredDiagnostics diagnostics;
2306 741 bool added = false;
2307 {
2308 741 std::unique_lock lock(m_bindings_rw_mutex);
2309
2310 741 const size_t old_count = m_bindings.size();
2311 741 const size_t new_count = old_count + 1;
2312
2313 try
2314 {
2315
2/2
✓ Branch 4 → 5 taken 734 times.
✓ Branch 4 → 50 taken 7 times.
741 ensure_lifecycle(binding);
2316
2317 // Build the replacement state array before mutation of m_bindings so an allocation failure leaves
2318 // both at their prior equal sizes. A mismatch causes an out-of-bounds poll read. Seed each retained
2319 // slot from the current value so a held binding does not flicker inactive.
2320
2/2
✓ Branch 5 → 6 taken 727 times.
✓ Branch 5 → 50 taken 7 times.
734 auto new_states = std::make_unique<std::atomic<uint8_t>[]>(new_count);
2321
2/2
✓ Branch 25 → 7 taken 45389 times.
✓ Branch 25 → 26 taken 727 times.
46116 for (size_t i = 0; i < old_count; ++i)
2322 {
2323 45389 new_states[i].store(
2324 90778 m_active_states[i].load(std::memory_order_relaxed),
2325 std::memory_order_relaxed
2326 );
2327 }
2328 727 new_states[old_count].store(0, std::memory_order_relaxed);
2329
2330 // push_back has the strong guarantee, so a reallocation failure leaves m_bindings unchanged and
2331 // simply discards the new_states array.
2332
2/2
✓ Branch 37 → 38 taken 724 times.
✓ Branch 37 → 48 taken 3 times.
1454 m_bindings.push_back(std::move(binding));
2333 724 m_active_states = std::move(new_states);
2334 724 recompute_modifier_caches_locked(diagnostics);
2335 724 added = true;
2336 727 }
2337 17 catch (...)
2338 {
2339 // Drop the binding and leave the poller unchanged. The false return lets the facade surface
2340 // the failure.
2341 17 diagnostics.add_binding_oom = true;
2342 17 }
2343 741 }
2344 741 diagnostics.emit();
2345 741 return added;
2346 }
2347
2348 3 bool InputPoller::add_bindings(std::vector<InputBinding> bindings) noexcept
2349 {
2350
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 3 times.
3 if (bindings.empty())
2351 {
2352 ✗ return true;
2353 }
2354
2355 3 DeferredDiagnostics diagnostics;
2356 3 bool added = false;
2357 {
2358 3 std::unique_lock lock(m_bindings_rw_mutex);
2359
2360 3 const size_t old_count = m_bindings.size();
2361 3 const size_t append_count = bindings.size();
2362 3 const size_t new_count = old_count + append_count;
2363
2364 try
2365 {
2366
2/2
✓ Branch 22 → 10 taken 5 times.
✓ Branch 22 → 23 taken 2 times.
10 for (auto &binding : bindings)
2367 {
2368
2/2
✓ Branch 12 → 13 taken 4 times.
✓ Branch 12 → 80 taken 1 time.
5 ensure_lifecycle(binding);
2369 }
2370
2371 // Allocate every replacement container before mutation of the live engine. Preserve an atomic
2372 // multi-combo registration under OOM.
2373
1/2
✓ Branch 23 → 24 taken 2 times.
✗ Branch 23 → 85 not taken.
2 auto new_states = std::make_unique<std::atomic<uint8_t>[]>(new_count);
2374 2 std::vector<InputBinding> rebuilt;
2375
1/2
✓ Branch 24 → 25 taken 2 times.
✗ Branch 24 → 81 not taken.
2 rebuilt.reserve(new_count);
2376
2377
2/2
✓ Branch 48 → 26 taken 2 times.
✓ Branch 48 → 49 taken 2 times.
4 for (size_t i = 0; i < old_count; ++i)
2378 {
2379 2 new_states[i].store(
2380 4 m_active_states[i].load(std::memory_order_relaxed),
2381 std::memory_order_relaxed
2382 );
2383
1/2
✓ Branch 46 → 47 taken 2 times.
✗ Branch 46 → 81 not taken.
4 rebuilt.push_back(std::move(m_bindings[i]));
2384 }
2385
2/2
✓ Branch 64 → 50 taken 4 times.
✓ Branch 64 → 65 taken 2 times.
6 for (size_t i = 0; i < append_count; ++i)
2386 {
2387 4 new_states[old_count + i].store(0, std::memory_order_relaxed);
2388
1/2
✓ Branch 62 → 63 taken 4 times.
✗ Branch 62 → 81 not taken.
8 rebuilt.push_back(std::move(bindings[i]));
2389 }
2390
2391 2 m_bindings = std::move(rebuilt);
2392 2 m_active_states = std::move(new_states);
2393 2 recompute_modifier_caches_locked(diagnostics);
2394 2 added = true;
2395 2 }
2396 1 catch (...)
2397 {
2398 // All allocation precedes any move from m_bindings, so the live poller remains unchanged.
2399 1 diagnostics.add_bindings_oom = true;
2400 1 }
2401 3 }
2402 3 diagnostics.emit();
2403 3 return added;
2404 }
2405
2406 418 size_t InputPoller::remove_bindings_by_name(std::string_view name, bool invoke_callbacks) noexcept
2407 {
2408 418 std::vector<HoldRelease> hold_releases;
2409 418 std::vector<BindingRundown> rundowns;
2410 418 std::vector<InputBinding> retired;
2411 418 std::vector<InputBinding> staged;
2412 418 size_t removed = 0;
2413 418 DeferredDiagnostics diagnostics;
2414
2415 try
2416 {
2417
1/2
✓ Branch 2 → 3 taken 418 times.
✗ Branch 2 → 157 not taken.
418 std::unique_lock lock(m_bindings_rw_mutex);
2418
1/2
✓ Branch 3 → 4 taken 418 times.
✗ Branch 3 → 155 not taken.
418 std::vector<size_t> indices = named_indices_locked(name);
2419
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 417 times.
418 if (indices.empty())
2420 {
2421 1 return 0;
2422 }
2423
2424 // Capture release callbacks before erasure, then dispatch them after unlock. Logic-DLL retirement
2425 // passes invoke_callbacks=false because the callbacks reside in a module near removal. Always capture a
2426 // gate-backed hold because the tombstone refuses any staged release. The m_active_states gate strands a
2427 // consumer whose release is staged but not dispatched. The gate swallows an unbalanced released(false).
2428 // A raw callback keeps the m_active_states gate.
2429
2/2
✓ Branch 7 → 8 taken 411 times.
✓ Branch 7 → 9 taken 6 times.
417 if (invoke_callbacks)
2430 {
2431
1/2
✓ Branch 8 → 9 taken 411 times.
✗ Branch 8 → 153 not taken.
411 stage_hold_releases(hold_releases, m_bindings, m_active_states, indices);
2432 }
2433
2434 // A flat skip-mask lets every retained binding inherit its prior atomic state, so a held binding
2435 // does not briefly report inactive after the reshape.
2436
1/2
✓ Branch 12 → 13 taken 417 times.
✗ Branch 12 → 141 not taken.
417 std::vector<bool> drop(m_bindings.size(), false);
2437
2/2
✓ Branch 29 → 16 taken 417 times.
✓ Branch 29 → 30 taken 417 times.
1251 for (size_t idx : indices)
2438 {
2439 417 drop[idx] = true;
2440 }
2441 417 const size_t survivor_count = m_bindings.size() - indices.size();
2442 417 std::vector<uint8_t> carried;
2443
1/2
✓ Branch 32 → 33 taken 417 times.
✗ Branch 32 → 149 not taken.
417 carried.reserve(survivor_count);
2444
2/2
✓ Branch 49 → 34 taken 626 times.
✓ Branch 49 → 50 taken 417 times.
1043 for (size_t i = 0; i < m_bindings.size(); ++i)
2445 {
2446
2/2
✓ Branch 36 → 37 taken 209 times.
✓ Branch 36 → 47 taken 417 times.
626 if (!drop[i])
2447 {
2448
1/2
✓ Branch 45 → 46 taken 209 times.
✗ Branch 45 → 145 not taken.
418 carried.push_back(m_active_states[i].load(std::memory_order_relaxed));
2449 }
2450 }
2451
2452 // Allocate the replacement state array before erasure, so an allocation failure leaves m_bindings
2453 // and m_active_states at their prior equal sizes.
2454
1/2
✓ Branch 50 → 51 taken 417 times.
✗ Branch 50 → 149 not taken.
417 auto new_states = std::make_unique<std::atomic<uint8_t>[]>(survivor_count);
2455
2/2
✓ Branch 64 → 52 taken 209 times.
✓ Branch 64 → 65 taken 417 times.
626 for (size_t i = 0; i < carried.size(); ++i)
2456 {
2457 209 new_states[i].store(carried[i], std::memory_order_relaxed);
2458 }
2459
1/2
✓ Branch 66 → 67 taken 417 times.
✗ Branch 66 → 147 not taken.
417 retired.reserve(indices.size());
2460
1/2
✓ Branch 67 → 68 taken 417 times.
✗ Branch 67 → 147 not taken.
417 staged.reserve(survivor_count);
2461
2462
1/2
✓ Branch 68 → 69 taken 417 times.
✗ Branch 68 → 147 not taken.
417 collect_rundowns(rundowns, m_bindings, indices);
2463
2/2
✓ Branch 84 → 71 taken 417 times.
✓ Branch 84 → 85 taken 417 times.
1251 for (auto &rundown : rundowns)
2464 {
2465 417 rundown.generation = rundown.lifecycle->tombstone();
2466 }
2467
2468 // Partition into reserved batches so dropped entries outlive the writer lock.
2469
2/2
✓ Branch 97 → 86 taken 626 times.
✓ Branch 97 → 98 taken 417 times.
1043 for (size_t i = 0; i < m_bindings.size(); ++i)
2470 {
2471
3/4
✓ Branch 88 → 89 taken 417 times.
✓ Branch 88 → 90 taken 209 times.
✓ Branch 94 → 95 taken 626 times.
✗ Branch 94 → 146 not taken.
1252 (drop[i] ? retired : staged).push_back(std::move(m_bindings[i]));
2472 }
2473 417 m_bindings.swap(staged);
2474 417 m_active_states = std::move(new_states);
2475 417 removed = indices.size();
2476
2477 417 recompute_modifier_caches_locked(diagnostics);
2478
4/4
✓ Branch 109 → 110 taken 417 times.
✓ Branch 109 → 111 taken 1 time.
✓ Branch 113 → 114 taken 417 times.
✓ Branch 113 → 117 taken 1 time.
419 }
2479 ✗ catch (...)
2480 {
2481 // Allocation precedes erasure, so the poller is left unchanged and no callbacks fire. The stack
2482 // unwind already released the writer lock, so this emission is off the lock.
2483 ✗ (void)log().try_log(
2484 LogLevel::Error,
2485 "InputPoller: out of memory in remove_bindings_by_name; bindings unchanged"
2486 );
2487 ✗ return 0;
2488 ✗ }
2489
2490 417 diagnostics.emit();
2491
2492 // invoke_callbacks == false means the caller owns the wait. The loader-lock abandon path must not block.
2493 // The typed unload drain bounds the wait on its own deadline. The tombstone is already published, so an
2494 // in-flight callback is abandoned rather than waited on. Normal removal drains.
2495
2/2
✓ Branch 116 → 118 taken 411 times.
✓ Branch 116 → 119 taken 6 times.
417 if (invoke_callbacks)
2496 {
2497 411 drain_rundowns(rundowns);
2498 }
2499
2500
2/2
✓ Branch 133 → 121 taken 403 times.
✓ Branch 133 → 134 taken 414 times.
1234 for (auto &[callback, binding_name] : hold_releases)
2501 {
2502 try
2503 {
2504
1/2
✓ Branch 123 → 124 taken 399 times.
✗ Branch 123 → 163 not taken.
403 callback(false);
2505 }
2506
0/2
✗ Branch 163 → 164 not taken.
✗ Branch 163 → 169 not taken.
✗ catch (const std::exception &e)
2507 {
2508 ✗ (void)log().try_log(
2509 LogLevel::Error,
2510 "InputPoller: Exception in hold release callback \"{}\": {}",
2511 ✗ binding_name,
2512 ✗ e.what()
2513 );
2514 ✗ }
2515 ✗ catch (...)
2516 {
2517 ✗ (void)log().try_log(
2518 LogLevel::Error,
2519 "InputPoller: Unknown exception in hold release callback \"{}\"",
2520 ✗ binding_name
2521 );
2522 ✗ }
2523 }
2524
2525 414 return removed;
2526 415 }
2527
2528 5 bool InputPoller::retire_gates_by_name(
2529 std::string_view name,
2530 std::chrono::steady_clock::time_point deadline
2531 ) noexcept
2532 {
2533 5 std::vector<std::shared_ptr<BindingGate>> gates;
2534 try
2535 {
2536 5 std::shared_lock lock(m_bindings_rw_mutex);
2537 5 std::size_t matched = 0;
2538
2/4
✓ Branch 3 → 4 taken 5 times.
✗ Branch 3 → 17 not taken.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 5 times.
5 if (!for_each_named_locked(name, [&matched](std::size_t) noexcept { ++matched; }))
2539 {
2540 ✗ return true;
2541 }
2542
1/2
✓ Branch 6 → 7 taken 5 times.
✗ Branch 6 → 19 not taken.
5 gates.reserve(matched);
2543 5 (void)for_each_named_locked(
2544 name,
2545
1/2
✓ Branch 7 → 8 taken 5 times.
✗ Branch 7 → 18 not taken.
5 [this, &gates](std::size_t idx)
2546 {
2547
1/2
✓ Branch 4 → 5 taken 5 times.
✗ Branch 4 → 7 not taken.
5 if (m_bindings[idx].gate)
2548 {
2549 5 gates.push_back(m_bindings[idx].gate);
2550 }
2551 5 }
2552 );
2553
1/2
✓ Branch 10 → 11 taken 5 times.
✗ Branch 10 → 13 not taken.
5 }
2554 ✗ catch (...)
2555 {
2556 // Handle collection exhausted memory. Failure is the only truthful result because retirement did not
2557 // occur. The drain must not tell its caller that the callbacks are gone.
2558 ✗ (void)log().try_log(LogLevel::Error, "InputPoller: out of memory collecting gates for retirement");
2559 ✗ return false;
2560 ✗ }
2561
2562 5 return retire_collected_gates(gates, deadline);
2563 5 }
2564
2565 294 bool InputPoller::retire_all_gates(std::chrono::steady_clock::time_point deadline) noexcept
2566 {
2567 294 std::vector<std::shared_ptr<BindingGate>> gates;
2568 try
2569 {
2570 294 std::shared_lock lock(m_bindings_rw_mutex);
2571
1/2
✓ Branch 4 → 5 taken 294 times.
✗ Branch 4 → 28 not taken.
294 gates.reserve(m_bindings.size());
2572
2/2
✓ Branch 21 → 7 taken 315 times.
✓ Branch 21 → 22 taken 294 times.
903 for (const auto &binding : m_bindings)
2573 {
2574
1/2
✓ Branch 10 → 11 taken 315 times.
✗ Branch 10 → 12 not taken.
315 if (binding.gate)
2575 {
2576
1/2
✓ Branch 11 → 12 taken 315 times.
✗ Branch 11 → 27 not taken.
315 gates.push_back(binding.gate);
2577 }
2578 }
2579 294 }
2580 ✗ catch (...)
2581 {
2582 ✗ (void)log().try_log(LogLevel::Error, "InputPoller: out of memory collecting gates for retirement");
2583 ✗ return false;
2584 ✗ }
2585
2586 294 return retire_collected_gates(gates, deadline);
2587 294 }
2588
2589 299 bool InputPoller::retire_collected_gates(
2590 const std::vector<std::shared_ptr<BindingGate>> &gates,
2591 std::chrono::steady_clock::time_point deadline
2592 ) noexcept
2593 {
2594 // Off the binding lock: retire() waits out an in-flight delivery, and the poll thread takes the same
2595 // lock to dispatch. Exploded combos share one gate. retire() is idempotent on the repeat.
2596 299 bool retired_all = true;
2597
2/2
✓ Branch 19 → 4 taken 320 times.
✓ Branch 19 → 20 taken 299 times.
918 for (const auto &gate : gates)
2598 {
2599 try
2600 {
2601
3/4
✓ Branch 7 → 8 taken 320 times.
✗ Branch 7 → 22 not taken.
✓ Branch 8 → 9 taken 4 times.
✓ Branch 8 → 10 taken 316 times.
320 if (!gate->retire(deadline))
2602 {
2603 4 retired_all = false;
2604 }
2605 }
2606
0/2
✗ Branch 22 → 23 not taken.
✗ Branch 22 → 28 not taken.
✗ catch (const std::exception &e)
2607 {
2608 // The callback is destroyed regardless (retire() moved it out first), so retirement itself
2609 // succeeded and only the consumer's edge failed.
2610 ✗ (void)log().try_log(
2611 LogLevel::Error,
2612 "InputPoller: Exception in retired hold release callback: {}",
2613 ✗ e.what()
2614 );
2615 ✗ }
2616 ✗ catch (...)
2617 {
2618 ✗ (void)log().try_log(
2619 LogLevel::Error,
2620 "InputPoller: Unknown exception in retired hold release callback"
2621 );
2622 ✗ }
2623 }
2624 299 return retired_all;
2625 }
2626
2627 297 void InputPoller::clear_bindings(bool invoke_callbacks) noexcept
2628 {
2629 297 std::vector<HoldRelease> hold_releases;
2630 297 std::vector<BindingRundown> rundowns;
2631 297 std::vector<InputBinding> retired;
2632 297 DeferredDiagnostics diagnostics;
2633
2634 try
2635 {
2636
1/2
✓ Branch 2 → 3 taken 297 times.
✗ Branch 2 → 116 not taken.
297 std::unique_lock lock(m_bindings_rw_mutex);
2637 // Skip release-callback capture during Logic-DLL retirement: the callbacks live in a module that
2638 // can start module removal. A gate-backed hold is captured unconditionally. See
2639 // remove_bindings_by_name.
2640
2/2
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 30 taken 293 times.
297 if (invoke_callbacks)
2641 {
2642
2/2
✓ Branch 29 → 5 taken 5 times.
✓ Branch 29 → 30 taken 4 times.
9 for (size_t i = 0; i < m_bindings.size(); ++i)
2643 {
2644
2/6
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 22 taken 5 times.
✗ Branch 9 → 10 not taken.
✗ Branch 9 → 22 not taken.
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 27 taken 5 times.
5 if (m_bindings[i].trigger == input::Trigger::Hold && m_bindings[i].on_state_change &&
2645
0/2
✗ Branch 11 → 12 not taken.
✗ Branch 11 → 21 not taken.
✗ (m_bindings[i].release_is_idempotent ||
2646
0/2
✗ Branch 20 → 21 not taken.
✗ Branch 20 → 22 not taken.
✗ m_active_states[i].load(std::memory_order_relaxed) != 0))
2647 {
2648
0/2
✗ Branch 26 → 27 not taken.
✗ Branch 26 → 114 not taken.
✗ hold_releases.emplace_back(m_bindings[i].on_state_change, m_bindings[i].name);
2649 }
2650 }
2651 }
2652
2653 // Allocate the empty replacement before the clear so an allocation failure leaves the poller
2654 // untouched. Nothing below allocates.
2655
1/2
✓ Branch 30 → 31 taken 297 times.
✗ Branch 30 → 114 not taken.
297 auto new_states = std::make_unique<std::atomic<uint8_t>[]>(0);
2656
2657
1/2
✓ Branch 32 → 33 taken 297 times.
✗ Branch 32 → 112 not taken.
297 rundowns.reserve(m_bindings.size());
2658
2/2
✓ Branch 47 → 35 taken 317 times.
✓ Branch 47 → 48 taken 297 times.
911 for (const auto &binding : m_bindings)
2659 {
2660
1/2
✓ Branch 37 → 38 taken 317 times.
✗ Branch 37 → 111 not taken.
317 add_rundown(rundowns, binding.lifecycle);
2661 }
2662
2/2
✓ Branch 63 → 50 taken 299 times.
✓ Branch 63 → 64 taken 297 times.
893 for (auto &rundown : rundowns)
2663 {
2664 299 rundown.generation = rundown.lifecycle->tombstone();
2665 }
2666
2667 297 retired.swap(m_bindings);
2668 297 m_name_index.clear();
2669 297 m_name_index_authoritative = true;
2670 297 m_known_modifiers.clear();
2671 // clear_bindings does not route through recompute_modifier_caches_locked, so advance the generation
2672 // here so live BindingTokens fail closed once the binding set is empty.
2673 297 m_binding_generation = next_binding_generation();
2674 297 m_has_gamepad_bindings.store(false, std::memory_order_relaxed);
2675 297 m_has_wheel_bindings.store(false, std::memory_order_relaxed);
2676 297 m_has_consume_gamepad_bindings.store(false, std::memory_order_relaxed);
2677 297 m_consume_rules.clear();
2678 297 publish_consume_rules_locked(diagnostics);
2679 297 m_active_states = std::move(new_states);
2680 297 }
2681 ✗ catch (...)
2682 {
2683 // The stack unwind already released the writer lock, so this emission is off the lock.
2684 ✗ (void)log().try_log(
2685 LogLevel::Error,
2686 "InputPoller: out of memory in clear_bindings; bindings unchanged"
2687 );
2688 ✗ return;
2689 ✗ }
2690
2691 297 diagnostics.emit();
2692
2693 // invoke_callbacks == false abandons in-flight callbacks (see remove_bindings_by_name). A normal clear
2694 // drains.
2695
2/2
✓ Branch 79 → 80 taken 4 times.
✓ Branch 79 → 81 taken 293 times.
297 if (invoke_callbacks)
2696 {
2697 4 drain_rundowns(rundowns);
2698 }
2699
2700
1/2
✗ Branch 95 → 83 not taken.
✓ Branch 95 → 96 taken 297 times.
594 for (auto &[callback, name] : hold_releases)
2701 {
2702 try
2703 {
2704
0/2
✗ Branch 85 → 86 not taken.
✗ Branch 85 → 124 not taken.
✗ callback(false);
2705 }
2706
0/2
✗ Branch 124 → 125 not taken.
✗ Branch 124 → 130 not taken.
✗ catch (const std::exception &e)
2707 {
2708 ✗ (void)log().try_log(
2709 LogLevel::Error,
2710 "InputPoller: Exception in hold release callback \"{}\": {}",
2711 ✗ name,
2712 ✗ e.what()
2713 );
2714 ✗ }
2715 ✗ catch (...)
2716 {
2717 ✗ (void)log().try_log(
2718 LogLevel::Error,
2719 "InputPoller: Unknown exception in hold release callback \"{}\"",
2720 ✗ name
2721 );
2722 ✗ }
2723 }
2724
3/6
✓ Branch 98 → 99 taken 297 times.
✗ Branch 98 → 100 not taken.
✓ Branch 102 → 103 taken 297 times.
✗ Branch 102 → 104 not taken.
✓ Branch 106 → 107 taken 297 times.
✗ Branch 106 → 109 not taken.
297 }
2725
2726 502 void InputPoller::release_active_holds() noexcept
2727 {
2728 // Snapshot under the writer lock, then dispatch after its release. The facade can still forward a
2729 // control-plane add_binding onto this poller because it captured a shared_ptr before shutdown() moved it
2730 // out. An unlocked read of these containers races that reshape. The collect-then-fire pattern matches
2731 // remove_bindings_by_name.
2732 502 std::vector<HoldRelease> hold_releases;
2733
2734 502 bool staging_failed = false;
2735 try
2736 {
2737
1/2
✓ Branch 2 → 3 taken 502 times.
✗ Branch 2 → 110 not taken.
502 std::unique_lock lock(m_bindings_rw_mutex);
2738 502 std::size_t release_count = 0;
2739
2/2
✓ Branch 23 → 4 taken 549 times.
✓ Branch 23 → 24 taken 502 times.
1051 for (std::size_t i = 0; i < m_bindings.size(); ++i)
2740 {
2741 549 const auto &binding = m_bindings[i];
2742 549 if (m_active_states[i].load(std::memory_order_relaxed) != 0 &&
2743
7/8
✓ Branch 13 → 14 taken 80 times.
✓ Branch 13 → 18 taken 469 times.
✓ Branch 14 → 15 taken 67 times.
✓ Branch 14 → 18 taken 13 times.
✓ Branch 16 → 17 taken 67 times.
✗ Branch 16 → 18 not taken.
✓ Branch 19 → 20 taken 67 times.
✓ Branch 19 → 21 taken 482 times.
549 binding.trigger == input::Trigger::Hold && binding.on_state_change)
2744 {
2745 67 ++release_count;
2746 }
2747 }
2748 // Allocate before any bit clears, so the staged release commit cannot fail.
2749
2/2
✓ Branch 24 → 25 taken 501 times.
✓ Branch 24 → 108 taken 1 time.
502 hold_releases.reserve(release_count);
2750
2/2
✓ Branch 78 → 26 taken 548 times.
✓ Branch 78 → 79 taken 501 times.
1049 for (size_t i = 0; i < m_bindings.size(); ++i)
2751 {
2752
2/2
✓ Branch 34 → 35 taken 469 times.
✓ Branch 34 → 36 taken 79 times.
1096 if (m_active_states[i].load(std::memory_order_relaxed) == 0)
2753 {
2754 482 continue;
2755 }
2756 79 const auto &binding = m_bindings[i];
2757
5/6
✓ Branch 37 → 38 taken 66 times.
✓ Branch 37 → 40 taken 13 times.
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 41 taken 66 times.
✓ Branch 42 → 43 taken 13 times.
✓ Branch 42 → 53 taken 66 times.
79 if (binding.trigger != input::Trigger::Hold || !binding.on_state_change)
2758 {
2759 13 m_active_states[i].store(0, std::memory_order_relaxed);
2760 13 continue;
2761 }
2762
2763 66 HoldRelease staged;
2764 66 bool have_callback = false;
2765 try
2766 {
2767
1/2
✓ Branch 54 → 55 taken 66 times.
✗ Branch 54 → 102 not taken.
66 staged.callback = binding.on_state_change;
2768 66 have_callback = true;
2769 // Copy the callback first. A name-copy failure costs only its label. A callback-copy failure
2770 // costs the consumer its compensatory edge.
2771
2/2
✓ Branch 55 → 56 taken 64 times.
✓ Branch 55 → 102 taken 2 times.
66 staged.name = binding.name;
2772 }
2773 2 catch (...)
2774 {
2775 2 staging_failed = true;
2776
1/2
✓ Branch 104 → 56 taken 2 times.
✗ Branch 104 → 105 not taken.
2 }
2777
1/2
✗ Branch 56 → 57 not taken.
✓ Branch 56 → 58 taken 66 times.
66 if (!have_callback)
2778 {
2779 // Nothing to deliver, so leave the bit set rather than advertise a release that never happened.
2780 ✗ continue;
2781 }
2782
1/2
✓ Branch 60 → 61 taken 66 times.
✗ Branch 60 → 105 not taken.
66 hold_releases.push_back(std::move(staged));
2783 // Cleared only now: the bit is the sole record that this binding is held, and a release is
2784 // guaranteed staged from here.
2785 66 m_active_states[i].store(0, std::memory_order_relaxed);
2786
1/2
✓ Branch 72 → 73 taken 66 times.
✗ Branch 72 → 75 not taken.
66 }
2787 502 }
2788 1 catch (...)
2789 {
2790 1 staging_failed = true;
2791 1 }
2792
2793
2/2
✓ Branch 81 → 82 taken 3 times.
✓ Branch 81 → 85 taken 499 times.
502 if (staging_failed)
2794 {
2795 // Report and continue. Every staged release still runs below.
2796 3 (void)log().try_log(LogLevel::Error, "InputPoller: out of memory staging hold-release callbacks");
2797 }
2798
2799
2/2
✓ Branch 99 → 87 taken 66 times.
✓ Branch 99 → 100 taken 502 times.
1070 for (auto &[callback, name] : hold_releases)
2800 {
2801 try
2802 {
2803
2/2
✓ Branch 89 → 90 taken 40 times.
✓ Branch 89 → 114 taken 26 times.
66 callback(false);
2804 }
2805
1/2
✓ Branch 114 → 115 taken 26 times.
✗ Branch 114 → 120 not taken.
26 catch (const std::exception &e)
2806 {
2807 26 (void)log().try_log(
2808 LogLevel::Error,
2809 "InputPoller: Exception in hold release callback \"{}\": {}",
2810 26 name,
2811 26 e.what()
2812 );
2813 26 }
2814 ✗ catch (...)
2815 {
2816 ✗ (void)log().try_log(
2817 LogLevel::Error,
2818 "InputPoller: Unknown exception in hold release callback \"{}\"",
2819 ✗ name
2820 );
2821 ✗ }
2822 }
2823 502 }
2824
2825 136 bool InputPoller::is_process_foreground() const noexcept
2826 {
2827 136 HWND foreground = GetForegroundWindow();
2828
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 136 times.
136 if (!foreground)
2829 {
2830 ✗ return false;
2831 }
2832 136 DWORD foreground_pid = 0;
2833 136 GetWindowThreadProcessId(foreground, &foreground_pid);
2834 136 return foreground_pid == GetCurrentProcessId();
2835 }
2836 } // namespace detail
2837 } // namespace DetourModKit
2838