GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 90.2% 573 / 0 / 635
Functions: 96.2% 76 / 0 / 79
Branches: 56.9% 447 / 0 / 785

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