./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
30 / 30
100.0%
Branches:
4 / 8
50.0%
Functions:
12 / 12
100.0%

include/DetourModKit/config.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_CONFIG_HPP
2 #define DETOURMODKIT_CONFIG_HPP
3
4 /**
5 * @file config.hpp
6 * @brief INI-backed configuration, hot reload, and INI-driven input combo bindings.
7 * @details Config is fail-soft, and no call returns an error for a missing or malformed value. A missing key or an
8 * empty bool value applies the registered default with no log record. A malformed int, float, or bool value
9 * applies the default, and a combo value with no parsable combo yields an empty list. Each logs a Warning,
10 * except the empty and "NONE" combo opt-outs.
11 * @note Thread safety: every setter must be reentrant and thread-safe. A setter can call the bind family and log_all().
12 * In a load() or reload() pass, a setter call to load(), reload(), or disable_auto_reload() is refused. A setter
13 * call to clear() in a pass is refused except on the reload-servicer thread.
14 * @warning `[B-100]` Run load(), reload(), registration, and enable_auto_reload() outside the loader lock. These calls
15 * allocate, and enable_auto_reload() creates the watcher thread. The loader-lock teardown path detaches the
16 * watcher without a wait. `ConfigWatcherLoaderLockTest.*` pins the boundary.
17 */
18
19 #include "DetourModKit/input.hpp"
20
21 #include <atomic>
22 #include <chrono>
23 #include <concepts>
24 #include <cstdint>
25 #include <functional>
26 #include <optional>
27 #include <string>
28 #include <string_view>
29
30 namespace DetourModKit
31 {
32 namespace config
33 {
34 /** @brief Outcome of enable_auto_reload(). */
35 enum class AutoReloadStatus : std::uint8_t
36 {
37 /// The watcher started.
38 Started,
39 /// A watcher was already installed and stays in place.
40 AlreadyRunning,
41 /// No load() path is cached, as before any load() or after clear(), so there is no path to watch.
42 NoPriorLoad,
43 /// The watcher did not start, for example on a failed directory open or handshake, or a set unload latch.
44 StartFailed
45 };
46
47 /// The value types that the atomic bind accepts.
48 template <typename T>
49 concept BindableScalar = std::same_as<T, int> || std::same_as<T, bool> || std::same_as<T, float>;
50
51 /**
52 * @brief Binds an integer INI key to a callback.
53 * @details Registration calls @p setter with @p default_value at once. Each load() or reload() pass that
54 * applies setters calls it again with the INI value. The value is decimal, or hex with a 0x prefix. An
55 * out-of-range value falls back to the default.
56 * @param display_name Not shown in log records. The records name @p key.
57 * @note Setup/control-plane only: registration may allocate and updates the config registry.
58 */
59 void bind_int(
60 std::string_view section,
61 std::string_view key,
62 std::string_view display_name,
63 std::function<void(int)> setter,
64 int default_value
65 );
66
67 /// Binds a floating-point INI key to a callback. See bind_int for the invocation contract.
68 void bind_float(
69 std::string_view section,
70 std::string_view key,
71 std::string_view display_name,
72 std::function<void(float)> setter,
73 float default_value
74 );
75
76 /// Binds a boolean INI key to a callback. See bind_int for the invocation contract.
77 void bind_bool(
78 std::string_view section,
79 std::string_view key,
80 std::string_view display_name,
81 std::function<void(bool)> setter,
82 bool default_value
83 );
84
85 /**
86 * @brief Binds a string INI key to a callback.
87 * @details The setter receives the raw INI value as narrow bytes, with no decoding. The string_view is valid
88 * only during the call and is not guaranteed to be NUL-terminated. The invocation contract matches
89 * bind_int.
90 * @note Setup/control-plane only: registration may allocate and updates the config registry.
91 */
92 void bind_string(
93 std::string_view section,
94 std::string_view key,
95 std::string_view display_name,
96 std::function<void(std::string_view)> setter,
97 std::string_view default_value
98 );
99
100 /**
101 * @brief Binds an INI combo string to a callback that receives the parsed combo list.
102 * @details It registers no input binding. The grammar and the "NONE" or empty opt-out match press_combo. The
103 * invocation contract matches bind_int.
104 * @param display_name Name shown in the typo Warning.
105 * @param default_value Combo string used when the key is absent.
106 * @note Setup/control-plane only: registration may allocate and updates the config registry.
107 */
108 void bind_combos(
109 std::string_view section,
110 std::string_view key,
111 std::string_view display_name,
112 std::function<void(const input::KeyComboList &)> setter,
113 std::string_view default_value
114 );
115
116 /**
117 * @brief Binds an INI key to a caller-supplied atomic.
118 * @details The matching bind_<T> stores each value into @p out with std::memory_order_relaxed. @p out must
119 * outlive every later load() and reload().
120 * @note Setup/control-plane only: registration may allocate and updates the config registry.
121 */
122 template <BindableScalar T>
123 16 void bind(
124 std::string_view section,
125 std::string_view key,
126 std::string_view display_name,
127 std::atomic<T> &out,
128 T default_value
129 )
130 {
131 if constexpr (std::same_as<T, int>)
132 {
133
1/2
✓ Branch 3 → 4 taken 11 times.
✗ Branch 3 → 6 not taken.
11 bind_int(
134 section,
135 key,
136 display_name,
137 52 [&out](int v) { out.store(v, std::memory_order_relaxed); },
138 default_value
139 );
140 }
141 else if constexpr (std::same_as<T, bool>)
142 {
143
1/2
✓ Branch 3 → 4 taken 3 times.
✗ Branch 3 → 6 not taken.
3 bind_bool(
144 section,
145 key,
146 display_name,
147 12 [&out](bool v) { out.store(v, std::memory_order_relaxed); },
148 default_value
149 );
150 }
151 else
152 {
153
1/2
✓ Branch 3 → 4 taken 2 times.
✗ Branch 3 → 6 not taken.
2 bind_float(
154 section,
155 key,
156 display_name,
157 8 [&out](float v) { out.store(v, std::memory_order_relaxed); },
158 default_value
159 );
160 }
161 16 }
162
163 /**
164 * @brief Binds an INI key to an atomic, with the current value of @p out as the default.
165 * @details Registration reads @p out once, relaxed. Initialize @p out before the call.
166 * @note Setup/control-plane only (see the default-taking overload).
167 */
168 template <BindableScalar T>
169 6 void bind(std::string_view section, std::string_view key, std::string_view display_name, std::atomic<T> &out)
170 {
171 8 bind<T>(section, key, display_name, out, out.load(std::memory_order_relaxed));
172 6 }
173
174 /**
175 * @brief Binds an INI key to an atomic uint32 through a caller parse function.
176 * @details The parsed value is stored into @p out with std::memory_order_relaxed. @p out has the lifetime rule
177 * of bind. The invocation contract matches bind_int.
178 * @param parse Pure function from the raw INI string to the stored value.
179 * @param default_value INI string that @p parse receives when the key is absent.
180 * @note Setup/control-plane only: registration may allocate and updates the config registry.
181 */
182 void bind_parsed(
183 std::string_view section,
184 std::string_view key,
185 std::string_view display_name,
186 std::atomic<std::uint32_t> &out,
187 std::function<std::uint32_t(std::string_view)> parse,
188 std::string_view default_value
189 );
190
191 /**
192 * @brief Binds a log-level INI key that sets the logger level.
193 * @details The logger's string-to-level mapping applies the value at registration and on each load() or
194 * reload(). An unrecognized value falls back to Info.
195 * @param default_value Level string used when the key is absent, for example "INFO" or "DEBUG".
196 * @note Setup/control-plane only: registration may allocate and updates the config registry.
197 */
198 void bind_log_level(std::string_view section, std::string_view key, std::string_view default_value = "INFO");
199
200 /**
201 * @brief Binds an INI combo string to a press-mode input binding and returns its guard.
202 * @details It registers a press binding under @p binding_name through input::register_combo. Each load() and
203 * reload() rebinds its keys through input::Input::rebind. Commas separate combos under OR logic,
204 * and '+' separates modifiers from the trailing trigger. A token is a key name or a hex VK code.
205 *
206 * An empty value or "NONE" (case-insensitive, whole trimmed value only) leaves the binding registered
207 * but unbound, with no log record. If no combo of another value parses, one Warning names @p log_name
208 * and the value.
209 * @param log_name Name shown in the typo Warning and in the registration failure record.
210 * @param binding_name Unique input binding name.
211 * @param on_press Callback fired on the key-down edge.
212 * @param default_combo Combo string used when the key is absent.
213 * @param consume If set, registers consume_flag on the key "<ini_key>.Consume" with this default.
214 * @return A guard that owns the binding. Store it, for example in an input::Scope. A dropped guard disables
215 * the binding. If input::register_combo fails, the guard is inert with an empty name(), and the failure
216 * is logged.
217 * @note Setup/control-plane only: the bind registers an input binding and updates the config registry.
218 */
219 [[nodiscard]] input::BindingGuard press_combo(
220 std::string_view section,
221 std::string_view ini_key,
222 std::string_view log_name,
223 std::string_view binding_name,
224 std::function<void()> on_press,
225 std::string_view default_combo,
226 std::optional<bool> consume = std::nullopt
227 );
228
229 /**
230 * @brief Binds an INI combo string to a hold-mode input binding and returns its guard.
231 * @details @p on_state_change receives true on the press edge and false on the release edge. Every other
232 * parameter and behavior matches press_combo.
233 * @return A guard that owns the binding. If the guard cancels a held binding, it synthesizes one final
234 * on_state_change(false). Destroy it only in setup or control-plane code.
235 * @note Setup/control-plane only: the bind registers an input binding and updates the config registry.
236 */
237 [[nodiscard]] input::BindingGuard hold_combo(
238 std::string_view section,
239 std::string_view ini_key,
240 std::string_view log_name,
241 std::string_view binding_name,
242 std::function<void(bool)> on_state_change,
243 std::string_view default_combo,
244 std::optional<bool> consume = std::nullopt
245 );
246
247 /**
248 * @brief Binds a boolean INI key that sets input suppression for a registered binding.
249 * @details The value sets input::ComboBinding::consume for @p binding_name. Register the binding first, because
250 * each application to an unknown name is a no-op. The invocation contract matches bind_int.
251 * @param default_value Suppression state when the key is absent.
252 * @note Setup/control-plane only: registration may allocate and updates the config registry.
253 */
254 void consume_flag(
255 std::string_view section,
256 std::string_view ini_key,
257 std::string_view display_name,
258 std::string_view binding_name,
259 bool default_value = false
260 );
261
262 /**
263 * @brief Registers a hotkey combo that triggers reload() on press.
264 * @details The binding is a press_combo, so the INI value follows the press_combo grammar, opt-out, and typo
265 * Warning. An opt-out @p default_combo instead fails the call, as @return states.
266 * The reload and its setters run on the reload-servicer thread, not on the input poll thread. A repeat
267 * call for the same @p ini_key updates that binding in place, so the last @p default_combo wins.
268 * @param ini_key Key in the [Input] section that holds the combo string.
269 * @param default_combo Combo used when the key is absent, for example "Ctrl+F5".
270 * @return true if the binding was registered, or updated for a repeated @p ini_key. false if @p default_combo
271 * is empty, NONE, or has no parsable combo. false also when an unload latch is set or the input
272 * registration or update fails. A false return keeps any earlier binding for @p ini_key.
273 * @note Setup/control-plane only: the bind registers an input binding and updates the config registry.
274 */
275 [[nodiscard]] bool reload_hotkey(std::string_view ini_key, std::string_view default_combo);
276
277 /**
278 * @brief Loads all bound settings from the named INI file.
279 * @details reload() reuses this path. If auto-reload watches a different file, the watcher moves to the new
280 * file and keeps its debounce and on_reload callback. On the watcher thread, load() skips that move
281 * and logs an Error record. Call load() from another thread to move the watcher.
282 * @param ini_filename The UTF-8 INI filename, resolved relative to filesystem::get_runtime_directory().
283 * Ill-formed UTF-8 or an embedded NUL loads the defaults with an Error record.
284 * @note Setup/control-plane only: the load reads the file and runs every bound setter.
285 */
286 void load(std::string_view ini_filename);
287
288 /**
289 * @brief Re-reads the file of the most recent load() and re-applies every bound setter.
290 * @details The setters are skipped if the file bytes are unchanged since the last fully applied pass and no
291 * bind_* call registered since then. A later pass that fails to read, parse, or apply every setter
292 * cancels that skip. The setters are also skipped if the file is unreadable or fails to parse. A skip
293 * keeps the last-applied values instead of the defaults.
294 * @return false if no load() path is cached, as before any load() or after clear(), or for a refused call from
295 * a bound setter. Otherwise true, also for a skip.
296 * @note Safe from any thread. Concurrent load() and reload() passes are serialized end to end, so a stale pass
297 * never overwrites a fresher one. Only C++ exceptions from setters are caught. A structured exception or
298 * a throw from a noexcept setter is not recoverable. The file header lists the refused setter calls.
299 * @note Setup/control-plane only: the reload reads the file and runs every bound setter.
300 */
301 [[nodiscard]] bool reload();
302
303 /**
304 * @brief Starts a background watcher that calls reload() when the INI file changes.
305 * @details The watcher observes the directory of the last load() path. It reloads once @p debounce passes with
306 * no further change, so a burst of saves merges into one reload. The reload and @p on_reload run on
307 * the watcher thread.
308 * @param on_reload Optional. Called after each reload attempt with true if at least one bound setter ran, and
309 * false otherwise. It is not called once an unload latch is set.
310 * @note Setup/control-plane only: the start creates the watcher thread.
311 */
312 [[nodiscard]] AutoReloadStatus enable_auto_reload(
313 std::chrono::milliseconds debounce = std::chrono::milliseconds{250},
314 std::function<void(bool)> on_reload = {}
315 );
316
317 /**
318 * @brief Stops the auto-reload watcher synchronously.
319 * @details Idempotent. On the authorized path it returns after any pending debounced reload callback runs and
320 * the watcher thread exits. If an unload phase is published or the fail-closed loader-lock probe
321 * vetoes, it detaches the watcher thread and does not wait for it. On the watcher thread, for example
322 * inside on_reload, it logs and does not stop the watcher.
323 * @warning The authorized path has no time bound and never detaches a callback in progress. This call waits for
324 * as long as the callback blocks.
325 * @note Setup/control-plane only: the stop joins the watcher thread on the authorized path.
326 */
327 void disable_auto_reload() noexcept;
328
329 /// Logs the current value of every bound setting, grouped by section.
330 void log_all();
331
332 /**
333 * @brief Clears every bound setting and the cached load path.
334 * @details It does not stop the auto-reload watcher. Call disable_auto_reload() first, so that no watcher
335 * callback fires against the cleared registry. It releases every reload_hotkey() binding and stops the
336 * reload-servicer thread, so it can wait for a hotkey reload in progress.
337 * @note Setup/control-plane only: the clear tears down the config registry.
338 */
339 void clear() noexcept;
340
341 class Ini;
342
343 /** @brief A copyable view that forwards each bind call to the matching free function with one section name. */
344 class SectionBinder
345 {
346 public:
347 /// Constructs a binder scoped to @p section. Prefer Ini::section() / config::section().
348 explicit SectionBinder(std::string_view section) : m_section(section) {}
349
350 /// Section-scoped atomic bind. See config::bind.
351 template <BindableScalar T> void bind(std::string_view key, std::atomic<T> &out, T default_value) const
352 {
353 config::bind<T>(m_section, key, key, out, default_value);
354 }
355
356 /// Section-scoped atomic bind with the atomic's current value as the default.
357 template <BindableScalar T> void bind(std::string_view key, std::atomic<T> &out) const
358 {
359 config::bind<T>(m_section, key, key, out);
360 }
361
362 /// Section-scoped atomic bind with an explicit display name.
363 template <BindableScalar T>
364 void bind(std::string_view key, std::string_view display_name, std::atomic<T> &out, T default_value) const
365 {
366 config::bind<T>(m_section, key, display_name, out, default_value);
367 }
368
369 /// Section-scoped integer callback bind. See config::bind_int.
370 void bind_int(
371 std::string_view key,
372 std::string_view display_name,
373 std::function<void(int)> setter,
374 int default_value
375 ) const
376 {
377 config::bind_int(m_section, key, display_name, std::move(setter), default_value);
378 }
379
380 /// Section-scoped float callback bind. See config::bind_float.
381 void bind_float(
382 std::string_view key,
383 std::string_view display_name,
384 std::function<void(float)> setter,
385 float default_value
386 ) const
387 {
388 config::bind_float(m_section, key, display_name, std::move(setter), default_value);
389 }
390
391 /// Section-scoped bool callback bind. See config::bind_bool.
392 void bind_bool(
393 std::string_view key,
394 std::string_view display_name,
395 std::function<void(bool)> setter,
396 bool default_value
397 ) const
398 {
399 config::bind_bool(m_section, key, display_name, std::move(setter), default_value);
400 }
401
402 /// Section-scoped string callback bind. See config::bind_string.
403 void bind_string(
404 std::string_view key,
405 std::string_view display_name,
406 std::function<void(std::string_view)> setter,
407 std::string_view default_value
408 ) const
409 {
410 config::bind_string(m_section, key, display_name, std::move(setter), default_value);
411 }
412
413 /// Section-scoped combo-list bind (no input binding). See config::bind_combos.
414 void bind_combos(
415 std::string_view key,
416 std::string_view display_name,
417 std::function<void(const input::KeyComboList &)> setter,
418 std::string_view default_value
419 ) const
420 {
421 config::bind_combos(m_section, key, display_name, std::move(setter), default_value);
422 }
423
424 /// Section-scoped parsed atomic-uint32 bind. See config::bind_parsed.
425 void bind_parsed(
426 std::string_view key,
427 std::string_view display_name,
428 std::atomic<std::uint32_t> &out,
429 std::function<std::uint32_t(std::string_view)> parse,
430 std::string_view default_value
431 ) const
432 {
433 config::bind_parsed(m_section, key, display_name, out, std::move(parse), default_value);
434 }
435
436 /// Section-scoped log-level bind. See config::bind_log_level.
437 void bind_log_level(std::string_view key, std::string_view default_value = "INFO") const
438 {
439 config::bind_log_level(m_section, key, default_value);
440 }
441
442 /// Section-scoped press_combo. See config::press_combo.
443 [[nodiscard]] input::BindingGuard press_combo(
444 std::string_view ini_key,
445 std::string_view log_name,
446 std::string_view binding_name,
447 std::function<void()> on_press,
448 std::string_view default_combo,
449 std::optional<bool> consume = std::nullopt
450 ) const
451 {
452 return config::press_combo(
453 m_section,
454 ini_key,
455 log_name,
456 binding_name,
457 std::move(on_press),
458 default_combo,
459 consume
460 );
461 }
462
463 /// Section-scoped hold_combo. See config::hold_combo.
464 [[nodiscard]] input::BindingGuard hold_combo(
465 std::string_view ini_key,
466 std::string_view log_name,
467 std::string_view binding_name,
468 std::function<void(bool)> on_state_change,
469 std::string_view default_combo,
470 std::optional<bool> consume = std::nullopt
471 ) const
472 {
473 return config::hold_combo(
474 m_section,
475 ini_key,
476 log_name,
477 binding_name,
478 std::move(on_state_change),
479 default_combo,
480 consume
481 );
482 }
483
484 /// Section-scoped consume_flag. See config::consume_flag.
485 void consume_flag(
486 std::string_view ini_key,
487 std::string_view display_name,
488 std::string_view binding_name,
489 bool default_value = false
490 ) const
491 {
492 config::consume_flag(m_section, ini_key, display_name, binding_name, default_value);
493 }
494
495 private:
496 std::string m_section;
497 };
498
499 /// Returns a section-scoped binder for @p name. Equivalent to Ini{}.section(name).
500 [[nodiscard]] inline SectionBinder section(std::string_view name)
501 {
502 return SectionBinder{name};
503 }
504
505 /** @brief A copyable handle to the one process registry that every Ini and every free function share. */
506 class Ini
507 {
508 public:
509 Ini() = default;
510
511 /// Returns a section-scoped binder. See config::section.
512 [[nodiscard]] SectionBinder section(std::string_view name) const { return SectionBinder{name}; }
513
514 /// Atomic bind. See config::bind.
515 template <BindableScalar T>
516 8 void bind(
517 std::string_view sec,
518 std::string_view key,
519 std::string_view display_name,
520 std::atomic<T> &out,
521 T default_value
522 ) const
523 {
524 8 config::bind<T>(sec, key, display_name, out, default_value);
525 8 }
526
527 /// Atomic bind with the atomic's current value as the default. See config::bind.
528 template <BindableScalar T>
529 void
530 bind(std::string_view sec, std::string_view key, std::string_view display_name, std::atomic<T> &out) const
531 {
532 config::bind<T>(sec, key, display_name, out);
533 }
534
535 /// Parsed atomic-uint32 bind. See config::bind_parsed.
536 void bind_parsed(
537 std::string_view sec,
538 std::string_view key,
539 std::string_view display_name,
540 std::atomic<std::uint32_t> &out,
541 std::function<std::uint32_t(std::string_view)> parse,
542 std::string_view default_value
543 ) const
544 {
545 config::bind_parsed(sec, key, display_name, out, std::move(parse), default_value);
546 }
547
548 /// Log-level bind. See config::bind_log_level.
549 void
550 bind_log_level(std::string_view sec, std::string_view key, std::string_view default_value = "INFO") const
551 {
552 config::bind_log_level(sec, key, default_value);
553 }
554
555 /// Loads the named INI file. See config::load.
556 29 void load(std::string_view ini_filename) const { config::load(ini_filename); }
557
558 /// Re-applies bound setters. See config::reload.
559 [[nodiscard]] bool reload() const { return config::reload(); }
560
561 /// Starts the auto-reload watcher. See config::enable_auto_reload.
562 8 [[nodiscard]] AutoReloadStatus enable_auto_reload(
563 std::chrono::milliseconds debounce = std::chrono::milliseconds{250},
564 std::function<void(bool)> on_reload = {}
565 ) const
566 {
567
1/2
✓ Branch 5 → 6 taken 8 times.
✗ Branch 5 → 10 not taken.
8 return config::enable_auto_reload(debounce, std::move(on_reload));
568 }
569
570 /// Stops the auto-reload watcher. See config::disable_auto_reload.
571 void disable_auto_reload() const noexcept { config::disable_auto_reload(); }
572
573 /// Logs every bound setting. See config::log_all.
574 void log_all() const { config::log_all(); }
575
576 /// Clears every bound setting. See config::clear.
577 void clear() const noexcept { config::clear(); }
578 };
579 } // namespace config
580 } // namespace DetourModKit
581
582 #endif // DETOURMODKIT_CONFIG_HPP
583