From 0ee0d593b25f98135184f6fa84de215308d8e729 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Wed, 9 Sep 2026 22:43:30 -0500 Subject: [PATCH 1/4] 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 2/4] 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 227fb354a1cd6ddacfef0751cd11621006723e9e Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 13 Sep 2026 03:44:44 -0500 Subject: [PATCH 3/4] 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 4/4] Retry CI after runner acquisition failure