From 0ee0d593b25f98135184f6fa84de215308d8e729 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Wed, 9 Sep 2026 22:43:30 -0500 Subject: [PATCH 01/20] Resolve Windows layout shortcuts with cached physical chords --- docs/keyboard-letter-layout.md | 44 +++++ example_community_patch_settings_da.toml | 5 + example_community_patch_settings_de.toml | 5 + ...munity_patch_settings_en-GB-x-cockney.toml | 5 + ...mmunity_patch_settings_en-x-minionese.toml | 5 + example_community_patch_settings_en.toml | 5 + example_community_patch_settings_es.toml | 5 + example_community_patch_settings_fr.toml | 5 + example_community_patch_settings_nl.toml | 5 + example_community_patch_settings_ru.toml | 5 + example_community_patch_settings_tlh.toml | 5 + mods/src/config.cc | 11 ++ mods/src/config.h | 1 + mods/src/defaultconfig.h | 1 + mods/src/patches/key.cc | 3 + mods/src/patches/keyboard_layout.cc | 163 +++++++++++++++++ mods/src/patches/keyboard_layout.h | 14 ++ mods/src/patches/keyboard_layout_mapping.h | 138 ++++++++++++++ .../patches/keyboard_layout_notifications.cc | 154 ++++++++++++++++ .../patches/keyboard_layout_notifications.h | 13 ++ mods/src/patches/keyboard_layout_refresh.h | 23 +++ mods/src/patches/keyboard_layout_windows.h | 80 +++++++++ mods/src/patches/mapkey.cc | 39 +++- mods/src/patches/mapkey.h | 2 +- tests/keyboard_chord_tests.cc | 48 +++++ tests/keyboard_layout_tests.cc | 168 ++++++++++++++++++ tests/shortcut_hint_cache.cc | 128 ++++++++++++- tests/xmake.lua | 30 ++++ xmake.lua | 1 + 29 files changed, 1092 insertions(+), 19 deletions(-) create mode 100644 docs/keyboard-letter-layout.md create mode 100644 mods/src/patches/keyboard_layout.cc create mode 100644 mods/src/patches/keyboard_layout.h create mode 100644 mods/src/patches/keyboard_layout_mapping.h create mode 100644 mods/src/patches/keyboard_layout_notifications.cc create mode 100644 mods/src/patches/keyboard_layout_notifications.h create mode 100644 mods/src/patches/keyboard_layout_refresh.h create mode 100644 mods/src/patches/keyboard_layout_windows.h create mode 100644 tests/keyboard_chord_tests.cc create mode 100644 tests/keyboard_layout_tests.cc create mode 100644 tests/xmake.lua diff --git a/docs/keyboard-letter-layout.md b/docs/keyboard-letter-layout.md new file mode 100644 index 000000000..6a92a26eb --- /dev/null +++ b/docs/keyboard-letter-layout.md @@ -0,0 +1,44 @@ +# Layout-aware shortcuts (Windows x64) + +The default `[control].keyboard_layout_mode = "physical"` preserves existing behavior. +Set it to `"layout"` and restart to interpret printable shortcut characters using +Windows' active keyboard layout. Subsequent layout changes apply live through +Unity device notifications. Release held keys before using the new layout. + +For a German layout: + +| Configured chord | Keys on a German keyboard | +| --- | --- | +| `/` | Shift+7 | +| `CTRL-'` | Ctrl+Shift+# | +| `ALT-^` | Alt+^ | +| `SHIFT-^` | Shift+^ | +| `Z` | Z (US physical Y position) | + +Remove old Y/Z workarounds when opting in. Uppercase letter tokens name keys; +they do not request Shift. Shift needed to produce punctuation is added to the +explicit configured modifiers. Side-specific modifiers retain their requirements. +Existing explicitly modified bindings accept extra modifiers; avoid overlapping +chords, since action dispatch order determines which matching action wins. + +Hints and runtime shortcut values retain configured TOML text. The internal +physical mapping is not a replacement hint. Named controls (function keys, arrows, +Space, mouse and numpad keys), hardcoded controls and Scopely shortcuts are unchanged. +Alliance Help and Armada retain the upstream `enable_experimental = true` requirement. + +The resolver uses Windows character-to-key translation and supported scan-code +positions. Unshifted dead keys use a non-composing fallback: no following Space +is needed for a shortcut, and lookup does not consume pending text accents. +Characters requiring inferred Ctrl/Alt/AltGr, unsupported positions and missing +characters are disabled individually. Explicit configured Ctrl/Alt still work. + +Mapping is cached per requested character and refreshed only at initialization or +a device notification. Held keys are suppressed through a layout transition until +released. Notification failure disables layout bindings until restart; there is no +polling or silent physical fallback. The runtime `[keyboard_mapping]` section gives +layout, generation and status; logs identify unresolved characters. + +Layout mode is currently supported on Windows x64. Other platforms retain default +physical behavior; explicitly requesting layout mode disables printable bindings +and reports `platform_unsupported`. The experimental macOS adapter and prototype +preview/diagnostic settings are not part of this implementation. diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index c60c5c39f..f728de7f6 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -97,6 +97,11 @@ queue_enabled = true # Sæt denne til sand, hvis du foretrækker at bruge Scopelys genvejstaster use_scopely_hotkeys = false +# Windows x64: "physical" preserves positions; "layout" follows the active layout. +# Required Shift is inferred; characters requiring Ctrl/AltGr are unsupported. +# Layout changes apply live; release held keys. See docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 6756ed409..7bb8bc67c 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -97,6 +97,11 @@ queue_enabled = true # Auf true setzen, um die Hotkeys von Scopely zu verwenden use_scopely_hotkeys = false +# Windows x64: "physical" behält Tastenpositionen bei; "layout" folgt dem aktiven Layout. +# Erforderliches Shift wird ergänzt; Ctrl/AltGr-Zeichen bleiben nicht unterstützt. +# Layoutwechsel gelten live; gehaltene Tasten vorher loslassen. Siehe docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Optionen # ------------------ diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index ded80a9ef..4171d59d4 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -97,6 +97,11 @@ queue_enabled = true # If you prefer to use Scopely's hotkeys set this to true use_scopely_hotkeys = false +# Windows x64: "physical" preserves positions; "layout" follows the active layout. +# Required Shift is inferred; characters requiring Ctrl/AltGr are unsupported. +# Layout changes apply live; release held keys. See docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 78b129750..028ca0805 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -97,6 +97,11 @@ queue_enabled = true # If you prefer to use Scopely's hotkeys set this to true use_scopely_hotkeys = false +# Windows x64: "physical" preserves positions; "layout" follows the active layout. +# Required Shift is inferred; characters requiring Ctrl/AltGr are unsupported. +# Layout changes apply live; release held keys. See docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 7706e7c95..805475da1 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -97,6 +97,11 @@ queue_enabled = true # If you prefer to use Scopely's hotkeys set this to true use_scopely_hotkeys = false +# Windows x64: "physical" preserves positions; "layout" follows the active layout. +# Required Shift is inferred; characters requiring Ctrl/AltGr are unsupported. +# Layout changes apply live; release held keys. See docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 68b2bb4f7..ff9184d6f 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -97,6 +97,11 @@ queue_enabled = true # Si prefieres usar los atajos de Scopely, establece este valor en verdadero use_scopely_hotkeys = false +# Windows x64: "physical" preserves positions; "layout" follows the active layout. +# Required Shift is inferred; characters requiring Ctrl/AltGr are unsupported. +# Layout changes apply live; release held keys. See docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index dd8e462ac..914d3712c 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -97,6 +97,11 @@ queue_enabled = true # Si vous préférez utiliser les raccourcis de Scopely mettez ça sur activer use_scopely_hotkeys = false +# Windows x64 : "physical" conserve les positions ; "layout" suit la disposition active. +# Maj nécessaire est ajouté ; les caractères nécessitant Ctrl/AltGr ne sont pas pris en charge. +# Les changements sont appliqués en direct ; relâchez les touches. Voir docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index b0726f8a9..0bd342583 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -97,6 +97,11 @@ queue_enabled = true # Als je de Scopely sneltoetsen prefereerd over die van de mod, schakel dit dan aan use_scopely_hotkeys = false +# Windows x64: "physical" behoudt toetsposities; "layout" volgt de actieve indeling. +# Vereiste Shift wordt toegevoegd; tekens met Ctrl/AltGr worden niet ondersteund. +# Wisselen werkt direct; laat ingedrukte toetsen los. Zie docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 1238fb805..8fca65215 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -97,6 +97,11 @@ queue_enabled = true # Включите этот параметр, если предпочитаете горячие клавиши Scopely use_scopely_hotkeys = false +# Windows x64: "physical" preserves positions; "layout" follows the active layout. +# Required Shift is inferred; characters requiring Ctrl/AltGr are unsupported. +# Layout changes apply live; release held keys. See docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index 1c45a5157..e3996c368 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -97,6 +97,11 @@ queue_enabled = true # If you prefer Daq lo' Scopely's hotkeys cher this Daq teH use_scopely_hotkeys = false +# Windows x64: "physical" preserves positions; "layout" follows the active layout. +# Required Shift is inferred; characters requiring Ctrl/AltGr are unsupported. +# Layout changes apply live; release held keys. See docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- diff --git a/mods/src/config.cc b/mods/src/config.cc index 25086345e..bc8912a42 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1,6 +1,7 @@ #include "config.h" #include "file.h" #include "patches/mapkey.h" +#include "patches/keyboard_layout.h" #include "prime/KeyCode.h" #include "ship_name_match.h" #include "str_utils.h" @@ -714,6 +715,7 @@ void parse_config_shortcut_value(toml::table& new_config, std::string_view item, if (mapKey.Key != KeyCode::None) { keyAdded = true; + keyboard_layout::RegisterShortcut(mapKey.Key); MapKey::AddMappedKey(gameFunction, std::move(mapKey)); } else if (!wantedKey.empty()) { spdlog::warn("Invalid shortcut token [shortcuts].{} token='{}' value='{}'; ignoring token.", @@ -942,6 +944,14 @@ void Config::Load() get_config_or_default(config, parsed, "control", "hotkeys_extended", DCC::hotkeys_extended, write_config); this->use_scopely_hotkeys = get_config_or_default(config, parsed, "control", "use_scopely_hotkeys", DCC::use_scopely_hotkeys, write_config); + this->keyboard_layout_mode = get_config_or_default( + config, parsed, "control", "keyboard_layout_mode", std::string(DCC::keyboard_layout_mode), write_config); + if (this->keyboard_layout_mode != "physical" && this->keyboard_layout_mode != "layout") { + spdlog::warn("Invalid keyboard_layout_mode '{}'; using physical", this->keyboard_layout_mode); + this->keyboard_layout_mode = "physical"; + parsed["control"].as_table()->insert_or_assign("keyboard_layout_mode", this->keyboard_layout_mode); + } + keyboard_layout::Configure(this->keyboard_layout_mode); this->select_timer = get_config_or_default(config, parsed, "control", "select_timer", DCC::select_timer, write_config); this->enable_experimental = @@ -1434,6 +1444,7 @@ void Config::Load() std::filesystem::remove(FILE_DEF_PARSED); } + keyboard_layout::InitializeDiagnostics(parsed); Config::Save(parsed, File::Vars()); std::cout << "\n\n-----------------------------\n\n" diff --git a/mods/src/config.h b/mods/src/config.h index cd9d4de6d..841dc78c0 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -182,6 +182,7 @@ class Config final bool hotkeys_enabled; bool hotkeys_extended; bool use_scopely_hotkeys; + std::string keyboard_layout_mode; bool use_presets_as_default; bool enable_experimental; float default_system_zoom; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index de6bc7e27..f7aff564b 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -25,6 +25,7 @@ namespace Control constexpr bool hotkeys_enabled = true; constexpr bool hotkeys_extended = true; constexpr bool use_scopely_hotkeys = false; + constexpr const char* keyboard_layout_mode = "physical"; constexpr bool queue_enabled = true; constexpr auto select_timer = 500; } // namespace Control diff --git a/mods/src/patches/key.cc b/mods/src/patches/key.cc index 2708cb858..b7dda3fa4 100644 --- a/mods/src/patches/key.cc +++ b/mods/src/patches/key.cc @@ -77,6 +77,9 @@ const std::unordered_map Key::mappedKeys = { {"MOUSE6", KeyCode::Mouse6}, {"SPACE", KeyCode::Space}, {"MINUS", KeyCode::Minus}, + {"EQUAL", KeyCode::Equals}, + // '-' separates modifiers and '|' separates alternative bindings in config. + {"PIPE", KeyCode::Pipe}, {"_", KeyCode::Underscore}, {",", KeyCode::Comma}, {";", KeyCode::Semicolon}, diff --git a/mods/src/patches/keyboard_layout.cc b/mods/src/patches/keyboard_layout.cc new file mode 100644 index 000000000..140e33aad --- /dev/null +++ b/mods/src/patches/keyboard_layout.cc @@ -0,0 +1,163 @@ +#include "keyboard_layout.h" +#include "config.h" +#include "file.h" +#include "key.h" +#include "keyboard_layout_mapping.h" +#include "keyboard_layout_notifications.h" +#include "keyboard_layout_windows.h" +#include "str_utils.h" +#include + +namespace keyboard_layout +{ +namespace +{ + bool enabled = false, initialized = false, failed = false, vars_ready = false; + RefreshState refresh; + BindingState bindings; + std::array requested{}, required_shift{}; + toml::table vars_snapshot; + std::string layout_name, status = "physical", reason = "configured_physical"; + unsigned generation = 0; + const MethodInfo * current_method = nullptr, *layout_method = nullptr; + int (*frame_count)() = nullptr; + + void WriteDiagnostics(toml::table& vars) + { + vars.insert_or_assign( + "keyboard_mapping", + toml::table{{"layout", layout_name}, {"status", status}, {"reason", reason}, {"generation", generation}}); + } + + void Publish() + { + ++generation; + if (vars_ready) { + WriteDiagnostics(vars_snapshot); + Config::Save(vars_snapshot, File::Vars()); + } + spdlog::info("[KeyboardLayout] status={} layout='{}' generation={} reason={}", status, layout_name, generation, + reason); + } + + void Unavailable(std::string_view why) + { + bindings.Clear(); + required_shift = {}; + layout_name.clear(); + if (status == "unavailable" && reason == why) + return; + status = "unavailable"; + reason = why; + Publish(); + } + + void Disable(std::string_view why) + { + failed = true; + notifications::Stop(); + Unavailable(why); + } + + Il2CppObject* Invoke(const MethodInfo* method, void* self = nullptr) + { + Il2CppException* exception = nullptr; + auto* result = il2cpp_runtime_invoke(method, self, nullptr, &exception); + if (exception) { + Disable("unity_invocation_failed"); + return nullptr; + } + return result; + } + + void Initialize() + { + initialized = true; + const auto subscription = notifications::Start(refresh); + if (subscription != notifications::Result::Started) { + Disable(subscription == notifications::Result::Unsupported ? "platform_unsupported" + : "notification_subscription_failed"); + return; + } + auto keyboard = il2cpp_get_class_helper("Unity.InputSystem", "UnityEngine.InputSystem", "Keyboard"); + current_method = keyboard.GetMethodInfo("get_current", 0); + layout_method = keyboard.GetMethodInfo("get_keyboardLayout", 0); + frame_count = il2cpp_resolve_icall_typed("UnityEngine.Time::get_frameCount()"); + if (!current_method || !layout_method || !frame_count) + Disable("missing_unity_api"); + } + + void Update() + { + if (!initialized) + Initialize(); + if (failed || !refresh.Consume()) + return; + auto* keyboard = Invoke(current_method); + if (!keyboard) { + if (!failed) + Unavailable("keyboard_absent"); + return; + } + auto* layout = reinterpret_cast(Invoke(layout_method, keyboard)); + if (!layout || !layout->length) { + if (!failed) + Unavailable("layout_unavailable"); + return; + } + layout_name = to_string(layout); + LayoutKeys keys{}; + std::array shift{}; + bool complete = true; + for (std::size_t index = 0; index < requested.size(); ++index) { + if (!requested[index]) + continue; +#if _WIN32 + const auto chord = ResolveWindowsChord(static_cast(index), layout_name); + keys[index] = chord.key; + shift[index] = chord.shift; +#endif + if (keys[index] == KeyCode::None) { + complete = false; + spdlog::warn("[KeyboardLayout] unresolved character '{}' ({}) on layout '{}'; binding disabled", + static_cast(index), index, layout_name); + } + } + bindings.Replace(keys, Key::Pressed, frame_count()); + required_shift = shift; + status = complete ? "resolved" : "partial"; + reason = complete ? "layout_lookup" : "unresolved_keys_disabled"; + Publish(); + } +} // namespace + +void Configure(std::string_view mode) +{ + enabled = mode == "layout"; + status = enabled ? "pending" : "physical"; + reason = enabled ? "awaiting_game_input" : "configured_physical"; +} + +void RegisterShortcut(KeyCode key) +{ + if (enabled && IsLayoutKey(key)) + requested[static_cast(key)] = true; +} + +void InitializeDiagnostics(toml::table& vars) +{ + WriteDiagnostics(vars); + if (enabled) { + vars_snapshot = vars; + vars_ready = true; + } +} + +ResolvedChord ResolveChord(KeyCode configured) +{ + if (!enabled || !IsLayoutKey(configured)) + return {configured, false}; + Update(); + return {bindings.Resolve(configured, frame_count, Key::Pressed), required_shift[static_cast(configured)]}; +} +} // namespace keyboard_layout diff --git a/mods/src/patches/keyboard_layout.h b/mods/src/patches/keyboard_layout.h new file mode 100644 index 000000000..797c296db --- /dev/null +++ b/mods/src/patches/keyboard_layout.h @@ -0,0 +1,14 @@ +#pragma once +#include "keyboard_layout_mapping.h" +#include +#include + +namespace keyboard_layout +{ +// Config-time calls do not access Unity or change configured shortcut text. +void Configure(std::string_view mode); +void RegisterShortcut(KeyCode key); +void InitializeDiagnostics(toml::table& vars); +// Game thread only; refresh on device notifications, never by polling. +ResolvedChord ResolveChord(KeyCode configured); +} // namespace keyboard_layout diff --git a/mods/src/patches/keyboard_layout_mapping.h b/mods/src/patches/keyboard_layout_mapping.h new file mode 100644 index 000000000..5fcd6a850 --- /dev/null +++ b/mods/src/patches/keyboard_layout_mapping.h @@ -0,0 +1,138 @@ +#pragma once + +#include + +#include + +namespace keyboard_layout +{ +struct ResolvedChord { + KeyCode key = KeyCode::None; + bool shift = false; +}; +// Printable keys accepted by Key::Parse. Named controls (including Space and +// explicit numpad keys) keep their identity, independent of display-name lookup. +constexpr bool IsLayoutKey(KeyCode key) +{ + return (key >= KeyCode::Exclaim && key <= KeyCode::At) + || (key >= KeyCode::LeftBracket && key <= KeyCode::Tilde); +} + +constexpr std::size_t LayoutKeyCount = static_cast(KeyCode::Tilde) + 1; + +// Unity.InputSystem.Key and legacy UnityEngine.KeyCode are different enums. +// Explicit US-reference positions, including punctuation (French M is at US ';'). +constexpr KeyCode ToLegacyKey(int input_system_key) +{ + constexpr std::array keys = { + KeyCode::None, + KeyCode::Space, + KeyCode::Return, + KeyCode::Tab, + KeyCode::BackQuote, + KeyCode::Quote, + KeyCode::Semicolon, + KeyCode::Comma, + KeyCode::Period, + KeyCode::Slash, + KeyCode::Backslash, + KeyCode::LeftBracket, + KeyCode::RightBracket, + KeyCode::Minus, + KeyCode::Equals, + KeyCode::A, + KeyCode::B, + KeyCode::C, + KeyCode::D, + KeyCode::E, + KeyCode::F, + KeyCode::G, + KeyCode::H, + KeyCode::I, + KeyCode::J, + KeyCode::K, + KeyCode::L, + KeyCode::M, + KeyCode::N, + KeyCode::O, + KeyCode::P, + KeyCode::Q, + KeyCode::R, + KeyCode::S, + KeyCode::T, + KeyCode::U, + KeyCode::V, + KeyCode::W, + KeyCode::X, + KeyCode::Y, + KeyCode::Z, + KeyCode::Alpha1, + KeyCode::Alpha2, + KeyCode::Alpha3, + KeyCode::Alpha4, + KeyCode::Alpha5, + KeyCode::Alpha6, + KeyCode::Alpha7, + KeyCode::Alpha8, + KeyCode::Alpha9, + KeyCode::Alpha0, + }; + return input_system_key > 0 && input_system_key < static_cast(keys.size()) ? keys[input_system_key] + : KeyCode::None; +} + +using LayoutKeys = std::array; + +// Physical input caches remain physical. Only action bindings are translated. +// Suppress the transition frame and held keys until release, so a layout change +// cannot turn an existing hold into a different action. +class BindingState +{ +public: + void Clear() + { + keys_ = {}; + blocked_.fill(false); + transition_ = false; + } + + template void Replace(const LayoutKeys& keys, Held held, int frame) + { + keys_ = keys; + transition_ = true; + transition_frame_ = frame; + blocked_.fill(false); + for (auto key : keys_) { + if (key != KeyCode::None) + blocked_[static_cast(key)] = held(key); + } + } + + template KeyCode Resolve(KeyCode configured, Frame frame, Held held) + { + if (!IsLayoutKey(configured)) + return configured; + const auto key = keys_[static_cast(configured)]; + if (key == KeyCode::None) + return key; + if (transition_) { + if (frame() == transition_frame_) + return KeyCode::None; + transition_ = false; + } + auto& blocked = blocked_[static_cast(key)]; + if (blocked) { + if (held(key)) + return KeyCode::None; + blocked = false; + } + return key; + } + +private: + LayoutKeys keys_{}; + std::array(KeyCode::Max)> blocked_{}; + bool transition_ = false; + int transition_frame_ = -1; +}; +} // namespace keyboard_layout diff --git a/mods/src/patches/keyboard_layout_notifications.cc b/mods/src/patches/keyboard_layout_notifications.cc new file mode 100644 index 000000000..1c2244db4 --- /dev/null +++ b/mods/src/patches/keyboard_layout_notifications.cc @@ -0,0 +1,154 @@ +#include "keyboard_layout_notifications.h" + +// Use the tested Windows x64 delegate ABI. +#if defined(_WIN32) && (defined(_M_X64) || defined(__x86_64__)) +#include "il2cpp/il2cpp_helper.h" + +namespace keyboard_layout::notifications +{ +namespace +{ + struct State { + MethodInfo native_method{}; // Private copy, never patch the game's metadata. + const MethodInfo * add = nullptr, *remove = nullptr; + Il2CppGCHandle root = 0; + RefreshState* refresh = nullptr; + std::atomic accepting{false}; + bool attempted = false, subscribed = false, active = false; + }; + + State& Get() + { + // Retain metadata even if removal fails. There is no supported live DLL unload. + static auto* state = new State; + return *state; + } + + void Changed(void*, int32_t change, const MethodInfo*) noexcept + { + auto& state = Get(); + if (!state.accepting.load()) + return; + // Added/Removed/Disconnected/Reconnected/Enabled/Disabled (0..5), + // or ConfigurationChanged (7). Any device can change which keyboard is current; + // conservatively invalidate without polling or touching Unity in the callback. + if ((change >= 0 && change <= 5) || change == 7) + state.refresh->Invalidate(); + } + + bool Invoke(const MethodInfo* method, void* self, void** args) + { + Il2CppException* exception = nullptr; + il2cpp_runtime_invoke(method, self, args, &exception); + return exception == nullptr; + } + + bool Create() + { + auto& state = Get(); + auto input = il2cpp_get_class_helper("Unity.InputSystem", "UnityEngine.InputSystem", "InputSystem"); + auto actions = il2cpp_get_class_helper("Unity.InputSystem", "UnityEngine.InputSystem", "InputActionState"); + state.add = input.GetMethodInfo("add_onDeviceChange", 1); + state.remove = input.GetMethodInfo("remove_onDeviceChange", 1); + const auto* signature = actions.GetMethodInfo("OnDeviceChange", 2); + if (!state.add || !state.remove || !signature) + return false; + const auto* delegate_type = il2cpp_method_get_param(state.add, 0); + if (!delegate_type || il2cpp_type_is_byref(delegate_type) + || !il2cpp_type_equals(delegate_type, il2cpp_method_get_param(state.remove, 0))) + return false; + auto* delegate_class = il2cpp_class_from_type(delegate_type); + if (!delegate_class) + return false; + const auto* ctor = il2cpp_class_get_method_from_name(delegate_class, ".ctor", 2); + const auto* invoke = il2cpp_class_get_method_from_name(delegate_class, "Invoke", 2); + if (!ctor || !ctor->methodPointer || !ctor->invoker_method || !invoke || !(signature->flags & 0x0010) + || signature->is_generic || signature->is_inflated || signature->parameters_count != 2 + || il2cpp_type_get_type(signature->return_type) != IL2CPP_TYPE_VOID + || il2cpp_type_get_type(invoke->return_type) != IL2CPP_TYPE_VOID) + return false; + // Validate complete by-value shapes as well as class identity before borrowing metadata. + for (unsigned i = 0; i < 2; ++i) { + const auto* param = il2cpp_method_get_param(signature, i); + const auto* target = il2cpp_method_get_param(invoke, i); + if (!param || !target || il2cpp_type_is_byref(param) || il2cpp_type_is_byref(target) + || !il2cpp_type_equals(param, target) + || il2cpp_type_get_type(param) != (i == 0 ? IL2CPP_TYPE_CLASS : IL2CPP_TYPE_VALUETYPE)) + return false; + if (i == 1) { + auto* enum_class = il2cpp_class_from_type(param); + if (!enum_class || !il2cpp_class_is_enum(enum_class)) + return false; + const auto* base = il2cpp_class_enum_basetype(enum_class); + if (!base || il2cpp_type_get_type(base) != IL2CPP_TYPE_I4) + return false; + } + } + auto* delegate = il2cpp_object_new(delegate_class); + if (!delegate) + return false; + state.root = il2cpp_gchandle_new(delegate, true); + if (!state.root) + return false; + state.native_method = *signature; + state.native_method.methodPointer = reinterpret_cast(&Changed); + state.native_method.virtualMethodPointer = state.native_method.methodPointer; + const MethodInfo* entry = &state.native_method; + void* ctor_args[]{nullptr, &entry}; + // Follow libil2cpp Type::InvokeDelegateConstructor: the client's generated + // invoker handles its platform's constructor ABI. Do not cast the constructor + // to a Windows-specific native signature. runtime_invoke's special delegate + // constructor path rejects the static null target on the researched client. + try { + ctor->invoker_method(ctor->methodPointer, ctor, delegate, ctor_args, nullptr); + } catch (...) { + return false; + } + auto* typed = reinterpret_cast(delegate); + if (typed->method != entry || typed->method_ptr != state.native_method.methodPointer) + return false; + state.accepting = true; + state.subscribed = true; // An exception need not mean the event was untouched. + void* args[]{delegate}; + return Invoke(state.add, nullptr, args); + } +} // namespace + +Result Start(RefreshState& refresh) +{ + auto& state = Get(); + if (!state.attempted) { + state.attempted = true; + state.refresh = &refresh; + state.active = Create(); + if (!state.active) + Stop(); + } + return state.active ? Result::Started : Result::Failed; +} + +void Stop() +{ + auto& state = Get(); + state.accepting = false; + state.active = false; + if (state.subscribed) { + auto* delegate = il2cpp_gchandle_get_target(state.root); + void* args[]{delegate}; + if (!delegate || !Invoke(state.remove, nullptr, args)) + return; // Preserve the root and metadata if listener removal is uncertain. + state.subscribed = false; + } + if (state.root) + il2cpp_gchandle_free(state.root); + state.root = 0; +} +} // namespace keyboard_layout::notifications +#else +namespace keyboard_layout::notifications +{ +Result Start(RefreshState&) +{ return Result::Unsupported; } +void Stop() {} +} // namespace keyboard_layout::notifications +#endif diff --git a/mods/src/patches/keyboard_layout_notifications.h b/mods/src/patches/keyboard_layout_notifications.h new file mode 100644 index 000000000..d5015e0a3 --- /dev/null +++ b/mods/src/patches/keyboard_layout_notifications.h @@ -0,0 +1,13 @@ +#pragma once + +#include "keyboard_layout_refresh.h" +#include + +namespace keyboard_layout::notifications +{ +// Game-thread calls only. The observer and refresh state must live until process exit. +enum class Result { Started, Unsupported, Failed }; +// Unsupported platforms or subscription failure disable layout mode; never poll. +Result Start(RefreshState& refresh); +void Stop(); +} // namespace keyboard_layout::notifications diff --git a/mods/src/patches/keyboard_layout_refresh.h b/mods/src/patches/keyboard_layout_refresh.h new file mode 100644 index 000000000..261f3ada5 --- /dev/null +++ b/mods/src/patches/keyboard_layout_refresh.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +namespace keyboard_layout +{ +// Shared with the notification callback; all other mapping state stays on the game thread. +class RefreshState +{ +public: + void Invalidate() noexcept + { dirty_.store(true); } + + bool Consume() + { + // Quiet queries do not need a read-modify-write, a frame clock, or Unity calls. + return dirty_.load() && dirty_.exchange(false); + } + +private: + std::atomic dirty_{true}; +}; +} // namespace keyboard_layout diff --git a/mods/src/patches/keyboard_layout_windows.h b/mods/src/patches/keyboard_layout_windows.h new file mode 100644 index 000000000..dfbfc2111 --- /dev/null +++ b/mods/src/patches/keyboard_layout_windows.h @@ -0,0 +1,80 @@ +#pragma once + +#if _WIN32 +#include "keyboard_layout_mapping.h" + +#include +#include + +namespace keyboard_layout +{ +inline constexpr unsigned kWindowsLayoutScans[]{ + 0x29, 0x28, 0x27, 0x33, 0x34, 0x35, 0x2b, 0x1a, 0x1b, 0x0c, 0x0d, 0x1e, 0x30, 0x2e, 0x20, 0x12, + 0x21, 0x22, 0x23, 0x17, 0x24, 0x25, 0x26, 0x32, 0x31, 0x18, 0x19, 0x10, 0x13, 0x1f, 0x14, 0x16, + 0x2f, 0x11, 0x2d, 0x15, 0x2c, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, +}; + +// Scan codes for the same supported US-reference positions as ToLegacyKey(4..50). +// These are physical positions, not a German (or other language) character table. +inline KeyCode FindUnshiftedDeadKey(char character, HKL layout) +{ + if (!layout) + return KeyCode::None; + KeyCode result = KeyCode::None; + for (unsigned index = 0; index < std::size(kWindowsLayoutScans); ++index) { + const auto vk = MapVirtualKeyExW(kWindowsLayoutScans[index], MAPVK_VSC_TO_VK_EX, layout); + const auto value = vk ? MapVirtualKeyExW(vk, MAPVK_VK_TO_CHAR, layout) : 0; + // MAPVK_VK_TO_CHAR marks dead keys with its top bit. Unlike ToUnicodeEx, + // this query does not perform text composition or consume a pending accent. + if ((value & 0x80000000u) && (value & 0xffffu) == static_cast(character)) { + if (result != KeyCode::None) + return KeyCode::None; // Ambiguous positions must not choose an arbitrary key. + result = ToLegacyKey(4 + index); + } + } + return result; +} + +inline KeyCode ResolveWindowsDeadKey(char character, std::string_view unity_layout) +{ + const auto layout = GetKeyboardLayout(0); + char name[KL_NAMELENGTH]{}; + if (!layout || !GetKeyboardLayoutNameA(name) || unity_layout != name) + return KeyCode::None; + const auto result = FindUnshiftedDeadKey(character, layout); + return GetKeyboardLayout(0) == layout ? result : KeyCode::None; +} + +inline ResolvedChord FindWindowsChord(char character, HKL layout) +{ + if (!layout) + return {}; + // Uppercase config tokens name letters, not uppercase text. + if (character >= 'A' && character <= 'Z') + character += 'a' - 'A'; + const auto translated = VkKeyScanExW(static_cast(character), layout); + if (translated == -1) + return {FindUnshiftedDeadKey(character, layout), false}; + const auto modifiers = (static_cast(translated) >> 8) & 0xff; + // Inferring Ctrl/Alt (including AltGr) needs a separate modifier policy. + if (modifiers & ~1u) + return {}; + const auto scan = MapVirtualKeyExW(static_cast(translated) & 0xff, MAPVK_VK_TO_VSC_EX, layout); + for (unsigned index = 0; index < std::size(kWindowsLayoutScans); ++index) { + if (scan == kWindowsLayoutScans[index]) + return {ToLegacyKey(4 + index), (modifiers & 1) != 0}; + } + return {}; +} + +inline ResolvedChord ResolveWindowsChord(char character, std::string_view unity_layout) +{ + const auto layout = GetKeyboardLayout(0); + char name[KL_NAMELENGTH]{}; + if (!layout || !GetKeyboardLayoutNameA(name) || unity_layout != name) + return {}; + const auto chord = FindWindowsChord(character, layout); + return GetKeyboardLayout(0) == layout ? chord : ResolvedChord{}; +} +} // namespace keyboard_layout +#endif diff --git a/mods/src/patches/mapkey.cc b/mods/src/patches/mapkey.cc index b8936f04c..4cd955c49 100644 --- a/mods/src/patches/mapkey.cc +++ b/mods/src/patches/mapkey.cc @@ -1,6 +1,7 @@ #include "mapkey.h" #include "gamefunctions.h" #include "key.h" +#include "keyboard_layout.h" #include "modifierkey.h" #include "str_utils.h" #include @@ -61,6 +62,7 @@ constexpr auto kCompactShortcutTokenMappings = std::to_array +#include + +void Check(bool value, const char* message) +{ + if (!value) + throw std::runtime_error(message); +} + +int main() +{ +#if _WIN32 + using namespace keyboard_layout; + HKL layouts[256]{}; + const int count = GetKeyboardLayoutList(256, layouts); + HKL de = nullptr, us = nullptr; + for (int i = 0; i < count && i < 256; ++i) { + const auto id = reinterpret_cast(layouts[i]) & 0xffffffffu; + if (id == 0x04070407u) + de = layouts[i]; + if (id == 0x04090409u) + us = layouts[i]; + } + if (!de || !us) { + std::cout << "SKIP: requires already-loaded standard US and German layouts\n"; + return 0; + } + // Query explicit layouts without activating, loading or unloading any layout. + const auto original = GetKeyboardLayout(0); + for (const auto [character, key] : + {std::pair{'/', KeyCode::Alpha7}, {'=', KeyCode::Alpha0}, {'`', KeyCode::Equals}, {'\'', KeyCode::Backslash}}) { + const auto chord = FindWindowsChord(character, de); + Check(chord.key == key && chord.shift, "German shifted punctuation"); + } + Check(FindWindowsChord('^', de).key == KeyCode::BackQuote && !FindWindowsChord('^', de).shift, + "German unshifted dead key"); + Check(FindWindowsChord('Z', de).key == KeyCode::Y && !FindWindowsChord('Z', de).shift, + "Uppercase config letter does not infer Shift"); + Check(FindWindowsChord('@', de).key == KeyCode::None, "AltGr stays unsupported"); + Check(FindWindowsChord('/', us).key == KeyCode::Slash && !FindWindowsChord('/', us).shift, "US slash"); + Check(FindWindowsChord('\'', us).key == KeyCode::Quote && !FindWindowsChord('\'', us).shift, "US apostrophe"); + Check(FindWindowsChord('/', nullptr).key == KeyCode::None, "Absent layout"); + Check(ResolveWindowsChord('/', "invalid").key == KeyCode::None, "Layout mismatch fails closed"); + Check(GetKeyboardLayout(0) == original, "Test did not switch layout"); +#endif + std::cout << "Keyboard chord tests passed\n"; +} diff --git a/tests/keyboard_layout_tests.cc b/tests/keyboard_layout_tests.cc new file mode 100644 index 000000000..27ea442f1 --- /dev/null +++ b/tests/keyboard_layout_tests.cc @@ -0,0 +1,168 @@ +#include "patches/keyboard_layout_mapping.h" +#include "patches/keyboard_layout_refresh.h" +#include "patches/keyboard_layout_windows.h" + +#include +#include +#include + +using namespace keyboard_layout; + +void Check(bool condition, const char* name) +{ + if (!condition) { + std::cerr << "FAIL: " << name << '\n'; + std::exit(1); + } +} + +int main() +{ + Check(ToLegacyKey(39) == KeyCode::Y && ToLegacyKey(40) == KeyCode::Z, "distinct enum translation Y/Z"); + Check(ToLegacyKey(6) == KeyCode::Semicolon, "French M punctuation position"); + for (const auto code : {-1, 0, 51, 9999}) + Check(ToLegacyKey(code) == KeyCode::None, "unsupported codes fail closed"); + +#if _WIN32 + // Use installed fixtures without loading/unloading system keyboard layouts. + const auto before = GetKeyboardLayout(0); + HKL existing[256]{}; + const auto count = GetKeyboardLayoutList(256, existing); + Check(count > 0, "existing Windows layouts available"); + HKL german = nullptr, american = nullptr; + for (int i = 0; i < count && i < 256; ++i) { + const auto id = reinterpret_cast(existing[i]) & 0xffffffffu; + if (id == 0x04070407u) german = existing[i]; + if (id == 0x04090409u) american = existing[i]; + } + if (german && american) { + Check(FindUnshiftedDeadKey('^', german) == KeyCode::BackQuote, "German circumflex physical position"); + BYTE keyboard_state[256]{}; + wchar_t translated[8]{}; + const auto accent_vk = MapVirtualKeyExW(0x29, MAPVK_VSC_TO_VK_EX, german); + Check(ToUnicodeEx(accent_vk, 0x29, keyboard_state, translated, 8, 0, german) < 0, "seed pending accent"); + Check(FindUnshiftedDeadKey('^', german) == KeyCode::BackQuote, "lookup with pending accent"); + Check(ToUnicodeEx('A', 0x1e, keyboard_state, translated, 8, 0, german) == 1 && translated[0] == L'\u00e2', + "native lookup preserves pending accent composition"); + for (const char symbol : {'/', '=', '`', 'a'}) + Check(FindUnshiftedDeadKey(symbol, german) == KeyCode::None, "shifted and non-dead symbols excluded"); + Check(FindUnshiftedDeadKey('^', american) == KeyCode::None, "US shifted caret excluded"); + Check(FindUnshiftedDeadKey('^', nullptr) == KeyCode::None, "missing Windows layout fails closed"); + Check(ResolveWindowsDeadKey('^', "not-the-active-layout") == KeyCode::None, "layout disagreement fails closed"); + Check(GetKeyboardLayout(0) == before, "lookup does not activate a layout"); + } else { + std::cout << "SKIP: native dead-key fixtures require already-loaded US and German layouts\n"; + } +#endif + + LayoutKeys us{}; + for (int i = 0; i < 26; ++i) { + us[static_cast(KeyCode::A) + i] = ToLegacyKey(15 + i); + Check(us[static_cast(KeyCode::A) + i] == static_cast(static_cast(KeyCode::A) + i), + "all US letter positions"); + } + us[static_cast(KeyCode::Alpha1)] = KeyCode::Alpha1; + us[static_cast(KeyCode::Slash)] = KeyCode::Slash; + std::array(KeyCode::Max)> held{}; + int frame = 10, clock_calls = 0, held_calls = 0; + auto is_held = [&](KeyCode key) { return held[static_cast(key)]; }; + BindingState state; + auto resolve = [&](KeyCode key) { + return state.Resolve(key, [&] { ++clock_calls; return frame; }, + [&](KeyCode physical) { ++held_calls; return is_held(physical); }); + }; + Check(resolve(KeyCode::Z) == KeyCode::None && resolve(KeyCode::Alpha1) == KeyCode::None, "pending keys disabled"); + for (const auto key : {KeyCode::Escape, KeyCode::UpArrow, KeyCode::F1, KeyCode::Space, KeyCode::Return, + KeyCode::LeftControl, KeyCode::Keypad1, KeyCode::KeypadPlus, KeyCode::Mouse0}) + Check(resolve(key) == key, "named controls keep identity"); + Check(clock_calls == 0 && held_calls == 0, "pending and named keys never query frame or input"); + + state.Replace(us, is_held, frame); + Check(resolve(KeyCode::Z) == KeyCode::None && resolve(KeyCode::Alpha1) == KeyCode::None, + "entire transition frame suppressed"); + ++frame; + Check(resolve(KeyCode::Z) == KeyCode::Z, "first later query clears transition"); + clock_calls = held_calls = 0; + for (int i = 0; i < 1000; ++i) { + ++frame; + Check(resolve(KeyCode::Z) == KeyCode::Z && resolve(KeyCode::Alpha1) == KeyCode::Alpha1 + && resolve(KeyCode::Slash) == KeyCode::Slash, "quiet cached mappings"); + } + Check(clock_calls == 0 && held_calls == 0, "quiet frames perform no clock or held-state polling"); + + auto de = us; + de[static_cast(KeyCode::Y)] = KeyCode::Z; + de[static_cast(KeyCode::Z)] = KeyCode::Y; + de[static_cast(KeyCode::Plus)] = KeyCode::RightBracket; + held[static_cast(KeyCode::Y)] = true; + held[static_cast(KeyCode::RightBracket)] = true; + state.Replace(de, is_held, frame); + Check(resolve(KeyCode::Z) == KeyCode::None, "German transition suppressed"); + ++frame; + Check(resolve(KeyCode::Y) == KeyCode::Z, "German Y uses US Z"); + Check(resolve(KeyCode::Z) == KeyCode::None && resolve(KeyCode::Plus) == KeyCode::None, + "held letter and punctuation blocked"); + ++frame; + Check(resolve(KeyCode::Z) == KeyCode::None, "hold remains blocked"); + held.fill(false); + Check(resolve(KeyCode::Z) == KeyCode::Y && resolve(KeyCode::Plus) == KeyCode::RightBracket, + "release unblocks only the queried target"); + Check(resolve(KeyCode::LeftParen) == KeyCode::None, "missing symbol has no physical fallback"); + clock_calls = held_calls = 0; + ++frame; + Check(resolve(KeyCode::Plus) == KeyCode::RightBracket && resolve(KeyCode::Z) == KeyCode::Y, + "released mapping cached"); + Check(clock_calls == 0 && held_calls == 0, "release checks stop once target is unblocked"); + + auto fr = us; + fr[static_cast(KeyCode::A)] = KeyCode::Q; + fr[static_cast(KeyCode::Q)] = KeyCode::A; + fr[static_cast(KeyCode::W)] = KeyCode::Z; + fr[static_cast(KeyCode::Z)] = KeyCode::W; + fr[static_cast(KeyCode::M)] = ToLegacyKey(6); + fr[static_cast(KeyCode::Y)] = KeyCode::None; + state.Replace(fr, is_held, frame); + ++frame; + Check(resolve(KeyCode::A) == KeyCode::Q && resolve(KeyCode::Q) == KeyCode::A, "French A/Q"); + Check(resolve(KeyCode::W) == KeyCode::Z && resolve(KeyCode::Z) == KeyCode::W, "French W/Z"); + Check(resolve(KeyCode::M) == KeyCode::Semicolon && resolve(KeyCode::Y) == KeyCode::None, "French M and missing key"); + + RefreshState refresh; + // A failed symbol lookup publishes a partial map, without losing unrelated + // letters/digits. A later layout generation must restore that symbol. + auto partial = us; + partial[static_cast(KeyCode::Slash)] = KeyCode::None; + state.Replace(partial, is_held, frame); + ++frame; + Check(resolve(KeyCode::Slash) == KeyCode::None && resolve(KeyCode::Z) == KeyCode::Z + && resolve(KeyCode::Alpha1) == KeyCode::Alpha1, "unavailable symbol isolates letters and digits"); + state.Replace(us, is_held, frame); + ++frame; + Check(resolve(KeyCode::Slash) == KeyCode::Slash, "later mapping restores unavailable symbol"); + Check(refresh.Consume(), "one initial lookup"); + for (int i = 0; i < 1000; ++i) + Check(!refresh.Consume(), "no notification means no refresh, regardless of elapsed frames"); + refresh.Invalidate(); + refresh.Invalidate(); + Check(refresh.Consume() && !refresh.Consume(), "notifications coalesce"); + refresh.Invalidate(); // A callback during rebuild must survive consumption. + Check(refresh.Consume(), "notification during refresh is preserved"); + state.Replace(us, is_held, frame); + refresh.Invalidate(); + Check(refresh.Consume(), "same-frame event refreshes too"); + state.Replace(de, is_held, frame); + Check(resolve(KeyCode::Y) == KeyCode::None, "same-frame rebuild cannot clear suppression"); + ++frame; + Check(resolve(KeyCode::Z) == KeyCode::Y, "next frame resolves after same-frame event"); + state.Clear(); + clock_calls = held_calls = 0; + Check(resolve(KeyCode::Z) == KeyCode::None && resolve(KeyCode::Plus) == KeyCode::None + && resolve(KeyCode::Alpha1) == KeyCode::None, "device/setup failure clears all printable mappings"); + Check(clock_calls == 0 && held_calls == 0, "unavailable mappings never poll"); + for (const auto key : {KeyCode::Alpha0, KeyCode::Alpha9, KeyCode::Exclaim, KeyCode::At, + KeyCode::LeftBracket, KeyCode::Tilde, KeyCode::Pipe, KeyCode::Minus}) + Check(IsLayoutKey(key), "digits and punctuation eligible"); + Check(!IsLayoutKey(KeyCode::None) && !IsLayoutKey(KeyCode::Delete) + && !IsLayoutKey(static_cast(65)), "enum gaps excluded"); + std::cout << "PASS: mapping, event-only refresh, zero quiet-frame polling, transitions, held keys, failure clearing\n"; +} diff --git a/tests/shortcut_hint_cache.cc b/tests/shortcut_hint_cache.cc index 872685ce3..029db36c2 100644 --- a/tests/shortcut_hint_cache.cc +++ b/tests/shortcut_hint_cache.cc @@ -1,23 +1,54 @@ -// Link against the built mods library. Only Unity-dependent Key operations are stubbed; -// MapKey parsing, ModifierKey parsing, binding detection, and hint caching are production code. +// Link production MapKey/ModifierKey parsing, action dispatch and hint caching from mods.lib. +// Key token parsing and input are test fixtures; layout lookup is injected below. +// These tests do not exercise Unity lookup, native notifications or legacy input caching. #include "patches/mapkey.h" +#include "patches/keyboard_layout.h" +#include "patches/keyboard_layout_mapping.h" #include #include +#include + +static std::array(KeyCode::Max)> pressed{}; +static std::array(KeyCode::Max)> down{}; +static keyboard_layout::BindingState layout_bindings; +static bool layout_enabled = false; +static std::array chords; KeyCode Key::Parse(std::string_view key) { - if (key == "F7") return KeyCode::F7; - if (key == "F8") return KeyCode::F8; - if (key == "G") return KeyCode::G; + static constexpr std::pair tokens[] = { + {"=", KeyCode::Equals}, {"Z", KeyCode::Z}, {"LSHIFT", KeyCode::LeftShift}, + {"F7", KeyCode::F7}, {"F8", KeyCode::F8}, {"G", KeyCode::G}, {"+", KeyCode::Plus}, + {"/", KeyCode::Slash}, {"(", KeyCode::LeftParen}, {"1", KeyCode::Alpha1}, + }; + for (const auto& [token, code] : tokens) { + if (key == token) + return code; + } return KeyCode::None; } -bool Key::IsModifier(KeyCode) { return false; } -bool Key::Pressed(KeyCode) { return false; } -bool Key::Down(KeyCode) { return false; } -bool Key::IsModified() { return false; } +bool Key::IsModifier(KeyCode key) { return key == KeyCode::LeftShift; } +bool Key::Pressed(KeyCode key) { return pressed[static_cast(key)]; } +bool Key::Down(KeyCode key) { return down[static_cast(key)]; } +bool Key::IsModified() { + for (auto key : {KeyCode::LeftShift, KeyCode::RightShift, KeyCode::LeftControl, KeyCode::RightControl, + KeyCode::LeftAlt, KeyCode::RightAlt, KeyCode::AltGr, KeyCode::LeftCommand, + KeyCode::RightCommand, KeyCode::LeftWindows, KeyCode::RightWindows}) + if (Key::Pressed(key)) return true; + return false; +} void Key::ClaimDirectionalInput(KeyCode) {} +namespace keyboard_layout +{ +KeyCode Resolve(KeyCode configured) { return layout_enabled ? layout_bindings.Resolve(configured, [] { return 1; }, Key::Pressed) : configured; } +ResolvedChord ResolveChord(KeyCode configured) { + return {Resolve(configured), layout_enabled && IsLayoutKey(configured) + && (chords[static_cast(configured)].shift) != 0}; +} +} // namespace keyboard_layout + void Check(bool condition, const char* message) { if (!condition) { @@ -28,6 +59,7 @@ void Check(bool condition, const char* message) int main() { + constexpr auto toggle = GameFunction::ToggleShortcutHints; constexpr auto galaxy = GameFunction::ShowGalaxy; Check(!MapKey::HasBinding(toggle), "Absent binding must not enable hints"); @@ -56,5 +88,83 @@ int main() Check(MapKey::GetShortcutHint(GameFunction::ShowResearch).empty(), "Unbound action acquired a badge"); MapKey::CacheShortcutHints(); Check(MapKey::GetShortcutHint(galaxy) == "^G", "Repeated cache preparation changed label"); + + // Supply a mapping fixture to test action dispatch through BindingState. + // This does not assert that Unity returns these mappings on any real layout. + keyboard_layout::LayoutKeys keys{}; + keys[static_cast(KeyCode::Plus)] = KeyCode::RightBracket; + keys[static_cast(KeyCode::Slash)] = KeyCode::Alpha7; + keys[static_cast(KeyCode::Alpha1)] = KeyCode::Alpha1; + layout_bindings.Replace(keys, Key::Pressed, 0); + layout_enabled = true; + MapKey::AddMappedKey(GameFunction::ShowDaily, MapKey::Parse("+")); + MapKey::AddMappedKey(GameFunction::ShowScrapYard, MapKey::Parse("SHIFT-/")); + MapKey::AddMappedKey(GameFunction::ShowResearch, MapKey::Parse("(")); + MapKey::AddMappedKey(GameFunction::ShowInventory, MapKey::Parse("1")); + pressed[static_cast(KeyCode::RightBracket)] = true; + down[static_cast(KeyCode::RightBracket)] = true; + Check(MapKey::IsDown(GameFunction::ShowDaily) && MapKey::IsPressed(GameFunction::ShowDaily), + "Punctuation action did not use resolved physical key"); + down[static_cast(KeyCode::RightBracket)] = false; + Check(!MapKey::IsDown(GameFunction::ShowDaily) && MapKey::IsPressed(GameFunction::ShowDaily), + "Held action was mistaken for a new key-down edge"); + down[static_cast(KeyCode::RightBracket)] = true; + pressed[static_cast(KeyCode::LeftShift)] = true; + Check(!MapKey::IsDown(GameFunction::ShowDaily), "Unmodified symbol unexpectedly accepts Shift"); + pressed[static_cast(KeyCode::Alpha7)] = true; + down[static_cast(KeyCode::Alpha7)] = true; + Check(MapKey::IsDown(GameFunction::ShowScrapYard), "Explicit Shift chord did not use resolved symbol key"); + pressed[static_cast(KeyCode::LeftShift)] = false; + Check(!MapKey::IsDown(GameFunction::ShowScrapYard), "Explicit modifier requirement was lost"); + pressed[static_cast(KeyCode::LeftParen)] = true; + down[static_cast(KeyCode::LeftParen)] = true; + Check(!MapKey::IsDown(GameFunction::ShowResearch), "Unresolved symbol silently fell back to physical"); + pressed[static_cast(KeyCode::Alpha1)] = true; + down[static_cast(KeyCode::Alpha1)] = true; + Check(MapKey::IsDown(GameFunction::ShowInventory), "Digit action did not use resolved mapping"); + Check(MapKey::GetShortcuts(GameFunction::ShowScrapYard) == "SHIFT-/", "Resolution rewrote configured chord"); + + // German slash needs Shift on either side; plain 7 must not fire slash. + auto& slash = chords[static_cast(KeyCode::Slash)]; + slash.shift = true; + constexpr auto slashAction = GameFunction::ShowAlliance; + MapKey::AddMappedKey(slashAction, MapKey::Parse("/")); + MapKey::CacheShortcutHints(); + pressed.fill(false); + down.fill(false); + pressed[static_cast(KeyCode::Alpha7)] = down[static_cast(KeyCode::Alpha7)] = true; + Check(!MapKey::IsDown(slashAction), "Bare 7 fired German slash"); + for (const auto shift : {KeyCode::LeftShift, KeyCode::RightShift}) { + pressed[static_cast(shift)] = true; + Check(MapKey::IsDown(slashAction) && MapKey::IsPressed(slashAction), "Inferred Shift chord did not dispatch"); + for (const auto extra : {KeyCode::LeftControl, KeyCode::RightAlt, KeyCode::AltGr, KeyCode::LeftWindows}) { + pressed[static_cast(extra)] = true; + Check(!MapKey::IsDown(slashAction), "Bare slash stole modified chord"); + pressed[static_cast(extra)] = false; + } + pressed[static_cast(shift)] = false; + } + Check(MapKey::GetShortcutHint(slashAction) == "/", "Hint replaced configured slash with physical recipe"); + Check(MapKey::GetShortcutHint(GameFunction::ShowScrapYard) == "+/", "Hint changed explicit configured Shift"); + Check(MapKey::GetShortcuts(slashAction) == "/", "Display rewrote configuration"); + const auto explicitCtrl = MapKey::Parse("CTRL-/"); + pressed[static_cast(KeyCode::LeftControl)] = true; + Check(!MapKey::HasCorrectModifiers(explicitCtrl, true), "Explicit Ctrl bypassed inferred Shift"); + pressed[static_cast(KeyCode::RightShift)] = true; + Check(MapKey::HasCorrectModifiers(explicitCtrl, true), "Explicit Ctrl was not combined with inferred Shift"); + const auto explicitSide = MapKey::Parse("LSHIFT-/"); + Check(!MapKey::HasCorrectModifiers(explicitSide, true), "Inferred Shift bypassed explicit left side"); + pressed[static_cast(KeyCode::LeftShift)] = true; + Check(MapKey::HasCorrectModifiers(explicitSide, true), "Explicit left Shift was not accepted"); + Check(MapKey::GetShortcutHint(slashAction) == "/", "Resolution status rewrote configured hint"); + // Simulate the next US generation; no stale German recipe may remain cached. + slash.shift = false; + Check(MapKey::GetShortcutHint(slashAction) == "/", "Hint retained previous layout's Shift"); + layout_enabled = false; + pressed.fill(false); + down.fill(false); + pressed[static_cast(KeyCode::Plus)] = true; + down[static_cast(KeyCode::Plus)] = true; + Check(MapKey::IsDown(GameFunction::ShowDaily), "Physical mode no longer uses configured key"); std::cout << "Shortcut hint cache tests passed\n"; } diff --git a/tests/xmake.lua b/tests/xmake.lua new file mode 100644 index 000000000..f1af1d1f8 --- /dev/null +++ b/tests/xmake.lua @@ -0,0 +1,30 @@ +target("keyboard-layout-tests") +do + set_kind("binary") + set_default(false) + add_files("keyboard_layout_tests.cc") + add_includedirs("../mods/src") + if is_plat("windows") then + add_syslinks("user32") + end +end + +target("shortcut-layout-dispatch-tests") +do + set_kind("binary") + set_default(false) + add_deps("mods") + add_files("shortcut_hint_cache.cc") + add_packages("libil2cpp", "eastl", "toml++", "spdlog") +end + +target("keyboard-chord-tests") +do + set_kind("binary") + set_default(false) + add_files("keyboard_chord_tests.cc") + add_includedirs("../mods/src") + if is_plat("windows") then + add_syslinks("user32") + end +end diff --git a/xmake.lua b/xmake.lua index 9b25acbfb..91cc1f416 100644 --- a/xmake.lua +++ b/xmake.lua @@ -26,3 +26,4 @@ add_rules("mode.releasedbg") includes("xmake/rules/protobuf_sccache.lua") includes("xmake/rules/cxx_sccache.lua") includes("mods") +includes("tests") From e8a488396ed40a13729c3271d6bec1ae22e96f91 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Wed, 9 Sep 2026 22:46:54 -0500 Subject: [PATCH 02/20] Document and test German experimental shortcut overlap --- docs/keyboard-letter-layout.md | 18 ++++++++++++++ mods/src/patches/keyboard_layout_windows.h | 10 -------- tests/keyboard_layout_tests.cc | 2 +- tests/shortcut_hint_cache.cc | 28 ++++++++++++++++++++++ 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/docs/keyboard-letter-layout.md b/docs/keyboard-letter-layout.md index 6a92a26eb..a7ff0849b 100644 --- a/docs/keyboard-letter-layout.md +++ b/docs/keyboard-letter-layout.md @@ -21,6 +21,24 @@ explicit configured modifiers. Side-specific modifiers retain their requirements Existing explicitly modified bindings accept extra modifiers; avoid overlapping chords, since action dispatch order determines which matching action wins. +**German users enabling experimental shortcuts:** the upstream defaults +`show_alliance_help = "SHIFT-'"` and `show_alliance_armada = "CTRL-'"` overlap +because German apostrophe already requires Shift. Help is checked first and +captures the Armada chord. Use the tested remap below in the existing sections: + +```toml +[control] +keyboard_layout_mode = "layout" +enable_experimental = true + +[shortcuts] +show_alliance = "ALT-^" +show_alliance_help = "SHIFT-^" +show_alliance_armada = "CTRL-'" +``` + +This keeps upstream modifier matching and shortcut defaults unchanged. + Hints and runtime shortcut values retain configured TOML text. The internal physical mapping is not a replacement hint. Named controls (function keys, arrows, Space, mouse and numpad keys), hardcoded controls and Scopely shortcuts are unchanged. diff --git a/mods/src/patches/keyboard_layout_windows.h b/mods/src/patches/keyboard_layout_windows.h index dfbfc2111..7e626c752 100644 --- a/mods/src/patches/keyboard_layout_windows.h +++ b/mods/src/patches/keyboard_layout_windows.h @@ -35,16 +35,6 @@ inline KeyCode FindUnshiftedDeadKey(char character, HKL layout) return result; } -inline KeyCode ResolveWindowsDeadKey(char character, std::string_view unity_layout) -{ - const auto layout = GetKeyboardLayout(0); - char name[KL_NAMELENGTH]{}; - if (!layout || !GetKeyboardLayoutNameA(name) || unity_layout != name) - return KeyCode::None; - const auto result = FindUnshiftedDeadKey(character, layout); - return GetKeyboardLayout(0) == layout ? result : KeyCode::None; -} - inline ResolvedChord FindWindowsChord(char character, HKL layout) { if (!layout) diff --git a/tests/keyboard_layout_tests.cc b/tests/keyboard_layout_tests.cc index 27ea442f1..fcff12e3b 100644 --- a/tests/keyboard_layout_tests.cc +++ b/tests/keyboard_layout_tests.cc @@ -48,7 +48,7 @@ int main() Check(FindUnshiftedDeadKey(symbol, german) == KeyCode::None, "shifted and non-dead symbols excluded"); Check(FindUnshiftedDeadKey('^', american) == KeyCode::None, "US shifted caret excluded"); Check(FindUnshiftedDeadKey('^', nullptr) == KeyCode::None, "missing Windows layout fails closed"); - Check(ResolveWindowsDeadKey('^', "not-the-active-layout") == KeyCode::None, "layout disagreement fails closed"); + Check(ResolveWindowsChord('^', "not-the-active-layout").key == KeyCode::None, "layout disagreement fails closed"); Check(GetKeyboardLayout(0) == before, "lookup does not activate a layout"); } else { std::cout << "SKIP: native dead-key fixtures require already-loaded US and German layouts\n"; diff --git a/tests/shortcut_hint_cache.cc b/tests/shortcut_hint_cache.cc index 029db36c2..95e50672d 100644 --- a/tests/shortcut_hint_cache.cc +++ b/tests/shortcut_hint_cache.cc @@ -21,6 +21,7 @@ KeyCode Key::Parse(std::string_view key) {"=", KeyCode::Equals}, {"Z", KeyCode::Z}, {"LSHIFT", KeyCode::LeftShift}, {"F7", KeyCode::F7}, {"F8", KeyCode::F8}, {"G", KeyCode::G}, {"+", KeyCode::Plus}, {"/", KeyCode::Slash}, {"(", KeyCode::LeftParen}, {"1", KeyCode::Alpha1}, + {"'", KeyCode::Quote}, {"^", KeyCode::Caret}, }; for (const auto& [token, code] : tokens) { if (key == token) @@ -160,6 +161,33 @@ int main() // Simulate the next US generation; no stale German recipe may remain cached. slash.shift = false; Check(MapKey::GetShortcutHint(slashAction) == "/", "Hint retained previous layout's Shift"); + + // The upstream German defaults overlap under minimum modifier matching. + // Preserve that policy; verify the documented Help remap makes Armada distinct. + pressed.fill(false); + down.fill(false); + keys[static_cast(KeyCode::Quote)] = KeyCode::Backslash; + keys[static_cast(KeyCode::Caret)] = KeyCode::BackQuote; + chords[static_cast(KeyCode::Quote)].shift = true; + layout_bindings.Replace(keys, Key::Pressed, 0); + MapKey::AddMappedKey(GameFunction::ShowAllianceHelp, MapKey::Parse("SHIFT-'")); + MapKey::AddMappedKey(GameFunction::ShowAllianceArmada, MapKey::Parse("CTRL-'")); + pressed[static_cast(KeyCode::LeftControl)] = true; + pressed[static_cast(KeyCode::LeftShift)] = true; + pressed[static_cast(KeyCode::Backslash)] = down[static_cast(KeyCode::Backslash)] = true; + Check(MapKey::IsDown(GameFunction::ShowAllianceHelp) && MapKey::IsDown(GameFunction::ShowAllianceArmada), + "German default overlap changed without an explicit modifier policy change"); + // Use a spare action slot for the alternative configuration; the public API + // intentionally has no live binding replacement operation. + constexpr auto remappedHelp = GameFunction::ShowOfficers; + MapKey::AddMappedKey(remappedHelp, MapKey::Parse("SHIFT-^")); + Check(!MapKey::IsDown(remappedHelp) && MapKey::IsDown(GameFunction::ShowAllianceArmada), + "Documented Help remap still captures Armada"); + pressed[static_cast(KeyCode::LeftControl)] = false; + pressed[static_cast(KeyCode::Backslash)] = down[static_cast(KeyCode::Backslash)] = false; + pressed[static_cast(KeyCode::BackQuote)] = down[static_cast(KeyCode::BackQuote)] = true; + Check(MapKey::IsDown(remappedHelp) && !MapKey::IsDown(GameFunction::ShowAllianceArmada), + "Documented Shift-caret Help chord failed"); layout_enabled = false; pressed.fill(false); down.fill(false); From c3aa4bce47783b439cc6f5711020fe8215fa5980 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 11 Sep 2026 20:13:51 -0500 Subject: [PATCH 03/20] Check startup config output before replacing files --- docs/config-save.md | 33 ++++++++++++++ mods/src/config.cc | 20 ++++++--- mods/src/config_save.cc | 91 +++++++++++++++++++++++++++++++++++++++ mods/src/config_save.h | 9 ++++ tests/config_save_test.cc | 64 +++++++++++++++++++++++++++ tests/run-config-save.ps1 | 25 +++++++++++ 6 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 docs/config-save.md create mode 100644 mods/src/config_save.cc create mode 100644 mods/src/config_save.h create mode 100644 tests/config_save_test.cc create mode 100644 tests/run-config-save.ps1 diff --git a/docs/config-save.md b/docs/config-save.md new file mode 100644 index 000000000..7b129ca6f --- /dev/null +++ b/docs/config-save.md @@ -0,0 +1,33 @@ +# Startup config saves + +`Config::Save` writes complete TOML documents for two startup callers: the initial +default config and the generated runtime snapshot. It keeps `File::MakePath` +routing and the existing generated-file warning. Save errors are logged once by +the caller; startup continues with the in-memory configuration. + +`SaveConfigDocument` serializes with toml++, parses the output before touching +disk, exclusively creates a sibling temporary file, checks writing and closing, +and replaces the destination. No threads, frame callbacks, runtime controls or +shutdown interception are installed. This is not the preserving TOML editor: +whole-document saves do not merge concurrent setting changes or preserve comments. + +Windows uses `ReplaceFileW` to preserve existing permissions and streams, with a +temporary backup for its documented partial-failure cases. Initial creation uses +a non-replacing move. Ordinary failures clean up the temporary file; partial +replacement failures retain recovery files and report their location. The backup +name is the reported temporary path plus `.bak`. Recovery is not automatic. +macOS uses rename after copying the existing permission bits. Extended metadata +and hard-link identity are not preserved by that path. Existing symlinks are +resolved before staging. Replacement requires directory permissions in addition +to any file access checks; it cannot exactly match an in-place overwrite. + +Successful close/replacement is not a guarantee against power loss. A forced exit +can leave a temporary file. No automatic stale-file sweep is installed. + +Run the isolated Windows fixtures with `tests/run-config-save.ps1` after the +normal AX build has installed toml++; `-TomlInclude` can select another include +directory. Fixtures never access the installed game's files. + +Native behavior references: +- [Windows ReplaceFileW](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew) +- [POSIX rename](https://pubs.opengroup.org/onlinepubs/9799919799/functions/rename.html) diff --git a/mods/src/config.cc b/mods/src/config.cc index 25086345e..04f4db9f7 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1,4 +1,5 @@ #include "config.h" +#include "config_save.h" #include "file.h" #include "patches/mapkey.h" #include "prime/KeyCode.h" @@ -93,10 +94,10 @@ Config::Config() void Config::Save(const toml::table& config, const std::string_view filename, bool apply_warning) { - std::ofstream config_file; + std::ostringstream config_file; auto config_path = File::MakePath(filename, true); - config_file.open(config_path); + config_file.exceptions(std::ios::badbit | std::ios::failbit); if (apply_warning) { char defaultFile[255], configFile[255]; @@ -118,8 +119,7 @@ void Config::Save(const toml::table& config, const std::string_view filename, bo config_file << "#######################################################################\n\n"; } - config_file << config; - config_file.close(); + SaveConfigDocument(config, std::filesystem::path(config_path), config_file.str()); } Config& Config::Get() @@ -1419,7 +1419,11 @@ void Config::Load() message << "Creating " << File::Config() << " (default config file)"; spdlog::warn(message.str()); - Config::Save(parsed, File::Config(), false); + try { + Config::Save(parsed, File::Config(), false); + } catch (const std::exception& error) { + spdlog::error("Could not save default config: {}", error.what()); + } } message.str(""); @@ -1434,7 +1438,11 @@ void Config::Load() std::filesystem::remove(FILE_DEF_PARSED); } - Config::Save(parsed, File::Vars()); + try { + Config::Save(parsed, File::Vars()); + } catch (const std::exception& error) { + spdlog::error("Could not save runtime config: {}", error.what()); + } std::cout << "\n\n-----------------------------\n\n" << parsed << "\n\n-----------------------------\nVersion " diff --git a/mods/src/config_save.cc b/mods/src/config_save.cc new file mode 100644 index 000000000..59240819b --- /dev/null +++ b/mods/src/config_save.cc @@ -0,0 +1,91 @@ +#include "config_save.h" + +#include +#include +#include +#include +#include +#include + +#if _WIN32 +#include +#endif + +void SaveConfigDocument(const toml::table& config, const std::filesystem::path& path, std::string_view header) +{ + // Serialize and validate before opening any file. Values are encoded by toml++, + // never interpolated into TOML source. Validate the header too. + std::ostringstream output; + output.exceptions(std::ios::badbit | std::ios::failbit); + output << header << config; + const auto bytes = output.str(); + (void)toml::parse(bytes); + + // Follow existing symlinks as the former ofstream save did. A sibling stays on + // the same filesystem. Exclusive creation avoids truncating another save's file. + const auto destination = std::filesystem::weakly_canonical(path); + static std::atomic sequence{0}; + auto temporary = destination; + temporary += ".tmp-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + "-" + + std::to_string(sequence.fetch_add(1, std::memory_order_relaxed)); + + // C11 exclusive creation avoids depending on newer libc++ fstream runtime + // support on our minimum supported macOS version. +#if _WIN32 + std::FILE* file = nullptr; + _wfopen_s(&file, temporary.c_str(), L"wbx"); +#else + auto* file = std::fopen(temporary.c_str(), "wbx"); +#endif + if (!file) { + throw std::system_error(errno, std::generic_category(), "could not create temporary config file"); + } + + bool replacing = false; + try { + if (std::fwrite(bytes.data(), 1, bytes.size(), file) != bytes.size()) { + throw std::system_error(errno, std::generic_category(), "could not write temporary config file"); + } + const auto closed = std::fclose(file); // Includes flushing; failure prevents replacement. + file = nullptr; + if (closed != 0) { + throw std::system_error(errno, std::generic_category(), "could not close temporary config file"); + } +#if _WIN32 + // Let Windows retain the existing file's permissions and streams. A backup + // protects the old contents in ReplaceFile's documented partial-failure cases. + auto backup = temporary; + backup += ".bak"; + if (!ReplaceFileW(destination.c_str(), temporary.c_str(), backup.c_str(), 0, nullptr, nullptr)) { + auto error = GetLastError(); + if (error == ERROR_FILE_NOT_FOUND) { + // Initial creation must not replace a config created in the meantime. + error = MoveFileExW(temporary.c_str(), destination.c_str(), 0) ? ERROR_SUCCESS : GetLastError(); + } + if (error != ERROR_SUCCESS) { + replacing = error == ERROR_UNABLE_TO_MOVE_REPLACEMENT || error == ERROR_UNABLE_TO_MOVE_REPLACEMENT_2; + throw std::filesystem::filesystem_error( + replacing ? "config replacement failed; retain temporary/backup for recovery" : "config replacement failed", + temporary, destination, std::error_code(error, std::system_category())); + } + } + std::error_code ignored; + std::filesystem::remove(backup, ignored); +#else + // Preserve ordinary permission bits when replacing an existing config. + if (std::filesystem::exists(destination)) { + std::filesystem::permissions(temporary, std::filesystem::status(destination).permissions()); + } + std::filesystem::rename(temporary, destination); +#endif + } catch (...) { + if (file) { + std::fclose(file); + } + std::error_code ignored; + if (!replacing) { + std::filesystem::remove(temporary, ignored); + } + throw; + } +} diff --git a/mods/src/config_save.h b/mods/src/config_save.h new file mode 100644 index 000000000..2150f5225 --- /dev/null +++ b/mods/src/config_save.h @@ -0,0 +1,9 @@ +#pragma once + +#include +#include +#include + +// Synchronous whole-document output for startup, not a runtime setting editor. +// Throws on failure; the caller owns reporting. Does not guarantee power-loss durability. +void SaveConfigDocument(const toml::table& config, const std::filesystem::path& path, std::string_view header = {}); diff --git a/tests/config_save_test.cc b/tests/config_save_test.cc new file mode 100644 index 000000000..b25a3b5d5 --- /dev/null +++ b/tests/config_save_test.cc @@ -0,0 +1,64 @@ +#include "config_save.h" + +#include +#include +#include + +#if _WIN32 +#include +#endif + +int main(int argc, char** argv) +{ + assert(argc == 2); + const std::filesystem::path root(argv[1]); + std::filesystem::create_directories(root); + const auto path = root / "settings.toml"; + const std::string value = "quotes: \"'\\\n[unexpected]\nenabled = true\nUnicode: \xc3\xa9"; + toml::table config{{"value", value}, {"enabled", false}}; + SaveConfigDocument(config, path, "# generated\n"); + auto parsed = toml::parse_file(path.string()); + assert(parsed["value"].value() == value); + assert(parsed.size() == 2); + config.insert_or_assign("enabled", true); + SaveConfigDocument(config, path); + assert(toml::parse_file(path.string())["enabled"].value() == true); + + bool failed = false; + try { + SaveConfigDocument(config, path, "invalid = [\n"); + } catch (const std::exception&) { + failed = true; + } + assert(failed); + assert(toml::parse_file(path.string())["value"].value() == value); + + const auto directory = root / "occupied"; + std::filesystem::create_directory(directory); + failed = false; + try { + SaveConfigDocument(config, directory); + } catch (const std::exception&) { + failed = true; + } + assert(failed && std::filesystem::is_directory(directory)); +#if _WIN32 + // A real sharing violation must leave the previous readable document intact. + auto handle = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr); + assert(handle != INVALID_HANDLE_VALUE); + failed = false; + config.insert_or_assign("enabled", false); + try { + SaveConfigDocument(config, path); + } catch (const std::exception&) { + failed = true; + } + CloseHandle(handle); + assert(failed); + assert(toml::parse_file(path.string())["enabled"].value() == true); +#endif + for (const auto& entry : std::filesystem::directory_iterator(root)) { + assert(entry.path().filename().string().find(".tmp-") == std::string::npos); + } + std::cout << "Config save fixtures passed\n"; +} diff --git a/tests/run-config-save.ps1 b/tests/run-config-save.ps1 new file mode 100644 index 000000000..f41c44ffd --- /dev/null +++ b/tests/run-config-save.ps1 @@ -0,0 +1,25 @@ +[CmdletBinding()] +param([string]$TomlInclude) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +Push-Location $repoRoot +try { + if (-not $TomlInclude) { + $packageRoot = Join-Path $env:LOCALAPPDATA '.xmake/packages/t/toml++' + $header = Get-ChildItem -LiteralPath $packageRoot -Recurse -Filter toml.h | + Where-Object { $_.Directory.Name -eq 'toml++' } | Select-Object -First 1 + if (-not $header) { throw 'Build with AX first, or supply -TomlInclude.' } + $TomlInclude = $header.Directory.Parent.FullName + } + New-Item -ItemType Directory -Force build/config-save-test | Out-Null + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/config_save_test.cc mods/src/config_save.cc /Febuild/config-save-test/test.exe ` + /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'Config save test compilation failed.' } + $fixtureRoot = Join-Path $repoRoot ('build/config-save-test/' + [guid]::NewGuid()) + & ./build/config-save-test/test.exe $fixtureRoot + if ($LASTEXITCODE -ne 0) { throw 'Config save regression failed.' } +} finally { + Pop-Location +} From 3ba601e19f05dde0d113fdd47210c19606b9c5d2 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 11 Sep 2026 20:17:20 -0500 Subject: [PATCH 04/20] Exercise startup save failures and permission retention --- docs/config-save.md | 5 +-- mods/src/config_save.cc | 15 ++++++-- tests/config_save_failure_test.cc | 60 +++++++++++++++++++++++++++++++ tests/run-config-save.ps1 | 20 +++++++++++ 4 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 tests/config_save_failure_test.cc diff --git a/docs/config-save.md b/docs/config-save.md index 7b129ca6f..357160b0d 100644 --- a/docs/config-save.md +++ b/docs/config-save.md @@ -12,8 +12,9 @@ shutdown interception are installed. This is not the preserving TOML editor: whole-document saves do not merge concurrent setting changes or preserve comments. Windows uses `ReplaceFileW` to preserve existing permissions and streams, with a -temporary backup for its documented partial-failure cases. Initial creation uses -a non-replacing move. Ordinary failures clean up the temporary file; partial +temporary backup for its documented partial-failure cases. A missing destination +falls back to a non-replacing move. The caller's startup existence check is not an +exclusive create transaction. Ordinary failures clean up the temporary file; partial replacement failures retain recovery files and report their location. The backup name is the reported temporary path plus `.bak`. Recovery is not automatic. macOS uses rename after copying the existing permission bits. Extended metadata diff --git a/mods/src/config_save.cc b/mods/src/config_save.cc index 59240819b..9e9a82b6d 100644 --- a/mods/src/config_save.cc +++ b/mods/src/config_save.cc @@ -11,6 +11,14 @@ #include #endif +// Compile-time substitutions are used only by the isolated failure fixture. +#ifndef CONFIG_SAVE_WRITE +#define CONFIG_SAVE_WRITE std::fwrite +#endif +#ifndef CONFIG_SAVE_CLOSE +#define CONFIG_SAVE_CLOSE std::fclose +#endif + void SaveConfigDocument(const toml::table& config, const std::filesystem::path& path, std::string_view header) { // Serialize and validate before opening any file. Values are encoded by toml++, @@ -43,10 +51,10 @@ void SaveConfigDocument(const toml::table& config, const std::filesystem::path& bool replacing = false; try { - if (std::fwrite(bytes.data(), 1, bytes.size(), file) != bytes.size()) { + if (CONFIG_SAVE_WRITE(bytes.data(), 1, bytes.size(), file) != bytes.size()) { throw std::system_error(errno, std::generic_category(), "could not write temporary config file"); } - const auto closed = std::fclose(file); // Includes flushing; failure prevents replacement. + const auto closed = CONFIG_SAVE_CLOSE(file); // Includes flushing; failure prevents replacement. file = nullptr; if (closed != 0) { throw std::system_error(errno, std::generic_category(), "could not close temporary config file"); @@ -59,7 +67,8 @@ void SaveConfigDocument(const toml::table& config, const std::filesystem::path& if (!ReplaceFileW(destination.c_str(), temporary.c_str(), backup.c_str(), 0, nullptr, nullptr)) { auto error = GetLastError(); if (error == ERROR_FILE_NOT_FOUND) { - // Initial creation must not replace a config created in the meantime. + // Missing-destination fallback: do not overwrite a file appearing before + // this move. The caller's earlier existence check is not a create-only transaction. error = MoveFileExW(temporary.c_str(), destination.c_str(), 0) ? ERROR_SUCCESS : GetLastError(); } if (error != ERROR_SUCCESS) { diff --git a/tests/config_save_failure_test.cc b/tests/config_save_failure_test.cc new file mode 100644 index 000000000..8bc083c3c --- /dev/null +++ b/tests/config_save_failure_test.cc @@ -0,0 +1,60 @@ +#include +#include + +static bool failClose = false; + +static std::size_t ShortWrite(const void* data, std::size_t size, std::size_t count, std::FILE* file) +{ + if (failClose) { + return std::fwrite(data, size, count, file); + } + const auto written = std::fwrite(data, size, count / 2, file); + errno = ENOSPC; + return written; +} + +static int FailedClose(std::FILE* file) +{ + std::fclose(file); + errno = ENOSPC; + return EOF; +} + +// Exercise the production cleanup path without adding runtime injection controls. +#define CONFIG_SAVE_WRITE ShortWrite +#define CONFIG_SAVE_CLOSE FailedClose +#include "../mods/src/config_save.cc" + +#include +#include +#include + +int main(int argc, char** argv) +{ + assert(argc == 2); + const std::filesystem::path root(argv[1]); + std::filesystem::create_directories(root); + const auto path = root / "settings.toml"; + const std::string original = "# keep this exactly\nenabled = false\n"; + { + std::ofstream out(path, std::ios::binary); + out << original; + } + for (bool closeFailure : {false, true}) { + failClose = closeFailure; + bool failed = false; + try { + SaveConfigDocument(toml::table{{"enabled", true}}, path); + } catch (const std::system_error&) { + failed = true; + } + assert(failed); + std::ifstream input(path, std::ios::binary); + const std::string actual(std::istreambuf_iterator{input}, {}); + assert(actual == original); + for (const auto& entry : std::filesystem::directory_iterator(root)) { + assert(entry.path() == path); + } + } + std::cout << "Short-write and failed-close fixtures passed\n"; +} diff --git a/tests/run-config-save.ps1 b/tests/run-config-save.ps1 index f41c44ffd..bdb7aa29b 100644 --- a/tests/run-config-save.ps1 +++ b/tests/run-config-save.ps1 @@ -20,6 +20,26 @@ try { $fixtureRoot = Join-Path $repoRoot ('build/config-save-test/' + [guid]::NewGuid()) & ./build/config-save-test/test.exe $fixtureRoot if ($LASTEXITCODE -ne 0) { throw 'Config save regression failed.' } + $testFile = Join-Path $fixtureRoot 'settings.toml' + $inherited = (Get-Acl -LiteralPath $testFile).Sddl + & ./build/config-save-test/test.exe $fixtureRoot + if ($LASTEXITCODE -ne 0 -or (Get-Acl -LiteralPath $testFile).Sddl -ne $inherited) { + throw 'Inherited ACL regression failed.' + } + $acl = Get-Acl -LiteralPath $testFile + $acl.SetAccessRuleProtection($true, $true) + Set-Acl -LiteralPath $testFile -AclObject $acl + $explicit = (Get-Acl -LiteralPath $testFile).Sddl + & ./build/config-save-test/test.exe $fixtureRoot + if ($LASTEXITCODE -ne 0 -or (Get-Acl -LiteralPath $testFile).Sddl -ne $explicit) { + throw 'Explicit ACL regression failed.' + } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/config_save_failure_test.cc /Febuild/config-save-test/failure-test.exe ` + /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'Config failure test compilation failed.' } + & ./build/config-save-test/failure-test.exe (Join-Path $fixtureRoot 'failures') + if ($LASTEXITCODE -ne 0) { throw 'Config failure regression failed.' } } finally { Pop-Location } From a8ed7bc77b96dcf77ea90fa3cf6678e9e96c06c8 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 11 Sep 2026 20:19:38 -0500 Subject: [PATCH 05/20] Capture inherited permissions before the first save --- tests/run-config-save.ps1 | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/run-config-save.ps1 b/tests/run-config-save.ps1 index bdb7aa29b..1fbcfada1 100644 --- a/tests/run-config-save.ps1 +++ b/tests/run-config-save.ps1 @@ -3,6 +3,18 @@ param([string]$TomlInclude) $ErrorActionPreference = 'Stop' $repoRoot = Split-Path -Parent $PSScriptRoot +function Get-PermissionState([string]$Path) { + $acl = Get-Acl -LiteralPath $Path + # Windows can normalize descriptor control bits; compare actual rules, + # ownership and inheritance protection rather than serialized SDDL spelling. + [ordered]@{ + Owner = $acl.Owner + Group = $acl.Group + Protected = $acl.AreAccessRulesProtected + Rules = @($acl.Access | Select-Object IdentityReference, FileSystemRights, + AccessControlType, IsInherited, InheritanceFlags, PropagationFlags) + } | ConvertTo-Json -Depth 5 -Compress +} Push-Location $repoRoot try { if (-not $TomlInclude) { @@ -18,20 +30,24 @@ try { /Fobuild/config-save-test/ -Wno-deprecated-literal-operator if ($LASTEXITCODE -ne 0) { throw 'Config save test compilation failed.' } $fixtureRoot = Join-Path $repoRoot ('build/config-save-test/' + [guid]::NewGuid()) - & ./build/config-save-test/test.exe $fixtureRoot - if ($LASTEXITCODE -ne 0) { throw 'Config save regression failed.' } + # Establish the baseline independently, before the first production save. + New-Item -ItemType Directory -Path $fixtureRoot | Out-Null $testFile = Join-Path $fixtureRoot 'settings.toml' - $inherited = (Get-Acl -LiteralPath $testFile).Sddl + Set-Content -LiteralPath $testFile -Value 'enabled = false' + if (-not ((Get-Acl -LiteralPath $testFile).Access | Where-Object IsInherited)) { + throw 'Fixture must have inherited permission entries.' + } + $inherited = Get-PermissionState $testFile & ./build/config-save-test/test.exe $fixtureRoot - if ($LASTEXITCODE -ne 0 -or (Get-Acl -LiteralPath $testFile).Sddl -ne $inherited) { + if ($LASTEXITCODE -ne 0 -or (Get-PermissionState $testFile) -ne $inherited) { throw 'Inherited ACL regression failed.' } $acl = Get-Acl -LiteralPath $testFile $acl.SetAccessRuleProtection($true, $true) Set-Acl -LiteralPath $testFile -AclObject $acl - $explicit = (Get-Acl -LiteralPath $testFile).Sddl + $explicit = Get-PermissionState $testFile & ./build/config-save-test/test.exe $fixtureRoot - if ($LASTEXITCODE -ne 0 -or (Get-Acl -LiteralPath $testFile).Sddl -ne $explicit) { + if ($LASTEXITCODE -ne 0 -or (Get-PermissionState $testFile) -ne $explicit) { throw 'Explicit ACL regression failed.' } & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` From 4d1a476962fee61a0be2929ba7baef5d7a35adc0 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 11 Sep 2026 20:20:05 -0500 Subject: [PATCH 06/20] Retain missing-file creation coverage alongside ACL fixtures --- tests/run-config-save.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/run-config-save.ps1 b/tests/run-config-save.ps1 index 1fbcfada1..6c80cd0e5 100644 --- a/tests/run-config-save.ps1 +++ b/tests/run-config-save.ps1 @@ -30,6 +30,9 @@ try { /Fobuild/config-save-test/ -Wno-deprecated-literal-operator if ($LASTEXITCODE -ne 0) { throw 'Config save test compilation failed.' } $fixtureRoot = Join-Path $repoRoot ('build/config-save-test/' + [guid]::NewGuid()) + & ./build/config-save-test/test.exe (Join-Path $fixtureRoot 'created') + if ($LASTEXITCODE -ne 0) { throw 'Initial config creation regression failed.' } + $fixtureRoot = Join-Path $fixtureRoot 'permissions' # Establish the baseline independently, before the first production save. New-Item -ItemType Directory -Path $fixtureRoot | Out-Null $testFile = Join-Path $fixtureRoot 'settings.toml' From eb56fa0b430b5e010e28975664c7cf9b0398f384 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 11 Sep 2026 20:28:02 -0500 Subject: [PATCH 07/20] Run config-save fixtures on native Windows and macOS CI --- .github/workflows/ci.yaml | 20 ++++++++++++++++++++ docs/config-save.md | 5 +++++ tests/config_save_test.cc | 10 ++++++++++ tests/run-config-save.sh | 15 +++++++++++++++ 4 files changed, 50 insertions(+) create mode 100644 tests/run-config-save.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c4db85207..de14c8611 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -196,6 +196,16 @@ jobs: shell: pwsh run: sccache --show-stats + - name: Test startup config saves + shell: pwsh + env: + PACKAGE_DIR: ${{ steps.xmake_cache_paths.outputs.package_dir }} + run: | + $header = Get-ChildItem -LiteralPath (Join-Path $env:PACKAGE_DIR 't/toml++') -Recurse -Filter toml.h | + Where-Object { $_.Directory.Name -eq 'toml++' } | Select-Object -First 1 + if (-not $header) { throw 'Built toml++ package not found.' } + ./tests/run-config-save.ps1 -TomlInclude $header.Directory.Parent.FullName + - name: Package shell: pwsh run: | @@ -477,6 +487,16 @@ jobs: shell: bash run: sccache --show-stats + - name: Test startup config saves + shell: bash + env: + PACKAGE_DIR: ${{ steps.xmake_cache_paths.outputs.package_dir }} + run: | + set -euo pipefail + TOML_HEADER=$(find "$PACKAGE_DIR/t/toml++" -path '*/include/toml++/toml.h' -print -quit) + test -n "$TOML_HEADER" + bash tests/run-config-save.sh "$(dirname "$(dirname "$TOML_HEADER")")" + - name: Report Swift module cache shell: bash run: | diff --git a/docs/config-save.md b/docs/config-save.md index 357160b0d..2bc9367ec 100644 --- a/docs/config-save.md +++ b/docs/config-save.md @@ -29,6 +29,11 @@ Run the isolated Windows fixtures with `tests/run-config-save.ps1` after the normal AX build has installed toml++; `-TomlInclude` can select another include directory. Fixtures never access the installed game's files. +On macOS, run `bash tests/run-config-save.sh TOML_INCLUDE_DIR`. Both native macOS +CI jobs run these fixtures after the normal build, including permission-bit and +symlink checks. The failure fixture injects short writes and failed closes at +compile time; it does not install test controls in the mod. + Native behavior references: - [Windows ReplaceFileW](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew) - [POSIX rename](https://pubs.opengroup.org/onlinepubs/9799919799/functions/rename.html) diff --git a/tests/config_save_test.cc b/tests/config_save_test.cc index b25a3b5d5..c34c7c409 100644 --- a/tests/config_save_test.cc +++ b/tests/config_save_test.cc @@ -56,6 +56,16 @@ int main(int argc, char** argv) CloseHandle(handle); assert(failed); assert(toml::parse_file(path.string())["enabled"].value() == true); +#else + const auto mode = std::filesystem::perms::owner_read | std::filesystem::perms::owner_write; + std::filesystem::permissions(path, mode); + const auto link = root / "linked.toml"; + std::filesystem::create_symlink(path, link); + config.insert_or_assign("enabled", false); + SaveConfigDocument(config, link); + assert(std::filesystem::is_symlink(link)); + assert(toml::parse_file(path.string())["enabled"].value() == false); + assert(std::filesystem::status(path).permissions() == mode); #endif for (const auto& entry : std::filesystem::directory_iterator(root)) { assert(entry.path().filename().string().find(".tmp-") == std::string::npos); diff --git a/tests/run-config-save.sh b/tests/run-config-save.sh new file mode 100644 index 000000000..c785669f2 --- /dev/null +++ b/tests/run-config-save.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Pass the include directory of the toml++ package used by the normal build. +toml_include="${1:?usage: run-config-save.sh TOML_INCLUDE_DIR}" +cd "$(dirname "$0")/.." +mkdir -p build/config-save-test +test_root="$(mktemp -d "$PWD/build/config-save-test/run-XXXXXX")" + +clang++ -std=c++23 -I mods/src -I "$toml_include" \ + tests/config_save_test.cc mods/src/config_save.cc -o "$test_root/test" +"$test_root/test" "$test_root/ordinary" +clang++ -std=c++23 -I mods/src -I "$toml_include" \ + tests/config_save_failure_test.cc -o "$test_root/failure-test" +"$test_root/failure-test" "$test_root/failures" From 227fb354a1cd6ddacfef0751cd11621006723e9e Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 13 Sep 2026 03:44:44 -0500 Subject: [PATCH 08/20] Contain keyboard diagnostic save failures at the input boundary --- mods/src/patches/keyboard_layout.cc | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/mods/src/patches/keyboard_layout.cc b/mods/src/patches/keyboard_layout.cc index 140e33aad..350c5d371 100644 --- a/mods/src/patches/keyboard_layout.cc +++ b/mods/src/patches/keyboard_layout.cc @@ -6,6 +6,7 @@ #include "keyboard_layout_notifications.h" #include "keyboard_layout_windows.h" #include "str_utils.h" +#include #include namespace keyboard_layout @@ -33,8 +34,18 @@ namespace { ++generation; if (vars_ready) { - WriteDiagnostics(vars_snapshot); - Config::Save(vars_snapshot, File::Vars()); + // Diagnostics are best effort: a checked save must not unwind an input callback. + try { + WriteDiagnostics(vars_snapshot); + Config::Save(vars_snapshot, File::Vars()); + } + catch (const std::exception& error) { + static bool reported = false; + if (!reported) { + reported = true; + spdlog::warn("[KeyboardLayout] Could not save diagnostics: {}", error.what()); + } + } } spdlog::info("[KeyboardLayout] status={} layout='{}' generation={} reason={}", status, layout_name, generation, reason); From 24b9a538af0ba9ee00295880974963ae69b4f4f3 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 13 Sep 2026 04:08:28 -0500 Subject: [PATCH 09/20] Retry CI after runner acquisition failure From fd15fb53245172579e570af433c892074434d1e5 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 21 Sep 2026 20:44:10 -0500 Subject: [PATCH 10/20] Add shared IL2CPP runtime boundary helpers with contract tests --- .github/workflows/ci.yaml | 12 +++ mods/src/il2cpp/il2cpp_helper.h | 6 +- mods/src/il2cpp/runtime.h | 59 +++++++++++++++ tests/il2cpp_runtime.cc | 127 ++++++++++++++++++++++++++++++++ xmake.lua | 11 +++ 5 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 mods/src/il2cpp/runtime.h create mode 100644 tests/il2cpp_runtime.cc diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 61cf42826..ff6de6ff7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -189,6 +189,13 @@ jobs: shell: pwsh run: sccache --zero-stats + - name: Test IL2CPP runtime helpers + run: | + xmake build -y il2cpp-runtime-tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + xmake run il2cpp-runtime-tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Build run: xmake build -y stfc-community-mod @@ -469,6 +476,11 @@ jobs: shell: bash run: sccache --zero-stats + - name: Test IL2CPP runtime helpers + run: | + xmake build -y il2cpp-runtime-tests + xmake run il2cpp-runtime-tests + - name: Build # -D keeps the existing XMake compiler-cache diagnostics too. run: xmake -y -D diff --git a/mods/src/il2cpp/il2cpp_helper.h b/mods/src/il2cpp/il2cpp_helper.h index 3d4ff6e3f..19708b2cd 100644 --- a/mods/src/il2cpp/il2cpp_helper.h +++ b/mods/src/il2cpp/il2cpp_helper.h @@ -337,10 +337,10 @@ class IL2CppClassHelper inline IL2CppClassHelper il2cpp_get_class_helper_impl(const char* assembly, const char* namespacez, const char* name) { auto domain = il2cpp_domain_get(); - auto assemblyT = il2cpp_domain_assembly_open(domain, assembly); - auto image = il2cpp_assembly_get_image(assemblyT); + auto assemblyT = domain ? il2cpp_domain_assembly_open(domain, assembly) : nullptr; + auto image = assemblyT ? il2cpp_assembly_get_image(assemblyT) : nullptr; - auto cls = il2cpp_class_from_name(image, namespacez, name); + auto cls = image ? il2cpp_class_from_name(image, namespacez, name) : nullptr; return IL2CppClassHelper{cls}; } diff --git a/mods/src/il2cpp/runtime.h b/mods/src/il2cpp/runtime.h new file mode 100644 index 000000000..8617cedb1 --- /dev/null +++ b/mods/src/il2cpp/runtime.h @@ -0,0 +1,59 @@ +#pragma once + +#include "il2cpp_helper.h" +#include + +// Mechanics shared by optional runtime features. Signature/overload selection, +// argument storage and feature-specific failure policy remain with the caller. +namespace Il2CppRuntime +{ +inline Il2CppClass* Class(const char* assembly, const char* ns, const char* name) +{ return il2cpp_get_class_helper(assembly, ns, name).get_cls(); } + +inline const MethodInfo* Method(Il2CppClass* cls, const char* name, int count) +{ return IL2CppClassHelper(cls).GetMethodInfo(name, count); } + +inline bool Type(const Il2CppType* type, int expected) +{ return type && !type->byref && type->type == expected; } + +inline bool Reference(const Il2CppType* type) +{ + return Type(type, IL2CPP_TYPE_CLASS) || Type(type, IL2CPP_TYPE_GENERICINST) || Type(type, IL2CPP_TYPE_OBJECT) + || Type(type, IL2CPP_TYPE_STRING); +} + +inline bool Instance(const MethodInfo* method, int count, int result) +{ + return method && method->methodPointer && method->invoker_method && !(method->flags & METHOD_ATTRIBUTE_STATIC) + && method->parameters_count == count && Type(method->return_type, result) + && !method->has_full_generic_sharing_signature; +} + +// The method and argument ABI must already be established by the caller. +// IL2CPP takes references directly, but value/byref arguments as addresses. +// A null target is valid for static methods. Success is independent of whether +// the return value is null (including void methods). Outputs change on success only. +inline bool TryInvoke(const MethodInfo* method, void* target, void** args, Il2CppObject** result = nullptr) +{ + if (!method) + return false; + Il2CppException* exception = nullptr; + auto* value = il2cpp_runtime_invoke(method, target, args, &exception); + if (exception) + return false; + if (result) + *result = value; + return true; +} + +inline bool TryBoolean(Il2CppObject* boxed, bool& value) +{ + if (!boxed || !boxed->klass || !Type(il2cpp_class_get_type(boxed->klass), IL2CPP_TYPE_BOOLEAN)) + return false; + auto* data = static_cast(il2cpp_object_unbox(boxed)); + if (!data) + return false; + value = *data; + return true; +} +} // namespace Il2CppRuntime diff --git a/tests/il2cpp_runtime.cc b/tests/il2cpp_runtime.cc new file mode 100644 index 000000000..1b7113340 --- /dev/null +++ b/tests/il2cpp_runtime.cc @@ -0,0 +1,127 @@ +// Production helpers with a controlled IL2CPP boundary, on Windows and macOS. +#include +#if _WIN32 +#undef IL2CPP_IMPORT +#define IL2CPP_IMPORT +#endif +#include "il2cpp/runtime.h" +#include +#include +#include + +namespace +{ +Il2CppClass klass; +Il2CppType type{}; +Il2CppObject object{}; +MethodInfo method{}; +int stage = 3, calls = 0; +bool fail = false, boxed = false, null_result = false; +void* seen_target = nullptr; +void** seen_args = nullptr; +} // namespace +#if _WIN32 +#define API(ret, name, params) extern "C" ret name params +#define END_API +#else +#define API(ret, name, params) name##_t name = +[] params->ret +#define END_API ; +#endif +API(Il2CppDomain*, il2cpp_domain_get, ()) +{ return stage >= 0 ? reinterpret_cast(&object) : nullptr; } +END_API +API(const Il2CppAssembly*, il2cpp_domain_assembly_open, (Il2CppDomain * domain, const char*)) +{ + if (!domain) + std::abort(); + return stage >= 1 ? reinterpret_cast(&object) : nullptr; +} +END_API +API(const Il2CppImage*, il2cpp_assembly_get_image, (const Il2CppAssembly* assembly)) +{ + if (!assembly) + std::abort(); + return stage >= 2 ? reinterpret_cast(&object) : nullptr; +} +END_API +API(Il2CppClass*, il2cpp_class_from_name, (const Il2CppImage* image, const char*, const char*)) +{ + if (!image) + std::abort(); + return stage >= 3 ? &klass : nullptr; +} +END_API +API(const MethodInfo*, il2cpp_class_get_method_from_name, (Il2CppClass * cls, const char*, int)) +{ + if (!cls) + std::abort(); + return &method; +} +END_API +API(const Il2CppType*, il2cpp_class_get_type, (Il2CppClass*)) +{ return &type; } +END_API +API(void*, il2cpp_object_unbox, (Il2CppObject*)) +{ return &boxed; } +END_API +API(Il2CppObject*, il2cpp_runtime_invoke, (const MethodInfo*, void* target, void** args, Il2CppException** error)) +{ + ++calls; + seen_target = target; + seen_args = args; + if (fail) + *error = reinterpret_cast(&object); + return null_result ? nullptr : &object; +} +END_API +void Require(bool condition) +{ + if (!condition) + throw std::runtime_error("runtime helper regression"); +} +int main() +{ + for (stage = -1; stage < 3; ++stage) + Require(!Il2CppRuntime::Class("Assembly", "Namespace", "Class")); + Require(Il2CppRuntime::Class("Assembly", "Namespace", "Class") == &klass); + Require(!Il2CppRuntime::Method(nullptr, "Method", 0)); + Require(Il2CppRuntime::Method(&klass, "Method", 0) == &method); + + Il2CppObject* result = &object; + Require(!Il2CppRuntime::TryInvoke(nullptr, nullptr, nullptr, &result) && calls == 0 && result == &object); + bool value = true; + void* args[] = {&value, &object, nullptr}; + Require(Il2CppRuntime::TryInvoke(&method, &object, args, &result)); + Require(seen_target == &object && seen_args == args && seen_args[0] == &value && seen_args[1] == &object); + // Static calls, null reference/void returns and exceptions remain distinct. + null_result = true; + Require(Il2CppRuntime::TryInvoke(&method, nullptr, args, &result) && !result && !seen_target); + result = &object; + fail = true; + Require(!Il2CppRuntime::TryInvoke(&method, nullptr, args, &result) && result == &object); + + object.klass = &klass; + type.type = IL2CPP_TYPE_BOOLEAN; + Require(Il2CppRuntime::TryBoolean(&object, value) && !value); + boxed = true; + Require(Il2CppRuntime::TryBoolean(&object, value) && value); + type.type = IL2CPP_TYPE_I4; + Require(!Il2CppRuntime::TryBoolean(&object, value) && value); + Require(!Il2CppRuntime::TryBoolean(nullptr, value)); + type.type = IL2CPP_TYPE_CLASS; + Require(Il2CppRuntime::Reference(&type)); + type.byref = true; + Require(!Il2CppRuntime::Reference(&type)); + type.byref = false; + type.type = IL2CPP_TYPE_VOID; + method.methodPointer = reinterpret_cast(1); + method.invoker_method = reinterpret_cast(1); + method.return_type = &type; + Require(Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); + method.flags = METHOD_ATTRIBUTE_STATIC; + Require(!Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); + method.flags = 0; + method.has_full_generic_sharing_signature = true; + Require(!Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); + std::cout << "IL2CPP runtime helper regressions passed\n"; +} diff --git a/xmake.lua b/xmake.lua index 9b25acbfb..ac08005a2 100644 --- a/xmake.lua +++ b/xmake.lua @@ -26,3 +26,14 @@ add_rules("mode.releasedbg") includes("xmake/rules/protobuf_sccache.lua") includes("xmake/rules/cxx_sccache.lua") includes("mods") + +target("il2cpp-runtime-tests") + set_kind("binary") + set_default(false) + add_files("tests/il2cpp_runtime.cc") + add_includedirs("mods/src") + add_packages("libil2cpp", "eastl") + set_exceptions("cxx") + if is_plat("windows") then + add_linkdirs("mods/src/il2cpp") + end From 733997cfe2cd34c9203b61cff755046ca9937643 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 21 Sep 2026 22:27:13 -0500 Subject: [PATCH 11/20] Narrow shared invocation helpers around existing loading screens --- mods/src/il2cpp/il2cpp_helper.h | 6 +- mods/src/il2cpp/runtime.h | 27 ++----- .../src/patches/parts/loading_screen_common.h | 16 ++--- tests/il2cpp_runtime.cc | 70 ++++--------------- 4 files changed, 28 insertions(+), 91 deletions(-) diff --git a/mods/src/il2cpp/il2cpp_helper.h b/mods/src/il2cpp/il2cpp_helper.h index 19708b2cd..3d4ff6e3f 100644 --- a/mods/src/il2cpp/il2cpp_helper.h +++ b/mods/src/il2cpp/il2cpp_helper.h @@ -337,10 +337,10 @@ class IL2CppClassHelper inline IL2CppClassHelper il2cpp_get_class_helper_impl(const char* assembly, const char* namespacez, const char* name) { auto domain = il2cpp_domain_get(); - auto assemblyT = domain ? il2cpp_domain_assembly_open(domain, assembly) : nullptr; - auto image = assemblyT ? il2cpp_assembly_get_image(assemblyT) : nullptr; + auto assemblyT = il2cpp_domain_assembly_open(domain, assembly); + auto image = il2cpp_assembly_get_image(assemblyT); - auto cls = image ? il2cpp_class_from_name(image, namespacez, name) : nullptr; + auto cls = il2cpp_class_from_name(image, namespacez, name); return IL2CppClassHelper{cls}; } diff --git a/mods/src/il2cpp/runtime.h b/mods/src/il2cpp/runtime.h index 8617cedb1..b3043673e 100644 --- a/mods/src/il2cpp/runtime.h +++ b/mods/src/il2cpp/runtime.h @@ -7,28 +7,6 @@ // argument storage and feature-specific failure policy remain with the caller. namespace Il2CppRuntime { -inline Il2CppClass* Class(const char* assembly, const char* ns, const char* name) -{ return il2cpp_get_class_helper(assembly, ns, name).get_cls(); } - -inline const MethodInfo* Method(Il2CppClass* cls, const char* name, int count) -{ return IL2CppClassHelper(cls).GetMethodInfo(name, count); } - -inline bool Type(const Il2CppType* type, int expected) -{ return type && !type->byref && type->type == expected; } - -inline bool Reference(const Il2CppType* type) -{ - return Type(type, IL2CPP_TYPE_CLASS) || Type(type, IL2CPP_TYPE_GENERICINST) || Type(type, IL2CPP_TYPE_OBJECT) - || Type(type, IL2CPP_TYPE_STRING); -} - -inline bool Instance(const MethodInfo* method, int count, int result) -{ - return method && method->methodPointer && method->invoker_method && !(method->flags & METHOD_ATTRIBUTE_STATIC) - && method->parameters_count == count && Type(method->return_type, result) - && !method->has_full_generic_sharing_signature; -} - // The method and argument ABI must already be established by the caller. // IL2CPP takes references directly, but value/byref arguments as addresses. // A null target is valid for static methods. Success is independent of whether @@ -48,7 +26,10 @@ inline bool TryInvoke(const MethodInfo* method, void* target, void** args, Il2Cp inline bool TryBoolean(Il2CppObject* boxed, bool& value) { - if (!boxed || !boxed->klass || !Type(il2cpp_class_get_type(boxed->klass), IL2CPP_TYPE_BOOLEAN)) + if (!boxed || !boxed->klass) + return false; + const auto* type = il2cpp_class_get_type(boxed->klass); + if (!type || type->byref || type->type != IL2CPP_TYPE_BOOLEAN) return false; auto* data = static_cast(il2cpp_object_unbox(boxed)); if (!data) diff --git a/mods/src/patches/parts/loading_screen_common.h b/mods/src/patches/parts/loading_screen_common.h index 071b63976..4f78890c7 100644 --- a/mods/src/patches/parts/loading_screen_common.h +++ b/mods/src/patches/parts/loading_screen_common.h @@ -3,7 +3,7 @@ #include "config.h" #include "errormsg.h" -#include +#include #include #include @@ -28,9 +28,8 @@ struct FakeColor { float r, g, b, a; }; inline Il2CppObject* InvokeRuntime(const MethodInfo* method, void* target, void** args, const char* name) { if (!method) return nullptr; - Il2CppException* exception = nullptr; - Il2CppObject* result = il2cpp_runtime_invoke(method, target, args, &exception); - if (exception) { + Il2CppObject* result = nullptr; + if (!Il2CppRuntime::TryInvoke(method, target, args, &result)) { spdlog::warn("[LS] {} invocation failed", name); return nullptr; } @@ -40,9 +39,7 @@ inline Il2CppObject* InvokeRuntime(const MethodInfo* method, void* target, void* inline bool InvokeVoid(const MethodInfo* method, void* target, void** args, const char* name) { if (!method) return false; - Il2CppException* exception = nullptr; - il2cpp_runtime_invoke(method, target, args, &exception); - if (exception) { + if (!Il2CppRuntime::TryInvoke(method, target, args)) { spdlog::warn("[LS] {} invocation failed", name); return false; } @@ -52,9 +49,8 @@ inline bool InvokeVoid(const MethodInfo* method, void* target, void** args, cons inline bool InvokeBool(const MethodInfo* method, void* target, void** args, const char* name) { Il2CppObject* result = InvokeRuntime(method, target, args, name); - if (!result) return false; - void* value = il2cpp_object_unbox(result); - return value ? *reinterpret_cast(value) : false; + bool value = false; + return Il2CppRuntime::TryBoolean(result, value) && value; } inline int32_t InvokeInt32(const MethodInfo* method, void* target, int32_t fallback, const char* name) diff --git a/tests/il2cpp_runtime.cc b/tests/il2cpp_runtime.cc index 1b7113340..b098a2bbc 100644 --- a/tests/il2cpp_runtime.cc +++ b/tests/il2cpp_runtime.cc @@ -15,8 +15,8 @@ Il2CppClass klass; Il2CppType type{}; Il2CppObject object{}; MethodInfo method{}; -int stage = 3, calls = 0; -bool fail = false, boxed = false, null_result = false; +int calls = 0; +bool fail = false, boxed = false, null_result = false, null_type = false, null_unbox = false; void* seen_target = nullptr; void** seen_args = nullptr; } // namespace @@ -27,42 +27,11 @@ void** seen_args = nullptr; #define API(ret, name, params) name##_t name = +[] params->ret #define END_API ; #endif -API(Il2CppDomain*, il2cpp_domain_get, ()) -{ return stage >= 0 ? reinterpret_cast(&object) : nullptr; } -END_API -API(const Il2CppAssembly*, il2cpp_domain_assembly_open, (Il2CppDomain * domain, const char*)) -{ - if (!domain) - std::abort(); - return stage >= 1 ? reinterpret_cast(&object) : nullptr; -} -END_API -API(const Il2CppImage*, il2cpp_assembly_get_image, (const Il2CppAssembly* assembly)) -{ - if (!assembly) - std::abort(); - return stage >= 2 ? reinterpret_cast(&object) : nullptr; -} -END_API -API(Il2CppClass*, il2cpp_class_from_name, (const Il2CppImage* image, const char*, const char*)) -{ - if (!image) - std::abort(); - return stage >= 3 ? &klass : nullptr; -} -END_API -API(const MethodInfo*, il2cpp_class_get_method_from_name, (Il2CppClass * cls, const char*, int)) -{ - if (!cls) - std::abort(); - return &method; -} -END_API API(const Il2CppType*, il2cpp_class_get_type, (Il2CppClass*)) -{ return &type; } +{ return null_type ? nullptr : &type; } END_API API(void*, il2cpp_object_unbox, (Il2CppObject*)) -{ return &boxed; } +{ return null_unbox ? nullptr : &boxed; } END_API API(Il2CppObject*, il2cpp_runtime_invoke, (const MethodInfo*, void* target, void** args, Il2CppException** error)) { @@ -81,12 +50,6 @@ void Require(bool condition) } int main() { - for (stage = -1; stage < 3; ++stage) - Require(!Il2CppRuntime::Class("Assembly", "Namespace", "Class")); - Require(Il2CppRuntime::Class("Assembly", "Namespace", "Class") == &klass); - Require(!Il2CppRuntime::Method(nullptr, "Method", 0)); - Require(Il2CppRuntime::Method(&klass, "Method", 0) == &method); - Il2CppObject* result = &object; Require(!Il2CppRuntime::TryInvoke(nullptr, nullptr, nullptr, &result) && calls == 0 && result == &object); bool value = true; @@ -108,20 +71,17 @@ int main() type.type = IL2CPP_TYPE_I4; Require(!Il2CppRuntime::TryBoolean(&object, value) && value); Require(!Il2CppRuntime::TryBoolean(nullptr, value)); - type.type = IL2CPP_TYPE_CLASS; - Require(Il2CppRuntime::Reference(&type)); + type.type = IL2CPP_TYPE_BOOLEAN; type.byref = true; - Require(!Il2CppRuntime::Reference(&type)); - type.byref = false; - type.type = IL2CPP_TYPE_VOID; - method.methodPointer = reinterpret_cast(1); - method.invoker_method = reinterpret_cast(1); - method.return_type = &type; - Require(Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); - method.flags = METHOD_ATTRIBUTE_STATIC; - Require(!Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); - method.flags = 0; - method.has_full_generic_sharing_signature = true; - Require(!Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); + Require(!Il2CppRuntime::TryBoolean(&object, value) && value); + type.byref = false; + null_type = true; + Require(!Il2CppRuntime::TryBoolean(&object, value) && value); + null_type = false; + null_unbox = true; + Require(!Il2CppRuntime::TryBoolean(&object, value) && value); + null_unbox = false; + object.klass = nullptr; + Require(!Il2CppRuntime::TryBoolean(&object, value) && value); std::cout << "IL2CPP runtime helper regressions passed\n"; } From d6c10b7d0d0988d4438c488cfa9354524d820106 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 21 Sep 2026 22:28:55 -0500 Subject: [PATCH 12/20] Guard existing IL2CPP class lookup when metadata is unavailable --- .github/workflows/ci.yaml | 12 ++++++ mods/src/il2cpp/il2cpp_helper.h | 6 +-- tests/il2cpp_class_lookup.cc | 71 +++++++++++++++++++++++++++++++++ xmake.lua | 11 +++++ 4 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/il2cpp_class_lookup.cc diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 61cf42826..025c4356b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -189,6 +189,13 @@ jobs: shell: pwsh run: sccache --zero-stats + - name: Test IL2CPP class lookup + run: | + xmake build -y il2cpp-class-lookup-tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + xmake run il2cpp-class-lookup-tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Build run: xmake build -y stfc-community-mod @@ -469,6 +476,11 @@ jobs: shell: bash run: sccache --zero-stats + - name: Test IL2CPP class lookup + run: | + xmake build -y il2cpp-class-lookup-tests + xmake run il2cpp-class-lookup-tests + - name: Build # -D keeps the existing XMake compiler-cache diagnostics too. run: xmake -y -D diff --git a/mods/src/il2cpp/il2cpp_helper.h b/mods/src/il2cpp/il2cpp_helper.h index 3d4ff6e3f..19708b2cd 100644 --- a/mods/src/il2cpp/il2cpp_helper.h +++ b/mods/src/il2cpp/il2cpp_helper.h @@ -337,10 +337,10 @@ class IL2CppClassHelper inline IL2CppClassHelper il2cpp_get_class_helper_impl(const char* assembly, const char* namespacez, const char* name) { auto domain = il2cpp_domain_get(); - auto assemblyT = il2cpp_domain_assembly_open(domain, assembly); - auto image = il2cpp_assembly_get_image(assemblyT); + auto assemblyT = domain ? il2cpp_domain_assembly_open(domain, assembly) : nullptr; + auto image = assemblyT ? il2cpp_assembly_get_image(assemblyT) : nullptr; - auto cls = il2cpp_class_from_name(image, namespacez, name); + auto cls = image ? il2cpp_class_from_name(image, namespacez, name) : nullptr; return IL2CppClassHelper{cls}; } diff --git a/tests/il2cpp_class_lookup.cc b/tests/il2cpp_class_lookup.cc new file mode 100644 index 000000000..0d471ea00 --- /dev/null +++ b/tests/il2cpp_class_lookup.cc @@ -0,0 +1,71 @@ +// Production helpers with a controlled IL2CPP boundary, on Windows and macOS. +#include +#if _WIN32 +#undef IL2CPP_IMPORT +#define IL2CPP_IMPORT +#endif +#include "il2cpp/il2cpp_helper.h" +#include +#include +#include + +namespace +{ +Il2CppClass klass; +Il2CppObject object{}; +MethodInfo method{}; +int stage = 3; +} // namespace +#if _WIN32 +#define API(ret, name, params) extern "C" ret name params +#define END_API +#else +#define API(ret, name, params) name##_t name = +[] params->ret +#define END_API ; +#endif +API(Il2CppDomain*, il2cpp_domain_get, ()) +{ return stage >= 0 ? reinterpret_cast(&object) : nullptr; } +END_API +API(const Il2CppAssembly*, il2cpp_domain_assembly_open, (Il2CppDomain * domain, const char*)) +{ + if (!domain) + std::abort(); + return stage >= 1 ? reinterpret_cast(&object) : nullptr; +} +END_API +API(const Il2CppImage*, il2cpp_assembly_get_image, (const Il2CppAssembly* assembly)) +{ + if (!assembly) + std::abort(); + return stage >= 2 ? reinterpret_cast(&object) : nullptr; +} +END_API +API(Il2CppClass*, il2cpp_class_from_name, (const Il2CppImage* image, const char*, const char*)) +{ + if (!image) + std::abort(); + return stage >= 3 ? &klass : nullptr; +} +END_API +API(const MethodInfo*, il2cpp_class_get_method_from_name, (Il2CppClass * cls, const char*, int)) +{ + if (!cls) + std::abort(); + return &method; +} +END_API +void Require(bool condition) +{ + if (!condition) + throw std::runtime_error("class lookup regression"); +} +int main() +{ + for (stage = -1; stage < 3; ++stage) + Require(!il2cpp_get_class_helper("Assembly", "Namespace", "Class").get_cls()); + Require(il2cpp_get_class_helper("Assembly", "Namespace", "Class").get_cls() == &klass); + Require(!IL2CppClassHelper(nullptr).GetMethodInfo("Method", 0)); + Require(IL2CppClassHelper(&klass).GetMethodInfo("Method", 0) == &method); + + std::cout << "IL2CPP class lookup regressions passed\n"; +} diff --git a/xmake.lua b/xmake.lua index 9b25acbfb..cfbefafff 100644 --- a/xmake.lua +++ b/xmake.lua @@ -26,3 +26,14 @@ add_rules("mode.releasedbg") includes("xmake/rules/protobuf_sccache.lua") includes("xmake/rules/cxx_sccache.lua") includes("mods") + +target("il2cpp-class-lookup-tests") + set_kind("binary") + set_default(false) + add_files("tests/il2cpp_class_lookup.cc") + add_includedirs("mods/src") + add_packages("libil2cpp", "eastl") + set_exceptions("cxx") + if is_plat("windows") then + add_linkdirs("mods/src/il2cpp") + end From 25d78b535335ea1a83863d4220c2cb19ec4152db Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 20:11:52 -0500 Subject: [PATCH 13/20] Protect staged configuration contents and follow dangling links --- docs/config-save.md | 13 +++++-- mods/src/config_save.cc | 59 +++++++++++++++++++++++++----- tests/config_save_failure_test.cc | 61 +++++++++++++++++++++++++++++-- tests/config_save_test.cc | 8 ++++ 4 files changed, 124 insertions(+), 17 deletions(-) diff --git a/docs/config-save.md b/docs/config-save.md index 2bc9367ec..608636154 100644 --- a/docs/config-save.md +++ b/docs/config-save.md @@ -6,11 +6,15 @@ routing and the existing generated-file warning. Save errors are logged once by the caller; startup continues with the in-memory configuration. `SaveConfigDocument` serializes with toml++, parses the output before touching -disk, exclusively creates a sibling temporary file, checks writing and closing, +disk, exclusively creates a private sibling temporary file, checks writing and closing, and replaces the destination. No threads, frame callbacks, runtime controls or shutdown interception are installed. This is not the preserving TOML editor: whole-document saves do not merge concurrent setting changes or preserve comments. +Staging is private before writing and remains private after close: Windows uses +a protected owner/system DACL, and macOS uses mode `0600`. New documents keep +these private permissions. + Windows uses `ReplaceFileW` to preserve existing permissions and streams, with a temporary backup for its documented partial-failure cases. A missing destination falls back to a non-replacing move. The caller's startup existence check is not an @@ -18,8 +22,8 @@ exclusive create transaction. Ordinary failures clean up the temporary file; par replacement failures retain recovery files and report their location. The backup name is the reported temporary path plus `.bak`. Recovery is not automatic. macOS uses rename after copying the existing permission bits. Extended metadata -and hard-link identity are not preserved by that path. Existing symlinks are -resolved before staging. Replacement requires directory permissions in addition +and hard-link identity are not preserved by that path. Existing and dangling final symlinks are +resolved before staging; cyclic links fail without replacing the link. Replacement requires directory permissions in addition to any file access checks; it cannot exactly match an in-place overwrite. Successful close/replacement is not a guarantee against power loss. A forced exit @@ -32,7 +36,8 @@ directory. Fixtures never access the installed game's files. On macOS, run `bash tests/run-config-save.sh TOML_INCLUDE_DIR`. Both native macOS CI jobs run these fixtures after the normal build, including permission-bit and symlink checks. The failure fixture injects short writes and failed closes at -compile time; it does not install test controls in the mod. +compile time and checks staging permissions before/during writes and after close; +it does not install test controls in the mod. Native behavior references: - [Windows ReplaceFileW](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew) diff --git a/mods/src/config_save.cc b/mods/src/config_save.cc index 9e9a82b6d..70ef49c87 100644 --- a/mods/src/config_save.cc +++ b/mods/src/config_save.cc @@ -9,6 +9,13 @@ #if _WIN32 #include +#include +#include +#include +#pragma comment(lib, "advapi32.lib") +#else +#include +#include #endif // Compile-time substitutions are used only by the isolated failure fixture. @@ -29,24 +36,58 @@ void SaveConfigDocument(const toml::table& config, const std::filesystem::path& const auto bytes = output.str(); (void)toml::parse(bytes); - // Follow existing symlinks as the former ofstream save did. A sibling stays on - // the same filesystem. Exclusive creation avoids truncating another save's file. - const auto destination = std::filesystem::weakly_canonical(path); + // weakly_canonical alone leaves a dangling final symlink unresolved. + // Follow it before staging, preserving the former ofstream behavior. + auto destination = std::filesystem::weakly_canonical(path); + unsigned links = 0; + while (std::filesystem::is_symlink(std::filesystem::symlink_status(destination))) { + if (++links > 40) + throw std::filesystem::filesystem_error("config symlink cycle", path, + std::make_error_code(std::errc::too_many_symbolic_link_levels)); + auto target = std::filesystem::read_symlink(destination); + destination = std::filesystem::weakly_canonical(target.is_absolute() ? target : destination.parent_path() / target); + } static std::atomic sequence{0}; auto temporary = destination; temporary += ".tmp-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + "-" + std::to_string(sequence.fetch_add(1, std::memory_order_relaxed)); - // C11 exclusive creation avoids depending on newer libc++ fstream runtime - // support on our minimum supported macOS version. -#if _WIN32 + // Configs may contain tokens. Protect staging at creation, before any bytes, + // even if the destination is private beneath a more permissive directory. std::FILE* file = nullptr; - _wfopen_s(&file, temporary.c_str(), L"wbx"); +#if _WIN32 + PSECURITY_DESCRIPTOR security = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + L"D:P(A;;FA;;;OW)(A;;FA;;;SY)", SDDL_REVISION_1, &security, nullptr)) + throw std::system_error(GetLastError(), std::system_category(), "could not protect temporary config file"); + SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), security, FALSE}; + const auto handle = CreateFileW(temporary.c_str(), GENERIC_WRITE | READ_CONTROL, 0, &attributes, + CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr); + const auto create_error = GetLastError(); + LocalFree(security); + if (handle == INVALID_HANDLE_VALUE) + throw std::system_error(create_error, std::system_category(), "could not create temporary config file"); + const auto descriptor = _open_osfhandle(reinterpret_cast(handle), _O_WRONLY | _O_BINARY); + if (descriptor == -1) { + CloseHandle(handle); + } else { + file = _fdopen(descriptor, "wb"); + if (!file) + _close(descriptor); + } #else - auto* file = std::fopen(temporary.c_str(), "wbx"); + const auto descriptor = ::open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0600); + if (descriptor == -1) + throw std::system_error(errno, std::generic_category(), "could not create temporary config file"); + file = ::fdopen(descriptor, "wb"); + if (!file) + ::close(descriptor); #endif if (!file) { - throw std::system_error(errno, std::generic_category(), "could not create temporary config file"); + const auto error = errno; + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + throw std::system_error(error, std::generic_category(), "could not open temporary config stream"); } bool replacing = false; diff --git a/tests/config_save_failure_test.cc b/tests/config_save_failure_test.cc index 8bc083c3c..aadc0ef50 100644 --- a/tests/config_save_failure_test.cc +++ b/tests/config_save_failure_test.cc @@ -1,14 +1,61 @@ #include #include +#include +#include +#if _WIN32 +#include +#include +#include +#include +#else +#include +#endif + +static std::filesystem::path staging; +static void CheckPrivateStaging(std::FILE* file) +{ +#if _WIN32 + PACL acl = nullptr; + PSECURITY_DESCRIPTOR security = nullptr; + const auto handle = file ? reinterpret_cast(_get_osfhandle(_fileno(file))) : nullptr; + const auto error = file ? GetSecurityInfo(handle, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, + nullptr, nullptr, &acl, nullptr, &security) + : GetNamedSecurityInfoW(const_cast(staging.c_str()), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, + nullptr, nullptr, &acl, nullptr, &security); + assert(error == ERROR_SUCCESS && acl && acl->AceCount == 2); + SECURITY_DESCRIPTOR_CONTROL control; + DWORD revision; + assert(GetSecurityDescriptorControl(security, &control, &revision) && (control & SE_DACL_PROTECTED)); + for (DWORD i = 0; i < acl->AceCount; ++i) { + void* entry = nullptr; + assert(GetAce(acl, i, &entry)); + const auto* ace = static_cast(entry); + assert(ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE && !(ace->Header.AceFlags & INHERITED_ACE)); + wchar_t* sid = nullptr; + assert(ConvertSidToStringSidW(const_cast(&ace->SidStart), &sid)); + assert(std::wstring_view(sid) == L"S-1-3-4" || std::wstring_view(sid) == L"S-1-5-18"); + LocalFree(sid); + } + LocalFree(security); +#else + struct stat status; + assert((file ? ::fstat(fileno(file), &status) : ::stat(staging.c_str(), &status)) == 0); + assert((status.st_mode & 0777) == 0600); +#endif +} static bool failClose = false; static std::size_t ShortWrite(const void* data, std::size_t size, std::size_t count, std::FILE* file) { - if (failClose) { - return std::fwrite(data, size, count, file); - } - const auto written = std::fwrite(data, size, count / 2, file); + for (const auto& entry : std::filesystem::directory_iterator(staging.parent_path())) + if (entry.path().filename().string().find(".tmp-") != std::string::npos) + staging = entry.path(); + CheckPrivateStaging(file); + const auto written = std::fwrite(data, size, failClose ? count : count / 2, file); + CheckPrivateStaging(file); + if (failClose) + return written; errno = ENOSPC; return written; } @@ -16,6 +63,7 @@ static std::size_t ShortWrite(const void* data, std::size_t size, std::size_t co static int FailedClose(std::FILE* file) { std::fclose(file); + CheckPrivateStaging(nullptr); errno = ENOSPC; return EOF; } @@ -35,11 +83,16 @@ int main(int argc, char** argv) const std::filesystem::path root(argv[1]); std::filesystem::create_directories(root); const auto path = root / "settings.toml"; + staging = path; const std::string original = "# keep this exactly\nenabled = false\n"; { std::ofstream out(path, std::ios::binary); out << original; } +#if !_WIN32 + ::umask(0022); + std::filesystem::permissions(path, std::filesystem::perms::owner_read | std::filesystem::perms::owner_write); +#endif for (bool closeFailure : {false, true}) { failClose = closeFailure; bool failed = false; diff --git a/tests/config_save_test.cc b/tests/config_save_test.cc index c34c7c409..c4f713548 100644 --- a/tests/config_save_test.cc +++ b/tests/config_save_test.cc @@ -66,6 +66,14 @@ int main(int argc, char** argv) assert(std::filesystem::is_symlink(link)); assert(toml::parse_file(path.string())["enabled"].value() == false); assert(std::filesystem::status(path).permissions() == mode); + for (bool relative : {false, true}) { + const auto target = root / (relative ? "relative-target.toml" : "absolute-target.toml"); + const auto dangling = root / (relative ? "relative-link.toml" : "absolute-link.toml"); + std::filesystem::create_symlink(relative ? target.filename() : std::filesystem::absolute(target), dangling); + SaveConfigDocument(config, dangling); + assert(std::filesystem::is_symlink(dangling)); + assert(toml::parse_file(target.string())["enabled"].value() == false); + } #endif for (const auto& entry : std::filesystem::directory_iterator(root)) { assert(entry.path().filename().string().find(".tmp-") == std::string::npos); From d9fcec1987fe2c0134f0c2a923472921ea320135 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 20:18:38 -0500 Subject: [PATCH 14/20] Give runtime persistence its own patch registry switch --- example_community_patch_settings_en.toml | 2 ++ mods/src/config.cc | 2 ++ mods/src/config.h | 1 + mods/src/defaultconfig.h | 1 + mods/src/patches/parts/hotkeys.cc | 1 - mods/src/patches/patches.cc | 2 ++ 6 files changed, 8 insertions(+), 1 deletion(-) diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index d1e47142d..bbeae9a03 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -263,6 +263,8 @@ loadingtiphooks = true doubleclickassignshiphooks = true forbiddentechconfirmhooks = true audioeventhooks = true +# Installs runtime config persistence independently of keyboard/native settings features. +runtimeconfighooks = true freeresizehooks = true game_version = true giftsbulkclaimhooks = true diff --git a/mods/src/config.cc b/mods/src/config.cc index fe5a6fa08..e112022fe 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -924,6 +924,8 @@ void Config::Load() get_config_or_default(config, parsed, "patches", "doubleclickassignshiphooks", DCP::doubleclickassignshiphooks, write_config); this->installForbiddenTechConfirmationHooks = get_config_or_default(config, parsed, "patches", "forbiddentechconfirmhooks", DCP::forbiddentechconfirmhooks, write_config); + this->installRuntimeConfigHooks = + get_config_or_default(config, parsed, "patches", "runtimeconfighooks", DCP::runtimeconfighooks, write_config); this->installAudioEventHooks = get_config_or_default(config, parsed, "patches", "audioeventhooks", DCP::audioeventhooks, write_config); this->installInstantCargoCounterHooks = diff --git a/mods/src/config.h b/mods/src/config.h index 2ab320ce8..bba82279d 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -278,6 +278,7 @@ class Config final bool installForbiddenTechConfirmationHooks; bool installInstantWarpConfirmationHooks; bool installAudioEventHooks; + bool installRuntimeConfigHooks; std::string config_settings_url; std::string config_assets_url_override; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index 10764b99f..61b47f4e9 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -94,6 +94,7 @@ namespace Patches constexpr bool doubleclickassignshiphooks = true; constexpr bool forbiddentechconfirmhooks = true; constexpr bool audioeventhooks = true; + constexpr bool runtimeconfighooks = true; constexpr bool instantcargocounterhooks = true; constexpr bool cargoformathooks = true; // on by default: cargo number precision override constexpr bool officersorthooks = true; // restore Below Deck Ability sort option diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index a57740c31..b7bfb6456 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -1741,7 +1741,6 @@ void InstallHotkeyHooks() InstallShortcutHintHooks(); install_screen_manager_update_hook(); - runtime_config::Install(); #ifdef _MODDBG fleet_watch::InstallRuntimeProbe(); #endif diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index d4e50a9d9..d3d492ee0 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -1,4 +1,5 @@ #include "patches.h" +#include "runtime_config.h" #include "file.h" #include "version.h" @@ -152,6 +153,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"InstantWarpConfirm", {InstallInstantWarpConfirmationHooks, &cfg.installInstantWarpConfirmationHooks}}, {"ForbiddenTechConfirm", {InstallForbiddenTechConfirmationHooks, &cfg.installForbiddenTechConfirmationHooks}}, {"AudioEvents", {InstallAudioEventHooks, &cfg.installAudioEventHooks}}, + {"RuntimeConfigHooks", {runtime_config::Install, &cfg.installRuntimeConfigHooks}}, }; printf("il2cpp_init_hook(%s)\n", domain_name); From 2395ba5f6599fd06942beffe0ad0212b0151754f Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 20:22:54 -0500 Subject: [PATCH 15/20] Reject settings path retargeting during staged edits --- docs/config-save.md | 4 ++-- mods/src/config_save.cc | 32 +++++++++++++++++++----------- tests/config_save_failure_test.cc | 33 +++++++++++++++++++++++++++++-- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/docs/config-save.md b/docs/config-save.md index c7b7ec72f..aae772254 100644 --- a/docs/config-save.md +++ b/docs/config-save.md @@ -92,8 +92,8 @@ changes. It uses the existing update dispatcher even if persistence setup failed The adapter conservatively retains failures from submissions it could not track. Settings UI wording and widgets belong to the consumers, not the writer. -The checked replacement re-reads the source after staging and rejects changed -bytes before commit. This is best-effort conflict detection, not an atomic +The checked replacement re-resolves the selected path and re-reads its source +after staging, rejecting a changed symlink target or changed bytes before commit. This is best-effort conflict detection, not an atomic compare-and-swap with arbitrary external editors: an external write can still race the final native replacement. File deletion is an I/O error, not permission to recreate the user's file from cached content. diff --git a/mods/src/config_save.cc b/mods/src/config_save.cc index 42c511258..e7e8685ad 100644 --- a/mods/src/config_save.cc +++ b/mods/src/config_save.cc @@ -27,6 +27,24 @@ #define CONFIG_SAVE_CLOSE std::fclose #endif +namespace +{ +std::filesystem::path ResolveConfigDestination(const std::filesystem::path& path) +{ + // Follow dangling final links too, preserving the former ofstream behavior. + auto destination = std::filesystem::weakly_canonical(path); + unsigned links = 0; + while (std::filesystem::is_symlink(std::filesystem::symlink_status(destination))) { + if (++links > 40) + throw std::filesystem::filesystem_error("config symlink cycle", path, + std::make_error_code(std::errc::too_many_symbolic_link_levels)); + auto target = std::filesystem::read_symlink(destination); + destination = std::filesystem::weakly_canonical(target.is_absolute() ? target : destination.parent_path() / target); + } + return destination; +} +} // namespace + void SaveConfigDocument(const toml::table& config, const std::filesystem::path& path, std::string_view header) { // Serialize and validate before opening any file. Values are encoded by toml++, @@ -60,17 +78,7 @@ bool ReplaceConfigText(const std::filesystem::path& path, std::string_view bytes { (void)toml::parse(bytes); - // weakly_canonical alone leaves a dangling final symlink unresolved. - // Follow it before staging, preserving the former ofstream behavior. - auto destination = std::filesystem::weakly_canonical(path); - unsigned links = 0; - while (std::filesystem::is_symlink(std::filesystem::symlink_status(destination))) { - if (++links > 40) - throw std::filesystem::filesystem_error("config symlink cycle", path, - std::make_error_code(std::errc::too_many_symbolic_link_levels)); - auto target = std::filesystem::read_symlink(destination); - destination = std::filesystem::weakly_canonical(target.is_absolute() ? target : destination.parent_path() / target); - } + const auto destination = ResolveConfigDestination(path); static std::atomic sequence{0}; auto temporary = destination; temporary += ".tmp-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + "-" @@ -126,7 +134,7 @@ bool ReplaceConfigText(const std::filesystem::path& path, std::string_view bytes } // Recheck after staging, immediately before commit. Another editor can still // race the native replacement; arbitrary external editors do not share our lock. - if (expected && ReadConfigText(path) != *expected) { + if (expected && (ResolveConfigDestination(path) != destination || ReadConfigText(destination) != *expected)) { std::error_code ignored; std::filesystem::remove(temporary, ignored); return false; diff --git a/tests/config_save_failure_test.cc b/tests/config_save_failure_test.cc index aadc0ef50..3cf6898b3 100644 --- a/tests/config_save_failure_test.cc +++ b/tests/config_save_failure_test.cc @@ -45,6 +45,10 @@ static void CheckPrivateStaging(std::FILE* file) } static bool failClose = false; +#if !_WIN32 +static bool retarget = false; +static std::filesystem::path retargetLink, retargetDestination; +#endif static std::size_t ShortWrite(const void* data, std::size_t size, std::size_t count, std::FILE* file) { @@ -52,9 +56,13 @@ static std::size_t ShortWrite(const void* data, std::size_t size, std::size_t co if (entry.path().filename().string().find(".tmp-") != std::string::npos) staging = entry.path(); CheckPrivateStaging(file); - const auto written = std::fwrite(data, size, failClose ? count : count / 2, file); + bool complete = failClose; +#if !_WIN32 + complete |= retarget; +#endif + const auto written = std::fwrite(data, size, complete ? count : count / 2, file); CheckPrivateStaging(file); - if (failClose) + if (complete) return written; errno = ENOSPC; return written; @@ -64,6 +72,13 @@ static int FailedClose(std::FILE* file) { std::fclose(file); CheckPrivateStaging(nullptr); +#if !_WIN32 + if (retarget) { + std::filesystem::remove(retargetLink); + std::filesystem::create_symlink(retargetDestination, retargetLink); + return 0; + } +#endif errno = ENOSPC; return EOF; } @@ -109,5 +124,19 @@ int main(int argc, char** argv) assert(entry.path() == path); } } +#if !_WIN32 + const auto other = root / "other.toml"; + { std::ofstream out(other); out << original; } + retargetLink = root / "selected.toml"; + retargetDestination = other.filename(); + std::filesystem::create_symlink(path.filename(), retargetLink); + staging = path; + retarget = true; + assert(!ReplaceConfigText(retargetLink, "enabled = true\n", original)); + assert(ReadConfigText(path) == original && ReadConfigText(other) == original); + assert(std::filesystem::read_symlink(retargetLink) == retargetDestination); + for (const auto& entry : std::filesystem::directory_iterator(root)) + assert(entry.path().filename().string().find(".tmp-") == std::string::npos); +#endif std::cout << "Short-write and failed-close fixtures passed\n"; } From 7f9ea43fd69c2299a0d751d9e8c169015196ca4d Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 20:34:48 -0500 Subject: [PATCH 16/20] Align settings groundwork documentation with current adapters --- docs/MOD_SETTINGS_FOUNDATION.md | 2 +- docs/MOD_SETTINGS_NAVIGATION.md | 43 +++++++++++++++++---------------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/docs/MOD_SETTINGS_FOUNDATION.md b/docs/MOD_SETTINGS_FOUNDATION.md index be0147672..e5a8408cb 100644 --- a/docs/MOD_SETTINGS_FOUNDATION.md +++ b/docs/MOD_SETTINGS_FOUNDATION.md @@ -78,7 +78,7 @@ label. The prefab must prove that those nodes are descendants of the row and do not contain the label; otherwise that UI is unsupported. Exact visual validation of this behavior remains a release gate. -Eight weak view records bound bookkeeping. Native contexts own rows/delegates; +Weak view records sized from the page plan bound bookkeeping. Native contexts own rows/delegates; there are no strong roots retaining historical settings pages. Native release clears records, with dead-record reclamation on binding as a fallback. A successful write refreshes other live framework views. No polling or file work is scheduled. diff --git a/docs/MOD_SETTINGS_NAVIGATION.md b/docs/MOD_SETTINGS_NAVIGATION.md index d1df7ae3f..346cea0eb 100644 --- a/docs/MOD_SETTINGS_NAVIGATION.md +++ b/docs/MOD_SETTINGS_NAVIGATION.md @@ -7,7 +7,7 @@ its stored setting or introduce another copy of its value. Confirmation controls continue to belong on the native confirmation page. `PageCatalog` holds stable page IDs, labels, parent IDs and references to existing -`BooleanSetting` instances. Parents register first; invalid parents, duplicate +`BooleanSetting`, `ChoiceSetting`, `SliderSetting` and `ActionSetting` adapters. Parents register first; invalid parents, duplicate pages and conflicting setting owners are rejected. The same setting can appear on different pages, with the same authoritative read/write adapter. Registration freezes at the first build. Definitions and setting owners outlive their views. @@ -24,35 +24,36 @@ duplicate roots within one context, and release any temporary roots on failure. Pooled widgets must clear owned label/state overrides before reuse. No setting registration may install an additional copy of an existing widget detour. -Current build261 metadata exposes both root and parent-taking `AddCategory` -overloads on `SettingsContext`, plus parent-taking toggle/selection builders. -The Windows bridge calls that native builder and adds boolean rows through the -existing confirmation adapter. It restores owned text overrides on category -unbind/rebind and page destruction; titles use the same scoped human-text override -as existing confirmation labels. No global localization hook is installed. -Four substantive category/page lifecycle hooks are installed only when registered -pages exist. Current x64 bodies are 366, 250, 572 and 608 bytes respectively, each -larger than SPUD's 24-byte overwrite. Other platforms omit the native UI pending -their own hook evidence. Metadata/builds alone do not validate presentation or -callback lifetime; repeated navigation/pooling remains a runtime gate. +The shared native adapter supports Windows x64 and macOS and creates boolean, +selection, slider and action rows through validated managed builders. It restores +owned text overrides on category unbind/rebind and page destruction, using scoped +human-text overrides without a global localization hook. Optional category/page, +heading and value hooks install only when their registered controls need them. + +Historical build261 measurements covered four category/page lifecycle methods. +Current Windows client270 static measurements cover those methods and the heading, +action, selection and slider families; every selected SPUD overwrite window fits +its method extent. Those disk measurements do not establish live relocation, +callback lifetime or native presentation. Exact artifact navigation/pooling smoke +and supported Mac native extent/execution evidence remain qualification gates. Register through `ModPages()` before settings installation. The production catalog is empty: no final group layout, settings placement or new preference is shipped by this infrastructure slice. This supersedes the earlier General > Community Mod placement proposal; native confirmation placement remains unchanged. -The current native bridge shares the `ModConfirmationSettings` patch installation -and its debug installation switch. Disabling that patch disables both native UI -surfaces. Settings retain their own identity and persistence independently of it. -The shared native adapter currently supports eight simultaneously bound mod -boolean rows across pages. Plan populated groups within that existing limit; -catalog registration does not itself guarantee native widget capacity. +The native adapter shares the `ModConfirmationSettings` registry entry, controlled +by default-enabled `[patches].nativesettingshooks` in all builds. Disabling it skips +native UI installation. Value records are sized from the registered page plan; +there is no fixed eight-row limit. Managed contexts own rows and delegates, while +weak records track bound views without retaining historical settings pages. Persistence stays with explicit feature adapters. A live mod change and its asynchronous save result are distinct; page construction never calls the TOML -writer. The current writer supports its one known mode setting. This work does -not add arbitrary TOML browsing, a second save worker, automatic config hot reload, -sliders/selection abstractions without a consumer, or speculative profiler options. +writer. The current writer registers only instant-warp mode. Its installation is owned +separately by `[patches].runtimeconfighooks`. Choice, slider and action adapters are +included as navigation groundwork; a populated consumer owns its registrations, +validation and persistence. Page construction does not register arbitrary TOML keys. Run `tests/run-settings.ps1` on Windows or `bash tests/run-settings.sh` on macOS. The catalog fixture covers repeated builds, empty branches, registration failures, From c8104d67b22ca334b208c4dc9c28f99028c27aab Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 20:43:15 -0500 Subject: [PATCH 17/20] Document independent installation controls for native settings --- docs/MOD_SETTINGS.md | 7 +++++-- docs/MOD_SETTINGS_CONTROLS.md | 10 ++++++---- docs/MOD_SETTINGS_NATIVE_ADAPTER.md | 5 +++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/MOD_SETTINGS.md b/docs/MOD_SETTINGS.md index 358e656bd..994d78bf9 100644 --- a/docs/MOD_SETTINGS.md +++ b/docs/MOD_SETTINGS.md @@ -15,8 +15,11 @@ Each game settings context gets a fresh tree from that immutable plan. The [native adapter map](MOD_SETTINGS_NATIVE_ADAPTER.md) identifies each hook owner. Interop, value widgets, action widgets, navigation and styling are separate concerns. Existing XMake source discovery builds them. Each detour has one owner. -The historical `ModConfirmationSettings` debug patch key remains compatible; -its C++ name is `installNativeSettings`. +The `ModConfirmationSettings` registry entry uses default-enabled +`[patches].nativesettingshooks` in every build; its C++ flag is +`installNativeSettings`. Persistence is independently controlled by +`[patches].runtimeconfighooks`. Forbidden Tech and fleet-label installation use +their owning patch switches, independent of native UI and feature values. ## Placement and summaries diff --git a/docs/MOD_SETTINGS_CONTROLS.md b/docs/MOD_SETTINGS_CONTROLS.md index d1aececa8..1f288d0ca 100644 --- a/docs/MOD_SETTINGS_CONTROLS.md +++ b/docs/MOD_SETTINGS_CONTROLS.md @@ -72,10 +72,12 @@ The native slider callbacks use the same typed snapshot/reentry guards as choice Unknown values suppress the slider and numeric label; disabled known values remain visible. Releasing a pooled widget restores its label, active state and interaction. -Windows installs the existing fleet-label and Forbidden Tech hooks when the mod -settings UI is enabled, so changing their values does not require a restart. -Each FT hook consults the current bypass flag; hook availability is separate from -the value. Other platforms retain startup-controlled installation and omit this UI. +The owning Zoom and Forbidden Tech patch switches control installation independently +of native settings and feature values. Supported callbacks install once; current +label profiles and the FT bypass flag are checked inside them, so live changes do +not require a restart. Windows x64 and macOS use shared adapters; native availability +requires validated metadata and completed hook families. Mac qualification remains +separate from the Windows evidence below. Confirmation ON means the bypass flag is false. Toggling must never invoke an upgrade callback by itself. diff --git a/docs/MOD_SETTINGS_NATIVE_ADAPTER.md b/docs/MOD_SETTINGS_NATIVE_ADAPTER.md index 6aecc3c72..6a49f1aae 100644 --- a/docs/MOD_SETTINGS_NATIVE_ADAPTER.md +++ b/docs/MOD_SETTINGS_NATIVE_ADAPTER.md @@ -36,8 +36,9 @@ installed by exactly one module. XMake's existing `src/**.cc` rule builds them. [the current state contract](MOD_SETTINGS.md). The entry point and member are `InstallNativeSettings` and -`Config::installNativeSettings`. The debug patch key `ModConfirmationSettings` -remains unchanged. Setting IDs, TOML keys and defaults remain stable while +`Config::installNativeSettings`. The registry entry name `ModConfirmationSettings` +remains stable, with default-enabled `[patches].nativesettingshooks` controlling +installation in every build. Setting IDs, TOML keys and defaults remain stable while presentation placement evolves. ## Validation From fe0e83d1b127514a359b8fe510e74f48f8260aa7 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 20:43:56 -0500 Subject: [PATCH 18/20] Clarify shared native settings platform implementation --- docs/MOD_SETTINGS_CONTROLS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/MOD_SETTINGS_CONTROLS.md b/docs/MOD_SETTINGS_CONTROLS.md index 1f288d0ca..4d4dafc55 100644 --- a/docs/MOD_SETTINGS_CONTROLS.md +++ b/docs/MOD_SETTINGS_CONTROLS.md @@ -19,7 +19,8 @@ See [the current architecture contract](MOD_SETTINGS.md) and | Future separate branch: Hotkeys | Rebind existing actions | Existing shortcut parser and `MapKey` registrations | | General > confirmation page | Confirm Forbidden Tech upgrades | Inverse of `ui.auto_confirm_ft_upgrade` | -The controls branch implements these controls on Windows x64. +The controls branch implements shared Windows x64 and macOS adapters; platform +qualification is recorded separately from implementation. Hotkey editing remains a separate branch. Native confirmation controls stay on the native page. FC retains its existing owner. @@ -43,7 +44,7 @@ Selected options use bold text and the native checkmark on a normal background, including instant warp and both Fleet Labels profiles. White fill is transient pressed feedback, not persistent selection or keyboard focus. The scoped adapter uses native sprites already rendered by settings rows and restores each Image's -previous override before pooling. A Windows-only `Selectable.DoStateTransition` +previous override before pooling. The shared `Selectable.DoStateTransition` hook observes input-state changes, calls the original once, then updates only owned selection rows. Other controls take the native path; there is no frame polling, animation replacement, asset loading or setting write in this hook. From 8fde63fc2d521d951d1f5c42082f3e118ed45b75 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 20:45:06 -0500 Subject: [PATCH 19/20] Align navigation notes with registered task pages --- docs/MOD_SETTINGS_NAVIGATION.md | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/docs/MOD_SETTINGS_NAVIGATION.md b/docs/MOD_SETTINGS_NAVIGATION.md index 3611306c9..798344008 100644 --- a/docs/MOD_SETTINGS_NAVIGATION.md +++ b/docs/MOD_SETTINGS_NAVIGATION.md @@ -5,9 +5,8 @@ control placement, conditional rows, summaries and native adapter ownership are documented in [Mod Settings](MOD_SETTINGS.md). This foundation separates presentation placement from a setting's owner. The -intended native path is Settings > Mod Settings > group > setting. Group names -and final membership are deliberately undecided; moving a control must not rename -its stored setting or introduce another copy of its value. Confirmation controls +intended native path is Settings > Mod Settings > group > setting. Current page membership follows the task layout in `MOD_SETTINGS.md`. Moving a +control must not rename its stored setting or introduce another copy of its value. Confirmation controls continue to belong on the native confirmation page. `PageCatalog` holds stable page IDs, labels, parent IDs and references to existing @@ -43,14 +42,12 @@ its method extent. Those disk measurements do not establish live relocation, callback lifetime or native presentation. Exact artifact navigation/pooling smoke and supported Mac native extent/execution evidence remain qualification gates. -Register through `ModPages()` before settings installation. The first production -groups follow populated TOML sections: User Interface > Instant warp mode shares -Alt+I's owner and persistence; Graphics > Fleet Labels places player/non-player -sections on one page, each with detail -choices and a percentage slider. Headings use native text-only rows with scoped -label overrides and optional row tints cleared on refresh/clear. Two text-widget hooks have Windows x64 -extents of 293 and 271 bytes. Future grouping follows the section-based direction in -[MOD_SETTINGS_CONTROLS.md](MOD_SETTINGS_CONTROLS.md). Native confirmation placement remains unchanged. +Register through `ModPages()` before settings installation. Current pages are +Camera, Fleet Labels, Map & Travel, and Previews & Cargo, as documented in +[Mod Settings](MOD_SETTINGS.md). Instant warp shares Alt+I's owner and persistence. +Fleet Labels places Player and Non-player sections on one page, each with detail +choices and a percentage slider. Native text-only headings use scoped labels and +row tints cleared on refresh/clear. Native confirmation placement stays unchanged. Selection controls share the typed setting/view guards with booleans and retain the whole integer value in each row snapshot. Three selection-widget hooks have verified Windows x64 extents of 146, 355 and 281 bytes. Selection prefabs may put From e69206b77affcabb31aebdfb7be8ec5b3bfbff50 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 21:15:44 -0500 Subject: [PATCH 20/20] Align shortcut docs with current popup and publication behavior --- docs/MOD_SETTINGS.md | 7 ++++--- docs/MOD_SETTINGS_CONTROLS.md | 15 ++++++++------- docs/MOD_SETTINGS_NAVIGATION.md | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/MOD_SETTINGS.md b/docs/MOD_SETTINGS.md index 262712fbb..0ef4454aa 100644 --- a/docs/MOD_SETTINGS.md +++ b/docs/MOD_SETTINGS.md @@ -76,9 +76,10 @@ unchanged. See [persistence contracts](config-save.md). ## Editor lifetime and native views -Shortcut changes, additions, removals and defaults stay drafts until Apply. -Restore uses the existing canonical default definition; `NONE` means unbound. -Overlap warnings allow keeping both, with Next to inspect each affected action. +Change and Add stay drafts until popup Confirm or Use anyway publishes the complete +action list. Remove, Restore and Undo publish immediately; there is no page-level +Apply step. Restore uses canonical defaults; `NONE` means unbound. Scrollable overlap +warnings are advisory and never remove another action's binding. Uncategorized gives newly registered actions an editor before presentation metadata is supplied; it does not discover arbitrary TOML values. See [shortcut contracts and extension guidance](MOD_SHORTCUT_SETTINGS.md). diff --git a/docs/MOD_SETTINGS_CONTROLS.md b/docs/MOD_SETTINGS_CONTROLS.md index 4d4dafc55..118013514 100644 --- a/docs/MOD_SETTINGS_CONTROLS.md +++ b/docs/MOD_SETTINGS_CONTROLS.md @@ -16,13 +16,13 @@ See [the current architecture contract](MOD_SETTINGS.md) and | Mod Settings > Fleet Labels | Non-player label detail and zoom threshold | `graphics.zoom_label_non_player_detail`, `graphics.zoom_label_non_player_threshold` | | Mod Settings > Camera | Keyboard zoom speed and pan glide | `graphics.keyboard_zoom_speed`, `graphics.system_pan_momentum_falloff` | | Mod Settings > Previews & Cargo | Locate/Recall while previewing; automatic cargo and target types | Existing preview/cargo keys in `[ui]` | -| Future separate branch: Hotkeys | Rebind existing actions | Existing shortcut parser and `MapKey` registrations | +| Mod Settings > Shortcuts | Rebind registered actions | Existing shortcut parser and `MapKey` registrations | | General > confirmation page | Confirm Forbidden Tech upgrades | Inverse of `ui.auto_confirm_ft_upgrade` | The controls branch implements shared Windows x64 and macOS adapters; platform qualification is recorded separately from implementation. -Hotkey editing remains a separate branch. Native confirmation -controls stay on the native page. FC retains its existing owner. +The shortcut editor shares that foundation and its existing dispatcher. Native +confirmation controls stay on the native page. FC retains its existing owner. ## Instant warp mode @@ -154,10 +154,11 @@ clearing is a separate operation and must not be called by this command. This is an idea only: the current branch adds neither restart-only controls nor a restart command. The ownership/relaunch details need their own design before implementation. -Hotkey editing follows the first real selection and persistence checks. Reuse the -current parser and binding map; add an explicit capture mode with Escape to cancel, -conflict feedback and a deliberate unbind action. Gameplay shortcuts must not fire -while a chord is being captured. Do not serialize display labels as key identities. +The delivered shortcut editor reuses the parser and binding map, with Escape to +cancel capture, overlap feedback and explicit Remove. Capture owns keyboard input +through release; display labels are never serialized as key identities. See +[shortcut contracts](MOD_SHORTCUT_SETTINGS.md) for current publication and undo +behavior before extending the editor. ## Runtime gate diff --git a/docs/MOD_SETTINGS_NAVIGATION.md b/docs/MOD_SETTINGS_NAVIGATION.md index 798344008..378da934e 100644 --- a/docs/MOD_SETTINGS_NAVIGATION.md +++ b/docs/MOD_SETTINGS_NAVIGATION.md @@ -43,7 +43,7 @@ callback lifetime or native presentation. Exact artifact navigation/pooling smok and supported Mac native extent/execution evidence remain qualification gates. Register through `ModPages()` before settings installation. Current pages are -Camera, Fleet Labels, Map & Travel, and Previews & Cargo, as documented in +Camera, Fleet Labels, Map & Travel, Previews & Cargo, and Shortcuts, as documented in [Mod Settings](MOD_SETTINGS.md). Instant warp shares Alt+I's owner and persistence. Fleet Labels places Player and Non-player sections on one page, each with detail choices and a percentage slider. Native text-only headings use scoped labels and