GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 73.9% 437 / 0 / 591
Functions: 97.7% 42 / 0 / 43
Branches: 49.5% 247 / 0 / 499

src/input.cpp
Line Branch Exec Source
1 /**
2 * @file input.cpp
3 * @brief Implementation of the input polling and hotkey management system.
4 *
5 * Provides InputPoller (RAII polling engine) and InputManager (singleton wrapper)
6 * for monitoring keyboard, mouse, and gamepad input states on a background thread.
7 * Supports press (edge-triggered) and hold (level-triggered) input modes with
8 * modifier combinations, focus-aware polling, and XInput gamepad support.
9 */
10
11 #include "DetourModKit/input.hpp"
12 #include "DetourModKit/config.hpp"
13 #include "DetourModKit/logger.hpp"
14
15 #include "platform.hpp"
16
17 #include <windows.h>
18 #include <Xinput.h>
19 #include <algorithm>
20 #include <exception>
21 #include <unordered_set>
22
23 using DetourModKit::detail::is_loader_lock_held;
24 using DetourModKit::detail::pin_current_module;
25
26 namespace DetourModKit
27 {
28 namespace
29 {
30 /**
31 * @brief Checks whether a single InputCode is currently pressed.
32 * @param code The input code to check.
33 * @param gamepad_state Cached XInput state for the current poll cycle.
34 * @param gamepad_connected Whether the gamepad is connected.
35 * @param trigger_threshold Analog trigger deadzone threshold.
36 * @param stick_threshold Thumbstick deadzone threshold.
37 * @return true if the input is currently pressed.
38 */
39 30 bool is_code_pressed(const InputCode &code,
40 const XINPUT_STATE &gamepad_state,
41 bool gamepad_connected,
42 int trigger_threshold,
43 int stick_threshold) noexcept
44 {
45
1/3
✓ Branch 2 → 3 taken 30 times.
✗ Branch 2 → 9 not taken.
✗ Branch 2 → 25 not taken.
30 switch (code.source)
46 {
47 30 case InputSource::Keyboard:
48 case InputSource::Mouse:
49
2/4
✓ Branch 3 → 4 taken 30 times.
✗ Branch 3 → 7 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 30 times.
30 return code.code != 0 && (GetAsyncKeyState(code.code) & 0x8000) != 0;
50 case InputSource::Gamepad:
51 {
52 if (!gamepad_connected)
53 {
54 return false;
55 }
56 // Fast path: digital button bitmask (all codes below synthetic range)
57 if (code.code < GamepadCode::LeftTrigger)
58 {
59 return (gamepad_state.Gamepad.wButtons & static_cast<WORD>(code.code)) != 0;
60 }
61 // Synthetic analog codes
62 switch (code.code)
63 {
64 case GamepadCode::LeftTrigger:
65 return gamepad_state.Gamepad.bLeftTrigger > trigger_threshold;
66 case GamepadCode::RightTrigger:
67 return gamepad_state.Gamepad.bRightTrigger > trigger_threshold;
68 case GamepadCode::LeftStickUp:
69 return gamepad_state.Gamepad.sThumbLY > stick_threshold;
70 case GamepadCode::LeftStickDown:
71 return gamepad_state.Gamepad.sThumbLY < -stick_threshold;
72 case GamepadCode::LeftStickLeft:
73 return gamepad_state.Gamepad.sThumbLX < -stick_threshold;
74 case GamepadCode::LeftStickRight:
75 return gamepad_state.Gamepad.sThumbLX > stick_threshold;
76 case GamepadCode::RightStickUp:
77 return gamepad_state.Gamepad.sThumbRY > stick_threshold;
78 case GamepadCode::RightStickDown:
79 return gamepad_state.Gamepad.sThumbRY < -stick_threshold;
80 case GamepadCode::RightStickLeft:
81 return gamepad_state.Gamepad.sThumbRX < -stick_threshold;
82 case GamepadCode::RightStickRight:
83 return gamepad_state.Gamepad.sThumbRX > stick_threshold;
84 default:
85 return false;
86 }
87 }
88 }
89 return false;
90 }
91
92 /**
93 * @brief Checks if a held input satisfies a required modifier.
94 * @details Returns true when the codes match exactly, or when both are
95 * keyboard modifiers in the same family (e.g., LShift satisfies
96 * generic Shift, and generic Shift satisfies LShift).
97 */
98 bool modifier_satisfies(const InputCode &required, const InputCode &held) noexcept
99 {
100 if (required == held)
101 {
102 return true;
103 }
104 if (required.source != InputSource::Keyboard || held.source != InputSource::Keyboard)
105 {
106 return false;
107 }
108 // Modifier family groups: {generic, left, right}
109 constexpr int families[][3] = {
110 {0x11, 0xA2, 0xA3}, // Ctrl, LCtrl, RCtrl
111 {0x10, 0xA0, 0xA1}, // Shift, LShift, RShift
112 {0x12, 0xA4, 0xA5}, // Alt, LAlt, RAlt
113 };
114 for (const auto &family : families)
115 {
116 bool req_in = false;
117 bool held_in = false;
118 for (int vk : family)
119 {
120 if (required.code == vk)
121 {
122 req_in = true;
123 }
124 if (held.code == vk)
125 {
126 held_in = true;
127 }
128 }
129 if (req_in && held_in)
130 {
131 return true;
132 }
133 }
134 return false;
135 }
136
137 /**
138 * @brief Scans bindings to determine if any use gamepad input codes.
139 * @param bindings The vector of bindings to scan.
140 * @return true if at least one binding contains a gamepad InputCode.
141 */
142 1082 bool scan_for_gamepad_bindings(const std::vector<InputBinding> &bindings) noexcept
143 {
144
2/2
✓ Branch 47 → 4 taken 1102 times.
✓ Branch 47 → 48 taken 1070 times.
3254 for (const auto &binding : bindings)
145 {
146
2/2
✓ Branch 21 → 8 taken 1105 times.
✓ Branch 21 → 22 taken 1090 times.
3297 for (const auto &key : binding.keys)
147 {
148
2/2
✓ Branch 10 → 11 taken 12 times.
✓ Branch 10 → 12 taken 1093 times.
1105 if (key.source == InputSource::Gamepad)
149 {
150 12 return true;
151 }
152 }
153
2/2
✓ Branch 37 → 24 taken 13 times.
✓ Branch 37 → 38 taken 1090 times.
2193 for (const auto &mod : binding.modifiers)
154 {
155
1/2
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 28 taken 13 times.
13 if (mod.source == InputSource::Gamepad)
156 {
157 return true;
158 }
159 }
160 }
161 1070 return false;
162 }
163 } // anonymous namespace
164
165 // --- InputPoller ---
166
167 70 InputPoller::InputPoller(std::vector<InputBinding> bindings,
168 std::chrono::milliseconds poll_interval,
169 bool require_focus,
170 int gamepad_index,
171 int trigger_threshold,
172 70 int stick_threshold)
173 140 : bindings_(std::move(bindings)),
174
1/2
✓ Branch 8 → 9 taken 70 times.
✗ Branch 8 → 38 not taken.
70 poll_interval_(std::clamp(poll_interval, MIN_POLL_INTERVAL, MAX_POLL_INTERVAL)),
175 70 require_focus_(require_focus),
176
1/2
✓ Branch 15 → 16 taken 70 times.
✗ Branch 15 → 32 not taken.
70 active_states_(std::make_unique<std::atomic<uint8_t>[]>(bindings_.size())),
177
1/2
✓ Branch 16 → 17 taken 70 times.
✗ Branch 16 → 24 not taken.
70 gamepad_index_(std::clamp(gamepad_index, 0, 3)),
178
3/6
✓ Branch 13 → 14 taken 70 times.
✗ Branch 13 → 34 not taken.
✓ Branch 17 → 18 taken 70 times.
✗ Branch 17 → 26 not taken.
✓ Branch 18 → 19 taken 70 times.
✗ Branch 18 → 28 not taken.
280 trigger_threshold_(std::clamp(trigger_threshold, 0, 255)), stick_threshold_(std::clamp(stick_threshold, 0, 32767))
179 {
180
1/2
✓ Branch 21 → 22 taken 70 times.
✗ Branch 21 → 30 not taken.
70 name_index_.reserve(bindings_.size());
181 70 recompute_modifier_caches_locked();
182 70 }
183
184 1082 void InputPoller::recompute_modifier_caches_locked() noexcept
185 {
186 1082 name_index_.clear();
187 1082 std::unordered_set<InputCode, InputCodeHash> modifier_set;
188
2/2
✓ Branch 29 → 5 taken 1106 times.
✓ Branch 29 → 30 taken 1082 times.
2188 for (size_t i = 0; i < bindings_.size(); ++i)
189 {
190
1/2
✓ Branch 7 → 8 taken 1106 times.
✗ Branch 7 → 11 not taken.
1106 if (!bindings_[i].name.empty())
191 {
192 1106 name_index_[bindings_[i].name].push_back(i);
193 }
194
2/2
✓ Branch 26 → 14 taken 17 times.
✓ Branch 26 → 27 taken 1106 times.
2229 for (const auto &mod : bindings_[i].modifiers)
195 {
196 17 modifier_set.insert(mod);
197 }
198 }
199 1082 known_modifiers_.assign(modifier_set.begin(), modifier_set.end());
200 1082 has_gamepad_bindings_.store(scan_for_gamepad_bindings(bindings_), std::memory_order_relaxed);
201 1082 }
202
203 70 InputPoller::~InputPoller() noexcept
204 {
205 70 shutdown();
206 70 }
207
208 61 void InputPoller::start()
209 {
210
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 7 taken 60 times.
61 if (poll_thread_.joinable())
211 {
212
1/2
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 13 not taken.
1 Logger::get_instance().debug("InputPoller: start() called while already running; no-op.");
213 1 return;
214 }
215
216 60 running_.store(true, std::memory_order_release);
217 try
218 {
219 60 poll_thread_ = std::jthread([this](std::stop_token token)
220
2/4
✓ Branch 5 → 6 taken 60 times.
✗ Branch 5 → 8 not taken.
✓ Branch 8 → 9 taken 60 times.
✗ Branch 8 → 14 not taken.
180 { poll_loop(std::move(token)); });
221 }
222 catch (...)
223 {
224 running_.store(false, std::memory_order_release);
225 throw;
226 }
227 }
228
229 44 bool InputPoller::is_running() const noexcept
230 {
231 44 return running_.load(std::memory_order_acquire);
232 }
233
234 38 size_t InputPoller::binding_count() const noexcept
235 {
236 38 return bindings_.size();
237 }
238
239 4 std::chrono::milliseconds InputPoller::poll_interval() const noexcept
240 {
241 4 return poll_interval_;
242 }
243
244 2 int InputPoller::gamepad_index() const noexcept
245 {
246 2 return gamepad_index_;
247 }
248
249 5 bool InputPoller::is_binding_active(size_t index) const noexcept
250 {
251 // Acquire the shared lock so the index/array pair stays consistent
252 // across a reshape (add_binding, remove_bindings_by_name,
253 // update_combos all swap active_states_ under the writer lock and
254 // resize bindings_ alongside it). The relaxed atomic load on the
255 // element itself is still cheap; it is the unique_ptr<atomic[]>
256 // ownership swap that needs synchronisation.
257 5 std::shared_lock lock(bindings_rw_mutex_);
258
2/2
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 3 times.
5 if (index >= bindings_.size())
259 {
260 2 return false;
261 }
262 6 return active_states_[index].load(std::memory_order_relaxed) != 0;
263 5 }
264
265 16 bool InputPoller::is_binding_active(std::string_view name) const noexcept
266 {
267 16 std::shared_lock lock(bindings_rw_mutex_);
268 16 const auto it = name_index_.find(name);
269
2/2
✓ Branch 6 → 7 taken 12 times.
✓ Branch 6 → 33 taken 4 times.
16 if (it != name_index_.end())
270 {
271
2/2
✓ Branch 31 → 10 taken 13 times.
✓ Branch 31 → 32 taken 12 times.
37 for (const size_t idx : it->second)
272 {
273
1/2
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 13 times.
26 if (active_states_[idx].load(std::memory_order_relaxed) != 0)
274 {
275 return true;
276 }
277 }
278 }
279 16 return false;
280 16 }
281
282 4 void InputPoller::set_require_focus(bool require_focus) noexcept
283 {
284 4 require_focus_.store(require_focus, std::memory_order_relaxed);
285 4 }
286
287 131 void InputPoller::shutdown() noexcept
288 {
289
2/2
✓ Branch 3 → 4 taken 71 times.
✓ Branch 3 → 5 taken 60 times.
131 if (!poll_thread_.joinable())
290 {
291 71 return;
292 }
293
294 60 poll_thread_.request_stop();
295 60 cv_.notify_all();
296
297
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 11 taken 60 times.
60 if (is_loader_lock_held())
298 {
299 pin_current_module();
300 poll_thread_.detach();
301 }
302 else
303 {
304 60 poll_thread_.join();
305 }
306
307 60 running_.store(false, std::memory_order_release);
308 60 release_active_holds();
309 }
310
311 60 void InputPoller::poll_loop(std::stop_token stop_token)
312 {
313 60 const int trigger_thresh = trigger_threshold_;
314 60 const int stick_thresh = stick_threshold_;
315
316 60 constexpr auto gamepad_reconnect_interval = std::chrono::seconds{2};
317 60 bool gamepad_was_connected = false;
318 60 auto last_gamepad_poll = std::chrono::steady_clock::time_point{};
319
320 struct PendingCallback
321 {
322 std::string name;
323 std::function<void()> on_press;
324 std::function<void(bool)> on_state_change;
325 bool hold_value;
326 };
327 60 std::vector<PendingCallback> pending;
328 {
329
1/2
✓ Branch 2 → 3 taken 60 times.
✗ Branch 2 → 221 not taken.
60 std::shared_lock lock(bindings_rw_mutex_);
330
1/2
✓ Branch 4 → 5 taken 60 times.
✗ Branch 4 → 219 not taken.
60 pending.reserve(bindings_.size());
331 60 }
332
333
2/2
✓ Branch 216 → 7 taken 76 times.
✓ Branch 216 → 217 taken 60 times.
136 while (!stop_token.stop_requested())
334 {
335 76 pending.clear();
336 const bool process_focused =
337
4/6
✓ Branch 9 → 10 taken 54 times.
✓ Branch 9 → 12 taken 22 times.
✓ Branch 10 → 11 taken 54 times.
✗ Branch 10 → 275 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 54 times.
76 !require_focus_.load(std::memory_order_relaxed) || is_process_foreground();
338
339 // Poll gamepad state once per cycle when connected.
340 // When disconnected, throttle reconnection attempts to avoid
341 // the per-cycle overhead of XInputGetState on empty slots.
342 76 XINPUT_STATE gamepad_state{};
343 76 bool gamepad_connected = false;
344
4/6
✓ Branch 15 → 16 taken 17 times.
✓ Branch 15 → 18 taken 59 times.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 17 times.
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 32 taken 76 times.
76 if (has_gamepad_bindings_.load(std::memory_order_relaxed) && process_focused)
345 {
346 const auto now = std::chrono::steady_clock::now();
347 if (gamepad_was_connected ||
348 (now - last_gamepad_poll) >= gamepad_reconnect_interval)
349 {
350 last_gamepad_poll = now;
351 gamepad_was_connected =
352 XInputGetState(static_cast<DWORD>(gamepad_index_),
353 &gamepad_state) == ERROR_SUCCESS;
354 }
355 gamepad_connected = gamepad_was_connected;
356 }
357
358 // Collect callbacks to fire outside the shared lock so user code
359 // can call back into update_binding_combos() without deadlocking.
360 {
361
1/2
✓ Branch 32 → 33 taken 76 times.
✗ Branch 32 → 251 not taken.
76 std::shared_lock lock(bindings_rw_mutex_);
362 76 const size_t count = bindings_.size();
363 76 const auto &known_mods = known_modifiers_;
364
365
2/2
✓ Branch 187 → 35 taken 106 times.
✓ Branch 187 → 188 taken 76 times.
182 for (size_t i = 0; i < count; ++i)
366 {
367 106 const auto &binding = bindings_[i];
368
2/2
✓ Branch 37 → 38 taken 3 times.
✓ Branch 37 → 39 taken 103 times.
106 if (binding.keys.empty())
369 {
370 3 continue;
371 }
372
373 103 bool any_pressed = false;
374
375
2/2
✓ Branch 39 → 40 taken 30 times.
✓ Branch 39 → 114 taken 73 times.
103 if (process_focused)
376 {
377 30 bool modifiers_held = true;
378
1/2
✗ Branch 56 → 42 not taken.
✓ Branch 56 → 57 taken 30 times.
60 for (const auto &mod : binding.modifiers)
379 {
380 if (!is_code_pressed(mod, gamepad_state, gamepad_connected, trigger_thresh, stick_thresh))
381 {
382 modifiers_held = false;
383 break;
384 }
385 }
386
387
1/2
✓ Branch 57 → 58 taken 30 times.
✗ Branch 57 → 95 not taken.
30 if (modifiers_held)
388 {
389 // Strict matching: reject if any known modifier that is
390 // NOT in this binding's required set is currently held.
391
1/2
✗ Branch 93 → 60 not taken.
✓ Branch 93 → 94 taken 30 times.
60 for (const auto &km : known_mods)
392 {
393 if (!is_code_pressed(km, gamepad_state, gamepad_connected, trigger_thresh, stick_thresh))
394 {
395 continue;
396 }
397 bool is_required = false;
398 for (const auto &mod : binding.modifiers)
399 {
400 if (modifier_satisfies(mod, km))
401 {
402 is_required = true;
403 break;
404 }
405 }
406 if (!is_required)
407 {
408 modifiers_held = false;
409 break;
410 }
411 }
412 }
413
414
1/2
✓ Branch 95 → 96 taken 30 times.
✗ Branch 95 → 114 not taken.
30 if (modifiers_held)
415 {
416
2/2
✓ Branch 112 → 98 taken 30 times.
✓ Branch 112 → 113 taken 30 times.
90 for (const auto &key : binding.keys)
417 {
418
1/2
✗ Branch 101 → 102 not taken.
✓ Branch 101 → 103 taken 30 times.
30 if (is_code_pressed(key, gamepad_state, gamepad_connected, trigger_thresh, stick_thresh))
419 {
420 any_pressed = true;
421 break;
422 }
423 }
424 }
425 }
426
427 const bool was_active =
428 103 active_states_[i].load(std::memory_order_relaxed) != 0;
429
430
2/3
✓ Branch 122 → 123 taken 81 times.
✓ Branch 122 → 155 taken 22 times.
✗ Branch 122 → 186 not taken.
103 switch (binding.mode)
431 {
432 81 case InputMode::Press:
433 {
434
2/8
✗ Branch 123 → 124 not taken.
✓ Branch 123 → 128 taken 81 times.
✗ Branch 124 → 125 not taken.
✗ Branch 124 → 128 not taken.
✗ Branch 126 → 127 not taken.
✗ Branch 126 → 128 not taken.
✗ Branch 129 → 130 not taken.
✓ Branch 129 → 142 taken 81 times.
81 if (any_pressed && !was_active && binding.on_press)
435 {
436 pending.push_back({binding.name, binding.on_press, {}, false});
437 }
438
1/2
✗ Branch 143 → 144 not taken.
✓ Branch 143 → 145 taken 81 times.
81 active_states_[i].store(any_pressed ? 1 : 0, std::memory_order_relaxed);
439 81 break;
440 }
441 22 case InputMode::Hold:
442 {
443
2/6
✗ Branch 155 → 156 not taken.
✓ Branch 155 → 159 taken 22 times.
✗ Branch 157 → 158 not taken.
✗ Branch 157 → 159 not taken.
✗ Branch 160 → 161 not taken.
✓ Branch 160 → 173 taken 22 times.
22 if (any_pressed != was_active && binding.on_state_change)
444 {
445 pending.push_back({binding.name, {}, binding.on_state_change, any_pressed});
446 }
447
1/2
✗ Branch 174 → 175 not taken.
✓ Branch 174 → 176 taken 22 times.
22 active_states_[i].store(any_pressed ? 1 : 0, std::memory_order_relaxed);
448 22 break;
449 }
450 }
451 }
452 76 }
453
454
1/2
✗ Branch 208 → 191 not taken.
✓ Branch 208 → 209 taken 76 times.
152 for (auto &p : pending)
455 {
456 try
457 {
458 if (p.on_press)
459 {
460 p.on_press();
461 }
462 else if (p.on_state_change)
463 {
464 p.on_state_change(p.hold_value);
465 }
466 }
467 catch (const std::exception &e)
468 {
469 Logger::get_instance().error(
470 "InputPoller: Exception in callback \"{}\": {}", p.name, e.what());
471 }
472 catch (...)
473 {
474 Logger::get_instance().error(
475 "InputPoller: Unknown exception in callback \"{}\"", p.name);
476 }
477 }
478
479
1/2
✓ Branch 209 → 210 taken 76 times.
✗ Branch 209 → 275 not taken.
76 std::unique_lock lock(cv_mutex_);
480
1/2
✓ Branch 211 → 212 taken 76 times.
✗ Branch 211 → 270 not taken.
76 cv_.wait_for(lock, stop_token, poll_interval_, [&stop_token]()
481 149 { return stop_token.stop_requested(); });
482 76 }
483
0/36
✗ Branch 130 → 131 not taken.
✗ Branch 130 → 236 not taken.
✗ Branch 131 → 132 not taken.
✗ Branch 131 → 233 not taken.
✗ Branch 133 → 134 not taken.
✗ Branch 133 → 225 not taken.
✗ Branch 135 → 136 not taken.
✗ Branch 135 → 137 not taken.
✗ Branch 137 → 138 not taken.
✗ Branch 137 → 139 not taken.
✗ Branch 139 → 140 not taken.
✗ Branch 139 → 141 not taken.
✗ Branch 161 → 162 not taken.
✗ Branch 161 → 248 not taken.
✗ Branch 163 → 164 not taken.
✗ Branch 163 → 242 not taken.
✗ Branch 164 → 165 not taken.
✗ Branch 164 → 237 not taken.
✗ Branch 166 → 167 not taken.
✗ Branch 166 → 168 not taken.
✗ Branch 168 → 169 not taken.
✗ Branch 168 → 170 not taken.
✗ Branch 170 → 171 not taken.
✗ Branch 170 → 172 not taken.
✗ Branch 227 → 228 not taken.
✗ Branch 227 → 229 not taken.
✗ Branch 230 → 231 not taken.
✗ Branch 230 → 232 not taken.
✗ Branch 233 → 234 not taken.
✗ Branch 233 → 235 not taken.
✗ Branch 239 → 240 not taken.
✗ Branch 239 → 241 not taken.
✗ Branch 242 → 243 not taken.
✗ Branch 242 → 244 not taken.
✗ Branch 245 → 246 not taken.
✗ Branch 245 → 247 not taken.
60 }
484
485 1002 bool InputPoller::update_combos(std::string_view name, const Config::KeyComboList &combos) noexcept
486 {
487 1002 std::vector<std::function<void(bool)>> hold_release_callbacks;
488 1002 std::vector<std::string> hold_release_names;
489
490 {
491 1002 std::unique_lock lock(bindings_rw_mutex_);
492 1002 const auto it = name_index_.find(name);
493
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 10 taken 1002 times.
1002 if (it == name_index_.end())
494 {
495 Logger::get_instance().debug("InputPoller: update_combos(\"{}\") ignored: name not found", name);
496 return false;
497 }
498
499 1002 std::vector<size_t> indices = it->second;
500
1/2
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 1002 times.
1002 if (indices.empty())
501 {
502 return false;
503 }
504
505 // Cardinality-preserving fast path: in-place rewrite of keys and
506 // modifiers leaves bindings_ and active_states_ in lockstep. The
507 // poll thread holds a shared_lock for the duration of one tick,
508 // so the unique_lock here serializes against it; concurrent
509 // is_binding_active(size_t) reads stay valid because the
510 // binding count and array sizes do not change.
511
2/2
✓ Branch 17 → 18 taken 1001 times.
✓ Branch 17 → 31 taken 1 time.
1002 if (indices.size() == combos.size())
512 {
513
2/2
✓ Branch 28 → 19 taken 1001 times.
✓ Branch 28 → 29 taken 1001 times.
2002 for (size_t i = 0; i < indices.size(); ++i)
514 {
515 1001 const size_t idx = indices[i];
516 1001 bindings_[idx].keys = combos[i].keys;
517 1001 bindings_[idx].modifiers = combos[i].modifiers;
518 }
519 1001 recompute_modifier_caches_locked();
520 1001 return true;
521 }
522
523 // Cardinality change requires rebuilding the bindings vector and
524 // the parallel active_states_ array. Capture the prototype from
525 // the first existing entry so callback identity, mode, and name
526 // stay stable across the rebuild.
527 1 InputBinding prototype = bindings_[indices.front()];
528
529 // Capture release callbacks for any held entries that this update
530 // is about to drop. Without this, a register_hold consumer whose
531 // combo cardinality changes via INI hot-reload would latch in the
532 // held state forever because the underlying entry vanishes from
533 // bindings_ before the next poll tick can observe the release.
534
2/2
✓ Branch 68 → 36 taken 2 times.
✓ Branch 68 → 69 taken 1 time.
4 for (size_t idx : indices)
535 {
536 2 if (active_states_[idx].load(std::memory_order_relaxed) != 0 &&
537
2/8
✗ Branch 46 → 47 not taken.
✓ Branch 46 → 53 taken 2 times.
✗ Branch 48 → 49 not taken.
✗ Branch 48 → 53 not taken.
✗ Branch 51 → 52 not taken.
✗ Branch 51 → 53 not taken.
✗ Branch 54 → 55 not taken.
✓ Branch 54 → 59 taken 2 times.
2 bindings_[idx].mode == InputMode::Hold &&
538 bindings_[idx].on_state_change)
539 {
540 hold_release_callbacks.push_back(bindings_[idx].on_state_change);
541 hold_release_names.push_back(bindings_[idx].name);
542 }
543 }
544
545 1 std::sort(indices.begin(), indices.end());
546
547 // Build a parallel old-state vector keyed to the new bindings_
548 // order so surviving entries carry their atomic value across the
549 // swap. Newly appended combos default to zero (the genuine
550 // cardinality-grew case has no prior state to inherit). Entries
551 // that get rewritten through the prototype path also start at
552 // zero because the underlying combo is logically replaced even
553 // if the binding name persists.
554 1 std::vector<InputBinding> rebuilt;
555 1 std::vector<uint8_t> rebuilt_states;
556 2 rebuilt.reserve(bindings_.size() - indices.size() +
557
1/2
✗ Branch 75 → 76 not taken.
✓ Branch 75 → 77 taken 1 time.
1 (combos.empty() ? 1 : combos.size()));
558 1 rebuilt_states.reserve(rebuilt.capacity());
559 1 size_t cursor = 0;
560
2/2
✓ Branch 110 → 83 taken 2 times.
✓ Branch 110 → 111 taken 1 time.
4 for (size_t skip : indices)
561 {
562
1/2
✗ Branch 100 → 86 not taken.
✓ Branch 100 → 101 taken 2 times.
2 for (size_t i = cursor; i < skip; ++i)
563 {
564 rebuilt_states.push_back(active_states_[i].load(std::memory_order_relaxed));
565 rebuilt.push_back(std::move(bindings_[i]));
566 }
567 2 cursor = skip + 1;
568 }
569
1/2
✗ Branch 127 → 112 not taken.
✓ Branch 127 → 128 taken 1 time.
1 for (size_t i = cursor; i < bindings_.size(); ++i)
570 {
571 rebuilt_states.push_back(active_states_[i].load(std::memory_order_relaxed));
572 rebuilt.push_back(std::move(bindings_[i]));
573 }
574
1/2
✗ Branch 129 → 130 not taken.
✓ Branch 129 → 139 taken 1 time.
1 if (combos.empty())
575 {
576 // Empty replacement leaves one inert sentinel entry so the
577 // binding name stays addressable for a subsequent
578 // update_combos() call. Without the sentinel the name would
579 // vanish from name_index_ and a later non-empty update
580 // would be rejected as "name not found", breaking the
581 // bound -> unbound -> bound INI hot-reload cycle.
582 InputBinding b = prototype;
583 b.keys.clear();
584 b.modifiers.clear();
585 rebuilt.push_back(std::move(b));
586 rebuilt_states.push_back(0);
587 }
588 else
589 {
590
2/2
✓ Branch 160 → 141 taken 1 time.
✓ Branch 160 → 161 taken 1 time.
3 for (const auto &combo : combos)
591 {
592 1 InputBinding b = prototype;
593 1 b.keys = combo.keys;
594 1 b.modifiers = combo.modifiers;
595 1 rebuilt.push_back(std::move(b));
596 1 rebuilt_states.push_back(0);
597 1 }
598 }
599 1 bindings_ = std::move(rebuilt);
600
601 // Reallocate active_states_ to match the new binding count and
602 // seed each slot from the captured pre-rebuild value. Surviving
603 // entries keep their atomic state so a held binding does not
604 // momentarily report inactive; the writer lock serialises the
605 // swap against any concurrent is_binding_active() reader.
606 1 auto new_states = std::make_unique<std::atomic<uint8_t>[]>(bindings_.size());
607
2/2
✓ Branch 180 → 168 taken 1 time.
✓ Branch 180 → 181 taken 1 time.
2 for (size_t i = 0; i < rebuilt_states.size(); ++i)
608 {
609 1 new_states[i].store(rebuilt_states[i], std::memory_order_relaxed);
610 }
611 1 active_states_ = std::move(new_states);
612
613 1 recompute_modifier_caches_locked();
614
4/4
✓ Branch 191 → 192 taken 1 time.
✓ Branch 191 → 193 taken 1001 times.
✓ Branch 195 → 196 taken 1 time.
✓ Branch 195 → 198 taken 1001 times.
2003 }
615
616 // Fire the captured release callbacks outside the writer lock so user
617 // code may safely call back into the InputManager (matching the
618 // remove_bindings_by_name pattern). This path runs in response to a
619 // user-driven INI reshape, never from a DllMain detach, so synchronous
620 // callback dispatch is safe here.
621
1/2
✗ Branch 203 → 199 not taken.
✓ Branch 203 → 204 taken 1 time.
1 for (size_t i = 0; i < hold_release_callbacks.size(); ++i)
622 {
623 try
624 {
625 hold_release_callbacks[i](false);
626 }
627 catch (const std::exception &e)
628 {
629 Logger::get_instance().error(
630 "InputPoller: Exception in hold release callback \"{}\": {}",
631 hold_release_names[i], e.what());
632 }
633 catch (...)
634 {
635 Logger::get_instance().error(
636 "InputPoller: Unknown exception in hold release callback \"{}\"",
637 hold_release_names[i]);
638 }
639 }
640
641 1 return true;
642 1002 }
643
644 8 void InputPoller::add_binding(InputBinding binding) noexcept
645 {
646 8 std::unique_lock lock(bindings_rw_mutex_);
647
648 // Capture the existing per-binding atomic states before the swap so
649 // surviving entries do not flicker through a one-tick "inactive" blip
650 // while the new active_states_ array is built. The relaxed load is
651 // sufficient: we already hold the writer lock, which serialises us
652 // against every other reader and writer of this array.
653 8 const size_t old_count = bindings_.size();
654 8 std::vector<uint8_t> carried;
655 8 carried.reserve(old_count);
656
2/2
✓ Branch 16 → 6 taken 10 times.
✓ Branch 16 → 17 taken 8 times.
18 for (size_t i = 0; i < old_count; ++i)
657 {
658 20 carried.push_back(active_states_[i].load(std::memory_order_relaxed));
659 }
660
661 16 bindings_.push_back(std::move(binding));
662
663 8 auto new_states = std::make_unique<std::atomic<uint8_t>[]>(bindings_.size());
664
2/2
✓ Branch 35 → 23 taken 10 times.
✓ Branch 35 → 36 taken 8 times.
18 for (size_t i = 0; i < carried.size(); ++i)
665 {
666 10 new_states[i].store(carried[i], std::memory_order_relaxed);
667 }
668 8 active_states_ = std::move(new_states);
669
670 8 recompute_modifier_caches_locked();
671 8 }
672
673 2 size_t InputPoller::remove_bindings_by_name(std::string_view name, bool invoke_callbacks) noexcept
674 {
675 2 std::vector<std::function<void(bool)>> hold_release_callbacks;
676 2 std::vector<std::string> hold_release_names;
677 2 size_t removed = 0;
678
679 {
680 2 std::unique_lock lock(bindings_rw_mutex_);
681 2 const auto it = name_index_.find(name);
682
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 2 times.
2 if (it == name_index_.end())
683 {
684 return 0;
685 }
686
687 2 std::vector<size_t> indices = it->second;
688 2 std::sort(indices.begin(), indices.end());
689
690 // Capture release callbacks for active hold bindings before
691 // erasure; fire them after the lock is released so user code
692 // is free to call back into the InputManager. The Bootstrap
693 // unload path passes invoke_callbacks=false to skip this step
694 // because the user callbacks live in a Logic DLL whose code
695 // pages may be about to be unmapped.
696
1/2
✓ Branch 13 → 14 taken 2 times.
✗ Branch 13 → 50 not taken.
2 if (invoke_callbacks)
697 {
698
2/2
✓ Branch 48 → 16 taken 2 times.
✓ Branch 48 → 49 taken 2 times.
6 for (size_t idx : indices)
699 {
700 2 if (active_states_[idx].load(std::memory_order_relaxed) != 0 &&
701
2/8
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 33 taken 2 times.
✗ Branch 28 → 29 not taken.
✗ Branch 28 → 33 not taken.
✗ Branch 31 → 32 not taken.
✗ Branch 31 → 33 not taken.
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 39 taken 2 times.
2 bindings_[idx].mode == InputMode::Hold &&
702 bindings_[idx].on_state_change)
703 {
704 hold_release_callbacks.push_back(bindings_[idx].on_state_change);
705 hold_release_names.push_back(bindings_[idx].name);
706 }
707 }
708 }
709
710 // Build a flat skip-mask so the new active_states_ slot for every
711 // surviving binding inherits its prior atomic value. Without this
712 // a held binding would briefly report inactive after the
713 // reshape, breaking register_hold consumers that observe the
714 // state through is_binding_active(size_t).
715 2 std::vector<bool> drop(bindings_.size(), false);
716
2/2
✓ Branch 70 → 57 taken 2 times.
✓ Branch 70 → 71 taken 2 times.
6 for (size_t idx : indices)
717 {
718 2 drop[idx] = true;
719 }
720 2 std::vector<uint8_t> carried;
721 2 carried.reserve(bindings_.size() - indices.size());
722
2/2
✓ Branch 90 → 75 taken 5 times.
✓ Branch 90 → 91 taken 2 times.
7 for (size_t i = 0; i < bindings_.size(); ++i)
723 {
724
2/2
✓ Branch 77 → 78 taken 3 times.
✓ Branch 77 → 88 taken 2 times.
5 if (!drop[i])
725 {
726 6 carried.push_back(active_states_[i].load(std::memory_order_relaxed));
727 }
728 }
729
730
2/2
✓ Branch 105 → 92 taken 2 times.
✓ Branch 105 → 106 taken 2 times.
4 for (auto idx_it = indices.rbegin(); idx_it != indices.rend(); ++idx_it)
731 {
732 6 bindings_.erase(bindings_.begin() + static_cast<std::ptrdiff_t>(*idx_it));
733 }
734 2 removed = indices.size();
735
736 2 auto new_states = std::make_unique<std::atomic<uint8_t>[]>(bindings_.size());
737
2/2
✓ Branch 122 → 110 taken 3 times.
✓ Branch 122 → 123 taken 2 times.
5 for (size_t i = 0; i < carried.size(); ++i)
738 {
739 3 new_states[i].store(carried[i], std::memory_order_relaxed);
740 }
741 2 active_states_ = std::move(new_states);
742
743 2 recompute_modifier_caches_locked();
744
1/2
✓ Branch 133 → 134 taken 2 times.
✗ Branch 133 → 136 not taken.
2 }
745
746
1/2
✗ Branch 141 → 137 not taken.
✓ Branch 141 → 142 taken 2 times.
2 for (size_t i = 0; i < hold_release_callbacks.size(); ++i)
747 {
748 try
749 {
750 hold_release_callbacks[i](false);
751 }
752 catch (const std::exception &e)
753 {
754 Logger::get_instance().error(
755 "InputPoller: Exception in hold release callback \"{}\": {}",
756 hold_release_names[i], e.what());
757 }
758 catch (...)
759 {
760 Logger::get_instance().error(
761 "InputPoller: Unknown exception in hold release callback \"{}\"",
762 hold_release_names[i]);
763 }
764 }
765
766 2 return removed;
767 2 }
768
769 1 void InputPoller::clear_bindings(bool invoke_callbacks) noexcept
770 {
771 1 std::vector<std::pair<std::function<void(bool)>, std::string>> hold_releases;
772
773 {
774 1 std::unique_lock lock(bindings_rw_mutex_);
775 // Skip the release-callback capture entirely on the loader-lock
776 // path (Bootstrap::on_logic_dll_unload_all). Running user
777 // callbacks under loader lock is unsafe because the Logic DLL
778 // hosting those callbacks may be in the middle of being
779 // unmapped, and any callback that touches Win32 LoadLibrary
780 // family or a peer DllMain's mutex would deadlock.
781
1/2
✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 28 not taken.
1 if (invoke_callbacks)
782 {
783
2/2
✓ Branch 27 → 5 taken 2 times.
✓ Branch 27 → 28 taken 1 time.
3 for (size_t i = 0; i < bindings_.size(); ++i)
784 {
785 2 if (active_states_[i].load(std::memory_order_relaxed) != 0 &&
786
2/8
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 20 taken 2 times.
✗ Branch 15 → 16 not taken.
✗ Branch 15 → 20 not taken.
✗ Branch 18 → 19 not taken.
✗ Branch 18 → 20 not taken.
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 25 taken 2 times.
2 bindings_[i].mode == InputMode::Hold &&
787 bindings_[i].on_state_change)
788 {
789 hold_releases.emplace_back(bindings_[i].on_state_change, bindings_[i].name);
790 }
791 }
792 }
793 1 bindings_.clear();
794 1 name_index_.clear();
795 1 known_modifiers_.clear();
796 1 has_gamepad_bindings_.store(false, std::memory_order_relaxed);
797 1 active_states_ = std::make_unique<std::atomic<uint8_t>[]>(0);
798 1 }
799
800
1/2
✗ Branch 52 → 38 not taken.
✓ Branch 52 → 53 taken 1 time.
2 for (auto &[cb, n] : hold_releases)
801 {
802 try
803 {
804 cb(false);
805 }
806 catch (const std::exception &e)
807 {
808 Logger::get_instance().error(
809 "InputPoller: Exception in hold release callback \"{}\": {}", n, e.what());
810 }
811 catch (...)
812 {
813 Logger::get_instance().error(
814 "InputPoller: Unknown exception in hold release callback \"{}\"", n);
815 }
816 }
817 1 }
818
819 60 void InputPoller::release_active_holds() noexcept
820 {
821
2/2
✓ Branch 31 → 3 taken 84 times.
✓ Branch 31 → 32 taken 60 times.
144 for (size_t i = 0; i < bindings_.size(); ++i)
822 {
823
1/2
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 29 taken 84 times.
168 if (active_states_[i].load(std::memory_order_relaxed) != 0)
824 {
825 active_states_[i].store(0, std::memory_order_relaxed);
826
827 const auto &binding = bindings_[i];
828 if (binding.mode == InputMode::Hold && binding.on_state_change)
829 {
830 try
831 {
832 binding.on_state_change(false);
833 }
834 catch (const std::exception &e)
835 {
836 Logger::get_instance().error(
837 "InputPoller: Exception in hold release callback \"{}\": {}",
838 binding.name, e.what());
839 }
840 catch (...)
841 {
842 Logger::get_instance().error(
843 "InputPoller: Unknown exception in hold release callback \"{}\"",
844 binding.name);
845 }
846 }
847 }
848 }
849 60 }
850
851 54 bool InputPoller::is_process_foreground() const
852 {
853
1/2
✓ Branch 2 → 3 taken 54 times.
✗ Branch 2 → 10 not taken.
54 HWND foreground = GetForegroundWindow();
854
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 54 times.
54 if (!foreground)
855 {
856 return false;
857 }
858 54 DWORD foreground_pid = 0;
859
1/2
✓ Branch 5 → 6 taken 54 times.
✗ Branch 5 → 10 not taken.
54 GetWindowThreadProcessId(foreground, &foreground_pid);
860
1/2
✓ Branch 6 → 7 taken 54 times.
✗ Branch 6 → 10 not taken.
54 return foreground_pid == GetCurrentProcessId();
861 }
862
863 // --- InputManager ---
864
865 243 void InputManager::register_press(std::string_view name, const std::vector<InputCode> &keys,
866 std::function<void()> callback)
867 {
868
1/2
✓ Branch 6 → 7 taken 247 times.
✗ Branch 6 → 10 not taken.
243 register_press(name, keys, {}, std::move(callback));
869 247 }
870
871 275 void InputManager::register_press(std::string_view name, const std::vector<InputCode> &keys,
872 const std::vector<InputCode> &modifiers,
873 std::function<void()> callback)
874 {
875 275 std::shared_ptr<InputPoller> live_poller;
876 275 InputBinding binding;
877
1/2
✓ Branch 5 → 6 taken 274 times.
✗ Branch 5 → 44 not taken.
276 binding.name = std::string{name};
878
1/2
✓ Branch 9 → 10 taken 278 times.
✗ Branch 9 → 51 not taken.
278 binding.keys = keys;
879
1/2
✓ Branch 10 → 11 taken 275 times.
✗ Branch 10 → 51 not taken.
278 binding.modifiers = modifiers;
880 275 binding.mode = InputMode::Press;
881 275 binding.on_press = std::move(callback);
882
883 {
884
1/2
✓ Branch 14 → 15 taken 278 times.
✗ Branch 14 → 50 not taken.
275 std::lock_guard lock(mutex_);
885
2/2
✓ Branch 16 → 17 taken 6 times.
✓ Branch 16 → 18 taken 272 times.
278 if (poller_)
886 {
887 6 live_poller = poller_;
888 }
889 else
890 {
891
1/2
✓ Branch 20 → 21 taken 272 times.
✗ Branch 20 → 48 not taken.
544 pending_bindings_.push_back(std::move(binding));
892 272 return;
893 }
894
2/2
✓ Branch 24 → 25 taken 6 times.
✓ Branch 24 → 33 taken 272 times.
278 }
895
896 // Forward outside the InputManager mutex so the poller's exclusive
897 // bindings_rw_mutex_ acquisition cannot AB/BA against any caller
898 // already holding mutex_.
899 12 live_poller->add_binding(std::move(binding));
900
4/4
✓ Branch 35 → 36 taken 6 times.
✓ Branch 35 → 37 taken 271 times.
✓ Branch 39 → 40 taken 6 times.
✓ Branch 39 → 42 taken 272 times.
549 }
901
902 10 void InputManager::register_hold(std::string_view name, const std::vector<InputCode> &keys,
903 std::function<void(bool)> callback)
904 {
905
1/2
✓ Branch 6 → 7 taken 10 times.
✗ Branch 6 → 10 not taken.
10 register_hold(name, keys, {}, std::move(callback));
906 10 }
907
908 21 void InputManager::register_hold(std::string_view name, const std::vector<InputCode> &keys,
909 const std::vector<InputCode> &modifiers,
910 std::function<void(bool)> callback)
911 {
912 21 std::shared_ptr<InputPoller> live_poller;
913 21 InputBinding binding;
914
1/2
✓ Branch 5 → 6 taken 21 times.
✗ Branch 5 → 44 not taken.
21 binding.name = std::string{name};
915
1/2
✓ Branch 9 → 10 taken 21 times.
✗ Branch 9 → 51 not taken.
21 binding.keys = keys;
916
1/2
✓ Branch 10 → 11 taken 21 times.
✗ Branch 10 → 51 not taken.
21 binding.modifiers = modifiers;
917 21 binding.mode = InputMode::Hold;
918 21 binding.on_state_change = std::move(callback);
919
920 {
921
1/2
✓ Branch 14 → 15 taken 21 times.
✗ Branch 14 → 50 not taken.
21 std::lock_guard lock(mutex_);
922
2/2
✓ Branch 16 → 17 taken 2 times.
✓ Branch 16 → 18 taken 19 times.
21 if (poller_)
923 {
924 2 live_poller = poller_;
925 }
926 else
927 {
928
1/2
✓ Branch 20 → 21 taken 19 times.
✗ Branch 20 → 48 not taken.
38 pending_bindings_.push_back(std::move(binding));
929 19 return;
930 }
931
2/2
✓ Branch 24 → 25 taken 2 times.
✓ Branch 24 → 33 taken 19 times.
21 }
932
933 4 live_poller->add_binding(std::move(binding));
934
4/4
✓ Branch 35 → 36 taken 2 times.
✓ Branch 35 → 37 taken 19 times.
✓ Branch 39 → 40 taken 2 times.
✓ Branch 39 → 42 taken 19 times.
40 }
935
936 20 void InputManager::register_press(std::string_view name, const Config::KeyComboList &combos,
937 std::function<void()> callback)
938 {
939 // An empty combo list still has to register the binding name so a
940 // later update_binding_combos() can attach a real combo. Without
941 // this the for-each loop produces zero bindings, the name never
942 // lands in pending_bindings_, and the INI-driven update silently
943 // fails with "name not found".
944
2/2
✓ Branch 3 → 4 taken 5 times.
✓ Branch 3 → 14 taken 15 times.
20 if (combos.empty())
945 {
946
1/2
✓ Branch 9 → 10 taken 5 times.
✗ Branch 9 → 33 not taken.
5 register_press(name, std::vector<InputCode>{}, std::vector<InputCode>{}, std::move(callback));
947 5 return;
948 }
949
2/2
✓ Branch 30 → 16 taken 19 times.
✓ Branch 30 → 31 taken 15 times.
49 for (const auto &combo : combos)
950 {
951
2/4
✓ Branch 18 → 19 taken 19 times.
✗ Branch 18 → 44 not taken.
✓ Branch 19 → 20 taken 19 times.
✗ Branch 19 → 42 not taken.
19 register_press(name, combo.keys, combo.modifiers, callback);
952 }
953 }
954
955 5 void InputManager::register_hold(std::string_view name, const Config::KeyComboList &combos,
956 std::function<void(bool)> callback)
957 {
958
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 14 taken 4 times.
5 if (combos.empty())
959 {
960
1/2
✓ Branch 9 → 10 taken 1 time.
✗ Branch 9 → 33 not taken.
1 register_hold(name, std::vector<InputCode>{}, std::vector<InputCode>{}, std::move(callback));
961 1 return;
962 }
963
2/2
✓ Branch 30 → 16 taken 7 times.
✓ Branch 30 → 31 taken 4 times.
15 for (const auto &combo : combos)
964 {
965
2/4
✓ Branch 18 → 19 taken 7 times.
✗ Branch 18 → 44 not taken.
✓ Branch 19 → 20 taken 7 times.
✗ Branch 19 → 42 not taken.
7 register_hold(name, combo.keys, combo.modifiers, callback);
966 }
967 }
968
969 19 void InputManager::set_require_focus(bool require_focus)
970 {
971
1/2
✓ Branch 2 → 3 taken 19 times.
✗ Branch 2 → 9 not taken.
19 std::lock_guard lock(mutex_);
972 19 require_focus_ = require_focus;
973
2/2
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 7 taken 17 times.
19 if (poller_)
974 {
975 2 poller_->set_require_focus(require_focus);
976 }
977 19 }
978
979 1 void InputManager::set_gamepad_index(int index)
980 {
981
1/2
✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 10 not taken.
1 std::lock_guard lock(mutex_);
982
1/2
✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 6 not taken.
1 gamepad_index_ = std::clamp(index, 0, 3);
983 1 }
984
985 1 void InputManager::set_trigger_threshold(int threshold)
986 {
987
1/2
✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 10 not taken.
1 std::lock_guard lock(mutex_);
988
1/2
✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 6 not taken.
1 trigger_threshold_ = std::clamp(threshold, 0, 255);
989 1 }
990
991 1 void InputManager::set_stick_threshold(int threshold)
992 {
993
1/2
✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 10 not taken.
1 std::lock_guard lock(mutex_);
994
1/2
✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 6 not taken.
1 stick_threshold_ = std::clamp(threshold, 0, 32767);
995 1 }
996
997 30 void InputManager::start(std::chrono::milliseconds poll_interval)
998 {
999
1/2
✓ Branch 2 → 3 taken 30 times.
✗ Branch 2 → 62 not taken.
30 std::lock_guard lock(mutex_);
1000
1001
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 8 taken 29 times.
30 if (poller_)
1002 {
1003
2/4
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 60 not taken.
✓ Branch 6 → 7 taken 1 time.
✗ Branch 6 → 51 not taken.
1 Logger::get_instance().debug("InputManager: start() called while already running; no-op.");
1004 1 return;
1005 }
1006
1007
2/2
✓ Branch 9 → 10 taken 1 time.
✓ Branch 9 → 11 taken 28 times.
29 if (pending_bindings_.empty())
1008 {
1009 1 return;
1010 }
1011
1012
1/2
✓ Branch 11 → 12 taken 28 times.
✗ Branch 11 → 60 not taken.
28 Logger &logger = Logger::get_instance();
1013 logger.info("InputManager: Starting with {} binding(s), poll interval {}ms",
1014
1/2
✓ Branch 14 → 15 taken 28 times.
✗ Branch 14 → 52 not taken.
28 pending_bindings_.size(), poll_interval.count());
1015
1016
2/2
✓ Branch 31 → 17 taken 40 times.
✓ Branch 31 → 32 taken 28 times.
96 for (const auto &binding : pending_bindings_)
1017 {
1018
1/2
✓ Branch 21 → 22 taken 40 times.
✗ Branch 21 → 55 not taken.
40 logger.trace("InputManager: Registered {} binding \"{}\" with {} key(s)",
1019 80 input_mode_to_string(binding.mode), binding.name, binding.keys.size());
1020 }
1021
1022
1/2
✓ Branch 34 → 35 taken 28 times.
✗ Branch 34 → 59 not taken.
56 poller_ = std::make_shared<InputPoller>(std::move(pending_bindings_), poll_interval,
1023 28 require_focus_, gamepad_index_, trigger_threshold_,
1024 56 stick_threshold_);
1025 28 pending_bindings_.clear();
1026
1/2
✓ Branch 39 → 40 taken 28 times.
✗ Branch 39 → 60 not taken.
28 poller_->start();
1027 28 active_poller_.store(poller_, std::memory_order_release);
1028 28 running_.store(true, std::memory_order_release);
1029
2/2
✓ Branch 46 → 47 taken 28 times.
✓ Branch 46 → 49 taken 2 times.
30 }
1030
1031 32 bool InputManager::is_running() const noexcept
1032 {
1033 32 return running_.load(std::memory_order_acquire);
1034 }
1035
1036 78 size_t InputManager::binding_count() const noexcept
1037 {
1038 78 std::lock_guard lock(mutex_);
1039
2/2
✓ Branch 4 → 5 taken 23 times.
✓ Branch 4 → 7 taken 55 times.
78 if (poller_)
1040 {
1041 23 return poller_->binding_count();
1042 }
1043 55 return pending_bindings_.size();
1044 78 }
1045
1046 16 bool InputManager::is_binding_active(std::string_view name) const noexcept
1047 {
1048 16 auto p = active_poller_.load(std::memory_order_acquire);
1049
2/2
✓ Branch 4 → 5 taken 10 times.
✓ Branch 4 → 7 taken 6 times.
16 if (p)
1050 {
1051 10 return p->is_binding_active(name);
1052 }
1053 6 return false;
1054 16 }
1055
1056 1026 void InputManager::update_binding_combos(std::string_view name,
1057 const Config::KeyComboList &combos) noexcept
1058 {
1059 1026 std::shared_ptr<InputPoller> local_poller;
1060 1026 bool updated_pending = false;
1061
1062 {
1063 1026 std::lock_guard lock(mutex_);
1064
2/2
✓ Branch 4 → 5 taken 1002 times.
✓ Branch 4 → 6 taken 24 times.
1026 if (poller_)
1065 {
1066 1002 local_poller = poller_;
1067 }
1068 else
1069 {
1070 24 std::vector<size_t> indices;
1071 24 indices.reserve(pending_bindings_.size());
1072
2/2
✓ Branch 16 → 9 taken 18 times.
✓ Branch 16 → 17 taken 24 times.
42 for (size_t i = 0; i < pending_bindings_.size(); ++i)
1073 {
1074
1/2
✓ Branch 12 → 13 taken 18 times.
✗ Branch 12 → 14 not taken.
18 if (pending_bindings_[i].name == name)
1075 {
1076 18 indices.push_back(i);
1077 }
1078 }
1079
2/2
✓ Branch 18 → 19 taken 8 times.
✓ Branch 18 → 22 taken 16 times.
24 if (indices.empty())
1080 {
1081 8 Logger::get_instance().debug(
1082 "InputManager: update_binding_combos(\"{}\") ignored: name not found", name);
1083 8 return;
1084 }
1085
1086
2/2
✓ Branch 24 → 25 taken 10 times.
✓ Branch 24 → 38 taken 6 times.
16 if (indices.size() == combos.size())
1087 {
1088
2/2
✓ Branch 36 → 26 taken 10 times.
✓ Branch 36 → 37 taken 10 times.
20 for (size_t i = 0; i < indices.size(); ++i)
1089 {
1090 10 pending_bindings_[indices[i]].keys = combos[i].keys;
1091 10 pending_bindings_[indices[i]].modifiers = combos[i].modifiers;
1092 }
1093 10 updated_pending = true;
1094 }
1095 else
1096 {
1097 6 InputBinding prototype = pending_bindings_[indices.front()];
1098 6 std::sort(indices.begin(), indices.end());
1099 6 std::vector<InputBinding> rebuilt;
1100 12 rebuilt.reserve(pending_bindings_.size() - indices.size() +
1101
2/2
✓ Branch 47 → 48 taken 4 times.
✓ Branch 47 → 49 taken 2 times.
6 (combos.empty() ? 1 : combos.size()));
1102 6 size_t cursor = 0;
1103
2/2
✓ Branch 71 → 53 taken 8 times.
✓ Branch 71 → 72 taken 6 times.
20 for (size_t skip : indices)
1104 {
1105
1/2
✗ Branch 61 → 56 not taken.
✓ Branch 61 → 62 taken 8 times.
8 for (size_t i = cursor; i < skip; ++i)
1106 {
1107 rebuilt.push_back(std::move(pending_bindings_[i]));
1108 }
1109 8 cursor = skip + 1;
1110 }
1111
1/2
✗ Branch 79 → 73 not taken.
✓ Branch 79 → 80 taken 6 times.
6 for (size_t i = cursor; i < pending_bindings_.size(); ++i)
1112 {
1113 rebuilt.push_back(std::move(pending_bindings_[i]));
1114 }
1115
2/2
✓ Branch 81 → 82 taken 4 times.
✓ Branch 81 → 90 taken 2 times.
6 if (combos.empty())
1116 {
1117 // Preserve a sentinel entry so the binding name
1118 // remains addressable for a later non-empty
1119 // update_binding_combos() call.
1120 4 InputBinding b = prototype;
1121 4 b.keys.clear();
1122 4 b.modifiers.clear();
1123 4 rebuilt.push_back(std::move(b));
1124 4 }
1125 else
1126 {
1127
2/2
✓ Branch 110 → 92 taken 3 times.
✓ Branch 110 → 111 taken 2 times.
7 for (const auto &combo : combos)
1128 {
1129 3 InputBinding b = prototype;
1130 3 b.keys = combo.keys;
1131 3 b.modifiers = combo.modifiers;
1132 3 rebuilt.push_back(std::move(b));
1133 3 }
1134 }
1135 6 pending_bindings_ = std::move(rebuilt);
1136 6 updated_pending = true;
1137 6 }
1138
2/2
✓ Branch 120 → 121 taken 16 times.
✓ Branch 120 → 123 taken 8 times.
24 }
1139
2/2
✓ Branch 126 → 127 taken 1018 times.
✓ Branch 126 → 130 taken 8 times.
1026 }
1140
1141
2/2
✓ Branch 129 → 131 taken 1002 times.
✓ Branch 129 → 133 taken 16 times.
1018 if (local_poller)
1142 {
1143 1002 (void)local_poller->update_combos(name, combos);
1144 }
1145
1/2
✓ Branch 133 → 134 taken 16 times.
✗ Branch 133 → 137 not taken.
16 else if (updated_pending)
1146 {
1147 16 Logger::get_instance().trace(
1148 "InputManager: update_binding_combos(\"{}\") applied to pending bindings", name);
1149 }
1150
2/2
✓ Branch 139 → 140 taken 1018 times.
✓ Branch 139 → 142 taken 8 times.
1026 }
1151
1152 11 size_t InputManager::remove_binding_by_name(std::string_view name, bool invoke_callbacks) noexcept
1153 {
1154 11 std::shared_ptr<InputPoller> live_poller;
1155 11 size_t removed_pending = 0;
1156
1157 {
1158 11 std::lock_guard lock(mutex_);
1159
2/2
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 9 times.
11 if (poller_)
1160 {
1161 2 live_poller = poller_;
1162 }
1163 else
1164 {
1165 9 auto new_end = std::remove_if(
1166 pending_bindings_.begin(), pending_bindings_.end(),
1167 8 [name](const InputBinding &b) { return b.name == name; });
1168 9 removed_pending = static_cast<size_t>(
1169 9 std::distance(new_end, pending_bindings_.end()));
1170 18 pending_bindings_.erase(new_end, pending_bindings_.end());
1171 }
1172 11 }
1173
1174
2/2
✓ Branch 33 → 34 taken 2 times.
✓ Branch 33 → 36 taken 9 times.
11 if (live_poller)
1175 {
1176 2 return live_poller->remove_bindings_by_name(name, invoke_callbacks);
1177 }
1178 9 return removed_pending;
1179 11 }
1180
1181 10 void InputManager::clear_bindings(bool invoke_callbacks) noexcept
1182 {
1183 10 std::shared_ptr<InputPoller> live_poller;
1184
1185 {
1186 10 std::lock_guard lock(mutex_);
1187 10 pending_bindings_.clear();
1188
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 9 times.
10 if (poller_)
1189 {
1190 1 live_poller = poller_;
1191 }
1192 10 }
1193
1194
2/2
✓ Branch 9 → 10 taken 1 time.
✓ Branch 9 → 12 taken 9 times.
10 if (live_poller)
1195 {
1196 1 live_poller->clear_bindings(invoke_callbacks);
1197 }
1198 10 }
1199
1200 176 void InputManager::shutdown() noexcept
1201 {
1202 176 std::shared_ptr<InputPoller> local_poller;
1203
1204 {
1205 176 std::lock_guard lock(mutex_);
1206 // Clear atomic shared_ptr before releasing the poller to ensure
1207 // concurrent is_binding_active() callers hold a valid shared_ptr.
1208 176 active_poller_.store(nullptr, std::memory_order_release);
1209 176 running_.store(false, std::memory_order_release);
1210 352 local_poller = std::move(poller_);
1211 176 pending_bindings_.clear();
1212 176 }
1213
1214
2/2
✓ Branch 13 → 14 taken 28 times.
✓ Branch 13 → 16 taken 148 times.
176 if (local_poller)
1215 {
1216 28 local_poller->shutdown();
1217 }
1218 176 }
1219 } // namespace DetourModKit
1220