GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 89.4% 591 / 0 / 661
Functions: 95.3% 82 / 0 / 86
Branches: 57.1% 487 / 0 / 853

src/config.cpp
Line Branch Exec Source
1 /**
2 * @file config.cpp
3 * @brief Implementation of configuration loading and management.
4 *
5 * Provides a system for registering configuration variables, loading their values from an INI file, and logging them.
6 * This allows mods to define their configuration needs and have DetourModKit handle the INI parsing and value
7 * assignment.
8 */
9
10 #include "DetourModKit/config.hpp"
11 #include "DetourModKit/config_watcher.hpp"
12 #include "DetourModKit/input.hpp"
13 #include "DetourModKit/input_codes.hpp"
14 #include "DetourModKit/logger.hpp"
15 #include "DetourModKit/filesystem.hpp"
16 #include "DetourModKit/format.hpp"
17 #include "DetourModKit/worker.hpp"
18
19 #include "config_input_fusion.hpp"
20
21 #include "SimpleIni.h"
22
23 #include <atomic>
24 #include <memory>
25
26 #include <windows.h>
27 #include <cctype>
28 #include <cerrno>
29 #include <condition_variable>
30 #include <cstdint>
31 #include <cstdlib>
32 #include <filesystem>
33 #include <fstream>
34 #include <limits>
35 #include <mutex>
36 #include <optional>
37 #include <string>
38 #include <string_view>
39 #include <thread>
40 #include <unordered_set>
41 #include <vector>
42
43 namespace DetourModKit
44 {
45 using DetourModKit::Filesystem::get_runtime_directory;
46 using DetourModKit::String::trim;
47
48 // Anonymous namespace for internal helpers and storage
49 namespace
50 {
51 /**
52 * @brief Parses a comma-separated string of input tokens into a vector of InputCodes.
53 * @details Each token is first matched against the named key table (case-insensitive). If no name matches, the
54 * token is parsed as a hexadecimal VK code (with or without 0x prefix), defaulting to
55 * InputSource::Keyboard. Handles inline semicolon comments, whitespace, and gracefully skips invalid
56 * tokens.
57 * @param input The raw string to parse.
58 * @return std::vector<InputCode> Parsed valid input codes.
59 */
60 141 std::vector<InputCode> parse_input_code_list(const std::string &input)
61 {
62 141 std::vector<InputCode> result;
63
64 // Strip trailing comment from the full line
65 141 const size_t comment_pos = input.find(';');
66 const std::string effective =
67
3/8
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 141 times.
✗ Branch 4 → 6 not taken.
✗ Branch 4 → 82 not taken.
✓ Branch 5 → 6 taken 141 times.
✗ Branch 5 → 82 not taken.
✓ Branch 7 → 8 taken 141 times.
✗ Branch 7 → 80 not taken.
141 trim((comment_pos != std::string::npos) ? input.substr(0, comment_pos) : input);
68
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 141 times.
141 if (effective.empty())
69 {
70 return result;
71 }
72
73 // Walk comma-delimited tokens without istringstream overhead
74 141 size_t pos = 0;
75
2/2
✓ Branch 75 → 13 taken 141 times.
✓ Branch 75 → 76 taken 141 times.
282 while (pos < effective.size())
76 {
77 141 const size_t comma = effective.find(',', pos);
78
1/2
✓ Branch 14 → 15 taken 141 times.
✗ Branch 14 → 16 not taken.
141 const size_t end = (comma != std::string::npos) ? comma : effective.size();
79
2/4
✓ Branch 17 → 18 taken 141 times.
✗ Branch 17 → 85 not taken.
✓ Branch 19 → 20 taken 141 times.
✗ Branch 19 → 83 not taken.
141 const std::string token = trim(effective.substr(pos, end - pos));
80 141 pos = end + 1;
81
82
1/2
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 141 times.
141 if (token.empty())
83 {
84 continue;
85 }
86
87 // Try named key lookup first (case-insensitive)
88
1/2
✓ Branch 25 → 26 taken 141 times.
✗ Branch 25 → 87 not taken.
141 auto named = parse_input_name(token);
89
2/2
✓ Branch 27 → 28 taken 94 times.
✓ Branch 27 → 31 taken 47 times.
141 if (named.has_value())
90 {
91
1/2
✓ Branch 29 → 30 taken 94 times.
✗ Branch 29 → 87 not taken.
94 result.push_back(*named);
92 94 continue;
93 }
94
95 // Fall back to hex parsing (defaults to Keyboard source)
96 47 size_t hex_start = 0;
97
8/10
✓ Branch 32 → 33 taken 47 times.
✗ Branch 32 → 40 not taken.
✓ Branch 34 → 35 taken 38 times.
✓ Branch 34 → 40 taken 9 times.
✓ Branch 36 → 37 taken 2 times.
✓ Branch 36 → 39 taken 36 times.
✓ Branch 38 → 39 taken 2 times.
✗ Branch 38 → 40 not taken.
✓ Branch 41 → 42 taken 38 times.
✓ Branch 41 → 43 taken 9 times.
47 if (token.size() >= 2 && token[0] == '0' && (token[1] == 'x' || token[1] == 'X'))
98 {
99 38 hex_start = 2;
100 }
101
2/2
✓ Branch 44 → 45 taken 4 times.
✓ Branch 44 → 46 taken 43 times.
47 if (hex_start >= token.size())
102 {
103 4 continue;
104 }
105
106 // Validate all remaining characters are hex digits
107 43 const std::string_view hex_part(token.data() + hex_start, token.size() - hex_start);
108
2/2
✓ Branch 50 → 51 taken 9 times.
✓ Branch 50 → 52 taken 34 times.
43 if (hex_part.find_first_not_of("0123456789abcdefABCDEF") != std::string_view::npos)
109 {
110 9 continue;
111 }
112
113 // Convert via strtoul -- no exception overhead on invalid input
114
1/2
✓ Branch 52 → 53 taken 34 times.
✗ Branch 52 → 87 not taken.
34 errno = 0;
115 34 char *end_ptr = nullptr;
116 34 const unsigned long value = std::strtoul(token.c_str() + hex_start, &end_ptr, 16);
117
6/8
✓ Branch 56 → 57 taken 34 times.
✗ Branch 56 → 59 not taken.
✓ Branch 57 → 58 taken 34 times.
✗ Branch 57 → 87 not taken.
✓ Branch 58 → 59 taken 2 times.
✓ Branch 58 → 60 taken 32 times.
✓ Branch 61 → 62 taken 2 times.
✓ Branch 61 → 63 taken 32 times.
34 if (end_ptr == token.c_str() + hex_start || errno == ERANGE)
118 {
119 2 continue;
120 }
121
2/2
✓ Branch 64 → 65 taken 3 times.
✓ Branch 64 → 66 taken 29 times.
32 if (value > static_cast<unsigned long>(std::numeric_limits<int>::max()))
122 {
123 3 continue;
124 }
125
126
1/2
✓ Branch 66 → 67 taken 29 times.
✗ Branch 66 → 86 not taken.
29 result.push_back(InputCode{InputSource::Keyboard, static_cast<int>(value)});
127
2/2
✓ Branch 69 → 70 taken 29 times.
✓ Branch 69 → 72 taken 112 times.
141 }
128
129 141 return result;
130 141 }
131
132 /**
133 * @brief Parses a single key combo string into a KeyCombo struct.
134 * @details Format: "modifier1+modifier2+trigger_key" where each token is a named key or hex VK code. The last
135 * '+'-delimited token is the trigger key, all preceding tokens are modifier keys (AND logic). This
136 * function expects a single combo with no commas; use parse_key_combo_list to split comma-separated
137 * alternatives first.
138 * @param input The raw string to parse (no commas expected).
139 * @return Config::KeyCombo Parsed key combination.
140 */
141 115 Config::KeyCombo parse_key_combo(const std::string &input)
142 {
143 115 Config::KeyCombo result;
144
145
1/2
✓ Branch 3 → 4 taken 115 times.
✗ Branch 3 → 65 not taken.
115 const std::string effective = trim(input);
146
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 115 times.
115 if (effective.empty())
147 {
148 return result;
149 }
150
151 // Split by '+' to get segments
152 115 std::vector<std::string> segments;
153 115 size_t pos = 0;
154
2/2
✓ Branch 22 → 8 taken 145 times.
✓ Branch 22 → 23 taken 115 times.
260 while (pos < effective.size())
155 {
156 145 const size_t plus = effective.find('+', pos);
157
2/2
✓ Branch 9 → 10 taken 113 times.
✓ Branch 9 → 11 taken 32 times.
145 const size_t end = (plus != std::string::npos) ? plus : effective.size();
158
2/4
✓ Branch 12 → 13 taken 145 times.
✗ Branch 12 → 51 not taken.
✓ Branch 14 → 15 taken 145 times.
✗ Branch 14 → 49 not taken.
145 const std::string segment = trim(effective.substr(pos, end - pos));
159 145 pos = end + 1;
160
2/2
✓ Branch 17 → 18 taken 141 times.
✓ Branch 17 → 19 taken 4 times.
145 if (!segment.empty())
161 {
162
1/2
✓ Branch 18 → 19 taken 141 times.
✗ Branch 18 → 52 not taken.
141 segments.push_back(segment);
163 }
164 145 }
165
166
2/2
✓ Branch 24 → 25 taken 1 time.
✓ Branch 24 → 26 taken 114 times.
115 if (segments.empty())
167 {
168 1 return result;
169 }
170
171 // Last segment is the trigger key
172
1/2
✓ Branch 27 → 28 taken 114 times.
✗ Branch 27 → 55 not taken.
114 result.keys = parse_input_code_list(segments.back());
173
174 // All preceding segments are individual modifier keys
175
2/2
✓ Branch 43 → 31 taken 27 times.
✓ Branch 43 → 44 taken 114 times.
141 for (size_t i = 0; i + 1 < segments.size(); ++i)
176 {
177
1/2
✓ Branch 32 → 33 taken 27 times.
✗ Branch 32 → 60 not taken.
27 auto mod_codes = parse_input_code_list(segments[i]);
178
1/2
✓ Branch 39 → 40 taken 27 times.
✗ Branch 39 → 56 not taken.
54 result.modifiers.insert(result.modifiers.end(), mod_codes.begin(), mod_codes.end());
179 27 }
180
181 114 return result;
182 115 }
183
184 /**
185 * @brief Returns true when @p text is the literal "NONE" sentinel (case-insensitive ASCII, exact length match).
186 * @details The whole-string-only rule keeps the sentinel unambiguous: a NONE token nested inside a
187 * comma-separated list cannot be told apart from a key-name typo without a per-token lookup, and the
188 * OR-of-combos semantic makes "an unbound slot inside an OR-list" meaningless. Caller must pass a
189 * pre-trimmed view.
190 */
191 83 [[nodiscard]] bool is_none_sentinel(std::string_view text) noexcept
192 {
193
2/2
✓ Branch 3 → 4 taken 74 times.
✓ Branch 3 → 5 taken 9 times.
83 if (text.size() != 4)
194 {
195 74 return false;
196 }
197 9 constexpr char target[] = {'N', 'O', 'N', 'E'};
198
2/2
✓ Branch 10 → 6 taken 27 times.
✓ Branch 10 → 11 taken 6 times.
33 for (size_t i = 0; i < 4; ++i)
199 {
200 27 const auto ch = static_cast<unsigned char>(text[i]);
201
2/2
✓ Branch 7 → 8 taken 3 times.
✓ Branch 7 → 9 taken 24 times.
27 if (static_cast<char>(std::toupper(ch)) != target[i])
202 {
203 3 return false;
204 }
205 }
206 6 return true;
207 }
208
209 /**
210 * @brief Parses a comma-separated string of key combos into a KeyComboList.
211 * @details Commas at the top level separate independent combos (OR logic between combos). Each combo is parsed
212 * by parse_key_combo. Handles inline semicolon comments and whitespace. Two opt-out sentinels yield an
213 * empty result silently: an empty (post-trim) input, and the literal "NONE" (case-insensitive,
214 * whole-string only). A non-empty input that is not the NONE sentinel and whose every comma-separated
215 * token fails to parse is treated as a user typo and emits a single WARNING naming the binding and the
216 * offending raw string. Empty inner tokens (e.g. "F4,,F5") are silently skipped; the WARNING fires
217 * only when the entire result list is empty.
218 * @param input The raw string to parse.
219 * @param binding_log_name Optional human-readable binding name used in the typo WARNING. Defaults to an empty
220 * view,
221 * in which case the WARNING uses "<unnamed>".
222 * @return Config::KeyComboList Parsed list of key combinations.
223 */
224 113 Config::KeyComboList parse_key_combo_list(const std::string &input, std::string_view binding_log_name = {})
225 {
226 113 Config::KeyComboList result;
227
228 // Strip trailing comment from the full line
229 113 const size_t comment_pos = input.find(';');
230 const std::string effective =
231
5/8
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 5 taken 109 times.
✓ Branch 4 → 6 taken 4 times.
✗ Branch 4 → 59 not taken.
✓ Branch 5 → 6 taken 109 times.
✗ Branch 5 → 59 not taken.
✓ Branch 7 → 8 taken 113 times.
✗ Branch 7 → 57 not taken.
113 trim((comment_pos != std::string::npos) ? input.substr(0, comment_pos) : input);
232
233 // Disposition 1: explicit opt-out via empty string. Silent.
234
2/2
✓ Branch 10 → 11 taken 30 times.
✓ Branch 10 → 12 taken 83 times.
113 if (effective.empty())
235 {
236 30 return result;
237 }
238
239 // Disposition 2: explicit opt-out via NONE sentinel (whole-string, case-insensitive, post-trim). Silent.
240
2/2
✓ Branch 14 → 15 taken 6 times.
✓ Branch 14 → 16 taken 77 times.
83 if (is_none_sentinel(effective))
241 {
242 6 return result;
243 }
244
245 // Split by comma into independent combo strings
246 77 size_t pos = 0;
247
2/2
✓ Branch 43 → 17 taken 122 times.
✓ Branch 43 → 44 taken 77 times.
199 while (pos < effective.size())
248 {
249 122 const size_t comma = effective.find(',', pos);
250
2/2
✓ Branch 18 → 19 taken 76 times.
✓ Branch 18 → 20 taken 46 times.
122 const size_t end = (comma != std::string::npos) ? comma : effective.size();
251
2/4
✓ Branch 21 → 22 taken 122 times.
✗ Branch 21 → 62 not taken.
✓ Branch 23 → 24 taken 122 times.
✗ Branch 23 → 60 not taken.
122 const std::string combo_str = trim(effective.substr(pos, end - pos));
252 122 pos = end + 1;
253
254
2/2
✓ Branch 26 → 27 taken 7 times.
✓ Branch 26 → 28 taken 115 times.
122 if (combo_str.empty())
255 {
256 7 continue;
257 }
258
259
1/2
✓ Branch 28 → 29 taken 115 times.
✗ Branch 28 → 65 not taken.
115 auto combo = parse_key_combo(combo_str);
260
2/2
✓ Branch 30 → 31 taken 97 times.
✓ Branch 30 → 34 taken 18 times.
115 if (!combo.keys.empty())
261 {
262
1/2
✓ Branch 33 → 34 taken 97 times.
✗ Branch 33 → 63 not taken.
97 result.push_back(std::move(combo));
263 }
264
2/2
✓ Branch 37 → 38 taken 115 times.
✓ Branch 37 → 40 taken 7 times.
122 }
265
266 // Disposition 3: input was non-empty and not the NONE sentinel, yet every token failed to parse. Real user
267 // typo, name it.
268
2/2
✓ Branch 45 → 46 taken 5 times.
✓ Branch 45 → 53 taken 72 times.
77 if (result.empty())
269 {
270 const std::string_view name_view =
271
1/2
✗ Branch 47 → 48 not taken.
✓ Branch 47 → 49 taken 5 times.
5 binding_log_name.empty() ? std::string_view{"<unnamed>"} : binding_log_name;
272
2/4
✓ Branch 50 → 51 taken 5 times.
✗ Branch 50 → 69 not taken.
✓ Branch 51 → 52 taken 5 times.
✗ Branch 51 → 68 not taken.
5 Logger::get_instance().warning("Config: combo string \"{}\" for binding '{}' did not parse to any "
273 "valid keys; binding will be unbound. Use \"\" or \"NONE\" to opt "
274 "out explicitly.",
275 effective, name_view);
276 }
277
278 77 return result;
279 113 }
280
281 /**
282 * @brief Formats a single KeyCombo as a human-readable string.
283 * @details Uses named keys where available, falls back to hex for unknown codes.
284 * @param combo The key combination to format.
285 * @return std::string Formatted string (e.g., "Ctrl+Shift+F3").
286 */
287 6 std::string format_key_combo(const Config::KeyCombo &combo)
288 {
289 6 std::string result;
290
2/2
✓ Branch 21 → 5 taken 1 time.
✓ Branch 21 → 22 taken 6 times.
13 for (const auto &mod : combo.modifiers)
291 {
292
3/6
✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 38 not taken.
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 36 not taken.
✓ Branch 9 → 10 taken 1 time.
✗ Branch 9 → 34 not taken.
1 result += DetourModKit::format_input_code(mod) + "+";
293 }
294
2/2
✓ Branch 31 → 23 taken 6 times.
✓ Branch 31 → 32 taken 6 times.
12 for (size_t i = 0; i < combo.keys.size(); ++i)
295 {
296
1/2
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 25 taken 6 times.
6 if (i > 0)
297 {
298 result += ",";
299 }
300
2/4
✓ Branch 26 → 27 taken 6 times.
✗ Branch 26 → 43 not taken.
✓ Branch 27 → 28 taken 6 times.
✗ Branch 27 → 41 not taken.
6 result += DetourModKit::format_input_code(combo.keys[i]);
301 }
302 6 return result;
303 }
304
305 /**
306 * @brief Formats a KeyComboList as a human-readable string.
307 * @details Joins individual combos with commas.
308 * @param combos The list of key combinations to format.
309 * @return std::string Formatted string (e.g., "F3,Gamepad_LT+Gamepad_B").
310 */
311 7 std::string format_key_combo_list(const Config::KeyComboList &combos)
312 {
313 7 std::string result;
314
2/2
✓ Branch 12 → 4 taken 6 times.
✓ Branch 12 → 13 taken 7 times.
13 for (size_t i = 0; i < combos.size(); ++i)
315 {
316
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 6 times.
6 if (i > 0)
317 {
318 result += ",";
319 }
320
2/4
✓ Branch 7 → 8 taken 6 times.
✗ Branch 7 → 17 not taken.
✓ Branch 8 → 9 taken 6 times.
✗ Branch 8 → 15 not taken.
6 result += format_key_combo(combos[i]);
321 }
322 7 return result;
323 }
324
325 /**
326 * @brief Base class for typed configuration items.
327 * @details This allows storing different types of configuration items polymorphically in a collection.
328 */
329 struct ConfigItemBase
330 {
331 std::string section;
332 std::string ini_key;
333 std::string log_key_name;
334
335 178 ConfigItemBase(std::string sec, std::string key, std::string log_name)
336 712 : section(std::move(sec)), ini_key(std::move(key)), log_key_name(std::move(log_name))
337 {
338 178 }
339 178 virtual ~ConfigItemBase() = default;
340 ConfigItemBase(const ConfigItemBase &) = delete;
341 ConfigItemBase &operator=(const ConfigItemBase &) = delete;
342 ConfigItemBase(ConfigItemBase &&) = delete;
343 ConfigItemBase &operator=(ConfigItemBase &&) = delete;
344
345 /**
346 * @brief Loads the configuration value from the INI file.
347 * @param ini Reference to the CSimpleIniA object.
348 * @param logger Reference to the Logger object.
349 */
350 virtual void load(CSimpleIniA &ini, Logger &logger) = 0;
351
352 /**
353 * @brief Returns a deferred callback to invoke the setter outside the config mutex.
354 * @return A self-contained callable, or empty if no setter is configured.
355 */
356 [[nodiscard]] virtual std::function<void()> take_deferred_apply() const = 0;
357
358 /**
359 * @brief Logs the current value of the configuration item.
360 * @param logger Reference to the Logger object.
361 */
362 virtual void log_current_value(Logger &logger) const = 0;
363 };
364
365 /**
366 * @brief Configuration item using std::function callback for value setting.
367 * @tparam T The data type of the configuration item (e.g., int, bool, std::string).
368 * @note Setter callbacks are invoked outside the config mutex to prevent deadlocks. See register_* and load()
369 * for
370 * the deferred invocation pattern.
371 */
372 template <typename T> struct CallbackConfigItem : public ConfigItemBase
373 {
374 std::function<void(T)> setter; // Callback function to set the value
375 T default_value;
376 T current_value;
377
378 178 CallbackConfigItem(std::string sec, std::string key, std::string log_name, std::function<void(T)> set_fn,
379 T def_val)
380 178 : ConfigItemBase(std::move(sec), std::move(key), std::move(log_name)), setter(std::move(set_fn)),
381
2/4
DetourModKit::(anonymous namespace)::CallbackConfigItem<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >::CallbackConfigItem(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::function<void (std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >)>, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >):
✓ Branch 18 → 19 taken 13 times.
✗ Branch 18 → 23 not taken.
DetourModKit::(anonymous namespace)::CallbackConfigItem<std::vector<DetourModKit::Config::KeyCombo, std::allocator<DetourModKit::Config::KeyCombo> > >::CallbackConfigItem(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::function<void (std::vector<DetourModKit::Config::KeyCombo, std::allocator<DetourModKit::Config::KeyCombo> >)>, std::vector<DetourModKit::Config::KeyCombo, std::allocator<DetourModKit::Config::KeyCombo> >):
✓ Branch 18 → 19 taken 76 times.
✗ Branch 18 → 23 not taken.
979 default_value(def_val), current_value(std::move(def_val))
382 {
383 178 }
384
385 112 void load(CSimpleIniA &ini, [[maybe_unused]] Logger &logger) override
386 {
387 // One generic body for the scalar/string config types. KeyComboList takes the explicit specialization
388 // below instead, because its parse path differs (nullptr INI value handling, combo-list parsing).
389 if constexpr (std::same_as<T, int>)
390 {
391 // SimpleIni's GetLongValue parses into a long, which is 32-bit on this LLP64 target, so a value
392 // beyond int range is silently saturated by strtol (e.g. "5000000000" becomes INT_MAX) rather than
393 // rejected. Read the raw string and parse it as a 64-bit integer instead, so an out-of-range or
394 // non-numeric value falls back to the registered default with a Warning -- mirroring how
395 // parse_input_code_list rejects a bad token rather than wrapping it. Preserve GetLongValue's base
396 // rules: 0x-prefixed values are hexadecimal and everything else is decimal (including leading-zero
397 // values such as "010").
398
1/2
✓ Branch 4 → 5 taken 77 times.
✗ Branch 4 → 35 not taken.
77 const char *raw = ini.GetValue(section.c_str(), ini_key.c_str(), nullptr);
399
2/2
✓ Branch 5 → 6 taken 11 times.
✓ Branch 5 → 7 taken 66 times.
77 if (raw == nullptr)
400 {
401 11 current_value = default_value;
402 }
403 else
404 {
405 66 const char *parse_begin = raw;
406 66 int base = 10;
407
4/6
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 11 taken 65 times.
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 10 not taken.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 1 time.
66 if (raw[0] == '0' && (raw[1] == 'x' || raw[1] == 'X'))
408 {
409 parse_begin = raw + 2;
410 base = 16;
411 }
412
413
1/2
✓ Branch 11 → 12 taken 66 times.
✗ Branch 11 → 34 not taken.
66 errno = 0;
414 66 char *end = nullptr;
415 66 const long long parsed = std::strtoll(parse_begin, &end, base);
416
4/6
✓ Branch 13 → 14 taken 66 times.
✗ Branch 13 → 17 not taken.
✓ Branch 14 → 15 taken 65 times.
✓ Branch 14 → 17 taken 1 time.
✓ Branch 15 → 16 taken 65 times.
✗ Branch 15 → 17 not taken.
66 const bool fully_consumed = (end != nullptr && end != parse_begin && *end == '\0');
417
2/4
✓ Branch 19 → 20 taken 65 times.
✗ Branch 19 → 34 not taken.
✓ Branch 20 → 21 taken 65 times.
✗ Branch 20 → 25 not taken.
65 if (!fully_consumed || errno == ERANGE ||
418
6/6
✓ Branch 18 → 19 taken 65 times.
✓ Branch 18 → 25 taken 1 time.
✓ Branch 22 → 23 taken 64 times.
✓ Branch 22 → 25 taken 1 time.
✓ Branch 27 → 28 taken 3 times.
✓ Branch 27 → 30 taken 63 times.
195 parsed < static_cast<long long>(std::numeric_limits<int>::min()) ||
419
2/2
✓ Branch 24 → 25 taken 1 time.
✓ Branch 24 → 26 taken 63 times.
64 parsed > static_cast<long long>(std::numeric_limits<int>::max()))
420 {
421 logger.warning("Config: value '{}' for '{}' is not a valid int (non-numeric or out of "
422 "range); using default {}.",
423
1/2
✓ Branch 28 → 29 taken 3 times.
✗ Branch 28 → 33 not taken.
3 raw, ini_key, default_value);
424 3 current_value = default_value;
425 }
426 else
427 {
428 63 current_value = static_cast<int>(parsed);
429 }
430 }
431 }
432 else if constexpr (std::same_as<T, float>)
433 {
434 10 current_value = static_cast<float>(
435 10 ini.GetDoubleValue(section.c_str(), ini_key.c_str(), static_cast<double>(default_value)));
436 }
437 else if constexpr (std::same_as<T, bool>)
438 {
439 15 current_value = ini.GetBoolValue(section.c_str(), ini_key.c_str(), default_value);
440 }
441 else if constexpr (std::same_as<T, std::string>)
442 {
443 10 current_value = ini.GetValue(section.c_str(), ini_key.c_str(), default_value.c_str());
444 }
445 112 }
446
447 10 void log_current_value(Logger &logger) const override
448 {
449 if constexpr (std::same_as<T, bool>)
450 {
451
3/4
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 1 time.
✓ Branch 5 → 6 taken 3 times.
✗ Branch 5 → 7 not taken.
3 logger.debug("Config: {} = {}", ini_key, current_value ? "true" : "false");
452 }
453 else if constexpr (std::same_as<T, std::string>)
454 {
455
1/2
✓ Branch 2 → 3 taken 2 times.
✗ Branch 2 → 4 not taken.
2 logger.debug("Config: {} = \"{}\"", ini_key, current_value);
456 }
457 else // int, float
458 {
459
2/4
DetourModKit::(anonymous namespace)::CallbackConfigItem<float>::log_current_value(DetourModKit::Logger&) const:
✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 4 not taken.
DetourModKit::(anonymous namespace)::CallbackConfigItem<int>::log_current_value(DetourModKit::Logger&) const:
✓ Branch 2 → 3 taken 4 times.
✗ Branch 2 → 4 not taken.
5 logger.debug("Config: {} = {}", ini_key, current_value);
460 }
461 10 }
462
463 /// Returns a self-contained callback that invokes setter with current_value.
464 165 [[nodiscard]] std::function<void()> take_deferred_apply() const override
465 {
466
5/10
DetourModKit::(anonymous namespace)::CallbackConfigItem<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >::take_deferred_apply() const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 10 times.
DetourModKit::(anonymous namespace)::CallbackConfigItem<std::vector<DetourModKit::Config::KeyCombo, std::allocator<DetourModKit::Config::KeyCombo> > >::take_deferred_apply() const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 53 times.
DetourModKit::(anonymous namespace)::CallbackConfigItem<bool>::take_deferred_apply() const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 15 times.
DetourModKit::(anonymous namespace)::CallbackConfigItem<float>::take_deferred_apply() const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 10 times.
DetourModKit::(anonymous namespace)::CallbackConfigItem<int>::take_deferred_apply() const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 77 times.
165 if (!setter)
467 return {};
468
19/48
DetourModKit::(anonymous namespace)::CallbackConfigItem<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >::take_deferred_apply() const:
✓ Branch 5 → 6 taken 10 times.
✗ Branch 5 → 19 not taken.
✓ Branch 6 → 7 taken 10 times.
✗ Branch 6 → 16 not taken.
✓ Branch 7 → 8 taken 10 times.
✗ Branch 7 → 14 not taken.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 10 times.
✗ Branch 16 → 17 not taken.
✗ Branch 16 → 18 not taken.
DetourModKit::(anonymous namespace)::CallbackConfigItem<std::vector<DetourModKit::Config::KeyCombo, std::allocator<DetourModKit::Config::KeyCombo> > >::take_deferred_apply() const:
✓ Branch 5 → 6 taken 53 times.
✗ Branch 5 → 19 not taken.
✓ Branch 6 → 7 taken 53 times.
✗ Branch 6 → 16 not taken.
✓ Branch 7 → 8 taken 53 times.
✗ Branch 7 → 14 not taken.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 53 times.
✗ Branch 16 → 17 not taken.
✗ Branch 16 → 18 not taken.
DetourModKit::(anonymous namespace)::CallbackConfigItem<bool>::take_deferred_apply() const:
✓ Branch 5 → 6 taken 15 times.
✗ Branch 5 → 18 not taken.
✓ Branch 6 → 7 taken 15 times.
✗ Branch 6 → 13 not taken.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 15 times.
✗ Branch 15 → 16 not taken.
✗ Branch 15 → 17 not taken.
DetourModKit::(anonymous namespace)::CallbackConfigItem<float>::take_deferred_apply() const:
✓ Branch 5 → 6 taken 10 times.
✗ Branch 5 → 18 not taken.
✓ Branch 6 → 7 taken 10 times.
✗ Branch 6 → 13 not taken.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 10 times.
✗ Branch 15 → 16 not taken.
✗ Branch 15 → 17 not taken.
DetourModKit::(anonymous namespace)::CallbackConfigItem<int>::take_deferred_apply() const:
✓ Branch 5 → 6 taken 77 times.
✗ Branch 5 → 18 not taken.
✓ Branch 6 → 7 taken 77 times.
✗ Branch 6 → 13 not taken.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 77 times.
✗ Branch 15 → 16 not taken.
✗ Branch 15 → 17 not taken.
DetourModKit::(anonymous namespace)::CallbackConfigItem<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >::take_deferred_apply() const::{lambda()#1}::operator()():
✓ Branch 5 → 6 taken 10 times.
✗ Branch 5 → 8 not taken.
DetourModKit::(anonymous namespace)::CallbackConfigItem<std::vector<DetourModKit::Config::KeyCombo, std::allocator<DetourModKit::Config::KeyCombo> > >::take_deferred_apply() const::{lambda()#1}::operator()():
✓ Branch 5 → 6 taken 53 times.
✗ Branch 5 → 8 not taken.
495 return [fn = setter, val = current_value]() mutable { fn(std::move(val)); };
469 }
470 };
471
472 // load() and log_current_value() use the generic if-constexpr bodies defined in the class above for the
473 // scalar/string types. Only KeyComboList needs an explicit specialization, because its parse path differs.
474
475 // For Config::KeyComboList (list of key combinations)
476 template <>
477 53 void CallbackConfigItem<Config::KeyComboList>::load(CSimpleIniA &ini, [[maybe_unused]] Logger &logger)
478 {
479 53 const char *ini_value_str = ini.GetValue(section.c_str(), ini_key.c_str(), nullptr);
480
2/2
✓ Branch 5 → 6 taken 33 times.
✓ Branch 5 → 16 taken 20 times.
53 if (ini_value_str != nullptr)
481 {
482
2/4
✓ Branch 9 → 10 taken 33 times.
✗ Branch 9 → 20 not taken.
✓ Branch 10 → 11 taken 33 times.
✗ Branch 10 → 18 not taken.
99 current_value = parse_key_combo_list(ini_value_str, log_key_name);
483 }
484 else
485 {
486 20 current_value = default_value;
487 }
488 53 }
489
490 7 template <> void CallbackConfigItem<Config::KeyComboList>::log_current_value(Logger &logger) const
491 {
492
1/2
✓ Branch 2 → 3 taken 7 times.
✗ Branch 2 → 15 not taken.
7 const std::string formatted = format_key_combo_list(current_value);
493
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 7 taken 6 times.
7 if (formatted.empty())
494 {
495
1/2
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 11 not taken.
1 logger.debug("Config: {} = (none)", ini_key);
496 }
497 else
498 {
499
1/2
✓ Branch 7 → 8 taken 6 times.
✗ Branch 7 → 12 not taken.
6 logger.debug("Config: {} = {}", ini_key, formatted);
500 }
501 7 }
502
503 // --- Global storage for registered configuration items ---
504 706 std::mutex &get_config_mutex()
505 {
506
3/4
✓ Branch 2 → 3 taken 163 times.
✓ Branch 2 → 8 taken 543 times.
✓ Branch 4 → 5 taken 163 times.
✗ Branch 4 → 8 not taken.
706 static std::mutex s_mtx;
507 706 return s_mtx;
508 }
509
510 951 std::vector<std::unique_ptr<ConfigItemBase>> &get_registered_config_items()
511 {
512 // Function-local static to ensure controlled initialization order.
513
3/4
✓ Branch 2 → 3 taken 163 times.
✓ Branch 2 → 7 taken 788 times.
✓ Branch 4 → 5 taken 163 times.
✗ Branch 4 → 7 not taken.
951 static std::vector<std::unique_ptr<ConfigItemBase>> s_registered_items;
514 951 return s_registered_items;
515 }
516
517 // Holds the INI path last passed to Config::load(). Empty until the first load() call -- reload() returns false
518 // in that window. Caller must hold get_config_mutex() when reading or writing.
519 487 std::string &get_last_loaded_ini_path()
520 {
521
3/4
✓ Branch 2 → 3 taken 163 times.
✓ Branch 2 → 7 taken 324 times.
✓ Branch 4 → 5 taken 163 times.
✗ Branch 4 → 7 not taken.
487 static std::string s_last_loaded_ini_path;
522 487 return s_last_loaded_ini_path;
523 }
524
525 // Tear-free snapshot of the last-loaded INI path. Takes get_config_mutex() itself and returns a copy, so a
526 // caller that only needs to read the path cannot observe a reference torn by a concurrent reload()/load()
527 // mutating the underlying string. Use this instead of get_last_loaded_ini_path() at any read site that is not
528 // already inside a held get_config_mutex() critical section (the mutex is non-recursive). The string copy can
529 // allocate, so this is intentionally not noexcept.
530 17 [[nodiscard]] std::string snapshot_last_loaded_ini_path()
531 {
532
1/2
✓ Branch 3 → 4 taken 17 times.
✗ Branch 3 → 12 not taken.
17 std::lock_guard<std::mutex> lock(get_config_mutex());
533
1/2
✓ Branch 5 → 6 taken 17 times.
✗ Branch 5 → 10 not taken.
34 return get_last_loaded_ini_path();
534 17 }
535
536 // Content hash of the bytes last successfully loaded from the INI file. std::nullopt until the first successful
537 // load() (or after clear_registered_items(), which wipes it alongside the path). Caller must hold
538 // get_config_mutex() when reading or writing.
539 498 std::optional<std::uint64_t> &get_last_loaded_ini_hash()
540 {
541 static std::optional<std::uint64_t> s_last_loaded_ini_hash;
542 498 return s_last_loaded_ini_hash;
543 }
544
545 /**
546 * @brief 64-bit FNV-1a hash over a raw byte range.
547 * @details Computed on the disk bytes (pre-parse) so cosmetic churn by SimpleIni's own parser (comment
548 * stripping, whitespace normalisation) cannot skew the result. Produces a stable value on any platform
549 * without pulling in a dependency.
550 */
551 121 [[nodiscard]] std::uint64_t fnv1a_64(const std::vector<std::uint8_t> &bytes) noexcept
552 {
553 121 constexpr std::uint64_t FNV_OFFSET_BASIS{0xcbf29ce484222325ULL};
554 121 constexpr std::uint64_t FNV_PRIME{0x00000100000001b3ULL};
555 121 std::uint64_t h{FNV_OFFSET_BASIS};
556
2/2
✓ Branch 15 → 4 taken 3019 times.
✓ Branch 15 → 16 taken 121 times.
3261 for (std::uint8_t b : bytes)
557 {
558 3019 h ^= static_cast<std::uint64_t>(b);
559 3019 h *= FNV_PRIME;
560 }
561 121 return h;
562 }
563
564 /**
565 * @brief Reads all bytes of @p path into memory.
566 * @details Returns std::nullopt when the file cannot be opened (e.g. mid-save by an editor that locks
567 * exclusively).
568 * Callers should treat a nullopt return as "unable to verify content; proceed with a full reload" --
569 * erring on the side of reloading is safer than skipping a real change.
570 */
571 [[nodiscard]] std::optional<std::vector<std::uint8_t>>
572 152 read_ini_bytes(const std::filesystem::path &path) noexcept
573 {
574 try
575 {
576
1/2
✓ Branch 2 → 3 taken 152 times.
✗ Branch 2 → 44 not taken.
152 std::ifstream in(path, std::ios::binary);
577
3/4
✓ Branch 3 → 4 taken 152 times.
✗ Branch 3 → 42 not taken.
✓ Branch 4 → 5 taken 31 times.
✓ Branch 4 → 6 taken 121 times.
152 if (!in)
578 {
579 31 return std::nullopt;
580 }
581
1/2
✓ Branch 6 → 7 taken 121 times.
✗ Branch 6 → 42 not taken.
121 in.seekg(0, std::ios::end);
582
1/2
✓ Branch 7 → 8 taken 121 times.
✗ Branch 7 → 36 not taken.
121 const std::streamsize size = in.tellg();
583
1/2
✓ Branch 9 → 10 taken 121 times.
✗ Branch 9 → 42 not taken.
121 in.seekg(0, std::ios::beg);
584
2/2
✓ Branch 10 → 11 taken 3 times.
✓ Branch 10 → 15 taken 118 times.
121 if (size <= 0)
585 {
586 3 return std::vector<std::uint8_t>{};
587 }
588
1/2
✓ Branch 17 → 18 taken 118 times.
✗ Branch 17 → 37 not taken.
118 std::vector<std::uint8_t> buf(static_cast<std::size_t>(size));
589
1/2
✓ Branch 20 → 21 taken 118 times.
✗ Branch 20 → 40 not taken.
118 in.read(reinterpret_cast<char *>(buf.data()), size);
590
3/10
✓ Branch 21 → 22 taken 118 times.
✗ Branch 21 → 40 not taken.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 26 taken 118 times.
✗ Branch 23 → 24 not taken.
✗ Branch 23 → 40 not taken.
✗ Branch 24 → 25 not taken.
✗ Branch 24 → 26 not taken.
✗ Branch 27 → 28 not taken.
✓ Branch 27 → 29 taken 118 times.
118 if (!in && !in.eof())
591 {
592 return std::nullopt;
593 }
594
1/2
✓ Branch 30 → 31 taken 118 times.
✗ Branch 30 → 40 not taken.
118 buf.resize(static_cast<std::size_t>(in.gcount()));
595 118 return buf;
596 152 }
597 catch (...)
598 {
599 return std::nullopt;
600 }
601 }
602
603 /**
604 * @brief Result of read-hash-parse pipeline used by load() and reload().
605 */
606 struct IniLoadOutcome
607 {
608 /// Bytes successfully read from disk
609 bool read_succeeded{false};
610 /// CSimpleIniA::LoadData returned SI_OK
611 bool parse_succeeded{false};
612 /// Raw SimpleIni return code (when read_succeeded)
613 SI_Error parse_rc{SI_OK};
614 /// FNV-1a hash of the read bytes
615 std::optional<std::uint64_t> hash;
616 };
617
618 /**
619 * @brief Reads the INI bytes once, computes their hash, and feeds those exact bytes to CSimpleIniA::LoadData.
620 * @details Closes the TOCTOU window where LoadFile would re-read the file after our byte snapshot: if the file
621 * was rewritten between the two reads, the cached hash would reflect one version and the parsed INI
622 * another. By using LoadData on the already-buffered bytes, the hash and the parse are guaranteed to
623 * reflect the same file state.
624 * @param path Absolute path to the INI file.
625 * @param ini SimpleIni instance to populate.
626 * @return IniLoadOutcome describing each pipeline stage.
627 */
628 152 [[nodiscard]] IniLoadOutcome load_ini_into(const std::filesystem::path &path, CSimpleIniA &ini) noexcept
629 {
630 152 IniLoadOutcome outcome{};
631 152 auto bytes = read_ini_bytes(path);
632
2/2
✓ Branch 4 → 5 taken 31 times.
✓ Branch 4 → 6 taken 121 times.
152 if (!bytes.has_value())
633 {
634 31 return outcome;
635 }
636 121 outcome.read_succeeded = true;
637 121 outcome.hash = fnv1a_64(*bytes);
638
639 // CSimpleIniA::LoadData(const char*, size_t). Empty buffers are accepted by SimpleIni (SI_OK, zero
640 // sections) -- we still preserve the hash so an empty file can be content-hash-skipped.
641 try
642 {
643
2/2
✓ Branch 11 → 12 taken 3 times.
✓ Branch 11 → 13 taken 118 times.
121 const char *data_ptr = bytes->empty() ? "" : reinterpret_cast<const char *>(bytes->data());
644
1/2
✓ Branch 17 → 18 taken 121 times.
✗ Branch 17 → 23 not taken.
121 outcome.parse_rc = ini.LoadData(data_ptr, bytes->size());
645 121 outcome.parse_succeeded = (outcome.parse_rc >= 0);
646 }
647 catch (...)
648 {
649 outcome.parse_rc = SI_FAIL;
650 outcome.parse_succeeded = false;
651 }
652 121 return outcome;
653 152 }
654
655 // Filesystem watcher owned by enable_auto_reload(). Separate mutex so
656 // start / stop transitions do not contend with registration traffic.
657 436 std::mutex &get_watcher_mutex()
658 {
659
3/4
✓ Branch 2 → 3 taken 163 times.
✓ Branch 2 → 8 taken 273 times.
✓ Branch 4 → 5 taken 163 times.
✗ Branch 4 → 8 not taken.
436 static std::mutex s_mtx;
660 436 return s_mtx;
661 }
662
663 85 std::unique_ptr<ConfigWatcher> &get_config_watcher()
664 {
665
3/4
✓ Branch 2 → 3 taken 34 times.
✓ Branch 2 → 7 taken 51 times.
✓ Branch 4 → 5 taken 34 times.
✗ Branch 4 → 7 not taken.
85 static std::unique_ptr<ConfigWatcher> s_watcher;
666 85 return s_watcher;
667 }
668
669 // Keeps reload-hotkey InputBindingGuards alive for the process lifetime. Returning the guard by value from
670 // register_reload_hotkey would immediately destroy it (the call site has nowhere to store it), and
671 // ~InputBindingGuard flips the binding's enabled flag to false, so the press callback would silently no-op
672 // forever. Protected by get_watcher_mutex() because it already serialises lifetime state that lives alongside
673 // the watcher (both are Config-wide, not per-item).
674 348 std::vector<DetourModKit::Config::InputBindingGuard> &get_reload_hotkey_guards() noexcept
675 {
676
3/4
✓ Branch 2 → 3 taken 163 times.
✓ Branch 2 → 7 taken 185 times.
✓ Branch 4 → 5 taken 163 times.
✗ Branch 4 → 7 not taken.
348 static std::vector<DetourModKit::Config::InputBindingGuard> s_guards;
677 348 return s_guards;
678 }
679
680 /**
681 * @class ReloadServicer
682 * @brief Background thread that coalesces reload-hotkey presses and invokes Config::reload() off the
683 * InputManager
684 * poll thread.
685 * @details The hotkey press callback must return in microseconds so other hotkeys do not jitter while a 30-item
686 * INI parse runs. The servicer latches a pending-reload flag; its worker thread blocks on a condition
687 * variable, drains the flag on wake, and invokes reload() at most once per batch of presses.
688 * Exceptions from reload() are caught so the servicer never dies.
689 *
690 * Lazy lifetime: created on the first register_reload_hotkey call, kept alive until clear_registered_items()
691 * tears it down. Shared via std::shared_ptr so a press callback that races with shutdown cannot dereference a
692 * freed channel.
693 */
694 class ReloadServicer
695 {
696 public:
697 2 ReloadServicer()
698 2 {
699 // Launch the servicer worker. StoppableWorker passes its own stop_token into the body; we observe it
700 // via stop_requested() inside the wait predicate. To make request_stop() wake a currently blocked
701 // cv.wait, we install a stop_callback on the body's token (captured inside service_loop) that flips
702 // m_shutdown and notifies the CV.
703 2 m_worker = std::make_unique<DetourModKit::StoppableWorker>(
704
1/2
✓ Branch 7 → 8 taken 2 times.
✗ Branch 7 → 11 not taken.
6 "ConfigReloadServicer", [this](std::stop_token st) { service_loop(std::move(st)); });
705 2 }
706
707 2 ~ReloadServicer() noexcept
708 {
709 // Flip the shutdown flag and wake the worker before the
710 // StoppableWorker destructor asks it to stop + join. notify_all() is harmless if the worker already
711 // exited.
712 {
713 2 std::lock_guard<std::mutex> lock(m_mutex);
714 2 m_shutdown.store(true, std::memory_order_release);
715 2 }
716 2 m_cv.notify_all();
717
718 // ~StoppableWorker requests stop + joins (or detaches under loader lock). Safe to let it run as-is.
719 2 m_worker.reset();
720 2 }
721
722 ReloadServicer(const ReloadServicer &) = delete;
723 ReloadServicer &operator=(const ReloadServicer &) = delete;
724 ReloadServicer(ReloadServicer &&) = delete;
725 ReloadServicer &operator=(ReloadServicer &&) = delete;
726
727 /**
728 * @brief Requests a reload. noexcept and allocation-free on the fast path; the press callback uses this and
729 * must not throw back onto the InputManager poll thread.
730 */
731 void request_reload() noexcept
732 {
733 // The predicate variable m_reload_requested must be mutated under m_mutex (or at minimum the notifier
734 // must take the mutex before notify_one) to close the lost-wakeup window on the waiter side: waiter
735 // evaluates the predicate false (pre-lock), then parks; if we stored + notified in that gap without
736 // touching the mutex, the press could be dropped until the next one. Taking the mutex here serialises
737 // against the waiter's predicate re-check under m_mutex, making the wakeup observation guaranteed.
738 {
739 std::lock_guard<std::mutex> lock(m_mutex);
740 m_reload_requested.store(true, std::memory_order_release);
741 }
742 m_cv.notify_one();
743 }
744
745 private:
746 2 void service_loop(std::stop_token st) noexcept
747 {
748 2 DetourModKit::Logger &logger = DetourModKit::Logger::get_instance();
749
750 // Wake the CV when the worker is asked to stop so the blocked wait exits promptly instead of waiting
751 // for the next press.
752 std::stop_callback stop_cb(st,
753 6 [this]() -> void
754 {
755 {
756
1/2
✓ Branch 2 → 3 taken 2 times.
✗ Branch 2 → 7 not taken.
2 std::lock_guard<std::mutex> lock(m_mutex);
757 2 m_shutdown.store(true, std::memory_order_release);
758 2 }
759 2 m_cv.notify_all();
760 4 });
761
762
3/6
✓ Branch 23 → 24 taken 2 times.
✗ Branch 23 → 27 not taken.
✓ Branch 25 → 26 taken 2 times.
✗ Branch 25 → 27 not taken.
✓ Branch 28 → 5 taken 2 times.
✗ Branch 28 → 29 not taken.
2 while (!st.stop_requested() && !m_shutdown.load(std::memory_order_acquire))
763 {
764 {
765 2 std::unique_lock<std::mutex> lock(m_mutex);
766 2 m_cv.wait(lock,
767 4 [&]()
768 {
769
4/6
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 8 taken 2 times.
✓ Branch 5 → 6 taken 2 times.
✗ Branch 5 → 8 not taken.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 2 times.
6 return st.stop_requested() || m_shutdown.load(std::memory_order_acquire) ||
770 6 m_reload_requested.load(std::memory_order_acquire);
771 });
772 2 }
773
774
2/6
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 12 taken 2 times.
✗ Branch 11 → 12 not taken.
✗ Branch 11 → 13 not taken.
✓ Branch 14 → 15 taken 2 times.
✗ Branch 14 → 16 not taken.
2 if (st.stop_requested() || m_shutdown.load(std::memory_order_acquire))
775 {
776 2 break;
777 }
778
779 // Coalesce: a burst of presses during the reload below
780 // collapses into at most one follow-up pass because the next iteration will exchange the flag once.
781 while (m_reload_requested.exchange(false, std::memory_order_acq_rel))
782 {
783 try
784 {
785 (void)DetourModKit::Config::reload();
786 }
787 catch (const std::exception &e)
788 {
789 logger.error("Config: reload servicer caught exception: {}", e.what());
790 }
791 catch (...)
792 {
793 logger.error("Config: reload servicer caught unknown exception.");
794 }
795 }
796 }
797 2 }
798
799 std::mutex m_mutex;
800 std::condition_variable m_cv;
801 std::atomic<bool> m_reload_requested{false};
802 std::atomic<bool> m_shutdown{false};
803 std::unique_ptr<DetourModKit::StoppableWorker> m_worker;
804 };
805
806 // Shared_ptr so a press callback holding its own strong reference cannot crash when clear_registered_items()
807 // resets the slot.
808 348 std::shared_ptr<ReloadServicer> &get_reload_servicer() noexcept
809 {
810
3/4
✓ Branch 2 → 3 taken 163 times.
✓ Branch 2 → 7 taken 185 times.
✓ Branch 4 → 5 taken 163 times.
✗ Branch 4 → 7 not taken.
348 static std::shared_ptr<ReloadServicer> s_servicer;
811 348 return s_servicer;
812 }
813
814 /**
815 * @brief Replaces an existing item with the same section+key, or appends if none found.
816 * @note Caller must hold get_config_mutex().
817 */
818 178 void replace_or_append(std::unique_ptr<ConfigItemBase> item)
819 {
820 178 auto &items = get_registered_config_items();
821
2/2
✓ Branch 31 → 5 taken 54 times.
✓ Branch 31 → 32 taken 174 times.
406 for (auto &existing : items)
822 {
823
6/6
✓ Branch 10 → 11 taken 42 times.
✓ Branch 10 → 16 taken 12 times.
✓ Branch 14 → 15 taken 4 times.
✓ Branch 14 → 16 taken 38 times.
✓ Branch 17 → 18 taken 4 times.
✓ Branch 17 → 22 taken 50 times.
54 if (existing->section == item->section && existing->ini_key == item->ini_key)
824 {
825 4 existing = std::move(item);
826 4 return;
827 }
828 }
829 174 items.push_back(std::move(item));
830 }
831
832 /**
833 * @brief Determines the full absolute path for the INI configuration file.
834 */
835 167 std::filesystem::path get_ini_file_path(const std::string &ini_filename, Logger &logger)
836 {
837
1/2
✓ Branch 2 → 3 taken 167 times.
✗ Branch 2 → 68 not taken.
167 std::wstring module_dir = get_runtime_directory();
838
839
4/8
✓ Branch 4 → 5 taken 167 times.
✗ Branch 4 → 7 not taken.
✓ Branch 5 → 6 taken 167 times.
✗ Branch 5 → 66 not taken.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 167 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 13 taken 167 times.
167 if (module_dir.empty() || module_dir == L".")
840 {
841 logger.warning(
842 "Config: Could not reliably determine module directory or it's current working directory. "
843 "Using relative path for INI: {}",
844 ini_filename);
845 // Fallback to relative path
846 return std::filesystem::path(ini_filename);
847 }
848
849 try
850 {
851 std::filesystem::path ini_path_obj =
852
4/8
✓ Branch 13 → 14 taken 167 times.
✗ Branch 13 → 39 not taken.
✓ Branch 14 → 15 taken 167 times.
✗ Branch 14 → 36 not taken.
✓ Branch 15 → 16 taken 167 times.
✗ Branch 15 → 34 not taken.
✓ Branch 16 → 17 taken 167 times.
✗ Branch 16 → 32 not taken.
167 (std::filesystem::path(module_dir) / ini_filename).lexically_normal();
853
2/4
✓ Branch 20 → 21 taken 167 times.
✗ Branch 20 → 44 not taken.
✓ Branch 21 → 22 taken 167 times.
✗ Branch 21 → 41 not taken.
167 logger.debug("Config: Determined INI file path: {}", ini_path_obj.string());
854 167 return ini_path_obj;
855 167 }
856 catch (const std::filesystem::filesystem_error &fs_err)
857 {
858 logger.warning("Config: Filesystem error constructing INI path: {}. Using relative path for INI: {}",
859 fs_err.what(), ini_filename);
860 }
861 catch (const std::exception &e)
862 {
863 logger.warning("Config: General error constructing INI path: {}. Using relative path for INI: {}",
864 e.what(), ini_filename);
865 }
866 return std::filesystem::path(ini_filename); // Fallback
867 167 }
868
869 } // anonymous namespace
870
871 // All register_* functions use the deferred callback pattern: state is mutated under get_config_mutex(), but the
872 // setter callback is invoked after the lock is released. This allows setters to call back into the Config API
873 // without deadlocking (no reentrancy guard needed).
874 62 void DetourModKit::Config::register_int(std::string_view section, std::string_view ini_key,
875 std::string_view log_key_name, std::function<void(int)> setter,
876 int default_value)
877 {
878 62 std::function<void()> deferred;
879 {
880
1/2
✓ Branch 4 → 5 taken 62 times.
✗ Branch 4 → 71 not taken.
62 std::lock_guard<std::mutex> lock(get_config_mutex());
881
2/4
✓ Branch 14 → 15 taken 62 times.
✗ Branch 14 → 43 not taken.
✓ Branch 16 → 17 taken 62 times.
✗ Branch 16 → 39 not taken.
62 replace_or_append(std::make_unique<CallbackConfigItem<int>>(
882
3/6
✓ Branch 7 → 8 taken 62 times.
✗ Branch 7 → 57 not taken.
✓ Branch 10 → 11 taken 62 times.
✗ Branch 10 → 51 not taken.
✓ Branch 13 → 14 taken 62 times.
✗ Branch 13 → 45 not taken.
372 std::string(section), std::string(ini_key), std::string(log_key_name), setter, default_value));
883
2/2
✓ Branch 26 → 27 taken 59 times.
✓ Branch 26 → 33 taken 3 times.
62 if (setter)
884 {
885
3/8
✓ Branch 27 → 28 taken 59 times.
✗ Branch 27 → 68 not taken.
✓ Branch 28 → 29 taken 59 times.
✗ Branch 28 → 63 not taken.
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 32 taken 59 times.
✗ Branch 65 → 66 not taken.
✗ Branch 65 → 67 not taken.
118 deferred = [setter, default_value]() { setter(default_value); };
886 }
887 62 }
888
2/2
✓ Branch 35 → 36 taken 59 times.
✓ Branch 35 → 37 taken 3 times.
62 if (deferred)
889 {
890
1/2
✓ Branch 36 → 37 taken 59 times.
✗ Branch 36 → 72 not taken.
59 deferred();
891 }
892 62 }
893
894 10 void DetourModKit::Config::register_float(std::string_view section, std::string_view ini_key,
895 std::string_view log_key_name, std::function<void(float)> setter,
896 float default_value)
897 {
898 10 std::function<void()> deferred;
899 {
900
1/2
✓ Branch 4 → 5 taken 10 times.
✗ Branch 4 → 71 not taken.
10 std::lock_guard<std::mutex> lock(get_config_mutex());
901
2/4
✓ Branch 14 → 15 taken 10 times.
✗ Branch 14 → 43 not taken.
✓ Branch 16 → 17 taken 10 times.
✗ Branch 16 → 39 not taken.
10 replace_or_append(std::make_unique<CallbackConfigItem<float>>(
902
3/6
✓ Branch 7 → 8 taken 10 times.
✗ Branch 7 → 57 not taken.
✓ Branch 10 → 11 taken 10 times.
✗ Branch 10 → 51 not taken.
✓ Branch 13 → 14 taken 10 times.
✗ Branch 13 → 45 not taken.
60 std::string(section), std::string(ini_key), std::string(log_key_name), setter, default_value));
903
1/2
✓ Branch 26 → 27 taken 10 times.
✗ Branch 26 → 33 not taken.
10 if (setter)
904 {
905
3/8
✓ Branch 27 → 28 taken 10 times.
✗ Branch 27 → 68 not taken.
✓ Branch 28 → 29 taken 10 times.
✗ Branch 28 → 63 not taken.
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 32 taken 10 times.
✗ Branch 65 → 66 not taken.
✗ Branch 65 → 67 not taken.
20 deferred = [setter, default_value]() { setter(default_value); };
906 }
907 10 }
908
1/2
✓ Branch 35 → 36 taken 10 times.
✗ Branch 35 → 37 not taken.
10 if (deferred)
909 {
910
1/2
✓ Branch 36 → 37 taken 10 times.
✗ Branch 36 → 72 not taken.
10 deferred();
911 }
912 10 }
913
914 17 void DetourModKit::Config::register_bool(std::string_view section, std::string_view ini_key,
915 std::string_view log_key_name, std::function<void(bool)> setter,
916 bool default_value)
917 {
918 17 std::function<void()> deferred;
919 {
920
1/2
✓ Branch 4 → 5 taken 17 times.
✗ Branch 4 → 71 not taken.
17 std::lock_guard<std::mutex> lock(get_config_mutex());
921
2/4
✓ Branch 14 → 15 taken 17 times.
✗ Branch 14 → 43 not taken.
✓ Branch 16 → 17 taken 17 times.
✗ Branch 16 → 39 not taken.
17 replace_or_append(std::make_unique<CallbackConfigItem<bool>>(
922
3/6
✓ Branch 7 → 8 taken 17 times.
✗ Branch 7 → 57 not taken.
✓ Branch 10 → 11 taken 17 times.
✗ Branch 10 → 51 not taken.
✓ Branch 13 → 14 taken 17 times.
✗ Branch 13 → 45 not taken.
102 std::string(section), std::string(ini_key), std::string(log_key_name), setter, default_value));
923
1/2
✓ Branch 26 → 27 taken 17 times.
✗ Branch 26 → 33 not taken.
17 if (setter)
924 {
925
3/8
✓ Branch 27 → 28 taken 17 times.
✗ Branch 27 → 68 not taken.
✓ Branch 28 → 29 taken 17 times.
✗ Branch 28 → 63 not taken.
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 32 taken 17 times.
✗ Branch 65 → 66 not taken.
✗ Branch 65 → 67 not taken.
34 deferred = [setter, default_value]() { setter(default_value); };
926 }
927 17 }
928
1/2
✓ Branch 35 → 36 taken 17 times.
✗ Branch 35 → 37 not taken.
17 if (deferred)
929 {
930
1/2
✓ Branch 36 → 37 taken 17 times.
✗ Branch 36 → 72 not taken.
17 deferred();
931 }
932 17 }
933
934 13 void DetourModKit::Config::register_string(std::string_view section, std::string_view ini_key,
935 std::string_view log_key_name,
936 std::function<void(const std::string &)> setter,
937 std::string default_value)
938 {
939 13 std::function<void()> deferred;
940 {
941
1/2
✓ Branch 4 → 5 taken 13 times.
✗ Branch 4 → 74 not taken.
13 std::lock_guard<std::mutex> lock(get_config_mutex());
942
2/4
✓ Branch 14 → 15 taken 13 times.
✗ Branch 14 → 46 not taken.
✓ Branch 16 → 17 taken 13 times.
✗ Branch 16 → 42 not taken.
13 replace_or_append(std::make_unique<CallbackConfigItem<std::string>>(
943
3/6
✓ Branch 7 → 8 taken 13 times.
✗ Branch 7 → 60 not taken.
✓ Branch 10 → 11 taken 13 times.
✗ Branch 10 → 54 not taken.
✓ Branch 13 → 14 taken 13 times.
✗ Branch 13 → 48 not taken.
78 std::string(section), std::string(ini_key), std::string(log_key_name), setter, default_value));
944
2/2
✓ Branch 26 → 27 taken 12 times.
✓ Branch 26 → 36 taken 1 time.
13 if (setter)
945 {
946
3/8
✓ Branch 27 → 28 taken 12 times.
✗ Branch 27 → 71 not taken.
✓ Branch 31 → 32 taken 12 times.
✗ Branch 31 → 66 not taken.
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 35 taken 12 times.
✗ Branch 68 → 69 not taken.
✗ Branch 68 → 70 not taken.
36 deferred = [setter, val = std::move(default_value)]() { setter(val); };
947 }
948 13 }
949
2/2
✓ Branch 38 → 39 taken 12 times.
✓ Branch 38 → 40 taken 1 time.
13 if (deferred)
950 {
951
1/2
✓ Branch 39 → 40 taken 12 times.
✗ Branch 39 → 75 not taken.
12 deferred();
952 }
953 13 }
954
955 1 void DetourModKit::Config::register_log_level(std::string_view section, std::string_view ini_key,
956 std::string_view default_value)
957 {
958
1/2
✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 12 not taken.
1 register_string(
959 2 section, ini_key, "Log level", [](const std::string &value)
960
1/2
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 19 not taken.
4 { Logger::get_instance().set_log_level(Logger::string_to_log_level(value)); }, std::string(default_value));
961 1 }
962
963 76 void DetourModKit::Config::register_key_combo(std::string_view section, std::string_view ini_key,
964 std::string_view log_key_name,
965 std::function<void(const KeyComboList &)> setter,
966 std::string_view default_value_str)
967 {
968
2/4
✓ Branch 4 → 5 taken 76 times.
✗ Branch 4 → 51 not taken.
✓ Branch 5 → 6 taken 76 times.
✗ Branch 5 → 49 not taken.
76 Config::KeyComboList default_combos = parse_key_combo_list(std::string(default_value_str), log_key_name);
969
970 76 std::function<void()> deferred;
971 {
972
1/2
✓ Branch 10 → 11 taken 76 times.
✗ Branch 10 → 87 not taken.
76 std::lock_guard<std::mutex> lock(get_config_mutex());
973
2/4
✓ Branch 20 → 21 taken 76 times.
✗ Branch 20 → 59 not taken.
✓ Branch 22 → 23 taken 76 times.
✗ Branch 22 → 55 not taken.
76 replace_or_append(std::make_unique<CallbackConfigItem<Config::KeyComboList>>(
974
3/6
✓ Branch 13 → 14 taken 76 times.
✗ Branch 13 → 73 not taken.
✓ Branch 16 → 17 taken 76 times.
✗ Branch 16 → 67 not taken.
✓ Branch 19 → 20 taken 76 times.
✗ Branch 19 → 61 not taken.
456 std::string(section), std::string(ini_key), std::string(log_key_name), setter, default_combos));
975
2/2
✓ Branch 32 → 33 taken 75 times.
✓ Branch 32 → 42 taken 1 time.
76 if (setter)
976 {
977
3/8
✓ Branch 33 → 34 taken 75 times.
✗ Branch 33 → 84 not taken.
✓ Branch 37 → 38 taken 75 times.
✗ Branch 37 → 79 not taken.
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 41 taken 75 times.
✗ Branch 81 → 82 not taken.
✗ Branch 81 → 83 not taken.
225 deferred = [setter, combos = std::move(default_combos)]() { setter(combos); };
978 }
979 76 }
980
2/2
✓ Branch 44 → 45 taken 75 times.
✓ Branch 44 → 46 taken 1 time.
76 if (deferred)
981 {
982
1/2
✓ Branch 45 → 46 taken 75 times.
✗ Branch 45 → 88 not taken.
75 deferred();
983 }
984 76 }
985
986 // Anonymous namespace: the shared combo-binding fusion behind register_press_combo() and register_hold_combo().
987 namespace
988 {
989 /// Trigger kind selected by the shared combo-binding fusion.
990 enum class TriggerMode
991 {
992 Press,
993 Hold
994 };
995
996 /**
997 * @brief Shared implementation behind register_press_combo() and register_hold_combo().
998 * @details Builds the combo INI item (register_key_combo, wired to update_binding_combos for reload rebind),
999 * registers the InputManager binding for @p trigger (press wraps a flag-gated void() callback; hold
1000 * wraps a HoldGate that adds the balancing-release lifecycle), optionally registers the
1001 * "<ini_key>.Consume" facet, and returns the owning guard. Exactly one of @p on_press /
1002 * @p on_state_change is used, per @p trigger.
1003 */
1004 15 Config::InputBindingGuard register_combo_binding(TriggerMode trigger, std::string_view section,
1005 std::string_view ini_key, std::string_view log_name,
1006 std::string_view input_binding_name,
1007 std::function<void()> on_press,
1008 std::function<void(bool)> on_state_change,
1009 std::string_view default_value, std::optional<bool> consume)
1010 {
1011
1/2
✓ Branch 2 → 3 taken 15 times.
✗ Branch 2 → 91 not taken.
15 auto enabled_flag = std::make_shared<std::atomic<bool>>(true);
1012 // Seed empty: register_key_combo() below parses default_value once and synchronously invokes the setter,
1013 // which writes the parsed default into current_combos before the InputManager binding reads it. Pre-parsing
1014 // the same default here would be a redundant second parse -- and a duplicate WARNING when the C++ literal
1015 // default carries a typo.
1016
1/2
✓ Branch 3 → 4 taken 15 times.
✗ Branch 3 → 152 not taken.
15 auto current_combos = std::make_shared<Config::KeyComboList>();
1017
1/2
✓ Branch 6 → 7 taken 15 times.
✗ Branch 6 → 92 not taken.
15 std::string binding_name_str(input_binding_name);
1018
1019
1/2
✓ Branch 11 → 12 taken 15 times.
✗ Branch 11 → 95 not taken.
15 Config::register_key_combo(
1020 section, ini_key, log_name,
1021
3/8
✓ Branch 9 → 10 taken 15 times.
✗ Branch 9 → 99 not taken.
✓ Branch 10 → 11 taken 15 times.
✗ Branch 10 → 97 not taken.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 15 times.
✗ Branch 99 → 100 not taken.
✗ Branch 99 → 101 not taken.
30 [current_combos, binding_name_str](const Config::KeyComboList &combos)
1022 {
1023 26 *current_combos = combos;
1024 26 InputManager::get_instance().update_binding_combos(binding_name_str, combos);
1025 26 },
1026 default_value);
1027
1028 // Empty for press; set for hold to synthesize the balancing false.
1029 15 std::function<void()> release_action;
1030
2/2
✓ Branch 17 → 18 taken 10 times.
✓ Branch 17 → 32 taken 5 times.
15 if (trigger == TriggerMode::Press)
1031 {
1032
1/2
✓ Branch 26 → 27 taken 10 times.
✗ Branch 26 → 104 not taken.
20 InputManager::get_instance().register_press(binding_name_str, *current_combos,
1033
2/6
✓ Branch 23 → 24 taken 10 times.
✗ Branch 23 → 106 not taken.
✗ Branch 29 → 30 not taken.
✓ Branch 29 → 31 taken 10 times.
✗ Branch 108 → 109 not taken.
✗ Branch 108 → 110 not taken.
30 [enabled_flag, cb = std::move(on_press)]()
1034 {
1035 if (cb && enabled_flag->load(std::memory_order_acquire))
1036 {
1037 cb();
1038 }
1039 });
1040 }
1041 else
1042 {
1043
1/2
✓ Branch 32 → 33 taken 5 times.
✗ Branch 32 → 124 not taken.
5 auto gate = std::make_shared<detail::HoldGate>();
1044 5 gate->enabled = enabled_flag;
1045 5 gate->on_state_change = std::move(on_state_change);
1046
1/2
✓ Branch 44 → 45 taken 5 times.
✗ Branch 44 → 113 not taken.
10 InputManager::get_instance().register_hold(binding_name_str, *current_combos,
1047
1/2
✓ Branch 41 → 42 taken 5 times.
✗ Branch 41 → 115 not taken.
10 [gate](bool active) { gate->deliver(active); });
1048
1/2
✓ Branch 48 → 49 taken 5 times.
✗ Branch 48 → 119 not taken.
10 release_action = [gate]() { gate->release(); };
1049 5 }
1050
1051 // Build the guard before registering the optional consume facet. register_consume_flag() allocates a
1052 // Config item and can throw; constructing the guard first means a throw there unwinds through the guard's
1053 // destructor, which disables the just-registered binding instead of leaking its callback live without an
1054 // owner. binding_name_str is copied (not moved) so it stays valid for the consume registration below; an
1055 // empty release_action (the press case) makes the three-argument guard behave exactly like the two-argument
1056 // one.
1057
1/2
✓ Branch 52 → 53 taken 15 times.
✗ Branch 52 → 125 not taken.
45 Config::InputBindingGuard guard{binding_name_str, std::move(enabled_flag), std::move(release_action)};
1058
1059 // Register the consume facet only after the binding exists: register_consume_flag()'s immediate setter
1060 // calls set_consume(), a no-op for an unknown name, so registering the bool item before
1061 // register_press/register_hold created the binding would drop the registration-time default.
1062
2/2
✓ Branch 64 → 65 taken 3 times.
✓ Branch 64 → 85 taken 12 times.
15 if (consume.has_value())
1063 {
1064
3/6
✓ Branch 74 → 75 taken 3 times.
✗ Branch 74 → 130 not taken.
✓ Branch 75 → 76 taken 3 times.
✗ Branch 75 → 128 not taken.
✓ Branch 77 → 78 taken 3 times.
✗ Branch 77 → 126 not taken.
6 Config::register_consume_flag(section, std::string(ini_key) + ".Consume",
1065
2/4
✓ Branch 69 → 70 taken 3 times.
✗ Branch 69 → 139 not taken.
✓ Branch 70 → 71 taken 3 times.
✗ Branch 70 → 137 not taken.
12 std::string(log_name) + " Consume", binding_name_str, *consume);
1066 }
1067
1068 15 return guard;
1069 15 }
1070 } // namespace
1071
1072 DetourModKit::Config::InputBindingGuard
1073 10 DetourModKit::Config::register_press_combo(std::string_view section, std::string_view ini_key,
1074 std::string_view log_name, std::string_view input_binding_name,
1075 std::function<void()> on_press, std::string_view default_value,
1076 std::optional<bool> consume)
1077 {
1078 10 return register_combo_binding(TriggerMode::Press, section, ini_key, log_name, input_binding_name,
1079
1/2
✓ Branch 6 → 7 taken 10 times.
✗ Branch 6 → 12 not taken.
30 std::move(on_press), nullptr, default_value, consume);
1080 }
1081
1082 DetourModKit::Config::InputBindingGuard
1083 5 DetourModKit::Config::register_hold_combo(std::string_view section, std::string_view ini_key,
1084 std::string_view log_name, std::string_view input_binding_name,
1085 std::function<void(bool)> on_state_change, std::string_view default_value,
1086 std::optional<bool> consume)
1087 {
1088 10 return register_combo_binding(TriggerMode::Hold, section, ini_key, log_name, input_binding_name, nullptr,
1089
1/2
✓ Branch 6 → 7 taken 5 times.
✗ Branch 6 → 12 not taken.
15 std::move(on_state_change), default_value, consume);
1090 }
1091
1092 4 void DetourModKit::Config::register_consume_flag(std::string_view section, std::string_view ini_key,
1093 std::string_view log_key_name, std::string_view input_binding_name,
1094 bool default_value)
1095 {
1096 // Capture the binding name by value so the setter, which outlives this call and re-runs on every
1097 // load()/reload(), stays valid. set_consume is a no-op for an unknown name, so registering this before the
1098 // binding exists is safe.
1099
1/2
✓ Branch 4 → 5 taken 4 times.
✗ Branch 4 → 13 not taken.
4 std::string binding_name_str(input_binding_name);
1100
1/2
✓ Branch 8 → 9 taken 4 times.
✗ Branch 8 → 16 not taken.
4 register_bool(
1101
2/4
✓ Branch 6 → 7 taken 4 times.
✗ Branch 6 → 20 not taken.
✓ Branch 7 → 8 taken 4 times.
✗ Branch 7 → 18 not taken.
8 section, ini_key, log_key_name, [binding_name_str](bool consume)
1102 5 { InputManager::get_instance().set_consume(binding_name_str, consume); }, default_value);
1103 4 }
1104
1105 121 void DetourModKit::Config::load(std::string_view ini_filename)
1106 {
1107 121 std::vector<std::function<void()>> deferred_callbacks;
1108
1109 {
1110
1/2
✓ Branch 3 → 4 taken 121 times.
✗ Branch 3 → 118 not taken.
121 std::lock_guard<std::mutex> lock(get_config_mutex());
1111
1112
1/2
✓ Branch 4 → 5 taken 121 times.
✗ Branch 4 → 116 not taken.
121 Logger &logger = Logger::get_instance();
1113
2/4
✓ Branch 7 → 8 taken 121 times.
✗ Branch 7 → 92 not taken.
✓ Branch 8 → 9 taken 121 times.
✗ Branch 8 → 90 not taken.
121 std::filesystem::path ini_path = get_ini_file_path(std::string(ini_filename), logger);
1114 // convert to narrow string for logger formatting
1115
1/2
✓ Branch 11 → 12 taken 121 times.
✗ Branch 11 → 114 not taken.
121 std::string ini_path_str = ini_path.string();
1116 121 CSimpleIniA ini;
1117 121 ini.SetUnicode(false); // Assume ASCII/MBCS INI
1118 121 ini.SetMultiKey(false); // Disallow duplicate keys in a section
1119
1120 // Read-hash-parse pipeline: read bytes once, hash them, feed the same buffer into CSimpleIniA::LoadData so
1121 // the cached hash and the parsed INI state are guaranteed to reflect identical file contents (TOCTOU-free
1122 // vs. a separate LoadFile call).
1123 121 IniLoadOutcome outcome = load_ini_into(ini_path, ini);
1124
1125
3/4
✓ Branch 16 → 17 taken 92 times.
✓ Branch 16 → 19 taken 29 times.
✓ Branch 17 → 18 taken 92 times.
✗ Branch 17 → 19 not taken.
121 const bool load_succeeded = outcome.read_succeeded && outcome.parse_succeeded;
1126
2/2
✓ Branch 20 → 21 taken 29 times.
✓ Branch 20 → 24 taken 92 times.
121 if (!outcome.read_succeeded)
1127 {
1128
1/2
✓ Branch 21 → 22 taken 29 times.
✗ Branch 21 → 96 not taken.
29 logger.error("Config: Failed to open '{}'. Using defaults.", ini_path_str);
1129 // File unreadable: wipe the cached hash so the next reload() does not short-circuit against a stale
1130 // value.
1131 29 get_last_loaded_ini_hash().reset();
1132 }
1133
1/2
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 28 taken 92 times.
92 else if (!outcome.parse_succeeded)
1134 {
1135 logger.error("Config: Failed to parse '{}' (error {}). Using defaults.", ini_path_str,
1136 static_cast<int>(outcome.parse_rc));
1137 // Parse failed: clear the hash so a subsequent successful load() does not spuriously hash-skip a reload
1138 // against a hash computed for bytes we could not actually parse.
1139 get_last_loaded_ini_hash().reset();
1140 }
1141 else
1142 {
1143
1/2
✓ Branch 28 → 29 taken 92 times.
✗ Branch 28 → 99 not taken.
92 logger.debug("Config: Opened {}", ini_path_str);
1144 92 get_last_loaded_ini_hash() = outcome.hash;
1145 }
1146
1147 // Read all values under lock, but defer setter callbacks
1148
2/2
✓ Branch 55 → 34 taken 144 times.
✓ Branch 55 → 56 taken 121 times.
386 for (const auto &item : get_registered_config_items())
1149 {
1150
1/2
✓ Branch 37 → 38 taken 144 times.
✗ Branch 37 → 102 not taken.
144 item->load(ini, logger);
1151
1/2
✓ Branch 39 → 40 taken 144 times.
✗ Branch 39 → 102 not taken.
144 auto cb = item->take_deferred_apply();
1152
1/2
✓ Branch 41 → 42 taken 144 times.
✗ Branch 41 → 45 not taken.
144 if (cb)
1153 {
1154
1/2
✓ Branch 44 → 45 taken 144 times.
✗ Branch 44 → 100 not taken.
144 deferred_callbacks.push_back(std::move(cb));
1155 }
1156 144 }
1157
1158 // Remember the INI path so reload() can re-run setters against the same file without the caller passing it
1159 // again. Only update the stored path on success; a failed load must leave the previously remembered path
1160 // (if any) untouched so subsequent reload() calls keep targeting the last good file rather than a missing
1161 // or malformed one.
1162
2/2
✓ Branch 56 → 57 taken 92 times.
✓ Branch 56 → 65 taken 29 times.
121 if (load_succeeded)
1163 {
1164
1/2
✓ Branch 59 → 60 taken 92 times.
✗ Branch 59 → 104 not taken.
184 get_last_loaded_ini_path() = std::string(ini_filename);
1165 }
1166
1167
1/2
✓ Branch 67 → 68 taken 121 times.
✗ Branch 67 → 108 not taken.
121 logger.info("Config: Loaded {} items from {}", get_registered_config_items().size(), ini_path_str);
1168 121 }
1169
1170 // Invoke setter callbacks outside the config mutex -- same deferred
1171 // pattern as register_*(). Setters may safely call back into Config.
1172 // Wrap each call so a single throwing setter cannot prevent the remaining setters from applying the freshly
1173 // loaded values, mirroring reload_impl(): the initial load() and a reload() share the same per-setter isolation
1174 // so one bad item degrades to a logged warning instead of aborting the whole load. The logger is acquired
1175 // outside the config mutex -- a custom Logger sink that re-enters Config cannot AB/BA deadlock here.
1176
1/2
✓ Branch 72 → 73 taken 121 times.
✗ Branch 72 → 135 not taken.
121 Logger &setter_logger = Logger::get_instance();
1177
2/2
✓ Branch 87 → 75 taken 144 times.
✓ Branch 87 → 88 taken 121 times.
386 for (auto &cb : deferred_callbacks)
1178 {
1179 try
1180 {
1181
1/2
✓ Branch 77 → 78 taken 144 times.
✗ Branch 77 → 119 not taken.
144 cb();
1182 }
1183 catch (const std::exception &e)
1184 {
1185 setter_logger.error("Config: load setter threw: {}", e.what());
1186 }
1187 catch (...)
1188 {
1189 setter_logger.error("Config: load setter threw unknown exception.");
1190 }
1191 }
1192 121 }
1193
1194 namespace
1195 {
1196 /**
1197 * @brief Internal reload implementation that also reports whether setters actually ran.
1198 * @param[out] out_setters_ran Set to true when setters were invoked. False when the content-hash short-circuit
1199 * skipped the reload.
1200 * @return true if a previous load() path was available and the reload proceeded; false if reload() was called
1201 * before any load().
1202 */
1203 33 bool reload_impl(bool &out_setters_ran)
1204 {
1205 33 out_setters_ran = false;
1206
1207 33 std::vector<std::function<void()>> deferred_callbacks;
1208 33 std::string ini_filename;
1209
1210 {
1211
1/2
✓ Branch 4 → 5 taken 33 times.
✗ Branch 4 → 125 not taken.
33 std::lock_guard<std::mutex> lock(get_config_mutex());
1212
1213
1/2
✓ Branch 6 → 7 taken 33 times.
✗ Branch 6 → 123 not taken.
33 ini_filename = get_last_loaded_ini_path();
1214
2/2
✓ Branch 8 → 9 taken 2 times.
✓ Branch 8 → 10 taken 31 times.
33 if (ini_filename.empty())
1215 {
1216 // No prior load() -- nothing to reload. Caller is expected to check the return value and either
1217 // call load() first or surface a user-facing error.
1218 2 return false;
1219 }
1220
1221
1/2
✓ Branch 10 → 11 taken 31 times.
✗ Branch 10 → 123 not taken.
31 DetourModKit::Logger &logger = DetourModKit::Logger::get_instance();
1222
1/2
✓ Branch 11 → 12 taken 31 times.
✗ Branch 11 → 123 not taken.
31 std::filesystem::path ini_path = get_ini_file_path(ini_filename, logger);
1223
1/2
✓ Branch 12 → 13 taken 31 times.
✗ Branch 12 → 121 not taken.
31 std::string ini_path_str = ini_path.string();
1224
1225 31 CSimpleIniA ini;
1226 31 ini.SetUnicode(false);
1227 31 ini.SetMultiKey(false);
1228
1229 // Read-hash-parse pipeline: the hash we compare against the cache and the bytes SimpleIni parses come
1230 // from a single read. Splitting the read (one for hashing, another via LoadFile for parsing) would let
1231 // an editor save slip between them and desync the cached hash from the parsed state.
1232 31 IniLoadOutcome outcome = load_ini_into(ini_path, ini);
1233
1234
2/2
✓ Branch 17 → 18 taken 2 times.
✓ Branch 17 → 22 taken 29 times.
31 if (!outcome.read_succeeded)
1235 {
1236 // Read failure: clear the cached hash before falling through to run setters with defaults. Leaving
1237 // it in place would let a later reload find identical bytes (same as the last successful load),
1238 // match the stale hash, and hash-skip -- silently leaving in-memory state at the defaults from this
1239 // failed reload.
1240 2 get_last_loaded_ini_hash() = std::nullopt;
1241
1/2
✓ Branch 20 → 21 taken 2 times.
✗ Branch 20 → 105 not taken.
2 logger.warning(
1242 "Config: reload() could not open '{}'; retaining last values where setters keep state.",
1243 ini_path_str);
1244 }
1245 else
1246 {
1247 // Content-hash skip: compare against the hash stored on the last successful load()/reload().
1248 // Identical bytes
1249 // -> no setters. Uses the hash we just computed in the pipeline; no second read.
1250
2/2
✓ Branch 24 → 25 taken 28 times.
✓ Branch 24 → 32 taken 1 time.
29 if (auto &cached_hash = get_last_loaded_ini_hash(); cached_hash.has_value())
1251 {
1252 28 const std::uint64_t current_hash = *outcome.hash;
1253
2/2
✓ Branch 27 → 28 taken 16 times.
✓ Branch 27 → 30 taken 12 times.
28 if (current_hash == *cached_hash)
1254 {
1255
1/2
✓ Branch 28 → 29 taken 16 times.
✗ Branch 28 → 106 not taken.
16 logger.debug("Config::reload: content unchanged (hash {:016x}); skipping setters.",
1256 current_hash);
1257 16 return true;
1258 }
1259 // Content changed: remember the new hash so a subsequent no-op reload can short-circuit.
1260 12 cached_hash = current_hash;
1261 }
1262 else
1263 {
1264 // No cached hash (prior failure or never-loaded):
1265 // adopt the current one so a subsequent no-op reload short-circuits.
1266 1 get_last_loaded_ini_hash() = outcome.hash;
1267 }
1268
1269
1/2
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 37 taken 13 times.
13 if (!outcome.parse_succeeded)
1270 {
1271 // Asymmetry with the read-failure branch above is
1272 // intentional: we have already advanced the cached
1273 // hash to these new bytes, so a later reload with identical bytes correctly short-circuits --
1274 // the partial state produced by re-parsing would be the same. The read-failure branch cannot
1275 // make that guarantee because it never observed the bytes.
1276 logger.warning("Config: reload() parse error on '{}' (error {}); "
1277 "retaining last values where setters keep state.",
1278 ini_path_str, static_cast<int>(outcome.parse_rc));
1279 }
1280 else
1281 {
1282
1/2
✓ Branch 37 → 38 taken 13 times.
✗ Branch 37 → 110 not taken.
13 logger.debug("Config: Reloading from {}", ini_path_str);
1283 }
1284 }
1285
1286
2/2
✓ Branch 63 → 42 taken 21 times.
✓ Branch 63 → 64 taken 15 times.
51 for (const auto &item : get_registered_config_items())
1287 {
1288
1/2
✓ Branch 45 → 46 taken 21 times.
✗ Branch 45 → 113 not taken.
21 item->load(ini, logger);
1289
1/2
✓ Branch 47 → 48 taken 21 times.
✗ Branch 47 → 113 not taken.
21 auto cb = item->take_deferred_apply();
1290
1/2
✓ Branch 49 → 50 taken 21 times.
✗ Branch 49 → 53 not taken.
21 if (cb)
1291 {
1292
1/2
✓ Branch 52 → 53 taken 21 times.
✗ Branch 52 → 111 not taken.
21 deferred_callbacks.push_back(std::move(cb));
1293 }
1294 21 }
1295
1296
1/2
✓ Branch 66 → 67 taken 15 times.
✗ Branch 66 → 115 not taken.
15 logger.info("Config: Reloaded {} items from {}", get_registered_config_items().size(), ini_path_str);
1297
8/8
✓ Branch 69 → 70 taken 15 times.
✓ Branch 69 → 71 taken 16 times.
✓ Branch 73 → 74 taken 15 times.
✓ Branch 73 → 75 taken 16 times.
✓ Branch 77 → 78 taken 15 times.
✓ Branch 77 → 79 taken 16 times.
✓ Branch 81 → 82 taken 15 times.
✓ Branch 81 → 84 taken 18 times.
81 }
1298
1299 // The registry mutex is released by the scope above; setters run unlocked (the standard deferred-setter
1300 // pattern). Wrap each call so a single throwing setter cannot prevent the remaining setters from seeing the
1301 // refreshed values. Logger::error() below is also outside the config mutex -- a custom Logger sink that
1302 // re-enters
1303 // Config cannot AB/BA deadlock here.
1304
1/2
✓ Branch 83 → 85 taken 15 times.
✗ Branch 83 → 142 not taken.
15 DetourModKit::Logger &logger = DetourModKit::Logger::get_instance();
1305
2/2
✓ Branch 99 → 87 taken 21 times.
✓ Branch 99 → 100 taken 15 times.
51 for (auto &cb : deferred_callbacks)
1306 {
1307 try
1308 {
1309
2/2
✓ Branch 89 → 90 taken 20 times.
✓ Branch 89 → 126 taken 1 time.
21 cb();
1310 }
1311
1/2
✓ Branch 126 → 127 taken 1 time.
✗ Branch 126 → 131 not taken.
1 catch (const std::exception &e)
1312 {
1313
1/2
✓ Branch 129 → 130 taken 1 time.
✗ Branch 129 → 134 not taken.
1 logger.error("Config: reload setter threw: {}", e.what());
1314 1 }
1315 catch (...)
1316 {
1317 logger.error("Config: reload setter threw unknown exception.");
1318 }
1319 }
1320 15 out_setters_ran = true;
1321 15 return true;
1322 33 }
1323 } // anonymous namespace
1324
1325 29 bool DetourModKit::Config::reload()
1326 {
1327 29 bool ignored = false;
1328
1/2
✓ Branch 2 → 3 taken 29 times.
✗ Branch 2 → 6 not taken.
58 return reload_impl(ignored);
1329 }
1330
1331 DetourModKit::Config::AutoReloadStatus
1332 17 DetourModKit::Config::enable_auto_reload(std::chrono::milliseconds debounce_window,
1333 std::function<void(bool)> on_reload)
1334 {
1335
1/2
✓ Branch 2 → 3 taken 17 times.
✗ Branch 2 → 61 not taken.
17 const std::string ini_filename = snapshot_last_loaded_ini_path();
1336
1337
1/2
✓ Branch 3 → 4 taken 17 times.
✗ Branch 3 → 59 not taken.
17 Logger &logger = Logger::get_instance();
1338
1339
2/2
✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 8 taken 15 times.
17 if (ini_filename.empty())
1340 {
1341
1/2
✓ Branch 6 → 7 taken 2 times.
✗ Branch 6 → 43 not taken.
2 logger.warning("Config: enable_auto_reload() called before load(); watcher not started.");
1342 2 return AutoReloadStatus::NoPriorLoad;
1343 }
1344
1345 // Resolve to the same absolute path load() uses so the watcher observes the actual file on disk rather than a
1346 // caller-supplied relative stub.
1347
1/2
✓ Branch 8 → 9 taken 15 times.
✗ Branch 8 → 59 not taken.
15 std::filesystem::path ini_path = get_ini_file_path(ini_filename, logger);
1348
1/2
✓ Branch 9 → 10 taken 15 times.
✗ Branch 9 → 57 not taken.
15 std::string resolved_path = ini_path.string();
1349
1350 // Hold get_watcher_mutex() across start() to serialize against a concurrent disable_auto_reload(). start()
1351 // normally returns in milliseconds; under a pathological handshake stall it returns within the 5 s timeout,
1352 // which is preferable to a use-after-free on the watcher if we released the lock and disable_auto_reload()
1353 // moved the unique_ptr out and destroyed it mid-start().
1354 {
1355
1/2
✓ Branch 11 → 12 taken 15 times.
✗ Branch 11 → 52 not taken.
15 std::lock_guard<std::mutex> wlock(get_watcher_mutex());
1356
1357 15 auto &watcher = get_config_watcher();
1358 // Guard on existence, not is_running(): there is a window between make_unique<ConfigWatcher> + start() and
1359 // the worker flipping its running flag true, during which a second concurrent caller would otherwise
1360 // overwrite the still-starting unique_ptr.
1361
2/2
✓ Branch 14 → 15 taken 2 times.
✓ Branch 14 → 17 taken 13 times.
15 if (watcher)
1362 {
1363
1/2
✓ Branch 15 → 16 taken 2 times.
✗ Branch 15 → 44 not taken.
2 logger.warning("Config: enable_auto_reload() called while a watcher is already present; "
1364 "call disable_auto_reload() first.");
1365 2 return AutoReloadStatus::AlreadyRunning;
1366 }
1367
1368
1/2
✓ Branch 20 → 21 taken 13 times.
✗ Branch 20 → 45 not taken.
26 watcher = std::make_unique<ConfigWatcher>(resolved_path, debounce_window,
1369 26 [user_cb = std::move(on_reload)]()
1370 {
1371 // Reload first so any user callback observes the refreshed
1372 // values. The internal impl reports whether setters actually
1373 // ran (false when the content-hash short-circuit skipped the
1374 // work) so the user callback can distinguish a real reload
1375 // from a no-op touch.
1376 4 bool setters_ran = false;
1377
1/2
✓ Branch 2 → 3 taken 4 times.
✗ Branch 2 → 7 not taken.
4 (void)reload_impl(setters_ran);
1378
1/2
✓ Branch 4 → 5 taken 4 times.
✗ Branch 4 → 6 not taken.
4 if (user_cb)
1379 {
1380
1/2
✓ Branch 5 → 6 taken 4 times.
✗ Branch 5 → 7 not taken.
4 user_cb(setters_ran);
1381 }
1382 17 });
1383
1384
3/4
✓ Branch 25 → 26 taken 13 times.
✗ Branch 25 → 50 not taken.
✓ Branch 26 → 27 taken 2 times.
✓ Branch 26 → 30 taken 11 times.
13 if (!watcher->start())
1385 {
1386
1/2
✓ Branch 27 → 28 taken 2 times.
✗ Branch 27 → 49 not taken.
2 logger.error("Config: Auto-reload watcher failed to start for {}", resolved_path);
1387 2 watcher.reset();
1388 2 return AutoReloadStatus::StartFailed;
1389 }
1390
2/2
✓ Branch 32 → 33 taken 11 times.
✓ Branch 32 → 36 taken 4 times.
15 }
1391
1392 logger.info("Config: Auto-reload enabled for {} (debounce {} ms)", resolved_path,
1393
1/2
✓ Branch 35 → 37 taken 11 times.
✗ Branch 35 → 53 not taken.
11 static_cast<long long>(debounce_window.count()));
1394 11 return AutoReloadStatus::Started;
1395 17 }
1396
1397 70 void DetourModKit::Config::disable_auto_reload() noexcept
1398 {
1399 70 std::unique_ptr<ConfigWatcher> to_drop;
1400 {
1401 70 std::lock_guard<std::mutex> wlock(get_watcher_mutex());
1402 70 auto &watcher = get_config_watcher();
1403 // Detect self-invocation from a setter that fires on the watcher thread. Moving out and destroying the
1404 // unique_ptr here would force the worker to join itself inside ~StoppableWorker, raising
1405 // std::system_error(resource_deadlock_would_occur) from std::thread::join(). Log and return instead --
1406 // callers that want to cancel from inside a reload should release the InputBindingGuard or flip their own
1407 // disable flag.
1408
6/6
✓ Branch 6 → 7 taken 12 times.
✓ Branch 6 → 12 taken 58 times.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 12 taken 11 times.
✓ Branch 13 → 14 taken 1 time.
✓ Branch 13 → 17 taken 69 times.
70 if (watcher && watcher->is_worker_thread(std::this_thread::get_id()))
1409 {
1410 1 Logger::get_instance().error(
1411 "Config::disable_auto_reload() called from the watcher thread; ignoring to avoid self-join "
1412 "deadlock. Call from a different thread or disable the hotkey binding instead.");
1413 1 return;
1414 }
1415 69 to_drop = std::move(watcher);
1416
2/2
✓ Branch 22 → 23 taken 69 times.
✓ Branch 22 → 25 taken 1 time.
70 }
1417 // Destructor of ConfigWatcher joins its worker outside our mutex to avoid holding the watcher mutex across a
1418 // thread-join.
1419
2/2
✓ Branch 27 → 28 taken 69 times.
✓ Branch 27 → 30 taken 1 time.
70 }
1420
1421 5 bool DetourModKit::Config::register_reload_hotkey(std::string_view ini_key, std::string_view default_combo)
1422 {
1423 // An empty or explicitly-opt-out default would leave the hotkey silently inert (a binding registered without
1424 // trigger keys never fires). Surface that to the caller as a false return so they can decide whether to fall
1425 // back to a different combo or skip the hotkey entirely.
1426
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 12 taken 4 times.
5 if (default_combo.empty())
1427 {
1428
2/4
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 129 not taken.
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 83 not taken.
2 Logger::get_instance().warning(
1429 "Config: register_reload_hotkey('{}', '<empty>') rejected; provide a non-empty default combo.",
1430
1/2
✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 86 not taken.
2 std::string(ini_key));
1431 1 return false;
1432 }
1433
1434 // Pre-parse the default. The parser emits its own WARNING when a non-empty, non-sentinel string fails to parse,
1435 // so no extra log is needed for the typo path. Explicit opt-out via the NONE sentinel still returns false
1436 // because a hotkey with no keys is useless.
1437
2/4
✓ Branch 15 → 16 taken 4 times.
✗ Branch 15 → 92 not taken.
✓ Branch 16 → 17 taken 4 times.
✗ Branch 16 → 90 not taken.
8 const Config::KeyComboList parsed = parse_key_combo_list(std::string(default_combo), "Config reload hotkey");
1438
2/2
✓ Branch 20 → 21 taken 1 time.
✓ Branch 20 → 22 taken 3 times.
4 if (parsed.empty())
1439 {
1440 1 return false;
1441 }
1442
1443 // Stable binding name keyed off the INI key so repeat registrations (e.g. across reload cycles) update in place
1444 // rather than stacking.
1445
2/4
✓ Branch 24 → 25 taken 3 times.
✗ Branch 24 → 99 not taken.
✓ Branch 25 → 26 taken 3 times.
✗ Branch 25 → 97 not taken.
3 std::string binding_name = "config_reload:" + std::string(ini_key);
1446
1447 // Lazily spin up the reload servicer thread on the first hotkey registration. Holding get_watcher_mutex() here
1448 // keeps the lifetime invariants aligned with disable_auto_reload / clear_registered_items.
1449 3 std::shared_ptr<ReloadServicer> servicer;
1450 {
1451
1/2
✓ Branch 29 → 30 taken 3 times.
✗ Branch 29 → 106 not taken.
3 std::lock_guard<std::mutex> lock(get_watcher_mutex());
1452 3 auto &slot = get_reload_servicer();
1453
2/2
✓ Branch 32 → 33 taken 2 times.
✓ Branch 32 → 37 taken 1 time.
3 if (!slot)
1454 {
1455
1/2
✓ Branch 33 → 34 taken 2 times.
✗ Branch 33 → 103 not taken.
2 slot = std::make_shared<ReloadServicer>();
1456 }
1457 3 servicer = slot;
1458 3 }
1459
1460 3 InputBindingGuard guard = Config::register_press_combo(
1461 3 "Input", ini_key, "Config reload hotkey", binding_name,
1462
1/2
✓ Branch 41 → 42 taken 3 times.
✗ Branch 41 → 111 not taken.
6 [servicer]() noexcept
1463 {
1464 // InputManager press callbacks run on the input-poll thread and must return promptly. Defer the actual
1465 // reload() work to the servicer thread so a 30-item INI parse cannot jitter other hotkeys. The servicer
1466 // holds the shared_ptr slot and cannot be destroyed while this capture is alive.
1467 if (servicer)
1468 {
1469 servicer->request_reload();
1470 }
1471 },
1472
1/2
✓ Branch 45 → 46 taken 3 times.
✗ Branch 45 → 107 not taken.
6 default_combo);
1473
1474 // Stash the guard under the watcher mutex so its destructor does not fire at the end of this function (which
1475 // would disable the binding). Replace any prior guard registered for the same INI key so repeat calls update in
1476 // place rather than stacking.
1477 {
1478
1/2
✓ Branch 49 → 50 taken 3 times.
✗ Branch 49 → 120 not taken.
3 std::lock_guard<std::mutex> lock(get_watcher_mutex());
1479 3 auto &guards = get_reload_hotkey_guards();
1480
2/2
✓ Branch 72 → 52 taken 1 time.
✓ Branch 72 → 73 taken 2 times.
6 for (auto it = guards.begin(); it != guards.end(); ++it)
1481 {
1482
1/2
✓ Branch 56 → 57 taken 1 time.
✗ Branch 56 → 62 not taken.
1 if (it->name() == binding_name)
1483 {
1484
1/2
✓ Branch 60 → 61 taken 1 time.
✗ Branch 60 → 116 not taken.
1 guards.erase(it);
1485 1 break;
1486 }
1487 }
1488
1/2
✓ Branch 75 → 76 taken 3 times.
✗ Branch 75 → 118 not taken.
3 guards.emplace_back(std::move(guard));
1489 3 }
1490
1491 3 return true;
1492 4 }
1493
1494 12 void DetourModKit::Config::log_all()
1495 {
1496
1/2
✓ Branch 3 → 4 taken 12 times.
✗ Branch 3 → 56 not taken.
12 std::lock_guard<std::mutex> lock(get_config_mutex());
1497
1498
1/2
✓ Branch 4 → 5 taken 12 times.
✗ Branch 4 → 54 not taken.
12 Logger &logger = Logger::get_instance();
1499 12 const auto &items = get_registered_config_items();
1500
2/2
✓ Branch 7 → 8 taken 2 times.
✓ Branch 7 → 10 taken 10 times.
12 if (items.empty())
1501 {
1502
1/2
✓ Branch 8 → 9 taken 2 times.
✗ Branch 8 → 45 not taken.
2 logger.info("Config: No configuration items registered.");
1503 2 return;
1504 }
1505
1506
1/2
✓ Branch 12 → 13 taken 10 times.
✗ Branch 12 → 46 not taken.
10 logger.info("Config: {} registered values across {} section(s)", items.size(),
1507 [&items]()
1508 {
1509 10 std::unordered_set<std::string_view> seen;
1510
2/2
✓ Branch 19 → 5 taken 17 times.
✓ Branch 19 → 20 taken 10 times.
37 for (const auto &item : items)
1511 {
1512
1/2
✓ Branch 9 → 10 taken 17 times.
✗ Branch 9 → 24 not taken.
17 seen.insert(item->section);
1513 }
1514 20 return seen.size();
1515
1/2
✓ Branch 10 → 11 taken 10 times.
✗ Branch 10 → 48 not taken.
20 }());
1516
1517 10 std::string current_section;
1518
2/2
✓ Branch 36 → 16 taken 17 times.
✓ Branch 36 → 37 taken 10 times.
37 for (const auto &item : items)
1519 {
1520
2/2
✓ Branch 20 → 21 taken 11 times.
✓ Branch 20 → 25 taken 6 times.
17 if (item->section != current_section)
1521 {
1522
1/2
✓ Branch 22 → 23 taken 11 times.
✗ Branch 22 → 51 not taken.
11 current_section = item->section;
1523
1/2
✓ Branch 23 → 24 taken 11 times.
✗ Branch 23 → 50 not taken.
11 logger.debug("Config: [{}]", current_section);
1524 }
1525
1/2
✓ Branch 26 → 27 taken 17 times.
✗ Branch 26 → 51 not taken.
17 item->log_current_value(logger);
1526 }
1527
2/2
✓ Branch 40 → 41 taken 10 times.
✓ Branch 40 → 43 taken 2 times.
12 }
1528
1529 345 void DetourModKit::Config::clear_registered_items() noexcept
1530 {
1531 345 std::lock_guard<std::mutex> lock(get_config_mutex());
1532
1533 345 Logger &logger = Logger::get_instance();
1534 345 size_t count = get_registered_config_items().size();
1535 // Logging routes through try_log (the no-throw, fail-closed path) rather than debug(): debug() formats through
1536 // a potentially-throwing sink, which would break this noexcept contract on a format or sink failure.
1537
2/2
✓ Branch 7 → 8 taken 144 times.
✓ Branch 7 → 12 taken 201 times.
345 if (count > 0)
1538 {
1539 144 get_registered_config_items().clear();
1540 144 (void)logger.try_log(LogLevel::Debug, "Config: Cleared {} registered configuration items.", count);
1541 }
1542 else
1543 {
1544 201 (void)logger.try_log(LogLevel::Debug,
1545 "Config: clear_registered_items called, but no items were registered.");
1546 }
1547
1548 // Drop the remembered INI path too so reload() does not act on a
1549 // previous file after a full reset. Leaves the watcher alone; the
1550 // caller owns its lifecycle via disable_auto_reload().
1551 345 get_last_loaded_ini_path().clear();
1552 // Wipe the cached content hash alongside the path so the next load() starts from a clean slate.
1553 345 get_last_loaded_ini_hash().reset();
1554
1555 // Release any reload-hotkey guards so the cancellation flags flip deterministically. Held under the watcher
1556 // mutex because that is where the vector itself is serialised. Also drop our strong reference to the reload
1557 // servicer.
1558 345 std::shared_ptr<ReloadServicer> servicer_to_drop;
1559 {
1560 345 std::lock_guard<std::mutex> wlock(get_watcher_mutex());
1561 345 get_reload_hotkey_guards().clear();
1562 690 servicer_to_drop = std::move(get_reload_servicer());
1563 345 }
1564 // Release our strong reference to the servicer. The InputManager binding registered by register_reload_hotkey()
1565 // still holds another strong ref via its captured lambda (InputBindingGuard::release() only flips the
1566 // cancellation flag; it does not unregister the binding or drop the captured shared_ptr). The servicer worker
1567 // therefore joins when InputManager::shutdown() ultimately tears down that binding, not at this reset() call.
1568 345 servicer_to_drop.reset();
1569 345 }
1570 } // namespace DetourModKit
1571