GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 90.3% 631 / 0 / 699
Functions: 97.5% 77 / 0 / 79
Branches: 60.0% 485 / 0 / 809

src/config.cpp
Line Branch Exec Source
1 /**
2 * @file config.cpp
3 * @brief This TU implements the INI parse and registry data plane and the INI-to-input combo fusion.
4 *
5 * The config module depends on input, never the reverse. SimpleIni stays confined to this TU. The reload control
6 * plane lives in src/internal/config_reload.cpp and the watcher control plane in config_watch.cpp.
7 */
8
9 #include "DetourModKit/config.hpp"
10 #include "DetourModKit/input.hpp"
11 #include "DetourModKit/input_codes.hpp"
12 #include "DetourModKit/logger.hpp"
13 #include "DetourModKit/filesystem.hpp"
14 #include "DetourModKit/format.hpp"
15
16 #include "internal/config_pass.hpp"
17 #include "internal/config_reload_gate.hpp"
18 #include "internal/config_reload_lifecycle.hpp"
19 #include "internal/config_watch_control.hpp"
20
21 #include <SimpleIni.h>
22
23 #include <atomic>
24 #include <charconv>
25 #include <cmath>
26 #include <cstdint>
27 #include <filesystem>
28 #include <fstream>
29 #include <limits>
30 #include <memory>
31 #include <mutex>
32 #include <optional>
33 #include <string>
34 #include <string_view>
35 #include <type_traits>
36 #include <unordered_set>
37 #include <vector>
38
39 namespace DetourModKit::detail
40 {
41 #if defined(DMK_ENABLE_TEST_SEAMS)
42 // Fires inside load()'s watcher re-point, between the stale-watcher join and replacement start. A test can then
43 // place a disable_auto_reload() call in that lock gap deterministically.
44 void (*g_config_repoint_window_test_hook)() = nullptr;
45
46 // Forces read_ini_bytes() through its seek/tell failure classification.
47 std::atomic<bool> g_config_read_seektell_fail{false};
48
49 // Forces one parse failure so an identical-byte retry can exercise hash invalidation.
50 std::atomic<bool> g_config_parse_fail_once{false};
51 #endif
52 } // namespace DetourModKit::detail
53
54 namespace DetourModKit
55 {
56 namespace config
57 {
58 using DetourModKit::filesystem::get_runtime_directory;
59 using DetourModKit::string::trim;
60
61 namespace
62 {
63 /**
64 * @brief Parses a comma-separated string of input tokens into a vector of InputCodes.
65 * @details Named keys resolve through parse_input_name. A bare hex token falls back to a Keyboard VK
66 * code, which closes format_input_code's bare-hex keyboard round-trip. Semicolon comments and
67 * invalid tokens are skipped.
68 */
69 168 std::vector<InputCode> parse_input_code_list(const std::string &input)
70 {
71 168 std::vector<InputCode> result;
72
73 168 const size_t comment_pos = input.find(';');
74 const std::string effective =
75
3/8
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 168 times.
✗ Branch 4 → 6 not taken.
✗ Branch 4 → 77 not taken.
✓ Branch 5 → 6 taken 168 times.
✗ Branch 5 → 77 not taken.
✓ Branch 7 → 8 taken 168 times.
✗ Branch 7 → 75 not taken.
168 trim((comment_pos != std::string::npos) ? input.substr(0, comment_pos) : input);
76
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 168 times.
168 if (effective.empty())
77 {
78 return result;
79 }
80
81 168 size_t pos = 0;
82
2/2
✓ Branch 70 → 13 taken 168 times.
✓ Branch 70 → 71 taken 168 times.
336 while (pos < effective.size())
83 {
84 168 const size_t comma = effective.find(',', pos);
85
1/2
✓ Branch 14 → 15 taken 168 times.
✗ Branch 14 → 16 not taken.
168 const size_t end = (comma != std::string::npos) ? comma : effective.size();
86
2/4
✓ Branch 17 → 18 taken 168 times.
✗ Branch 17 → 80 not taken.
✓ Branch 19 → 20 taken 168 times.
✗ Branch 19 → 78 not taken.
168 const std::string token = trim(effective.substr(pos, end - pos));
87 168 pos = end + 1;
88
89
1/2
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 168 times.
168 if (token.empty())
90 {
91 continue;
92 }
93
94
1/2
✓ Branch 25 → 26 taken 168 times.
✗ Branch 25 → 82 not taken.
168 auto named = parse_input_name(token);
95
2/2
✓ Branch 27 → 28 taken 119 times.
✓ Branch 27 → 31 taken 49 times.
168 if (named.has_value())
96 {
97
1/2
✓ Branch 29 → 30 taken 119 times.
✗ Branch 29 → 82 not taken.
119 result.push_back(*named);
98 119 continue;
99 }
100
101 49 size_t hex_start = 0;
102
8/10
✓ Branch 32 → 33 taken 49 times.
✗ Branch 32 → 40 not taken.
✓ Branch 34 → 35 taken 39 times.
✓ Branch 34 → 40 taken 10 times.
✓ Branch 36 → 37 taken 2 times.
✓ Branch 36 → 39 taken 37 times.
✓ Branch 38 → 39 taken 2 times.
✗ Branch 38 → 40 not taken.
✓ Branch 41 → 42 taken 39 times.
✓ Branch 41 → 43 taken 10 times.
49 if (token.size() >= 2 && token[0] == '0' && (token[1] == 'x' || token[1] == 'X'))
103 {
104 39 hex_start = 2;
105 }
106
2/2
✓ Branch 44 → 45 taken 4 times.
✓ Branch 44 → 46 taken 45 times.
49 if (hex_start >= token.size())
107 {
108 4 continue;
109 }
110
111 45 const std::string_view hex_part(token.data() + hex_start, token.size() - hex_start);
112
2/2
✓ Branch 50 → 51 taken 10 times.
✓ Branch 50 → 52 taken 35 times.
45 if (hex_part.find_first_not_of("0123456789abcdefABCDEF") != std::string_view::npos)
113 {
114 10 continue;
115 }
116
117 35 unsigned int value = 0;
118 35 const char *const hex_begin = hex_part.data();
119 35 const char *const hex_end = hex_begin + hex_part.size();
120
1/2
✓ Branch 54 → 55 taken 35 times.
✗ Branch 54 → 82 not taken.
35 const auto [parsed_end, parse_ec] = std::from_chars(hex_begin, hex_end, value, 16);
121
3/4
✓ Branch 55 → 56 taken 33 times.
✓ Branch 55 → 57 taken 2 times.
✗ Branch 56 → 57 not taken.
✓ Branch 56 → 58 taken 33 times.
35 if (parse_ec != std::errc{} || parsed_end != hex_end)
122 {
123 2 continue;
124 }
125
2/2
✓ Branch 59 → 60 taken 3 times.
✓ Branch 59 → 61 taken 30 times.
33 if (value > static_cast<unsigned int>(std::numeric_limits<int>::max()))
126 {
127 3 continue;
128 }
129
130
1/2
✓ Branch 61 → 62 taken 30 times.
✗ Branch 61 → 81 not taken.
30 result.push_back(InputCode{InputSource::Keyboard, static_cast<int>(value)});
131
2/2
✓ Branch 64 → 65 taken 30 times.
✓ Branch 64 → 67 taken 138 times.
168 }
132
133 168 return result;
134 168 }
135
136 /**
137 * @brief Parses a single key combo string ("mod1+mod2+trigger") into a KeyCombo struct.
138 * @details The last '+'-delimited token is the trigger. Earlier tokens are AND-logic modifiers. The
139 * function expects no commas. parse_key_combo_list splits alternatives first.
140 */
141 140 input::KeyCombo parse_key_combo(const std::string &input)
142 {
143 140 input::KeyCombo result;
144
145
1/2
✓ Branch 3 → 4 taken 140 times.
✗ Branch 3 → 65 not taken.
140 const std::string effective = trim(input);
146
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 140 times.
140 if (effective.empty())
147 {
148 return result;
149 }
150
151 140 std::vector<std::string> segments;
152 140 size_t pos = 0;
153
2/2
✓ Branch 22 → 8 taken 172 times.
✓ Branch 22 → 23 taken 140 times.
312 while (pos < effective.size())
154 {
155 172 const size_t plus = effective.find('+', pos);
156
2/2
✓ Branch 9 → 10 taken 138 times.
✓ Branch 9 → 11 taken 34 times.
172 const size_t end = (plus != std::string::npos) ? plus : effective.size();
157
2/4
✓ Branch 12 → 13 taken 172 times.
✗ Branch 12 → 51 not taken.
✓ Branch 14 → 15 taken 172 times.
✗ Branch 14 → 49 not taken.
172 const std::string segment = trim(effective.substr(pos, end - pos));
158 172 pos = end + 1;
159
2/2
✓ Branch 17 → 18 taken 168 times.
✓ Branch 17 → 19 taken 4 times.
172 if (!segment.empty())
160 {
161
1/2
✓ Branch 18 → 19 taken 168 times.
✗ Branch 18 → 52 not taken.
168 segments.push_back(segment);
162 }
163 172 }
164
165
2/2
✓ Branch 24 → 25 taken 1 time.
✓ Branch 24 → 26 taken 139 times.
140 if (segments.empty())
166 {
167 1 return result;
168 }
169
170
1/2
✓ Branch 27 → 28 taken 139 times.
✗ Branch 27 → 55 not taken.
139 result.keys = parse_input_code_list(segments.back());
171
172
2/2
✓ Branch 43 → 31 taken 29 times.
✓ Branch 43 → 44 taken 139 times.
168 for (size_t i = 0; i + 1 < segments.size(); ++i)
173 {
174
1/2
✓ Branch 32 → 33 taken 29 times.
✗ Branch 32 → 60 not taken.
29 auto mod_codes = parse_input_code_list(segments[i]);
175
1/2
✓ Branch 39 → 40 taken 29 times.
✗ Branch 39 → 56 not taken.
58 result.modifiers.insert(result.modifiers.end(), mod_codes.begin(), mod_codes.end());
176 29 }
177
178 139 return result;
179 140 }
180
181 /**
182 * @brief Returns true when @p text is the literal "NONE" sentinel (case-insensitive ASCII, pre-trimmed).
183 * @details Whole-string only: a NONE token nested inside an OR-list is indistinguishable from a typo, and
184 * an unbound slot inside an OR-list is meaningless.
185 */
186 108 [[nodiscard]] bool is_none_sentinel(std::string_view text) noexcept
187 {
188
2/2
✓ Branch 3 → 4 taken 98 times.
✓ Branch 3 → 5 taken 10 times.
108 if (text.size() != 4)
189 {
190 98 return false;
191 }
192 10 constexpr char target[] = {'N', 'O', 'N', 'E'};
193
2/2
✓ Branch 14 → 6 taken 28 times.
✓ Branch 14 → 15 taken 6 times.
34 for (size_t i = 0; i < 4; ++i)
194 {
195 28 const char ch = text[i];
196
3/4
✓ Branch 7 → 8 taken 12 times.
✓ Branch 7 → 10 taken 16 times.
✓ Branch 8 → 9 taken 12 times.
✗ Branch 8 → 10 not taken.
28 const char folded = (ch >= 'a' && ch <= 'z') ? static_cast<char>(ch - ('a' - 'A')) : ch;
197
2/2
✓ Branch 11 → 12 taken 4 times.
✓ Branch 11 → 13 taken 24 times.
28 if (folded != target[i])
198 {
199 4 return false;
200 }
201 }
202 6 return true;
203 }
204
205 } // anonymous namespace
206
207 namespace detail
208 {
209 // Contract in internal/config_pass.hpp. The grammar helpers above stay file-local.
210 138 input::KeyComboList parse_key_combo_list(
211 const std::string &input,
212 DeferredDiagnostics &diags,
213 std::string_view binding_log_name
214 )
215 {
216 138 input::KeyComboList result;
217
218 138 const size_t comment_pos = input.find(';');
219 const std::string effective =
220
5/8
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 5 taken 134 times.
✓ Branch 4 → 6 taken 4 times.
✗ Branch 4 → 58 not taken.
✓ Branch 5 → 6 taken 134 times.
✗ Branch 5 → 58 not taken.
✓ Branch 7 → 8 taken 138 times.
✗ Branch 7 → 56 not taken.
138 trim((comment_pos != std::string::npos) ? input.substr(0, comment_pos) : input);
221
222 // An empty string and the NONE sentinel are silent opt-outs.
223
2/2
✓ Branch 10 → 11 taken 30 times.
✓ Branch 10 → 12 taken 108 times.
138 if (effective.empty())
224 {
225 30 return result;
226 }
227
2/2
✓ Branch 14 → 15 taken 6 times.
✓ Branch 14 → 16 taken 102 times.
108 if (is_none_sentinel(effective))
228 {
229 6 return result;
230 }
231
232 102 size_t pos = 0;
233
2/2
✓ Branch 43 → 17 taken 147 times.
✓ Branch 43 → 44 taken 102 times.
249 while (pos < effective.size())
234 {
235 147 const size_t comma = effective.find(',', pos);
236
2/2
✓ Branch 18 → 19 taken 101 times.
✓ Branch 18 → 20 taken 46 times.
147 const size_t end = (comma != std::string::npos) ? comma : effective.size();
237
2/4
✓ Branch 21 → 22 taken 147 times.
✗ Branch 21 → 61 not taken.
✓ Branch 23 → 24 taken 147 times.
✗ Branch 23 → 59 not taken.
147 const std::string combo_str = trim(effective.substr(pos, end - pos));
238 147 pos = end + 1;
239
240
2/2
✓ Branch 26 → 27 taken 7 times.
✓ Branch 26 → 28 taken 140 times.
147 if (combo_str.empty())
241 {
242 7 continue;
243 }
244
245
1/2
✓ Branch 28 → 29 taken 140 times.
✗ Branch 28 → 64 not taken.
140 auto combo = parse_key_combo(combo_str);
246
2/2
✓ Branch 30 → 31 taken 121 times.
✓ Branch 30 → 34 taken 19 times.
140 if (!combo.keys.empty())
247 {
248
1/2
✓ Branch 33 → 34 taken 121 times.
✗ Branch 33 → 62 not taken.
121 result.push_back(std::move(combo));
249 }
250
2/2
✓ Branch 37 → 38 taken 140 times.
✓ Branch 37 → 40 taken 7 times.
147 }
251
252 // If non-empty, non-sentinel input has no valid token, report the user typo by name.
253
2/2
✓ Branch 45 → 46 taken 6 times.
✓ Branch 45 → 52 taken 96 times.
102 if (result.empty())
254 {
255 const std::string_view name_view =
256
1/2
✗ Branch 47 → 48 not taken.
✓ Branch 47 → 49 taken 6 times.
6 binding_log_name.empty() ? std::string_view{"<unnamed>"} : binding_log_name;
257
1/2
✓ Branch 50 → 51 taken 6 times.
✗ Branch 50 → 67 not taken.
6 defer_diagnostic(
258 diags,
259 LogLevel::Warning,
260 "Config: combo string \"{}\" for binding '{}' did not parse to any "
261 "valid keys; binding will be unbound. Use \"\" or \"NONE\" to opt "
262 "out explicitly.",
263 effective,
264 name_view
265 );
266 }
267
268 102 return result;
269 138 }
270 } // namespace detail
271
272 namespace
273 {
274 /// Formats a single KeyCombo as a human-readable string (e.g. "Ctrl+Shift+F3").
275 7 std::string format_key_combo(const input::KeyCombo &combo)
276 {
277 7 std::string result;
278
2/2
✓ Branch 21 → 5 taken 1 time.
✓ Branch 21 → 22 taken 7 times.
15 for (const auto &mod : combo.modifiers)
279 {
280
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) + "+";
281 }
282
2/2
✓ Branch 31 → 23 taken 7 times.
✓ Branch 31 → 32 taken 7 times.
14 for (size_t i = 0; i < combo.keys.size(); ++i)
283 {
284
1/2
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 25 taken 7 times.
7 if (i > 0)
285 {
286 result += ",";
287 }
288
2/4
✓ Branch 26 → 27 taken 7 times.
✗ Branch 26 → 43 not taken.
✓ Branch 27 → 28 taken 7 times.
✗ Branch 27 → 41 not taken.
7 result += DetourModKit::format_input_code(combo.keys[i]);
289 }
290 7 return result;
291 }
292
293 /// Formats a KeyComboList as a comma-joined human-readable string (e.g. "F3,Gamepad_LT+Gamepad_B").
294 8 std::string format_key_combo_list(const input::KeyComboList &combos)
295 {
296 8 std::string result;
297
2/2
✓ Branch 12 → 4 taken 7 times.
✓ Branch 12 → 13 taken 8 times.
15 for (size_t i = 0; i < combos.size(); ++i)
298 {
299
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 7 times.
7 if (i > 0)
300 {
301 result += ",";
302 }
303
2/4
✓ Branch 7 → 8 taken 7 times.
✗ Branch 7 → 17 not taken.
✓ Branch 8 → 9 taken 7 times.
✗ Branch 8 → 15 not taken.
7 result += format_key_combo(combos[i]);
304 }
305 8 return result;
306 }
307
308 /**
309 * @brief Selects the argument type the deferred setter receives.
310 * @details The string bind delivers a std::string_view valid only for the call. Every other bound type
311 * passes the parsed value by value.
312 */
313 template <typename T>
314 using SetterArg = std::conditional_t<std::same_as<T, std::string>, std::string_view, T>;
315
316 /// ConfigItemBase stores typed configuration items polymorphically in the registry.
317 struct ConfigItemBase
318 {
319 std::string section;
320 std::string ini_key;
321 std::string log_key_name;
322
323 241 ConfigItemBase(std::string sec, std::string key, std::string log_name)
324 964 : section(std::move(sec)), ini_key(std::move(key)), log_key_name(std::move(log_name))
325 {
326 241 }
327 241 virtual ~ConfigItemBase() = default;
328 ConfigItemBase(const ConfigItemBase &) = delete;
329 ConfigItemBase &operator=(const ConfigItemBase &) = delete;
330 ConfigItemBase(ConfigItemBase &&) = delete;
331 ConfigItemBase &operator=(ConfigItemBase &&) = delete;
332
333 /// Loads the configuration value from the INI file and defers every diagnostic it produces.
334 virtual void load(CSimpleIniA &ini, detail::DeferredDiagnostics &diags) = 0;
335
336 /// Returns a deferred callback to invoke the setter outside the config mutex, or empty without one.
337 [[nodiscard]] virtual std::function<void()> take_deferred_apply() const = 0;
338
339 /// Defers one record that names the current value of the configuration item.
340 virtual void log_current_value(detail::DeferredDiagnostics &diags) const = 0;
341 };
342
343 /**
344 * @brief Trims ASCII blanks from both ends of @p text and strips one initial '+'.
345 * @details from_chars rejects an initial '+' (unlike the strtod/strtoll it replaced), so this function
346 * strips one for a positive value. It returns a view into @p text and allocates nothing.
347 */
348 176 [[nodiscard]] std::string_view trim_blanks_and_leading_plus(std::string_view text) noexcept
349 {
350 352 constexpr auto is_blank = [](char c) noexcept
351
4/8
✓ Branch 2 → 3 taken 352 times.
✗ Branch 2 → 6 not taken.
✓ Branch 3 → 4 taken 352 times.
✗ Branch 3 → 6 not taken.
✓ Branch 4 → 5 taken 352 times.
✗ Branch 4 → 6 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 352 times.
352 { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; };
352
3/6
✓ Branch 5 → 6 taken 176 times.
✗ Branch 5 → 10 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 176 times.
✗ Branch 11 → 3 not taken.
✓ Branch 11 → 12 taken 176 times.
176 while (!text.empty() && is_blank(text.front()))
353 {
354 text.remove_prefix(1);
355 }
356
3/6
✓ Branch 15 → 16 taken 176 times.
✗ Branch 15 → 20 not taken.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 176 times.
✗ Branch 21 → 13 not taken.
✓ Branch 21 → 22 taken 176 times.
176 while (!text.empty() && is_blank(text.back()))
357 {
358 text.remove_suffix(1);
359 }
360
5/6
✓ Branch 23 → 24 taken 176 times.
✗ Branch 23 → 27 not taken.
✓ Branch 25 → 26 taken 2 times.
✓ Branch 25 → 27 taken 174 times.
✓ Branch 28 → 29 taken 2 times.
✓ Branch 28 → 30 taken 174 times.
176 if (!text.empty() && text.front() == '+')
361 {
362 2 text.remove_prefix(1);
363 }
364 176 return text;
365 }
366
367 /**
368 * @brief Parses an INI boolean and distinguishes an unrecognized value from a valid one.
369 * @details Matches SimpleIni's GetBoolValue forms.
370 * An initial t/y/1 means true, and f/n/0 means false.
371 * The exact values "on" and "off" also map to true and false. An unrecognized value returns
372 * nullopt for diagnosis. @p value must be non-null and non-empty.
373 */
374 15 [[nodiscard]] std::optional<bool> parse_ini_bool(const char *value) noexcept
375 {
376
3/4
✓ Branch 2 → 3 taken 7 times.
✓ Branch 2 → 6 taken 6 times.
✗ Branch 2 → 9 not taken.
✓ Branch 2 → 22 taken 2 times.
15 switch (value[0])
377 {
378 7 case 't':
379 case 'T':
380 case 'y':
381 case 'Y':
382 case '1':
383 7 return true;
384 6 case 'f':
385 case 'F':
386 case 'n':
387 case 'N':
388 case '0':
389 6 return false;
390 case 'o':
391 case 'O':
392 if (value[1] == 'n' || value[1] == 'N')
393 {
394 return true;
395 }
396 if (value[1] == 'f' || value[1] == 'F')
397 {
398 return false;
399 }
400 return std::nullopt;
401 2 default:
402 2 return std::nullopt;
403 }
404 }
405
406 /**
407 * @brief Stores a configuration item with a std::function value setter.
408 * @note Setter callbacks run outside the config mutex. This prevents deadlocks. The bind_* functions and
409 * load() use this deferred invocation pattern.
410 */
411 template <typename T> struct CallbackConfigItem : public ConfigItemBase
412 {
413 std::function<void(SetterArg<T>)> setter;
414 T default_value;
415 T current_value;
416
417 241 CallbackConfigItem(
418 std::string sec,
419 std::string key,
420 std::string log_name,
421 std::function<void(SetterArg<T>)> set_fn,
422 T def_val
423 )
424 241 : ConfigItemBase(std::move(sec), std::move(key), std::move(log_name)), setter(std::move(set_fn)),
425
2/4
DetourModKit::config::(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::basic_string_view<char, std::char_traits<char> >)>, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >):
✓ Branch 18 → 19 taken 14 times.
✗ Branch 18 → 23 not taken.
DetourModKit::config::(anonymous namespace)::CallbackConfigItem<std::vector<DetourModKit::input::KeyCombo, std::allocator<DetourModKit::input::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::input::KeyCombo, std::allocator<DetourModKit::input::KeyCombo> >)>, std::vector<DetourModKit::input::KeyCombo, std::allocator<DetourModKit::input::KeyCombo> >):
✓ Branch 18 → 19 taken 88 times.
✗ Branch 18 → 23 not taken.
1307 default_value(def_val), current_value(std::move(def_val))
426 {
427 241 }
428
429 221 void load(CSimpleIniA &ini, [[maybe_unused]] detail::DeferredDiagnostics &diags) override
430 {
431 // The generic body handles scalar and string types. KeyComboList uses the explicit specialization.
432 if constexpr (std::same_as<T, int>)
433 {
434 // SimpleIni's GetLongValue parses into a 32-bit long on LLP64 and can saturate. Parse the raw
435 // string with std::from_chars and warn-and-default on a bad value. Values with a 0x prefix
436 // are hexadecimal. All other values are decimal.
437
1/2
✓ Branch 4 → 5 taken 171 times.
✗ Branch 4 → 47 not taken.
171 const char *raw = ini.GetValue(section.c_str(), ini_key.c_str(), nullptr);
438
2/2
✓ Branch 5 → 6 taken 10 times.
✓ Branch 5 → 7 taken 161 times.
171 if (raw == nullptr)
439 {
440 10 current_value = default_value;
441 }
442 else
443 {
444 161 std::string_view text = trim_blanks_and_leading_plus(std::string_view{raw});
445
446 161 int base = 10;
447
7/10
✓ Branch 10 → 11 taken 58 times.
✓ Branch 10 → 18 taken 103 times.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 18 taken 57 times.
✓ Branch 14 → 15 taken 1 time.
✗ Branch 14 → 17 not taken.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 1 time.
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 22 taken 161 times.
161 if (text.size() >= 2 && text[0] == '0' && (text[1] == 'x' || text[1] == 'X'))
448 {
449 text.remove_prefix(2);
450 base = 16;
451 }
452
453 161 long long parsed = 0;
454 const auto [end, ec] =
455
1/2
✓ Branch 25 → 26 taken 161 times.
✗ Branch 25 → 46 not taken.
161 std::from_chars(text.data(), text.data() + text.size(), parsed, base);
456
3/4
✓ Branch 26 → 27 taken 159 times.
✓ Branch 26 → 31 taken 2 times.
✓ Branch 29 → 30 taken 159 times.
✗ Branch 29 → 31 not taken.
161 const bool fully_consumed = (ec == std::errc{} && end == text.data() + text.size());
457
6/6
✓ Branch 32 → 33 taken 159 times.
✓ Branch 32 → 37 taken 2 times.
✓ Branch 34 → 35 taken 158 times.
✓ Branch 34 → 37 taken 1 time.
✓ Branch 39 → 40 taken 4 times.
✓ Branch 39 → 42 taken 157 times.
319 if (!fully_consumed || parsed < static_cast<long long>(std::numeric_limits<int>::min()) ||
458
2/2
✓ Branch 36 → 37 taken 1 time.
✓ Branch 36 → 38 taken 157 times.
158 parsed > static_cast<long long>(std::numeric_limits<int>::max()))
459 {
460 detail::defer_diagnostic(
461 diags,
462 LogLevel::Warning,
463 "Config: value '{}' for '{}' is not a valid int (non-numeric or out of "
464 "range); using default {}.",
465 raw,
466 4 ini_key,
467
1/2
✓ Branch 40 → 41 taken 4 times.
✗ Branch 40 → 45 not taken.
4 default_value
468 );
469 4 current_value = default_value;
470 }
471 else
472 {
473 157 current_value = static_cast<int>(parsed);
474 }
475 }
476 }
477 else if constexpr (std::same_as<T, float>)
478 {
479 // SimpleIni's GetDoubleValue routes through the locale-dependent strtod: a comma-decimal host
480 // locale silently turns "1.5" into the default. Parse the raw string with the
481 // locale-independent std::from_chars instead, with the int path's warn-and-default discipline.
482
1/2
✓ Branch 4 → 5 taken 20 times.
✗ Branch 4 → 32 not taken.
20 const char *raw = ini.GetValue(section.c_str(), ini_key.c_str(), nullptr);
483
2/2
✓ Branch 5 → 6 taken 5 times.
✓ Branch 5 → 7 taken 15 times.
20 if (raw == nullptr)
484 {
485 5 current_value = default_value;
486 }
487 else
488 {
489 15 std::string_view text = trim_blanks_and_leading_plus(std::string_view{raw});
490
491 15 float parsed = default_value;
492 15 const auto [end, ec] = std::from_chars(text.data(), text.data() + text.size(), parsed);
493
4/4
✓ Branch 13 → 14 taken 14 times.
✓ Branch 13 → 18 taken 1 time.
✓ Branch 16 → 17 taken 13 times.
✓ Branch 16 → 18 taken 1 time.
15 const bool fully_consumed = (ec == std::errc{} && end == text.data() + text.size());
494 // std::from_chars(general) accepts "inf"/"infinity"/"nan". Reject a non-finite result
495 // because it poisons bound arithmetic downstream.
496
6/6
✓ Branch 19 → 20 taken 13 times.
✓ Branch 19 → 22 taken 2 times.
✓ Branch 21 → 22 taken 2 times.
✓ Branch 21 → 23 taken 11 times.
✓ Branch 24 → 25 taken 4 times.
✓ Branch 24 → 27 taken 11 times.
15 if (!fully_consumed || !std::isfinite(parsed))
497 {
498 detail::defer_diagnostic(
499 diags,
500 LogLevel::Warning,
501 "Config: value '{}' for '{}' is not a valid finite float (non-numeric, "
502 "non-finite, or out of range); using default {}.",
503 raw,
504 4 ini_key,
505
1/2
✓ Branch 25 → 26 taken 4 times.
✗ Branch 25 → 30 not taken.
4 default_value
506 );
507 4 current_value = default_value;
508 }
509 else
510 {
511 11 current_value = parsed;
512 }
513 }
514 }
515 else if constexpr (std::same_as<T, bool>)
516 {
517
1/2
✓ Branch 4 → 5 taken 20 times.
✗ Branch 4 → 23 not taken.
20 const char *raw = ini.GetValue(section.c_str(), ini_key.c_str(), nullptr);
518
3/4
✓ Branch 5 → 6 taken 15 times.
✓ Branch 5 → 7 taken 5 times.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 15 times.
20 if (raw == nullptr || raw[0] == '\0')
519 {
520 // If absent or empty, use the default silently. This matches SimpleIni's GetBoolValue.
521 5 current_value = default_value;
522 }
523
2/2
✓ Branch 10 → 11 taken 13 times.
✓ Branch 10 → 13 taken 2 times.
15 else if (const std::optional<bool> parsed = parse_ini_bool(raw); parsed.has_value())
524 {
525 13 current_value = *parsed;
526 }
527 else
528 {
529 // If present but unrecognized, diagnose it under the int/float warn-and-default rule.
530 detail::defer_diagnostic(
531 diags,
532 LogLevel::Warning,
533 "Config: value '{}' for '{}' is not a valid bool "
534 "(true/false, yes/no, on/off, 1/0); using default {}.",
535 raw,
536 2 ini_key,
537
2/4
✓ Branch 13 → 14 taken 2 times.
✗ Branch 13 → 15 not taken.
✓ Branch 16 → 17 taken 2 times.
✗ Branch 16 → 20 not taken.
2 default_value ? "true" : "false"
538 );
539 2 current_value = default_value;
540 }
541 }
542 else if constexpr (std::same_as<T, std::string>)
543 {
544 10 current_value = ini.GetValue(section.c_str(), ini_key.c_str(), default_value.c_str());
545 }
546 221 }
547
548 13 void log_current_value(detail::DeferredDiagnostics &diags) const override
549 {
550 if constexpr (std::same_as<T, bool>)
551 {
552 detail::defer_diagnostic(
553 diags,
554 LogLevel::Debug,
555 "Config: {} = {}",
556 4 ini_key,
557
3/4
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 2 times.
✓ Branch 5 → 6 taken 4 times.
✗ Branch 5 → 7 not taken.
4 current_value ? "true" : "false"
558 );
559 }
560 else if constexpr (std::same_as<T, std::string>)
561 {
562 4 detail::defer_diagnostic(
563 diags,
564 LogLevel::Debug,
565 "Config: {} = \"{}\"",
566 2 ini_key,
567
1/2
✓ Branch 2 → 3 taken 2 times.
✗ Branch 2 → 4 not taken.
2 current_value
568 );
569 }
570 else // int, float
571 {
572
2/4
DetourModKit::config::(anonymous namespace)::CallbackConfigItem<float>::log_current_value(DetourModKit::config::detail::DeferredDiagnostics&) const:
✓ Branch 2 → 3 taken 2 times.
✗ Branch 2 → 4 not taken.
DetourModKit::config::(anonymous namespace)::CallbackConfigItem<int>::log_current_value(DetourModKit::config::detail::DeferredDiagnostics&) const:
✓ Branch 2 → 3 taken 5 times.
✗ Branch 2 → 4 not taken.
7 detail::defer_diagnostic(diags, LogLevel::Debug, "Config: {} = {}", ini_key, current_value);
573 }
574 13 }
575
576 /// Returns a self-contained callback that invokes setter with current_value.
577 282 [[nodiscard]] std::function<void()> take_deferred_apply() const override
578 {
579
6/10
DetourModKit::config::(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::config::(anonymous namespace)::CallbackConfigItem<std::vector<DetourModKit::input::KeyCombo, std::allocator<DetourModKit::input::KeyCombo> > >::take_deferred_apply() const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 61 times.
DetourModKit::config::(anonymous namespace)::CallbackConfigItem<bool>::take_deferred_apply() const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 20 times.
DetourModKit::config::(anonymous namespace)::CallbackConfigItem<float>::take_deferred_apply() const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 20 times.
DetourModKit::config::(anonymous namespace)::CallbackConfigItem<int>::take_deferred_apply() const:
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 169 times.
282 if (!setter)
580 2 return {};
581 if constexpr (std::same_as<T, std::string>)
582 {
583 // Capture the owned string by value and hand out a view into that copy.
584
4/10
✓ 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.
20 return [fn = setter, val = current_value]() mutable { fn(std::string_view{val}); };
585 }
586 else
587 {
588
14/36
DetourModKit::config::(anonymous namespace)::CallbackConfigItem<std::vector<DetourModKit::input::KeyCombo, std::allocator<DetourModKit::input::KeyCombo> > >::take_deferred_apply() const:
✓ Branch 5 → 6 taken 61 times.
✗ Branch 5 → 19 not taken.
✓ Branch 6 → 7 taken 61 times.
✗ Branch 6 → 16 not taken.
✓ Branch 7 → 8 taken 61 times.
✗ Branch 7 → 14 not taken.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 61 times.
✗ Branch 16 → 17 not taken.
✗ Branch 16 → 18 not taken.
DetourModKit::config::(anonymous namespace)::CallbackConfigItem<bool>::take_deferred_apply() const:
✓ Branch 5 → 6 taken 20 times.
✗ Branch 5 → 18 not taken.
✓ Branch 6 → 7 taken 20 times.
✗ Branch 6 → 13 not taken.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 20 times.
✗ Branch 15 → 16 not taken.
✗ Branch 15 → 17 not taken.
DetourModKit::config::(anonymous namespace)::CallbackConfigItem<float>::take_deferred_apply() const:
✓ Branch 5 → 6 taken 20 times.
✗ Branch 5 → 18 not taken.
✓ Branch 6 → 7 taken 20 times.
✗ Branch 6 → 13 not taken.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 20 times.
✗ Branch 15 → 16 not taken.
✗ Branch 15 → 17 not taken.
DetourModKit::config::(anonymous namespace)::CallbackConfigItem<int>::take_deferred_apply() const:
✓ Branch 5 → 6 taken 169 times.
✗ Branch 5 → 18 not taken.
✓ Branch 6 → 7 taken 169 times.
✗ Branch 6 → 13 not taken.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 169 times.
✗ Branch 15 → 16 not taken.
✗ Branch 15 → 17 not taken.
DetourModKit::config::(anonymous namespace)::CallbackConfigItem<std::vector<DetourModKit::input::KeyCombo, std::allocator<DetourModKit::input::KeyCombo> > >::take_deferred_apply() const::{lambda()#1}::operator()():
✓ Branch 5 → 6 taken 60 times.
✗ Branch 5 → 8 not taken.
806 return [fn = setter, val = current_value]() mutable { fn(std::move(val)); };
589 }
590 }
591 };
592
593 // KeyComboList needs an explicit specialization because its parse path differs.
594 template <>
595 61 void CallbackConfigItem<input::KeyComboList>::load(CSimpleIniA &ini, detail::DeferredDiagnostics &diags)
596 {
597 61 const char *ini_value_str = ini.GetValue(section.c_str(), ini_key.c_str(), nullptr);
598
2/2
✓ Branch 5 → 6 taken 35 times.
✓ Branch 5 → 16 taken 26 times.
61 if (ini_value_str != nullptr)
599 {
600
2/4
✓ Branch 9 → 10 taken 35 times.
✗ Branch 9 → 20 not taken.
✓ Branch 10 → 11 taken 35 times.
✗ Branch 10 → 18 not taken.
105 current_value = detail::parse_key_combo_list(ini_value_str, diags, log_key_name);
601 }
602 else
603 {
604 26 current_value = default_value;
605 }
606 61 }
607
608 template <>
609 8 void CallbackConfigItem<input::KeyComboList>::log_current_value(detail::DeferredDiagnostics &diags) const
610 {
611
1/2
✓ Branch 2 → 3 taken 8 times.
✗ Branch 2 → 15 not taken.
8 const std::string formatted = format_key_combo_list(current_value);
612
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 7 taken 7 times.
8 if (formatted.empty())
613 {
614
1/2
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 11 not taken.
1 detail::defer_diagnostic(diags, LogLevel::Debug, "Config: {} = (none)", ini_key);
615 }
616 else
617 {
618
1/2
✓ Branch 7 → 8 taken 7 times.
✗ Branch 7 → 12 not taken.
7 detail::defer_diagnostic(diags, LogLevel::Debug, "Config: {} = {}", ini_key, formatted);
619 }
620 8 }
621
622 // Stores the global registry of configuration items.
623 1496 std::mutex &get_config_mutex()
624 {
625
3/4
✓ Branch 2 → 3 taken 352 times.
✓ Branch 2 → 8 taken 1144 times.
✓ Branch 4 → 5 taken 352 times.
✗ Branch 4 → 8 not taken.
1496 static std::mutex s_mtx;
626 1496 return s_mtx;
627 }
628
629 1595 std::vector<std::unique_ptr<ConfigItemBase>> &get_registered_config_items()
630 {
631
3/4
✓ Branch 2 → 3 taken 352 times.
✓ Branch 2 → 7 taken 1243 times.
✓ Branch 4 → 5 taken 352 times.
✗ Branch 4 → 7 not taken.
1595 static std::vector<std::unique_ptr<ConfigItemBase>> s_registered_items;
632 1595 return s_registered_items;
633 }
634
635 // Holds the INI path last passed to load(). Empty until the first load() call, so reload() returns false
636 // in that window. Caller must hold get_config_mutex() for every read or write.
637 1003 std::string &get_last_loaded_ini_path()
638 {
639
3/4
✓ Branch 2 → 3 taken 351 times.
✓ Branch 2 → 7 taken 652 times.
✓ Branch 4 → 5 taken 351 times.
✗ Branch 4 → 7 not taken.
1003 static std::string s_last_loaded_ini_path;
640 1003 return s_last_loaded_ini_path;
641 }
642
643 } // anonymous namespace
644
645 namespace detail
646 {
647 // The config mutex is non-recursive, so callers outside a held config-mutex section use this helper.
648 55 std::string snapshot_last_loaded_ini_path()
649 {
650
1/2
✓ Branch 3 → 4 taken 55 times.
✗ Branch 3 → 12 not taken.
55 std::lock_guard<std::mutex> lock(get_config_mutex());
651
1/2
✓ Branch 5 → 6 taken 55 times.
✗ Branch 5 → 10 not taken.
110 return get_last_loaded_ini_path();
652 55 }
653 } // namespace detail
654
655 namespace
656 {
657
658 // Stores the content hash from the last successful load. It is std::nullopt before that load and after
659 // clear(). Caller must hold get_config_mutex().
660 1184 std::optional<std::uint64_t> &get_last_loaded_ini_hash()
661 {
662 static std::optional<std::uint64_t> s_last_loaded_ini_hash;
663 1184 return s_last_loaded_ini_hash;
664 }
665
666 // This monotonic counter advances on every registration or re-bind. reload() folds it into the unchanged-
667 // content decision, so a bind_* added after a load() hydrates from disk despite unchanged bytes.
668 // Caller must hold get_config_mutex().
669 489 std::uint64_t &get_binding_generation() noexcept
670 {
671 static std::uint64_t s_generation = 0;
672 489 return s_generation;
673 }
674
675 // Stores the binding generation from the last successful apply beside the content hash. reload()
676 // hash-skips only when BOTH are unchanged. Caller must hold get_config_mutex().
677 996 std::optional<std::uint64_t> &get_applied_binding_generation() noexcept
678 {
679 static std::optional<std::uint64_t> s_applied_generation;
680 996 return s_applied_generation;
681 }
682
683 /**
684 * @brief Computes a 64-bit FNV-1a hash over a raw byte range.
685 * @details It hashes the
686 * pre-parse disk bytes, so SimpleIni's cosmetic churn cannot skew the result.
687 */
688 217 [[nodiscard]] std::uint64_t fnv1a_64(const std::vector<std::uint8_t> &bytes) noexcept
689 {
690 217 constexpr std::uint64_t FNV_OFFSET_BASIS{0xcbf29ce484222325ULL};
691 217 constexpr std::uint64_t FNV_PRIME{0x00000100000001b3ULL};
692 217 std::uint64_t h{FNV_OFFSET_BASIS};
693
2/2
✓ Branch 15 → 4 taken 4462 times.
✓ Branch 15 → 16 taken 217 times.
4896 for (std::uint8_t b : bytes)
694 {
695 4462 h ^= static_cast<std::uint64_t>(b);
696 4462 h *= FNV_PRIME;
697 }
698 217 return h;
699 }
700
701 /**
702 * @brief Reads all bytes of @p path into memory, or std::nullopt when the file cannot be opened.
703 * @details On nullopt, load() proceeds with the bound defaults while reload() clears the cached hash and
704 * retains the last-applied values.
705 */
706 [[nodiscard]] std::optional<std::vector<std::uint8_t>>
707 252 read_ini_bytes(const std::filesystem::path &path) noexcept
708 {
709 try
710 {
711
1/2
✓ Branch 2 → 3 taken 252 times.
✗ Branch 2 → 55 not taken.
252 std::ifstream in(path, std::ios::binary);
712
3/4
✓ Branch 3 → 4 taken 252 times.
✗ Branch 3 → 53 not taken.
✓ Branch 4 → 5 taken 34 times.
✓ Branch 4 → 6 taken 218 times.
252 if (!in)
713 {
714 34 return std::nullopt;
715 }
716
1/2
✓ Branch 6 → 7 taken 218 times.
✗ Branch 6 → 53 not taken.
218 in.seekg(0, std::ios::end);
717
1/2
✓ Branch 7 → 8 taken 218 times.
✗ Branch 7 → 47 not taken.
218 std::streamsize size = in.tellg();
718
1/2
✓ Branch 9 → 10 taken 218 times.
✗ Branch 9 → 53 not taken.
218 in.seekg(0, std::ios::beg);
719 #if defined(DMK_ENABLE_TEST_SEAMS)
720
2/2
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 14 taken 217 times.
218 if (DetourModKit::detail::g_config_read_seektell_fail.load(std::memory_order_acquire))
721 {
722 // Simulate a failed tellg() so the I/O-failure classification below runs deterministically.
723
1/2
✓ Branch 12 → 13 taken 1 time.
✗ Branch 12 → 53 not taken.
1 in.setstate(std::ios::failbit);
724 1 size = -1;
725 }
726 #endif
727 // A seek/tell failure is an I/O failure (nullopt), not a successful empty read. reload() then
728 // retains the last-good values instead of defaults from an empty-file hash.
729
6/8
✓ Branch 14 → 15 taken 218 times.
✗ Branch 14 → 53 not taken.
✓ Branch 15 → 16 taken 217 times.
✓ Branch 15 → 17 taken 1 time.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 217 times.
✓ Branch 19 → 20 taken 1 time.
✓ Branch 19 → 21 taken 217 times.
218 if (!in || size < 0)
730 {
731 1 return std::nullopt;
732 }
733
2/2
✓ Branch 21 → 22 taken 3 times.
✓ Branch 21 → 26 taken 214 times.
217 if (size == 0)
734 {
735 3 return std::vector<std::uint8_t>{};
736 }
737
1/2
✓ Branch 28 → 29 taken 214 times.
✗ Branch 28 → 48 not taken.
214 std::vector<std::uint8_t> buf(static_cast<std::size_t>(size));
738
1/2
✓ Branch 31 → 32 taken 214 times.
✗ Branch 31 → 51 not taken.
214 in.read(reinterpret_cast<char *>(buf.data()), size);
739
3/10
✓ Branch 32 → 33 taken 214 times.
✗ Branch 32 → 51 not taken.
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 37 taken 214 times.
✗ Branch 34 → 35 not taken.
✗ Branch 34 → 51 not taken.
✗ Branch 35 → 36 not taken.
✗ Branch 35 → 37 not taken.
✗ Branch 38 → 39 not taken.
✓ Branch 38 → 40 taken 214 times.
214 if (!in && !in.eof())
740 {
741 return std::nullopt;
742 }
743
1/2
✓ Branch 41 → 42 taken 214 times.
✗ Branch 41 → 51 not taken.
214 buf.resize(static_cast<std::size_t>(in.gcount()));
744 214 return buf;
745 252 }
746 catch (...)
747 {
748 return std::nullopt;
749 }
750 }
751
752 /// IniLoadOutcome reports the read-hash-parse pipeline result for load() and reload().
753 struct IniLoadOutcome
754 {
755 /// Reports whether the byte read from disk succeeded.
756 bool read_succeeded{false};
757 /// Reports whether CSimpleIniA::LoadData returned SI_OK.
758 bool parse_succeeded{false};
759 /// Stores the raw SimpleIni return code when read_succeeded is true.
760 SI_Error parse_rc{SI_OK};
761 /// Stores the FNV-1a hash of the read bytes.
762 std::optional<std::uint64_t> hash;
763 };
764
765 /**
766 * @brief Reads the INI bytes once, computes their hash, and feeds those exact bytes to
767 * CSimpleIniA::LoadData.
768 * @details Closes the TOCTOU window from a LoadFile re-read after the byte snapshot. The hash and parse
769 * reflect the same file state.
770 */
771 252 [[nodiscard]] IniLoadOutcome load_ini_into(const std::filesystem::path &path, CSimpleIniA &ini) noexcept
772 {
773 252 IniLoadOutcome outcome{};
774 252 auto bytes = read_ini_bytes(path);
775
2/2
✓ Branch 4 → 5 taken 35 times.
✓ Branch 4 → 6 taken 217 times.
252 if (!bytes.has_value())
776 {
777 35 return outcome;
778 }
779 217 outcome.read_succeeded = true;
780 217 outcome.hash = fnv1a_64(*bytes);
781
782 // SimpleIni accepts empty buffers (SI_OK). Preserve the hash so an empty file can hash-skip.
783 try
784 {
785
2/2
✓ Branch 11 → 12 taken 3 times.
✓ Branch 11 → 13 taken 214 times.
217 const char *data_ptr = bytes->empty() ? "" : reinterpret_cast<const char *>(bytes->data());
786
1/2
✓ Branch 17 → 18 taken 217 times.
✗ Branch 17 → 26 not taken.
217 outcome.parse_rc = ini.LoadData(data_ptr, bytes->size());
787 217 outcome.parse_succeeded = (outcome.parse_rc >= 0);
788 }
789 catch (...)
790 {
791 outcome.parse_rc = SI_FAIL;
792 outcome.parse_succeeded = false;
793 }
794 #if defined(DMK_ENABLE_TEST_SEAMS)
795
2/2
✓ Branch 20 → 21 taken 1 time.
✓ Branch 20 → 22 taken 216 times.
217 if (DetourModKit::detail::g_config_parse_fail_once.exchange(false, std::memory_order_acq_rel))
796 {
797 // Simulate a transient SimpleIni allocation failure. The seam clears itself before the next retry.
798 1 outcome.parse_rc = SI_FAIL;
799 1 outcome.parse_succeeded = false;
800 }
801 #endif
802 217 return outcome;
803 252 }
804
805 /**
806 * @brief Replaces an item with the same section+key, or appends if none exists.
807 * @note Caller must hold get_config_mutex().
808 */
809 241 void replace_or_append(std::unique_ptr<ConfigItemBase> item)
810 {
811 // Advance the binding generation so reload()'s unchanged-content fast path re-hydrates this item.
812 241 ++get_binding_generation();
813 241 auto &items = get_registered_config_items();
814
2/2
✓ Branch 32 → 6 taken 78 times.
✓ Branch 32 → 33 taken 236 times.
555 for (auto &existing : items)
815 {
816
6/6
✓ Branch 11 → 12 taken 59 times.
✓ Branch 11 → 17 taken 19 times.
✓ Branch 15 → 16 taken 5 times.
✓ Branch 15 → 17 taken 54 times.
✓ Branch 18 → 19 taken 5 times.
✓ Branch 18 → 23 taken 73 times.
78 if (existing->section == item->section && existing->ini_key == item->ini_key)
817 {
818 5 existing = std::move(item);
819 5 return;
820 }
821 }
822 236 items.push_back(std::move(item));
823 }
824
825 } // anonymous namespace
826
827 namespace detail
828 {
829 // Contract in internal/config_pass.hpp.
830 304 std::filesystem::path get_ini_file_path(const std::string &ini_filename, DeferredDiagnostics &diags)
831 {
832
1/2
✓ Branch 2 → 3 taken 304 times.
✗ Branch 2 → 68 not taken.
304 std::wstring module_dir = get_runtime_directory();
833
834
4/8
✓ Branch 4 → 5 taken 304 times.
✗ Branch 4 → 7 not taken.
✓ Branch 5 → 6 taken 304 times.
✗ Branch 5 → 66 not taken.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 304 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 13 taken 304 times.
304 if (module_dir.empty() || module_dir == L".")
835 {
836 defer_diagnostic(
837 diags,
838 LogLevel::Warning,
839 "Config: Could not reliably determine module directory or it's current working directory. "
840 "Using relative path for INI: {}",
841 ini_filename
842 );
843 return std::filesystem::path(ini_filename);
844 }
845
846 try
847 {
848 std::filesystem::path ini_path_obj =
849
4/8
✓ Branch 13 → 14 taken 304 times.
✗ Branch 13 → 39 not taken.
✓ Branch 14 → 15 taken 304 times.
✗ Branch 14 → 36 not taken.
✓ Branch 15 → 16 taken 304 times.
✗ Branch 15 → 34 not taken.
✓ Branch 16 → 17 taken 304 times.
✗ Branch 16 → 32 not taken.
304 (std::filesystem::path(module_dir) / ini_filename).lexically_normal();
850
1/2
✓ Branch 21 → 22 taken 304 times.
✗ Branch 21 → 41 not taken.
304 defer_diagnostic(
851 diags,
852 LogLevel::Debug,
853 "Config: Determined INI file path: {}",
854
1/2
✓ Branch 20 → 21 taken 304 times.
✗ Branch 20 → 44 not taken.
608 ini_path_obj.string()
855 );
856 304 return ini_path_obj;
857 304 }
858 catch (const std::filesystem::filesystem_error &fs_err)
859 {
860 defer_diagnostic(
861 diags,
862 LogLevel::Warning,
863 "Config: Filesystem error constructing INI path: {}. Using relative path for INI: {}",
864 fs_err.what(),
865 ini_filename
866 );
867 }
868 catch (const std::exception &e)
869 {
870 defer_diagnostic(
871 diags,
872 LogLevel::Warning,
873 "Config: General error constructing INI path: {}. Using relative path for INI: {}",
874 e.what(),
875 ini_filename
876 );
877 }
878 return std::filesystem::path(ini_filename); // Fallback
879 304 }
880 } // namespace detail
881
882 namespace
883 {
884 // All bind_* functions use the deferred callback pattern. State mutates under get_config_mutex(). The
885 // setter runs after release, so a setter can re-enter the data-plane config API with no deadlock.
886 // The load()/reload() pass lock is a separate, stricter contract documented on those functions.
887 template <typename T>
888 153 void bind_scalar(
889 std::string_view section,
890 std::string_view ini_key,
891 std::string_view log_key_name,
892 std::function<void(SetterArg<T>)> setter,
893 T default_value
894 )
895 {
896 153 std::function<void()> deferred;
897 {
898
4/8
void DetourModKit::config::(anonymous namespace)::bind_scalar<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<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::basic_string_view<char, std::char_traits<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >::type)>, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >):
✓ Branch 4 → 5 taken 14 times.
✗ Branch 4 → 76 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<bool>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<bool, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, bool>::type)>, bool):
✓ Branch 4 → 5 taken 20 times.
✗ Branch 4 → 75 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<float>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<float, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, float>::type)>, float):
✓ Branch 4 → 5 taken 18 times.
✗ Branch 4 → 75 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<int>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<int, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, int>::type)>, int):
✓ Branch 4 → 5 taken 101 times.
✗ Branch 4 → 75 not taken.
153 std::lock_guard<std::mutex> lock(get_config_mutex());
899
4/8
void DetourModKit::config::(anonymous namespace)::bind_scalar<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<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::basic_string_view<char, std::char_traits<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >::type)>, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >):
✓ Branch 16 → 17 taken 14 times.
✗ Branch 16 → 44 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<bool>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<bool, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, bool>::type)>, bool):
✓ Branch 16 → 17 taken 20 times.
✗ Branch 16 → 43 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<float>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<float, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, float>::type)>, float):
✓ Branch 16 → 17 taken 18 times.
✗ Branch 16 → 43 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<int>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<int, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, int>::type)>, int):
✓ Branch 16 → 17 taken 101 times.
✗ Branch 16 → 43 not taken.
153 replace_or_append(
900
4/8
void DetourModKit::config::(anonymous namespace)::bind_scalar<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<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::basic_string_view<char, std::char_traits<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >::type)>, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >):
✓ Branch 14 → 15 taken 14 times.
✗ Branch 14 → 48 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<bool>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<bool, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, bool>::type)>, bool):
✓ Branch 14 → 15 taken 20 times.
✗ Branch 14 → 47 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<float>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<float, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, float>::type)>, float):
✓ Branch 14 → 15 taken 18 times.
✗ Branch 14 → 47 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<int>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<int, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, int>::type)>, int):
✓ Branch 14 → 15 taken 101 times.
✗ Branch 14 → 47 not taken.
306 std::make_unique<CallbackConfigItem<T>>(
901
4/8
void DetourModKit::config::(anonymous namespace)::bind_scalar<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<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::basic_string_view<char, std::char_traits<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >::type)>, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >):
✓ Branch 13 → 14 taken 14 times.
✗ Branch 13 → 50 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<bool>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<bool, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, bool>::type)>, bool):
✓ Branch 13 → 14 taken 20 times.
✗ Branch 13 → 49 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<float>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<float, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, float>::type)>, float):
✓ Branch 13 → 14 taken 18 times.
✗ Branch 13 → 49 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<int>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<int, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, int>::type)>, int):
✓ Branch 13 → 14 taken 101 times.
✗ Branch 13 → 49 not taken.
306 std::string(section),
902
4/8
void DetourModKit::config::(anonymous namespace)::bind_scalar<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<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::basic_string_view<char, std::char_traits<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >::type)>, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >):
✓ Branch 10 → 11 taken 14 times.
✗ Branch 10 → 56 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<bool>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<bool, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, bool>::type)>, bool):
✓ Branch 10 → 11 taken 20 times.
✗ Branch 10 → 55 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<float>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<float, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, float>::type)>, float):
✓ Branch 10 → 11 taken 18 times.
✗ Branch 10 → 55 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<int>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<int, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, int>::type)>, int):
✓ Branch 10 → 11 taken 101 times.
✗ Branch 10 → 55 not taken.
306 std::string(ini_key),
903
4/8
void DetourModKit::config::(anonymous namespace)::bind_scalar<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<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::basic_string_view<char, std::char_traits<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >::type)>, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >):
✓ Branch 7 → 8 taken 14 times.
✗ Branch 7 → 62 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<bool>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<bool, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, bool>::type)>, bool):
✓ Branch 7 → 8 taken 20 times.
✗ Branch 7 → 61 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<float>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<float, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, float>::type)>, float):
✓ Branch 7 → 8 taken 18 times.
✗ Branch 7 → 61 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<int>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<int, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, int>::type)>, int):
✓ Branch 7 → 8 taken 101 times.
✗ Branch 7 → 61 not taken.
306 std::string(log_key_name),
904 setter,
905 default_value
906 )
907 );
908
6/8
void DetourModKit::config::(anonymous namespace)::bind_scalar<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<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::basic_string_view<char, std::char_traits<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >::type)>, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >):
✓ Branch 26 → 27 taken 13 times.
✓ Branch 26 → 38 taken 1 time.
void DetourModKit::config::(anonymous namespace)::bind_scalar<bool>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<bool, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, bool>::type)>, bool):
✓ Branch 26 → 27 taken 20 times.
✗ Branch 26 → 37 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<float>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<float, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, float>::type)>, float):
✓ Branch 26 → 27 taken 18 times.
✗ Branch 26 → 37 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<int>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<int, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, int>::type)>, int):
✓ Branch 26 → 27 taken 97 times.
✓ Branch 26 → 37 taken 4 times.
153 if (setter)
909 {
910
8/24
void DetourModKit::config::(anonymous namespace)::bind_scalar<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<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::basic_string_view<char, std::char_traits<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >::type)>, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >):
✓ Branch 33 → 34 taken 13 times.
✗ Branch 33 → 68 not taken.
✗ Branch 35 → 36 not taken.
✓ Branch 35 → 37 taken 13 times.
✗ Branch 70 → 71 not taken.
✗ Branch 70 → 72 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<bool>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<bool, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, bool>::type)>, bool):
✓ Branch 32 → 33 taken 20 times.
✗ Branch 32 → 67 not taken.
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 36 taken 20 times.
✗ Branch 69 → 70 not taken.
✗ Branch 69 → 71 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<float>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<float, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, float>::type)>, float):
✓ Branch 32 → 33 taken 18 times.
✗ Branch 32 → 67 not taken.
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 36 taken 18 times.
✗ Branch 69 → 70 not taken.
✗ Branch 69 → 71 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<int>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<int, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, int>::type)>, int):
✓ Branch 32 → 33 taken 97 times.
✗ Branch 32 → 67 not taken.
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 36 taken 97 times.
✗ Branch 69 → 70 not taken.
✗ Branch 69 → 71 not taken.
592 deferred = [setter = std::move(setter), val = std::move(default_value)]() mutable
911 {
912 if constexpr (std::same_as<T, std::string>)
913 {
914 13 setter(std::string_view{val});
915 }
916 else
917 {
918 135 setter(val);
919 }
920 };
921 }
922 153 }
923
6/8
void DetourModKit::config::(anonymous namespace)::bind_scalar<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<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::basic_string_view<char, std::char_traits<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >::type)>, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >):
✓ Branch 40 → 41 taken 13 times.
✓ Branch 40 → 42 taken 1 time.
void DetourModKit::config::(anonymous namespace)::bind_scalar<bool>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<bool, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, bool>::type)>, bool):
✓ Branch 39 → 40 taken 20 times.
✗ Branch 39 → 41 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<float>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<float, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, float>::type)>, float):
✓ Branch 39 → 40 taken 18 times.
✗ Branch 39 → 41 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<int>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<int, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, int>::type)>, int):
✓ Branch 39 → 40 taken 97 times.
✓ Branch 39 → 41 taken 4 times.
153 if (deferred)
924 {
925
4/8
void DetourModKit::config::(anonymous namespace)::bind_scalar<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<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::basic_string_view<char, std::char_traits<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >::type)>, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >):
✓ Branch 41 → 42 taken 13 times.
✗ Branch 41 → 77 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<bool>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<bool, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, bool>::type)>, bool):
✓ Branch 40 → 41 taken 20 times.
✗ Branch 40 → 76 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<float>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<float, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, float>::type)>, float):
✓ Branch 40 → 41 taken 18 times.
✗ Branch 40 → 76 not taken.
void DetourModKit::config::(anonymous namespace)::bind_scalar<int>(std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >, std::function<void (std::conditional<same_as<int, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, std::basic_string_view<char, std::char_traits<char> >, int>::type)>, int):
✓ Branch 40 → 41 taken 97 times.
✗ Branch 40 → 76 not taken.
148 deferred();
926 }
927 153 }
928
929 } // anonymous namespace
930
931 101 void bind_int(
932 std::string_view section,
933 std::string_view key,
934 std::string_view display_name,
935 std::function<void(int)> setter,
936 int default_value
937 )
938 {
939
1/2
✓ Branch 5 → 6 taken 101 times.
✗ Branch 5 → 8 not taken.
101 bind_scalar<int>(section, key, display_name, std::move(setter), default_value);
940 101 }
941
942 18 void bind_float(
943 std::string_view section,
944 std::string_view key,
945 std::string_view display_name,
946 std::function<void(float)> setter,
947 float default_value
948 )
949 {
950
1/2
✓ Branch 5 → 6 taken 18 times.
✗ Branch 5 → 8 not taken.
18 bind_scalar<float>(section, key, display_name, std::move(setter), default_value);
951 18 }
952
953 20 void bind_bool(
954 std::string_view section,
955 std::string_view key,
956 std::string_view display_name,
957 std::function<void(bool)> setter,
958 bool default_value
959 )
960 {
961
1/2
✓ Branch 5 → 6 taken 20 times.
✗ Branch 5 → 8 not taken.
40 bind_scalar<bool>(section, key, display_name, std::move(setter), default_value);
962 20 }
963
964 14 void bind_string(
965 std::string_view section,
966 std::string_view key,
967 std::string_view display_name,
968 std::function<void(std::string_view)> setter,
969 std::string_view default_value
970 )
971 {
972
2/4
✓ Branch 4 → 5 taken 14 times.
✗ Branch 4 → 18 not taken.
✓ Branch 8 → 9 taken 14 times.
✗ Branch 8 → 13 not taken.
28 bind_scalar<std::string>(section, key, display_name, std::move(setter), std::string(default_value));
973 14 }
974
975 void bind_parsed(
976 std::string_view section,
977 std::string_view key,
978 std::string_view display_name,
979 std::atomic<std::uint32_t> &out,
980 std::function<std::uint32_t(std::string_view)> parse,
981 std::string_view default_value
982 )
983 {
984 // parse is captured by value so the setter stays valid across every load()/reload(). out is captured by
985 // reference and must outlive the registration.
986 bind_string(
987 section,
988 key,
989 display_name,
990 [&out, parse = std::move(parse)](std::string_view value)
991 { out.store(parse(value), std::memory_order_relaxed); },
992 default_value
993 );
994 }
995
996 1 void bind_log_level(std::string_view section, std::string_view key, std::string_view default_value)
997 {
998
1/2
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 7 not taken.
1 bind_string(
999 section,
1000 key,
1001 1 "Log level",
1002 4 [](std::string_view value) { log().set_log_level(string_to_log_level(value)); },
1003 default_value
1004 );
1005 1 }
1006
1007 88 void bind_combos(
1008 std::string_view section,
1009 std::string_view key,
1010 std::string_view display_name,
1011 std::function<void(const input::KeyComboList &)> setter,
1012 std::string_view default_value
1013 )
1014 {
1015 88 detail::DeferredDiagnostics diags = detail::open_deferred_diagnostics();
1016 input::KeyComboList default_combos =
1017
2/4
✓ Branch 5 → 6 taken 88 times.
✗ Branch 5 → 56 not taken.
✓ Branch 6 → 7 taken 88 times.
✗ Branch 6 → 54 not taken.
88 detail::parse_key_combo_list(std::string(default_value), diags, display_name);
1018 88 detail::emit_deferred_diagnostics(diags);
1019
1020 88 std::function<void()> deferred;
1021 {
1022
1/2
✓ Branch 12 → 13 taken 88 times.
✗ Branch 12 → 92 not taken.
88 std::lock_guard<std::mutex> lock(get_config_mutex());
1023
1/2
✓ Branch 24 → 25 taken 88 times.
✗ Branch 24 → 60 not taken.
88 replace_or_append(
1024
1/2
✓ Branch 22 → 23 taken 88 times.
✗ Branch 22 → 64 not taken.
176 std::make_unique<CallbackConfigItem<input::KeyComboList>>(
1025
1/2
✓ Branch 21 → 22 taken 88 times.
✗ Branch 21 → 66 not taken.
176 std::string(section),
1026
1/2
✓ Branch 18 → 19 taken 88 times.
✗ Branch 18 → 72 not taken.
176 std::string(key),
1027
1/2
✓ Branch 15 → 16 taken 88 times.
✗ Branch 15 → 78 not taken.
176 std::string(display_name),
1028 setter,
1029 default_combos
1030 )
1031 );
1032
2/2
✓ Branch 34 → 35 taken 87 times.
✓ Branch 34 → 46 taken 1 time.
88 if (setter)
1033 {
1034
2/6
✓ Branch 41 → 42 taken 87 times.
✗ Branch 41 → 84 not taken.
✗ Branch 43 → 44 not taken.
✓ Branch 43 → 45 taken 87 times.
✗ Branch 86 → 87 not taken.
✗ Branch 86 → 88 not taken.
261 deferred = [setter = std::move(setter), combos = std::move(default_combos)]() { setter(combos); };
1035 }
1036 88 }
1037
2/2
✓ Branch 48 → 49 taken 87 times.
✓ Branch 48 → 50 taken 1 time.
88 if (deferred)
1038 {
1039
1/2
✓ Branch 49 → 50 taken 87 times.
✗ Branch 49 → 93 not taken.
87 deferred();
1040 }
1041 88 }
1042
1043 4 void consume_flag(
1044 std::string_view section,
1045 std::string_view ini_key,
1046 std::string_view display_name,
1047 std::string_view binding_name,
1048 bool default_value
1049 )
1050 {
1051 // An unknown name makes set_consume a no-op, so registration before the binding exists is safe.
1052
1/2
✓ Branch 4 → 5 taken 4 times.
✗ Branch 4 → 13 not taken.
4 std::string binding_name_str(binding_name);
1053
1/2
✓ Branch 8 → 9 taken 4 times.
✗ Branch 8 → 16 not taken.
4 bind_bool(
1054 section,
1055 ini_key,
1056 display_name,
1057
2/4
✓ Branch 6 → 7 taken 4 times.
✗ Branch 6 → 20 not taken.
✓ Branch 7 → 8 taken 4 times.
✗ Branch 7 → 18 not taken.
13 [binding_name_str](bool consume) { input::Input::instance().set_consume(binding_name_str, consume); },
1058 default_value
1059 );
1060 4 }
1061
1062 namespace
1063 {
1064 /**
1065 * @brief Implements the shared path behind press_combo() and hold_combo().
1066 * @details The helper registers the input binding and wires a combo config item. An INI change rebinds it
1067 * on every load() or reload(). It optionally registers the "<ini_key>.Consume" facet. A
1068 * registration error logs and yields an inert default guard.
1069 */
1070 25 input::BindingGuard register_combo_fusion(
1071 input::Trigger trigger,
1072 std::string_view section,
1073 std::string_view ini_key,
1074 std::string_view log_name,
1075 std::string_view binding_name,
1076 std::function<void()> on_press,
1077 std::function<void(bool)> on_state_change,
1078 std::string_view default_combo,
1079 std::optional<bool> consume
1080 )
1081 {
1082
1/2
✓ Branch 4 → 5 taken 25 times.
✗ Branch 4 → 67 not taken.
25 const std::string binding_name_str(binding_name);
1083
1084 // Register the binding with an empty combo set. The combo config item below parses default_combo
1085 // exactly once and rebinds. A prior parse here duplicates the parse and any typo WARNING.
1086 25 input::ComboBinding binding{
1087 .name = binding_name_str,
1088 .trigger = trigger,
1089 .combos = {},
1090 50 .consume = consume.value_or(false),
1091 25 .on_press = std::move(on_press),
1092 25 .on_state_change = std::move(on_state_change),
1093
4/8
✓ Branch 6 → 7 taken 25 times.
✗ Branch 6 → 98 not taken.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 25 times.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 25 times.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 25 times.
75 };
1094
1095 25 input::BindingGuard guard;
1096
1/2
✓ Branch 27 → 28 taken 25 times.
✗ Branch 27 → 32 not taken.
25 if (auto reg = input::register_combo(std::move(binding)); reg.has_value())
1097 {
1098 50 guard = std::move(*reg);
1099 }
1100 else
1101 {
1102 (void)log().try_log(
1103 LogLevel::Error,
1104 "Config: failed to register input binding '{}' for '{}'; "
1105 "binding will be inert.",
1106 binding_name_str,
1107 log_name
1108 );
1109 25 }
1110
1111 // The setter rebinds the named binding on every load()/reload() without another registration.
1112
1/2
✓ Branch 38 → 39 taken 25 times.
✗ Branch 38 → 70 not taken.
25 bind_combos(
1113 section,
1114 ini_key,
1115 log_name,
1116
2/4
✓ Branch 36 → 37 taken 25 times.
✗ Branch 36 → 74 not taken.
✓ Branch 37 → 38 taken 25 times.
✗ Branch 37 → 72 not taken.
50 [binding_name_str](const input::KeyComboList &combos)
1117
1/2
✓ Branch 3 → 4 taken 40 times.
✗ Branch 3 → 8 not taken.
40 { (void)input::Input::instance().rebind(binding_name_str, combos); },
1118 default_combo
1119 );
1120
1121 // Register the consume facet only after the binding exists. Otherwise its immediate default reaches
1122 // set_consume()'s unknown-name no-op and is lost.
1123
2/2
✓ Branch 42 → 43 taken 3 times.
✓ Branch 42 → 63 taken 22 times.
25 if (consume.has_value())
1124 {
1125
1/2
✓ Branch 55 → 56 taken 3 times.
✗ Branch 55 → 76 not taken.
6 consume_flag(
1126 section,
1127
2/4
✓ Branch 52 → 53 taken 3 times.
✗ Branch 52 → 80 not taken.
✓ Branch 53 → 54 taken 3 times.
✗ Branch 53 → 78 not taken.
6 std::string(ini_key) + ".Consume",
1128
2/4
✓ Branch 47 → 48 taken 3 times.
✗ Branch 47 → 89 not taken.
✓ Branch 48 → 49 taken 3 times.
✗ Branch 48 → 87 not taken.
9 std::string(log_name) + " Consume",
1129 binding_name_str,
1130 3 *consume
1131 );
1132 }
1133
1134 25 return guard;
1135 25 }
1136 } // anonymous namespace
1137
1138 20 input::BindingGuard press_combo(
1139 std::string_view section,
1140 std::string_view ini_key,
1141 std::string_view log_name,
1142 std::string_view binding_name,
1143 std::function<void()> on_press,
1144 std::string_view default_combo,
1145 std::optional<bool> consume
1146 )
1147 {
1148 20 return register_combo_fusion(
1149 input::Trigger::Press,
1150 section,
1151 ini_key,
1152 log_name,
1153 binding_name,
1154 20 std::move(on_press),
1155 40 nullptr,
1156 default_combo,
1157 consume
1158
1/2
✓ Branch 6 → 7 taken 20 times.
✗ Branch 6 → 12 not taken.
40 );
1159 }
1160
1161 5 input::BindingGuard hold_combo(
1162 std::string_view section,
1163 std::string_view ini_key,
1164 std::string_view log_name,
1165 std::string_view binding_name,
1166 std::function<void(bool)> on_state_change,
1167 std::string_view default_combo,
1168 std::optional<bool> consume
1169 )
1170 {
1171 5 return register_combo_fusion(
1172 input::Trigger::Hold,
1173 section,
1174 ini_key,
1175 log_name,
1176 binding_name,
1177 10 nullptr,
1178 5 std::move(on_state_change),
1179 default_combo,
1180 consume
1181
1/2
✓ Branch 6 → 7 taken 5 times.
✗ Branch 6 → 12 not taken.
10 );
1182 }
1183
1184 184 void load(std::string_view ini_filename)
1185 {
1186 // Re-arm background reloads. A Logic DLL that (re)loads and calls load() must be able to hot-reload
1187 // again after an unload latched them off.
1188 184 detail::rearm_reloads();
1189
1190 // Serialize the whole pass (see internal/config_reload_lifecycle.hpp). Fail fast on same-thread re-entry.
1191
1/2
✓ Branch 3 → 4 taken 184 times.
✗ Branch 3 → 183 not taken.
184 detail::ReloadApplyLock apply_lock;
1192
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 9 taken 183 times.
184 if (!apply_lock.engaged())
1193 {
1194 1 (void)log().try_log(
1195 LogLevel::Error,
1196 "Config: load() re-entered from a bound setter on the same thread; ignoring to "
1197 "avoid a self-deadlock. Do not call load()/reload() from a config setter."
1198 );
1199 1 return;
1200 }
1201
1202 183 std::vector<std::function<void()>> deferred_callbacks;
1203 183 std::string loaded_resolved_path;
1204 183 std::optional<std::uint64_t> hash_to_commit;
1205 183 std::uint64_t generation_to_commit = 0;
1206
1207 // The filename is a caller argument, so the whole path resolution runs before the registry lock.
1208 183 detail::DeferredDiagnostics diags = detail::open_deferred_diagnostics();
1209
2/4
✓ Branch 13 → 14 taken 183 times.
✗ Branch 13 → 128 not taken.
✓ Branch 14 → 15 taken 183 times.
✗ Branch 14 → 126 not taken.
183 std::filesystem::path ini_path = detail::get_ini_file_path(std::string(ini_filename), diags);
1210
1/2
✓ Branch 17 → 18 taken 183 times.
✗ Branch 17 → 173 not taken.
183 std::string ini_path_str = ini_path.string();
1211
1/2
✓ Branch 18 → 19 taken 183 times.
✗ Branch 18 → 171 not taken.
183 loaded_resolved_path = ini_path_str;
1212
1213 {
1214
1/2
✓ Branch 20 → 21 taken 183 times.
✗ Branch 20 → 150 not taken.
183 std::lock_guard<std::mutex> lock(get_config_mutex());
1215
1216 183 CSimpleIniA ini;
1217 183 ini.SetUnicode(false); // Assume ASCII/MBCS INI
1218 183 ini.SetMultiKey(false); // Disallow duplicate keys in a section
1219
1220 183 IniLoadOutcome outcome = load_ini_into(ini_path, ini);
1221
1222
2/2
✓ Branch 25 → 26 taken 31 times.
✓ Branch 25 → 29 taken 152 times.
183 if (!outcome.read_succeeded)
1223 {
1224
1/2
✓ Branch 26 → 27 taken 31 times.
✗ Branch 26 → 132 not taken.
31 detail::defer_diagnostic(
1225 diags,
1226 LogLevel::Error,
1227 "Config: Failed to open '{}'. Using defaults.",
1228 ini_path_str
1229 );
1230 // Wipe the cached hash so the next reload() does not short-circuit against a stale value.
1231 31 get_last_loaded_ini_hash().reset();
1232 }
1233
1/2
✗ Branch 29 → 30 not taken.
✓ Branch 29 → 33 taken 152 times.
152 else if (!outcome.parse_succeeded)
1234 {
1235 detail::defer_diagnostic(
1236 diags,
1237 LogLevel::Error,
1238 "Config: Failed to parse '{}' (error {}). Using defaults.",
1239 ini_path_str,
1240 static_cast<int>(outcome.parse_rc)
1241 );
1242 // Clear the hash: it was computed for bytes that did not parse and must not enable a hash-skip.
1243 get_last_loaded_ini_hash().reset();
1244 }
1245 else
1246 {
1247
1/2
✓ Branch 33 → 34 taken 152 times.
✗ Branch 33 → 135 not taken.
152 detail::defer_diagnostic(diags, LogLevel::Debug, "Config: Opened {}", ini_path_str);
1248 // Do not publish this hash until every deferred setter succeeds.
1249 // Reset the prior snapshot so a setter failure cannot suppress an identical-byte retry.
1250 152 get_last_loaded_ini_hash().reset();
1251 152 hash_to_commit = outcome.hash;
1252 }
1253
1254 // Read all values under lock, but defer setter callbacks and diagnostics.
1255
2/2
✓ Branch 61 → 40 taken 220 times.
✓ Branch 61 → 62 taken 183 times.
586 for (const auto &item : get_registered_config_items())
1256 {
1257
1/2
✓ Branch 43 → 44 taken 220 times.
✗ Branch 43 → 138 not taken.
220 item->load(ini, diags);
1258
1/2
✓ Branch 45 → 46 taken 220 times.
✗ Branch 45 → 138 not taken.
220 auto cb = item->take_deferred_apply();
1259
2/2
✓ Branch 47 → 48 taken 219 times.
✓ Branch 47 → 51 taken 1 time.
220 if (cb)
1260 {
1261
1/2
✓ Branch 50 → 51 taken 219 times.
✗ Branch 50 → 136 not taken.
219 deferred_callbacks.push_back(std::move(cb));
1262 }
1263 220 }
1264 // Snapshot the binding generation for the item set just read. Commit it with the hash.
1265 183 generation_to_commit = get_binding_generation();
1266
1267 // Remember the INI path on every outcome, not just success. A ship-with-defaults first run has no INI
1268 // on disk yet. enable_auto_reload() needs the recorded path to detect the file after it appears. A
1269 // failed load resets the hash, and reload() retains the last values, so this path remains safe.
1270
1/2
✓ Branch 65 → 66 taken 183 times.
✗ Branch 65 → 140 not taken.
183 get_last_loaded_ini_path() = std::string(ini_filename);
1271
1272 detail::defer_diagnostic(
1273 diags,
1274 LogLevel::Info,
1275 "Config: Loaded {} items from {}",
1276
1/2
✓ Branch 72 → 73 taken 183 times.
✗ Branch 72 → 144 not taken.
183 get_registered_config_items().size(),
1277 ini_path_str
1278 );
1279 183 }
1280
1281 183 detail::emit_deferred_diagnostics(diags);
1282
1283 // Invoke setters outside the config mutex under the deferred pattern. Setters can re-enter the data-plane
1284 // API. The held pass lock forbids load()/reload()/disable_auto_reload()/clear() because those calls
1285 // self-deadlock (see config.hpp). A per-call wrapper lets every later setter run after an exception.
1286 183 Logger &setter_logger = log();
1287 183 bool all_setters_applied = true;
1288
2/2
✓ Branch 91 → 79 taken 219 times.
✓ Branch 91 → 92 taken 183 times.
585 for (auto &cb : deferred_callbacks)
1289 {
1290 try
1291 {
1292
2/2
✓ Branch 81 → 82 taken 218 times.
✓ Branch 81 → 151 taken 1 time.
219 cb();
1293 }
1294
1/2
✓ Branch 151 → 152 taken 1 time.
✗ Branch 151 → 156 not taken.
1 catch (const std::exception &e)
1295 {
1296 1 all_setters_applied = false;
1297
1/2
✓ Branch 154 → 155 taken 1 time.
✗ Branch 154 → 159 not taken.
1 setter_logger.error("Config: load setter threw: {}", e.what());
1298 1 }
1299 catch (...)
1300 {
1301 all_setters_applied = false;
1302 setter_logger.error("Config: load setter threw unknown exception.");
1303 }
1304 }
1305
6/6
✓ Branch 92 → 93 taken 182 times.
✓ Branch 92 → 96 taken 1 time.
✓ Branch 94 → 95 taken 151 times.
✓ Branch 94 → 96 taken 31 times.
✓ Branch 97 → 98 taken 151 times.
✓ Branch 97 → 105 taken 32 times.
183 if (all_setters_applied && hash_to_commit.has_value())
1306 {
1307
1/2
✓ Branch 99 → 100 taken 151 times.
✗ Branch 99 → 167 not taken.
151 std::lock_guard<std::mutex> lock(get_config_mutex());
1308 151 get_last_loaded_ini_hash() = hash_to_commit;
1309 151 get_applied_binding_generation() = generation_to_commit;
1310 151 }
1311
1312 // Re-point the auto-reload watcher if load() switched the config file out from under it. Otherwise edits
1313 // to the active file never trigger reload. The stale watcher joins only after pass-lock release, so a
1314 // queued background reload can finish and let the old watcher exit.
1315 {
1316
1/2
✓ Branch 106 → 107 taken 183 times.
✗ Branch 106 → 170 not taken.
183 detail::WatchRepoint repoint = detail::detach_watcher_if_repointed(loaded_resolved_path);
1317
1318 // Drop the pass lock before the stale-watcher join. Perform the join OUTSIDE both mutexes. The stale
1319 // worker's final callback can enter disable/enable. A held watcher mutex then causes deadlock.
1320 183 apply_lock.unlock();
1321 183 repoint.stale.reset();
1322
2/2
✓ Branch 109 → 110 taken 5 times.
✓ Branch 109 → 113 taken 178 times.
183 if (repoint.repoint)
1323 {
1324 // Deterministically drive disable_auto_reload() into the lost-disable window.
1325 #if defined(DMK_ENABLE_TEST_SEAMS)
1326
2/2
✓ Branch 110 → 111 taken 1 time.
✓ Branch 110 → 112 taken 4 times.
5 if (const auto hook = DetourModKit::detail::g_config_repoint_window_test_hook)
1327 {
1328
1/2
✓ Branch 111 → 112 taken 1 time.
✗ Branch 111 → 168 not taken.
1 hook();
1329 }
1330 #endif
1331
1/2
✓ Branch 112 → 113 taken 5 times.
✗ Branch 112 → 168 not taken.
5 detail::restart_watcher_after_repoint(repoint.debounce, repoint.generation_at_move);
1332 }
1333 183 }
1334
2/2
✓ Branch 121 → 122 taken 183 times.
✓ Branch 121 → 124 taken 1 time.
184 }
1335
1336 namespace detail
1337 {
1338 // Contract in internal/config_pass.hpp.
1339 73 bool reload_impl(bool &out_setters_ran, const BackgroundReloadGuard *background_guard)
1340 {
1341 73 out_setters_ran = false;
1342
1343 // Serialize the whole pass (see internal/config_reload_lifecycle.hpp). Fail fast on same-thread
1344 // re-entry.
1345
1/2
✓ Branch 2 → 3 taken 73 times.
✗ Branch 2 → 82 not taken.
73 ReloadApplyLock apply_lock;
1346
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 8 taken 72 times.
73 if (!apply_lock.engaged())
1347 {
1348 1 (void)DetourModKit::log().try_log(
1349 LogLevel::Error,
1350 "Config: reload() re-entered from a bound setter on the same thread; ignoring to avoid a "
1351 "self-deadlock. Do not call load()/reload() from a config setter."
1352 );
1353 1 return false;
1354 }
1355
1356 72 std::vector<std::function<void()>> deferred_callbacks;
1357 72 std::string ini_filename;
1358 72 std::optional<std::uint64_t> hash_to_commit;
1359 72 std::uint64_t generation_to_commit = 0;
1360
1361 // reload() takes its path from registry state, so resolution stays under the lock and reports through
1362 // deferred records. The pass emits them immediately after the unlock, before any setter runs.
1363 72 DeferredDiagnostics diags = open_deferred_diagnostics();
1364
1365 // The locked pass returns a value only when it stops early. Every exit path then reaches one emit.
1366 const std::optional<bool> early_result = [&]() -> std::optional<bool>
1367 {
1368
1/2
✓ Branch 3 → 4 taken 72 times.
✗ Branch 3 → 114 not taken.
72 std::lock_guard<std::mutex> lock(get_config_mutex());
1369
1370
1/2
✓ Branch 5 → 6 taken 72 times.
✗ Branch 5 → 112 not taken.
72 ini_filename = get_last_loaded_ini_path();
1371
2/2
✓ Branch 7 → 8 taken 3 times.
✓ Branch 7 → 11 taken 69 times.
72 if (ini_filename.empty())
1372 {
1373 // Without a prior load(), reload has no path to use.
1374 3 return false;
1375 }
1376
1377
1/2
✓ Branch 11 → 12 taken 69 times.
✗ Branch 11 → 112 not taken.
69 std::filesystem::path ini_path = get_ini_file_path(ini_filename, diags);
1378
1/2
✓ Branch 12 → 13 taken 69 times.
✗ Branch 12 → 110 not taken.
69 std::string ini_path_str = ini_path.string();
1379
1380 69 CSimpleIniA ini;
1381 69 ini.SetUnicode(false);
1382 69 ini.SetMultiKey(false);
1383
1384 69 IniLoadOutcome outcome = load_ini_into(ini_path, ini);
1385
1386
2/2
✓ Branch 17 → 18 taken 4 times.
✓ Branch 17 → 24 taken 65 times.
69 if (!outcome.read_succeeded)
1387 {
1388 // A read can fail when another process locks the file mid-save. Clear the cached hash so an
1389 // identical-byte retry cannot hash-skip. Return before the setter pass. item->load against the
1390 // unpopulated CSimpleIniA replaces live state with defaults.
1391 4 get_last_loaded_ini_hash() = std::nullopt;
1392
1/2
✓ Branch 20 → 21 taken 4 times.
✗ Branch 20 → 94 not taken.
4 defer_diagnostic(
1393 diags,
1394 LogLevel::Warning,
1395 "Config: reload() could not open '{}'; retaining last values (setters not "
1396 "re-run).",
1397 ini_path_str
1398 );
1399 4 return true;
1400 }
1401
1402 {
1403 // load_ini_into sets hash whenever read_succeeded, and this is the read_succeeded branch.
1404 // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
1405 65 const std::uint64_t current_hash = *outcome.hash;
1406 65 generation_to_commit = get_binding_generation();
1407 // Skip only when both bytes and binding generation match the last successful apply. A late
1408 // bind_* then forces a full setter pass and hydrates from disk.
1409 65 const auto &cached_hash = get_last_loaded_ini_hash();
1410 65 const auto &applied_generation = get_applied_binding_generation();
1411
7/8
✓ Branch 29 → 30 taken 59 times.
✓ Branch 29 → 37 taken 6 times.
✓ Branch 31 → 32 taken 19 times.
✓ Branch 31 → 37 taken 40 times.
✓ Branch 33 → 34 taken 19 times.
✗ Branch 33 → 37 not taken.
✓ Branch 38 → 39 taken 18 times.
✓ Branch 38 → 43 taken 47 times.
84 if (cached_hash.has_value() && current_hash == *cached_hash && applied_generation.has_value() &&
1412
2/2
✓ Branch 35 → 36 taken 18 times.
✓ Branch 35 → 37 taken 1 time.
19 *applied_generation == generation_to_commit)
1413 {
1414
1/2
✓ Branch 39 → 40 taken 18 times.
✗ Branch 39 → 95 not taken.
18 defer_diagnostic(
1415 diags,
1416 LogLevel::Debug,
1417 "Config: reload content unchanged (hash {:016x}, binding gen {}); skipping "
1418 "setters.",
1419 current_hash,
1420 generation_to_commit
1421 );
1422 18 return true;
1423 }
1424
1425
2/2
✓ Branch 43 → 44 taken 1 time.
✓ Branch 43 → 50 taken 46 times.
47 if (!outcome.parse_succeeded)
1426 {
1427 // LoadData accepts any byte content, so a negative code is a transient SimpleIni
1428 // allocation failure, not a property of the bytes. Treat it like the read-failure
1429 // branch: retain last values and CLEAR the cached hash so the same bytes stay retryable.
1430 1 get_last_loaded_ini_hash() = std::nullopt;
1431 defer_diagnostic(
1432 diags,
1433 LogLevel::Warning,
1434 "Config: reload() parse error on '{}' (error {}); retaining last values "
1435 "(setters not re-run).",
1436 ini_path_str,
1437
1/2
✓ Branch 46 → 47 taken 1 time.
✗ Branch 46 → 96 not taken.
1 static_cast<int>(outcome.parse_rc)
1438 );
1439 1 return true;
1440 }
1441
1442 // Drop the previous snapshot now and defer this one, as load() does. A setter failure or
1443 // unload-latch interruption leaves partial state. The prior pair lets old bytes hash-skip and
1444 // pin that state.
1445 46 get_last_loaded_ini_hash().reset();
1446 46 get_applied_binding_generation().reset();
1447 46 hash_to_commit = current_hash;
1448
1/2
✓ Branch 55 → 56 taken 46 times.
✗ Branch 55 → 98 not taken.
46 defer_diagnostic(diags, LogLevel::Debug, "Config: Reloading from {}", ini_path_str);
1449 }
1450
1451
2/2
✓ Branch 81 → 60 taken 62 times.
✓ Branch 81 → 82 taken 46 times.
154 for (const auto &item : get_registered_config_items())
1452 {
1453
1/2
✓ Branch 63 → 64 taken 62 times.
✗ Branch 63 → 102 not taken.
62 item->load(ini, diags);
1454
1/2
✓ Branch 65 → 66 taken 62 times.
✗ Branch 65 → 102 not taken.
62 auto cb = item->take_deferred_apply();
1455
2/2
✓ Branch 67 → 68 taken 61 times.
✓ Branch 67 → 71 taken 1 time.
62 if (cb)
1456 {
1457
1/2
✓ Branch 70 → 71 taken 61 times.
✗ Branch 70 → 100 not taken.
122 deferred_callbacks.push_back(std::move(cb));
1458 }
1459 62 }
1460
1461 defer_diagnostic(
1462 diags,
1463 LogLevel::Info,
1464 "Config: Reloaded {} items from {}",
1465
1/2
✓ Branch 84 → 85 taken 46 times.
✗ Branch 84 → 104 not taken.
46 get_registered_config_items().size(),
1466 ini_path_str
1467 );
1468 46 return std::nullopt;
1469
1/2
✓ Branch 10 → 11 taken 72 times.
✗ Branch 10 → 56 not taken.
144 }();
1470
1471 72 emit_deferred_diagnostics(diags);
1472
2/2
✓ Branch 13 → 14 taken 26 times.
✓ Branch 13 → 16 taken 46 times.
72 if (early_result.has_value())
1473 {
1474 26 return *early_result;
1475 }
1476
1477 // Setters run unlocked (the deferred pattern), each wrapped so one throw cannot block the rest.
1478 46 DetourModKit::Logger &logger = DetourModKit::log();
1479 46 bool all_setters_applied = true;
1480 // out_setters_ran comes from the real applied count: a pass that runs none honestly reports it. A
1481 // setter that throws still counts as invoked.
1482 46 bool any_setter_invoked = false;
1483
2/2
✓ Branch 40 → 19 taken 61 times.
✓ Branch 40 → 41 taken 44 times.
151 for (auto &cb : deferred_callbacks)
1484 {
1485 // Abort early if a Logic DLL unload latched reloads off mid-pass. Every later setter resides in
1486 // the Logic DLL under unload. Partial application is acceptable in teardown.
1487
8/8
✓ Branch 22 → 23 taken 60 times.
✓ Branch 22 → 26 taken 1 time.
✓ Branch 23 → 24 taken 27 times.
✓ Branch 23 → 27 taken 33 times.
✓ Branch 25 → 26 taken 1 time.
✓ Branch 25 → 27 taken 26 times.
✓ Branch 28 → 29 taken 2 times.
✓ Branch 28 → 30 taken 59 times.
61 if (background_reloads_disabled() || (background_guard != nullptr && !background_guard->current()))
1488 {
1489 2 all_setters_applied = false;
1490 2 break;
1491 }
1492 59 any_setter_invoked = true;
1493 try
1494 {
1495
2/2
✓ Branch 30 → 31 taken 56 times.
✓ Branch 30 → 57 taken 3 times.
59 cb();
1496 }
1497
1/2
✓ Branch 57 → 58 taken 3 times.
✗ Branch 57 → 62 not taken.
3 catch (const std::exception &e)
1498 {
1499 3 all_setters_applied = false;
1500
1/2
✓ Branch 60 → 61 taken 3 times.
✗ Branch 60 → 65 not taken.
3 logger.error("Config: reload setter threw: {}", e.what());
1501 3 }
1502 catch (...)
1503 {
1504 all_setters_applied = false;
1505 logger.error("Config: reload setter threw unknown exception.");
1506 }
1507 }
1508 // The real applied count closes the race where a concurrent load() re-arm clears the latch between
1509 // an abort and the watcher's downstream re-check.
1510 46 out_setters_ran = any_setter_invoked;
1511
2/2
✓ Branch 41 → 42 taken 41 times.
✓ Branch 41 → 49 taken 5 times.
46 if (all_setters_applied)
1512 {
1513
1/2
✓ Branch 43 → 44 taken 41 times.
✗ Branch 43 → 73 not taken.
41 std::lock_guard<std::mutex> lock(get_config_mutex());
1514 41 get_last_loaded_ini_hash() = hash_to_commit;
1515 41 get_applied_binding_generation() = generation_to_commit;
1516 41 }
1517 46 return true;
1518 73 }
1519 } // namespace detail
1520
1521 46 bool reload()
1522 {
1523 46 bool ignored = false;
1524
1/2
✓ Branch 2 → 3 taken 46 times.
✗ Branch 2 → 6 not taken.
92 return detail::reload_impl(ignored);
1525 }
1526
1527 14 void log_all()
1528 {
1529 14 detail::DeferredDiagnostics diags = detail::open_deferred_diagnostics();
1530
1531 {
1532
1/2
✓ Branch 4 → 5 taken 14 times.
✗ Branch 4 → 78 not taken.
14 std::lock_guard<std::mutex> lock(get_config_mutex());
1533
1534 14 const auto &items = get_registered_config_items();
1535
2/2
✓ Branch 7 → 8 taken 3 times.
✓ Branch 7 → 10 taken 11 times.
14 if (items.empty())
1536 {
1537
1/2
✓ Branch 8 → 9 taken 3 times.
✗ Branch 8 → 62 not taken.
3 detail::defer_diagnostic(diags, LogLevel::Info, "Config: No configuration items registered.");
1538 }
1539 else
1540 {
1541 11 std::unordered_set<std::string_view> sections;
1542
2/2
✓ Branch 27 → 13 taken 21 times.
✓ Branch 27 → 28 taken 11 times.
43 for (const auto &item : items)
1543 {
1544
1/2
✓ Branch 17 → 18 taken 21 times.
✗ Branch 17 → 63 not taken.
21 sections.insert(item->section);
1545 }
1546 detail::defer_diagnostic(
1547 diags,
1548 LogLevel::Info,
1549 "Config: {} registered values across {} section(s)",
1550
1/2
✓ Branch 30 → 31 taken 11 times.
✗ Branch 30 → 66 not taken.
11 items.size(),
1551 11 sections.size()
1552 );
1553
1554 11 std::string current_section;
1555
2/2
✓ Branch 54 → 34 taken 21 times.
✓ Branch 54 → 55 taken 11 times.
43 for (const auto &item : items)
1556 {
1557
2/2
✓ Branch 38 → 39 taken 12 times.
✓ Branch 38 → 43 taken 9 times.
21 if (item->section != current_section)
1558 {
1559
1/2
✓ Branch 40 → 41 taken 12 times.
✗ Branch 40 → 70 not taken.
12 current_section = item->section;
1560
1/2
✓ Branch 41 → 42 taken 12 times.
✗ Branch 41 → 69 not taken.
12 detail::defer_diagnostic(diags, LogLevel::Debug, "Config: [{}]", current_section);
1561 }
1562
1/2
✓ Branch 44 → 45 taken 21 times.
✗ Branch 44 → 70 not taken.
21 item->log_current_value(diags);
1563 }
1564 11 }
1565 14 }
1566
1567 14 detail::emit_deferred_diagnostics(diags);
1568 14 }
1569
1570 693 void clear() noexcept
1571 {
1572 693 Logger &logger = log();
1573
1574
4/6
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 8 taken 692 times.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 1 time.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 12 taken 693 times.
693 if (detail::reload_apply_lock_held_by_current_thread() && !detail::on_reload_servicer_thread())
1575 {
1576 (void)logger.try_log(
1577 LogLevel::Error,
1578 "Config: clear() called from a bound setter; ignoring to avoid joining a "
1579 "reload worker that may be waiting for the active pass."
1580 );
1581 return;
1582 }
1583
1584 693 size_t count = 0;
1585
1586 {
1587 693 std::lock_guard<std::mutex> lock(get_config_mutex());
1588 693 count = get_registered_config_items().size();
1589
2/2
✓ Branch 16 → 17 taken 189 times.
✓ Branch 16 → 19 taken 504 times.
693 if (count > 0)
1590 {
1591 189 get_registered_config_items().clear();
1592 }
1593
1594 // Drop the remembered path, hash, and generation so the next load() starts clean. The watcher's
1595 // lifecycle stays with disable_auto_reload().
1596 693 get_last_loaded_ini_path().clear();
1597 693 get_last_loaded_ini_hash().reset();
1598 693 get_applied_binding_generation().reset();
1599 693 }
1600
1601 // Move the hotkey guards and servicer out under the watcher mutex. Dispose after unlock. A guard release
1602 // can wait on a drain whose disposal joins the servicer, whose worker needs this mutex.
1603 693 detail::WatchHotkeyControl hotkey_control = detail::detach_hotkey_control();
1604 693 detail::dispose_reload_hotkey_guards(hotkey_control.guards);
1605 // A live hotkey binding's callback capture keeps the servicer alive. Otherwise this reset can be the
1606 // final drop, which runs outside get_config_mutex so a worker inside reload() cannot deadlock.
1607 693 hotkey_control.servicer.reset();
1608
1609 // Use try_log rather than debug(). A sink exception breaks this noexcept contract.
1610
2/2
✓ Branch 29 → 30 taken 189 times.
✓ Branch 29 → 32 taken 504 times.
693 if (count > 0)
1611 {
1612 189 (void)logger.try_log(LogLevel::Debug, "Config: Cleared {} registered configuration items.", count);
1613 }
1614 else
1615 {
1616 504 (void)logger.try_log(LogLevel::Debug, "Config: clear called, but no items were registered.");
1617 }
1618 693 }
1619 } // namespace config
1620
1621 #if defined(DMK_ENABLE_TEST_SEAMS)
1622 namespace detail
1623 {
1624 // Reports whether the registry mutex is free right now. A record producer cannot pass this probe under the
1625 // same non-recursive mutex.
1626 46 bool config_registry_mutex_free_for_test() noexcept
1627 {
1628 46 std::unique_lock<std::mutex> probe(config::get_config_mutex(), std::try_to_lock);
1629 46 return probe.owns_lock();
1630 46 }
1631 } // namespace detail
1632 #endif
1633 } // namespace DetourModKit
1634