From 263476c3709aa66cc6680fdc029b01321a4b2984 Mon Sep 17 00:00:00 2001 From: Dominik Madarasz Date: Wed, 30 Sep 2026 16:09:38 +0200 Subject: [PATCH 1/2] Input: Share native event state, physical polling and holds --- code/framework/src/input/button_state.h | 110 ++++++++++++ code/framework/src/input/input.h | 19 +- code/framework/src/input/physical_key_state.h | 58 +++++++ code/framework/src/input/resource_holds.h | 63 +++++++ code/framework/src/input/window_input.h | 131 ++++++++++++++ .../client/scripting/builtins/keybinds.cpp | 25 +-- code/tests/CMakeLists.txt | 10 ++ code/tests/framework_ut.cpp | 4 + code/tests/input_ut.cpp | 28 +++ code/tests/modules/input_state_ut.h | 163 ++++++++++++++++++ code/tests/modules/window_input_ut.h | 137 +++++++++++++++ docs/native_input.md | 77 +++++++++ 12 files changed, 799 insertions(+), 26 deletions(-) create mode 100644 code/framework/src/input/button_state.h create mode 100644 code/framework/src/input/physical_key_state.h create mode 100644 code/framework/src/input/resource_holds.h create mode 100644 code/framework/src/input/window_input.h create mode 100644 code/tests/input_ut.cpp create mode 100644 code/tests/modules/input_state_ut.h create mode 100644 code/tests/modules/window_input_ut.h create mode 100644 docs/native_input.md diff --git a/code/framework/src/input/button_state.h b/code/framework/src/input/button_state.h new file mode 100644 index 000000000..730af860b --- /dev/null +++ b/code/framework/src/input/button_state.h @@ -0,0 +1,110 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include +#include + +namespace Framework::Input { + // Event state: clear edges after every consumer, never between consumers. + template + class ButtonState final { + public: + bool IsDown(int index) const { + return Valid(index) && _down[index]; + } + bool IsUp(int index) const { + return Valid(index) && !_down[index]; + } + bool IsPressed(int index) const { + return Valid(index) && _pressed[index]; + } + bool IsReleased(int index) const { + return Valid(index) && _released[index]; + } + + void Set(int index, bool down, bool recordEdge = true) { + if (!Valid(index) || _down[index] == down) { + return; + } + _down[index] = down; + if (!recordEdge) { + return; + } + if (down) { + _pressed[index] = true; + } + else { + _released[index] = true; + } + } + + void ClearEdges() { + _pressed.fill(false); + _released.fill(false); + } + + void ReleaseAll() { + for (std::size_t index = 0; index < Size; ++index) { + Set(static_cast(index), false); + } + } + + private: + static bool Valid(int index) { + return index >= 0 && static_cast(index) < Size; + } + std::array _down {}; + std::array _pressed {}; + std::array _released {}; + }; + + // Sampled state: seed on acquisition/resume, so keys already held do not + // become new presses. A frozen device must not manufacture releases. + class KeySnapshot final { + public: + template + void Update(Reader &&read, bool available, bool stale = false) { + _keys.ClearEdges(); + _available = available && !stale; + if (!available) { + _keys.ReleaseAll(); + _keys.ClearEdges(); + _resync = true; + return; + } + if (stale) { + _resync = true; + return; + } + for (int key = 0; key < 256; ++key) { + _keys.Set(key, read(key)); + } + if (_resync) { + _keys.ClearEdges(); + } + _resync = false; + } + + bool IsDown(int key) const { + return _available && _keys.IsDown(key); + } + bool IsPressed(int key) const { + return _available && _keys.IsPressed(key); + } + bool IsReleased(int key) const { + return _available && _keys.IsReleased(key); + } + + private: + ButtonState<256> _keys; + bool _resync = true; + bool _available = false; + }; +} // namespace Framework::Input diff --git a/code/framework/src/input/input.h b/code/framework/src/input/input.h index 694fa026e..89f080368 100644 --- a/code/framework/src/input/input.h +++ b/code/framework/src/input/input.h @@ -13,12 +13,17 @@ #include "input_keymap.h" namespace Framework::Input { + enum class KeyCodeSpace { + LayoutVirtualKey, + PhysicalPosition + }; + class IInput { public: - virtual ~IInput() = default; + virtual ~IInput() = default; virtual void Update() = 0; - virtual void SetMousePosition(int x, int y) = 0; + virtual void SetMousePosition(int x, int y) = 0; virtual void GetMousePosition(int &x, int &y) const = 0; virtual void SetMouseVisible(bool visible) = 0; virtual bool IsMouseVisible() const = 0; @@ -27,12 +32,18 @@ namespace Framework::Input { virtual void SetInputLocked(bool locked) = 0; virtual bool IsInputLocked() const = 0; - // Opt-in: WndProc-fed modules can't answer a physical poll (no mouse VKs, no left/right - // modifiers, and keys latch when a focus loss eats their WM_KEYUP). + // Opt-in: a device provider can answer held state independently of window events. + // Event-only providers cannot discover keys already held on focus reacquisition. virtual bool ProvidesPhysicalKeyState() const { return false; } + // Legacy device adapters map layout VKs to scan codes. A provider + // storing physical-position codes must declare it to avoid translation twice. + virtual KeyCodeSpace GetKeyCodeSpace() const { + return KeyCodeSpace::LayoutVirtualKey; + } + // Device state frozen rather than idle; callers edge-detecting must hold, not latch. virtual bool IsStateStale() const { return false; diff --git a/code/framework/src/input/physical_key_state.h b/code/framework/src/input/physical_key_state.h new file mode 100644 index 000000000..1ae8d50a3 --- /dev/null +++ b/code/framework/src/input/physical_key_state.h @@ -0,0 +1,58 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include "button_state.h" +#include "input.h" +#include "physical_keys.h" + +namespace Framework::Input::PhysicalKeyState { + inline const IInput *Source(const IInput *input) { + return input && input->ProvidesPhysicalKeyState() ? input : nullptr; + } + + inline bool IsForeground() { + DWORD pid = 0; + ::GetWindowThreadProcessId(::GetForegroundWindow(), &pid); + return pid == ::GetCurrentProcessId(); + } + + // Key names denote US physical positions. Legacy device adapters accept + // layout VKs; adapters with physical codes explicitly declare that space. + inline bool IsDown(int key, const IInput *input = nullptr) { + if (key < 0 || key > 255) { + return false; + } + if (const auto *source = Source(input)) { + if (source->IsStateStale()) { + return false; + } + const UINT code = source->GetKeyCodeSpace() == KeyCodeSpace::PhysicalPosition ? static_cast(key) : PhysicalKeys::ToLayoutVirtualKey(static_cast(key), ::GetKeyboardLayout(0)); + return code != 0 && source->IsKeyDown(static_cast(code)); + } + DWORD pid = 0; + const DWORD thread = ::GetWindowThreadProcessId(::GetForegroundWindow(), &pid); + if (pid != ::GetCurrentProcessId()) { + return false; + } + const UINT code = PhysicalKeys::ToLayoutVirtualKey(static_cast(key), ::GetKeyboardLayout(thread)); + return code != 0 && (::GetAsyncKeyState(static_cast(code)) & 0x8000) != 0; + } + + // Passing no provider keeps UI hotkeys live while native game devices are + // paused. Calling code chooses its UI/gameplay gate separately. + inline void Update(KeySnapshot &snapshot, const IInput *input = nullptr) { + const auto *source = Source(input); + snapshot.Update( + [input](int key) { + return IsDown(key, input); + }, + IsForeground(), source && source->IsStateStale()); + } +} // namespace Framework::Input::PhysicalKeyState diff --git a/code/framework/src/input/resource_holds.h b/code/framework/src/input/resource_holds.h new file mode 100644 index 000000000..59777c3bc --- /dev/null +++ b/code/framework/src/input/resource_holds.h @@ -0,0 +1,63 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include +#include + +namespace Framework::Input { + // Counts only the named owner's holds. The game applies each transition + // and releases TakeAll(owner) holds when that resource stops. + class ResourceHolds final { + public: + void Acquire(const std::string &owner) { + ++_holds[owner]; + } + bool Release(const std::string &owner) { + const auto held = _holds.find(owner); + if (held == _holds.end()) { + return false; + } + if (--held->second == 0) { + _holds.erase(held); + } + return true; + } + int TakeAll(const std::string &owner) { + const auto held = _holds.find(owner); + if (held == _holds.end()) { + return 0; + } + const int count = held->second; + _holds.erase(held); + return count; + } + // Idempotent ownership for setEnabled-style APIs. + void Set(const std::string &owner, bool held) { + if (held && !IsHeld(owner)) { + Acquire(owner); + } + else if (!held) { + TakeAll(owner); + } + } + bool IsHeld(const std::string &owner) const { + return _holds.find(owner) != _holds.end(); + } + bool IsHeld() const { + return !_holds.empty(); + } + void Clear() { + _holds.clear(); + } + + private: + std::map _holds; + }; +} // namespace Framework::Input diff --git a/code/framework/src/input/window_input.h b/code/framework/src/input/window_input.h new file mode 100644 index 000000000..29c37e535 --- /dev/null +++ b/code/framework/src/input/window_input.h @@ -0,0 +1,131 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include "button_state.h" +#include "input.h" +#include "physical_keys.h" + +namespace Framework::Input { + // State acquisition only. Cursor presentation and native control locking + // remain on the game's adapter. This does not process WM_CHAR or IME. + class WindowInput: public IInput { + public: + void Update() override { + _keys.ClearEdges(); + _buttons.ClearEdges(); + } + + void GetMousePosition(int &x, int &y) const override { + x = _mouseX; + y = _mouseY; + } + + uint32_t MapKey(uint32_t key) const override { + return key; + } + KeyCodeSpace GetKeyCodeSpace() const override { + return KeyCodeSpace::PhysicalPosition; + } + bool IsKeyDown(int key) const override { + return _keys.IsDown(key); + } + bool IsKeyUp(int key) const override { + return _keys.IsUp(key); + } + bool IsKeyPressed(int key) const override { + return _keys.IsPressed(key); + } + bool IsKeyReleased(int key) const override { + return _keys.IsReleased(key); + } + bool IsMouseButtonDown(int button) const override { + return _buttons.IsDown(button); + } + bool IsMouseButtonUp(int button) const override { + return _buttons.IsUp(button); + } + bool IsMouseButtonPressed(int button) const override { + return _buttons.IsPressed(button); + } + bool IsMouseButtonReleased(int button) const override { + return _buttons.IsReleased(button); + } + + void ProcessEvent(HWND, UINT message, WPARAM key, LPARAM flags) { + switch (message) { + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + case WM_KEYUP: + case WM_SYSKEYUP: { + const bool down = message == WM_KEYDOWN || message == WM_SYSKEYDOWN; + const UINT physical = PhysicalKeys::FromKeyMessage(key, flags); + // Retain the generic modifier while either side is held. + UINT generic = physical; + UINT side = physical; + if (physical == VK_SHIFT) { + side = ((flags >> 16) & 0xFF) == 0x36 ? VK_RSHIFT : VK_LSHIFT; + } + else if (physical == VK_CONTROL) { + side = (flags & (1L << 24)) != 0 ? VK_RCONTROL : VK_LCONTROL; + } + else if (physical == VK_MENU) { + side = (flags & (1L << 24)) != 0 ? VK_RMENU : VK_LMENU; + } + if (physical == VK_LSHIFT || physical == VK_RSHIFT) + generic = VK_SHIFT; + else if (physical == VK_LCONTROL || physical == VK_RCONTROL) + generic = VK_CONTROL; + else if (physical == VK_LMENU || physical == VK_RMENU) + generic = VK_MENU; + const bool recordEdge = !down || (flags & (1L << 30)) == 0; + _keys.Set(static_cast(side), down, recordEdge); + if (side != generic) { + _keys.Set(static_cast(generic), _keys.IsDown(static_cast(side)) || _keys.IsDown(static_cast(side == VK_LSHIFT || side == VK_LCONTROL || side == VK_LMENU ? side + 1 : side - 1)), recordEdge); + } + break; + } + case WM_LBUTTONDOWN: SetButton(0, VK_LBUTTON, true); break; + case WM_LBUTTONUP: SetButton(0, VK_LBUTTON, false); break; + case WM_RBUTTONDOWN: SetButton(1, VK_RBUTTON, true); break; + case WM_RBUTTONUP: SetButton(1, VK_RBUTTON, false); break; + case WM_MBUTTONDOWN: SetButton(2, VK_MBUTTON, true); break; + case WM_MBUTTONUP: SetButton(2, VK_MBUTTON, false); break; + case WM_XBUTTONDOWN: + case WM_XBUTTONUP: { + const bool first = HIWORD(key) == XBUTTON1; + SetButton(first ? 3 : 4, first ? VK_XBUTTON1 : VK_XBUTTON2, message == WM_XBUTTONDOWN); + break; + } + case WM_MOUSEMOVE: + _mouseX = static_cast(LOWORD(flags)); + _mouseY = static_cast(HIWORD(flags)); + break; + case WM_KILLFOCUS: + // Discard pending presses too: a focus transfer must not fire + // a toggle which has not yet been consumed by the game tick. + Update(); + _keys.ReleaseAll(); + _buttons.ReleaseAll(); + break; + default: break; + } + } + + private: + void SetButton(int index, int key, bool down) { + _buttons.Set(index, down); + _keys.Set(key, down); + } + ButtonState<256> _keys; + ButtonState<5> _buttons; + int _mouseX = 0; + int _mouseY = 0; + }; +} // namespace Framework::Input diff --git a/code/framework/src/integrations/client/scripting/builtins/keybinds.cpp b/code/framework/src/integrations/client/scripting/builtins/keybinds.cpp index 54d539938..5ddfea777 100644 --- a/code/framework/src/integrations/client/scripting/builtins/keybinds.cpp +++ b/code/framework/src/integrations/client/scripting/builtins/keybinds.cpp @@ -18,7 +18,7 @@ #include #include -#include +#include #include #include @@ -57,30 +57,11 @@ namespace Framework::Integrations::Client::Scripting::Builtins { } const Input::IInput *PhysicalKeySource() { - const auto *input = CoreModules::GetInput(); - return input && input->ProvidesPhysicalKeyState() ? input : nullptr; + return Input::PhysicalKeyState::Source(CoreModules::GetInput()); } bool IsPhysicallyDown(int vk) { - if (const auto *input = PhysicalKeySource()) { - if (input->IsStateStale()) { - return false; - } - // Existing device providers accept layout VKs and map them to - // scan codes with MapVirtualKey on this thread. - const UINT layoutKey = Input::PhysicalKeys::ToLayoutVirtualKey(static_cast(vk), ::GetKeyboardLayout(0)); - return layoutKey != 0 && input->IsKeyDown(static_cast(layoutKey)); - } - // GetAsyncKeyState is a GLOBAL key state — it reads the key even while our window is in the - // background (alt-tab). Honor the documented "false while backgrounded" by requiring our own - // process to own the foreground window. - DWORD pid = 0; - const DWORD thread = ::GetWindowThreadProcessId(::GetForegroundWindow(), &pid); - if (pid != ::GetCurrentProcessId()) { - return false; - } - const UINT layoutKey = Input::PhysicalKeys::ToLayoutVirtualKey(static_cast(vk), ::GetKeyboardLayout(thread)); - return layoutKey != 0 && (::GetAsyncKeyState(static_cast(layoutKey)) & 0x8000) != 0; + return Input::PhysicalKeyState::IsDown(vk, CoreModules::GetInput()); } } // anonymous namespace diff --git a/code/tests/CMakeLists.txt b/code/tests/CMakeLists.txt index 4f2cca075..145665593 100644 --- a/code/tests/CMakeLists.txt +++ b/code/tests/CMakeLists.txt @@ -36,3 +36,13 @@ target_compile_definitions(FrameworkTests PRIVATE NODE_WANT_INTERNALS=1) add_custom_target(RunFrameworkTests COMMAND $ DEPENDS $) + +# Native input primitives can be tested without building or launching clients. +add_executable(FrameworkInputTests input_ut.cpp) +target_include_directories(FrameworkInputTests PRIVATE . ../framework/src) +if(WIN32) + target_link_libraries(FrameworkInputTests PRIVATE user32) +endif() +add_custom_target(RunFrameworkInputTests + COMMAND $ + DEPENDS FrameworkInputTests) diff --git a/code/tests/framework_ut.cpp b/code/tests/framework_ut.cpp index bb6aafad1..4d9289b9a 100644 --- a/code/tests/framework_ut.cpp +++ b/code/tests/framework_ut.cpp @@ -17,6 +17,7 @@ #include "modules/delegation_ut.h" #include "modules/gui_resources_ut.h" #include "modules/interest_grid_ut.h" +#include "modules/input_state_ut.h" #include "modules/interpolator_ut.h" #include "modules/network_packets_ut.h" #include "modules/network_work_ut.h" @@ -46,6 +47,7 @@ // The hooking layer, and so the pattern table, is Windows-only. #include "modules/pattern_table_ut.h" #include "modules/physical_keys_ut.h" +#include "modules/window_input_ut.h" #endif // Scripting tests @@ -70,6 +72,7 @@ int main() { UNIT_MODULE(replication_rate); UNIT_MODULE(transform_batch); UNIT_MODULE(interest_grid); + UNIT_MODULE(input_state); UNIT_MODULE(delegation); UNIT_MODULE(state_bag); UNIT_MODULE(state_bag_scripting); @@ -91,6 +94,7 @@ int main() { #ifdef _WIN32 UNIT_MODULE(pattern_table); UNIT_MODULE(physical_keys); + UNIT_MODULE(window_input); #endif // Scripting tests diff --git a/code/tests/input_ut.cpp b/code/tests/input_ut.cpp new file mode 100644 index 000000000..4613f7319 --- /dev/null +++ b/code/tests/input_ut.cpp @@ -0,0 +1,28 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +// Dependency-free input tests: no renderer, browser, network or game process. +// clang-format off +#include "unit.h" +#include "modules/input_state_ut.h" +#ifdef _WIN32 +#include "modules/physical_keys_ut.h" +#include "modules/window_input_ut.h" +#endif + +// clang-format on + +int main() { + UNIT_CREATE("FrameworkInputTests"); + UNIT_MODULE(input_state); +#ifdef _WIN32 + UNIT_MODULE(window_input); + UNIT_MODULE(physical_keys); +#endif + return UNIT_RUN(); +} diff --git a/code/tests/modules/input_state_ut.h b/code/tests/modules/input_state_ut.h new file mode 100644 index 000000000..8427a3bc5 --- /dev/null +++ b/code/tests/modules/input_state_ut.h @@ -0,0 +1,163 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include +#include + +MODULE(input_state, { + IT("held keys repeat without creating new press edges", { + Framework::Input::ButtonState<256> keys; + keys.Set('W', true); + EQUALS(keys.IsPressed('W'), true); + keys.ClearEdges(); + keys.Set('W', true); + EQUALS(keys.IsDown('W'), true); + EQUALS(keys.IsPressed('W'), false); + keys.Set('W', false); + EQUALS(keys.IsReleased('W'), true); + keys.ClearEdges(); + keys.Set('W', false); + EQUALS(keys.IsReleased('W'), false); + }); + IT("retains a quick tap's down and up edges for all frame consumers", { + Framework::Input::ButtonState<256> keys; + keys.Set('T', true); + keys.Set('T', false); + EQUALS(keys.IsPressed('T'), true); + EQUALS(keys.IsReleased('T'), true); + EQUALS(keys.IsDown('T'), false); + keys.ClearEdges(); + EQUALS(keys.IsPressed('T'), false); + EQUALS(keys.IsReleased('T'), false); + }); + IT("rejects invalid caller supplied indices", { + Framework::Input::ButtonState<5> buttons; + for (int index : {-1, 5, 256}) { + buttons.Set(index, true); + EQUALS(buttons.IsDown(index), false); + EQUALS(buttons.IsUp(index), false); + EQUALS(buttons.IsPressed(index), false); + EQUALS(buttons.IsReleased(index), false); + } + }); + IT("releases held state while leaving independent keys alone", { + Framework::Input::ButtonState<256> keys; + keys.Set('W', true); + keys.Set('A', true); + keys.ClearEdges(); + keys.ReleaseAll(); + EQUALS(keys.IsDown('W'), false); + EQUALS(keys.IsReleased('W'), true); + EQUALS(keys.IsReleased('A'), true); + EQUALS(keys.IsReleased('S'), false); + }); + IT("can seed a repeated key after focus acquisition without a toggle edge", { + Framework::Input::ButtonState<256> keys; + keys.Set('T', true, false); + EQUALS(keys.IsDown('T'), true); + EQUALS(keys.IsPressed('T'), false); + }); + IT("snapshots track physical transitions independently of consumers", { + Framework::Input::KeySnapshot snapshot; + bool held = false; + const auto read = [&held](int key) { + return key == 'T' && held; + }; + snapshot.Update(read, true); + held = true; + snapshot.Update(read, true); + EQUALS(snapshot.IsPressed('T'), true); + EQUALS(snapshot.IsPressed('T'), true); + snapshot.Update(read, true); + EQUALS(snapshot.IsPressed('T'), false); + held = false; + snapshot.Update(read, true); + EQUALS(snapshot.IsReleased('T'), true); + }); + IT("snapshot acquisition and foreground regain swallow already held keys", { + Framework::Input::KeySnapshot snapshot; + const auto held = [](int key) { + return key == 'W'; + }; + snapshot.Update(held, true); + EQUALS(snapshot.IsDown('W'), true); + EQUALS(snapshot.IsPressed('W'), false); + snapshot.Update(held, false); + EQUALS(snapshot.IsDown('W'), false); + EQUALS(snapshot.IsReleased('W'), false); + snapshot.Update(held, true); + EQUALS(snapshot.IsDown('W'), true); + EQUALS(snapshot.IsPressed('W'), false); + }); + IT("stale device state holds the detector and resynchronizes on resume", { + Framework::Input::KeySnapshot snapshot; + bool held = false; + const auto read = [&held](int key) { + return key == 'T' && held; + }; + snapshot.Update(read, true); + held = true; + snapshot.Update(read, true, true); + EQUALS(snapshot.IsPressed('T'), false); + snapshot.Update(read, true); + EQUALS(snapshot.IsDown('T'), true); + EQUALS(snapshot.IsPressed('T'), false); + snapshot.Update(read, true, true); + EQUALS(snapshot.IsDown('T'), false); + held = false; + snapshot.Update(read, true, true); + EQUALS(snapshot.IsReleased('T'), false); + snapshot.Update(read, true); + EQUALS(snapshot.IsDown('T'), false); + EQUALS(snapshot.IsReleased('T'), false); + held = true; + snapshot.Update(read, true); + EQUALS(snapshot.IsPressed('T'), true); + }); + IT("one resource cannot release another resource's hold", { + Framework::Input::ResourceHolds holds; + holds.Acquire("panel-a"); + holds.Acquire("panel-b"); + EQUALS(holds.Release("missing"), false); + EQUALS(holds.Release("panel-a"), true); + EQUALS(holds.Release("panel-a"), false); + EQUALS(holds.IsHeld("panel-b"), true); + EQUALS(holds.IsHeld(), true); + }); + IT("resource stop drains all of its counted holds exactly once", { + Framework::Input::ResourceHolds holds; + holds.Acquire("panel-a"); + holds.Acquire("panel-a"); + holds.Acquire("panel-b"); + EQUALS(holds.TakeAll("panel-a"), 2); + EQUALS(holds.TakeAll("panel-a"), 0); + EQUALS(holds.TakeAll("panel-b"), 1); + EQUALS(holds.IsHeld(), false); + }); + IT("forced cleanup drops ownership so a later stop cannot unlock a new owner", { + Framework::Input::ResourceHolds holds; + holds.Acquire("old"); + holds.Clear(); + holds.Acquire("new"); + EQUALS(holds.TakeAll("old"), 0); + EQUALS(holds.IsHeld("new"), true); + }); + IT("setEnabled-style ownership is idempotent and independent per resource", { + Framework::Input::ResourceHolds holds; + holds.Set("panel-a", true); + holds.Set("panel-a", true); + holds.Set("panel-b", true); + EQUALS(holds.TakeAll("panel-a"), 1); + holds.Set("panel-a", false); + EQUALS(holds.IsHeld("panel-b"), true); + holds.Set("panel-b", false); + EQUALS(holds.IsHeld(), false); + }); +}); diff --git a/code/tests/modules/window_input_ut.h b/code/tests/modules/window_input_ut.h new file mode 100644 index 000000000..94d9dcc71 --- /dev/null +++ b/code/tests/modules/window_input_ut.h @@ -0,0 +1,137 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include +#include + +namespace InputTests { + class WindowAdapter final: public Framework::Input::WindowInput { + public: + bool physicalProvider = false; + bool stale = false; + Framework::Input::KeyCodeSpace codeSpace = Framework::Input::KeyCodeSpace::PhysicalPosition; + mutable int lastQuery = -1; + bool ProvidesPhysicalKeyState() const override { + return physicalProvider; + } + bool IsStateStale() const override { + return stale; + } + Framework::Input::KeyCodeSpace GetKeyCodeSpace() const override { + return codeSpace; + } + bool IsKeyDown(int key) const override { + lastQuery = key; + return Framework::Input::WindowInput::IsKeyDown(key); + } + void SetMousePosition(int, int) override {} + void SetMouseVisible(bool) override {} + bool IsMouseVisible() const override { + return false; + } + void SetMouseLocked(bool) override {} + bool IsMouseLocked() const override { + return false; + } + void SetInputLocked(bool) override {} + bool IsInputLocked() const override { + return false; + } + }; +} // namespace InputTests +MODULE(window_input, { + IT("normalizes down and up even when the layout changes mid-hold", { + InputTests::WindowAdapter input; + input.ProcessEvent(nullptr, WM_KEYDOWN, 'Z', 0x11L << 16); + EQUALS(input.IsKeyDown('W'), true); + EQUALS(input.IsKeyDown('Z'), false); + input.Update(); + input.ProcessEvent(nullptr, WM_KEYUP, 'X', (0x11L << 16) | (1L << 30)); + EQUALS(input.IsKeyReleased('W'), true); + EQUALS(input.IsKeyDown('W'), false); + EQUALS(static_cast(input.GetKeyCodeSpace()), static_cast(Framework::Input::KeyCodeSpace::PhysicalPosition)); + EQUALS(input.ProvidesPhysicalKeyState(), false); + }); + IT("filters repeats including the first repeat after focus loss", { + InputTests::WindowAdapter input; + input.ProcessEvent(nullptr, WM_KEYDOWN, VK_F8, 0); + EQUALS(input.IsKeyPressed(VK_F8), true); + input.Update(); + input.ProcessEvent(nullptr, WM_KEYDOWN, VK_F8, 1L << 30); + EQUALS(input.IsKeyPressed(VK_F8), false); + input.ProcessEvent(nullptr, WM_KILLFOCUS, 0, 0); + input.Update(); + input.ProcessEvent(nullptr, WM_KEYDOWN, VK_F8, 1L << 30); + EQUALS(input.IsKeyDown(VK_F8), true); + EQUALS(input.IsKeyPressed(VK_F8), false); + }); + IT("mouse messages use button indices rather than modifier masks", { + InputTests::WindowAdapter input; + input.ProcessEvent(nullptr, WM_LBUTTONDOWN, MK_LBUTTON | MK_SHIFT | MK_CONTROL, 0); + EQUALS(input.IsMouseButtonDown(0), true); + EQUALS(input.IsMouseButtonDown(1), false); + EQUALS(input.IsKeyDown(VK_LBUTTON), true); + input.ProcessEvent(nullptr, WM_LBUTTONUP, MK_SHIFT, 0); + EQUALS(input.IsMouseButtonReleased(0), true); + EQUALS(input.IsKeyDown(VK_LBUTTON), false); + input.ProcessEvent(nullptr, WM_XBUTTONDOWN, static_cast(XBUTTON2) << 16, 0); + EQUALS(input.IsMouseButtonDown(4), true); + EQUALS(input.IsKeyDown(VK_XBUTTON2), true); + }); + IT("generic modifiers stay held until both sides are up", { + InputTests::WindowAdapter input; + input.ProcessEvent(nullptr, WM_KEYDOWN, VK_SHIFT, 0x2AL << 16); + input.ProcessEvent(nullptr, WM_KEYDOWN, VK_SHIFT, 0x36L << 16); + input.Update(); + input.ProcessEvent(nullptr, WM_KEYUP, VK_SHIFT, 0x2AL << 16); + EQUALS(input.IsKeyDown(VK_LSHIFT), false); + EQUALS(input.IsKeyDown(VK_RSHIFT), true); + EQUALS(input.IsKeyDown(VK_SHIFT), true); + EQUALS(input.IsKeyReleased(VK_SHIFT), false); + input.ProcessEvent(nullptr, WM_KEYUP, VK_SHIFT, 0x36L << 16); + EQUALS(input.IsKeyReleased(VK_SHIFT), true); + }); + IT("focus loss releases every held key and mouse button and discards pending toggles", { + InputTests::WindowAdapter input; + input.ProcessEvent(nullptr, WM_KEYDOWN, 'W', 0x11L << 16); + input.ProcessEvent(nullptr, WM_RBUTTONDOWN, MK_RBUTTON, 0); + input.ProcessEvent(nullptr, WM_KILLFOCUS, 0, 0); + EQUALS(input.IsKeyDown('W'), false); + EQUALS(input.IsKeyPressed('W'), false); + EQUALS(input.IsKeyReleased('W'), true); + EQUALS(input.IsMouseButtonDown(1), false); + EQUALS(input.IsMouseButtonPressed(1), false); + EQUALS(input.IsMouseButtonReleased(1), true); + }); + IT("client coordinates retain negative positions", { + InputTests::WindowAdapter input; + input.ProcessEvent(nullptr, WM_MOUSEMOVE, 0, static_cast(static_cast(-17)) | (static_cast(static_cast(-42)) << 16)); + int x = 0, y = 0; + input.GetMousePosition(x, y); + EQUALS(x, -17); + EQUALS(y, -42); + }); + IT("physical providers are queried without a second layout conversion", { + InputTests::WindowAdapter input; + input.physicalProvider = true; + input.ProcessEvent(nullptr, WM_KEYDOWN, 'Z', 0x11L << 16); + EQUALS(Framework::Input::PhysicalKeyState::IsDown('W', &input), true); + EQUALS(input.lastQuery, 'W'); + input.stale = true; + EQUALS(Framework::Input::PhysicalKeyState::IsDown('W', &input), false); + }); + IT("legacy providers still receive virtual keys in their current layout", { + InputTests::WindowAdapter input; + input.physicalProvider = true; + input.codeSpace = Framework::Input::KeyCodeSpace::LayoutVirtualKey; + Framework::Input::PhysicalKeyState::IsDown('W', &input); + EQUALS(input.lastQuery, static_cast(Framework::Input::PhysicalKeys::ToLayoutVirtualKey('W', GetKeyboardLayout(0)))); + }); +}); diff --git a/docs/native_input.md b/docs/native_input.md new file mode 100644 index 000000000..d8b68ec06 --- /dev/null +++ b/docs/native_input.md @@ -0,0 +1,77 @@ +# Native input integration + +Native multiplayer controls and scripted `Key` bindings share the framework's +input primitives. Game adapters keep native device acquisition, control locking, +cursor presentation and engine action-map policy. + +## Window-message adapters + +Derive the game adapter from `Framework::Input::WindowInput` and implement the +remaining cursor and control-lock methods. Forward every window message to +`ProcessEvent` before a UI handler can consume it. Call `Update` once, after all +consumers have read that frame's pressed/released edges. + +The backend normalizes printable keys to US physical positions, filters +repeats, tracks both sides of modifiers, publishes mouse buttons by index and +as virtual keys, preserves signed client coordinates, and releases held state +on focus loss. Character and IME messages remain on the text/UI path. + +This is an event backend. It does not opt into `ProvidesPhysicalKeyState()`: +window events cannot discover keys which were already held when focus returned. +`PhysicalKeyState` uses its foreground-aware OS fallback for these adapters. + +## Physical polling and UI snapshots + +`PhysicalKeyState::IsDown(key, input)` accepts a key in the same physical +namespace as `Key`. An opted-in device provider must declare its key space: + +- `KeyCodeSpace::LayoutVirtualKey` is the default for existing device adapters + which translate layout virtual keys to native scan codes. +- `KeyCodeSpace::PhysicalPosition` means the adapter already accepts physical + positions, so the reader must not translate a second time. + +A stale provider answers false. Without an opted-in provider the helper polls +only while this process owns the foreground window, using that window's layout +to translate the requested physical position. + +Use `PhysicalKeyState::Update(snapshot)` for UI hotkeys which must remain live +while native gameplay devices are paused. `KeySnapshot` seeds held keys on +initial acquisition, foreground regain and stale-state recovery without firing +new press edges. Its state is unavailable while the source is unavailable or +stale. Gameplay availability remains a separate policy: use the client's +`IsLocalInputAvailable()` for gameplay hotkeys and explicit UI/editor policy for +controls which must work while a menu owns gameplay input. + +## Resource-owned controls + +`ResourceHolds` stores ownership, not native locking operations. Use +`Acquire`/`Release` for counted APIs, or `Set` for idempotent enable/disable APIs. +At resource stop/error, `TakeAll(resource)` returns exactly how many native +holds that owner must release. Clear the ledger whenever a forced cleanup also +drains native holds. A resource cannot release another owner's holds. + +Resource callbacks and native transitions stay in the project's lifecycle +adapter. Engine action-map bindings remain appropriate for controls which +inherit native rebinding, priority, combat or inventory behavior. + +## Unit tests + +`FrameworkInputTests` has no renderer, browser, server or game dependency. Its +portable suite covers transitions, repeat filtering, frame edge retention, +focus/stale resynchronization and resource ownership. Windows additionally +covers actual window messages, modifiers, mouse masks, physical key spaces and +the physical-key mapping suite. The same modules run in `FrameworkTests`. + +Linux, using the canonical build: + +```sh +cmake --build build --target FrameworkInputTests +build/bin/FrameworkInputTests +``` + +Windows, using the canonical build script: + +```bat +builds\build.bat FrameworkInputTests 64 +builds\build-64\bin\FrameworkInputTests.exe +``` From 689c27139ba8e0123e33cfb54f869d27f15c7001 Mon Sep 17 00:00:00 2001 From: Dominik Madarasz Date: Wed, 30 Sep 2026 17:21:34 +0200 Subject: [PATCH 2/2] Input: Unify native key queries behind IInput --- code/framework/src/input/button_state.h | 10 +- code/framework/src/input/detail/win32_keys.h | 29 +++++ code/framework/src/input/input.h | 29 +---- code/framework/src/input/physical_key_state.h | 58 --------- code/framework/src/input/physical_keys.h | 98 +++++++++++++++ code/framework/src/input/polling_input.h | 35 ++++++ code/framework/src/input/win32_input.h | 117 ++++++++++++++++++ code/framework/src/input/window_input.h | 75 +++++------ .../client/scripting/builtins/keybinds.cpp | 41 +++--- .../client/scripting/builtins/keybinds.h | 2 +- code/framework/src/utils/key_names.cpp | 73 ++++++----- code/framework/src/utils/key_names.h | 2 +- code/tests/framework_ut.cpp | 2 + code/tests/input_ut.cpp | 2 + code/tests/modules/input_state_ut.h | 8 +- code/tests/modules/physical_keys_ut.h | 24 ++-- code/tests/modules/polling_input_ut.h | 76 ++++++++++++ code/tests/modules/window_input_ut.h | 111 ++++++++++------- docs/native_input.md | 88 +++++++------ 19 files changed, 594 insertions(+), 286 deletions(-) create mode 100644 code/framework/src/input/detail/win32_keys.h delete mode 100644 code/framework/src/input/physical_key_state.h create mode 100644 code/framework/src/input/polling_input.h create mode 100644 code/framework/src/input/win32_input.h create mode 100644 code/tests/modules/polling_input_ut.h diff --git a/code/framework/src/input/button_state.h b/code/framework/src/input/button_state.h index 730af860b..cfabbfe17 100644 --- a/code/framework/src/input/button_state.h +++ b/code/framework/src/input/button_state.h @@ -66,23 +66,19 @@ namespace Framework::Input { }; // Sampled state: seed on acquisition/resume, so keys already held do not - // become new presses. A frozen device must not manufacture releases. + // become new presses. Unavailable input does not publish release edges. class KeySnapshot final { public: template - void Update(Reader &&read, bool available, bool stale = false) { + void Update(Reader &&read, bool available) { _keys.ClearEdges(); - _available = available && !stale; + _available = available; if (!available) { _keys.ReleaseAll(); _keys.ClearEdges(); _resync = true; return; } - if (stale) { - _resync = true; - return; - } for (int key = 0; key < 256; ++key) { _keys.Set(key, read(key)); } diff --git a/code/framework/src/input/detail/win32_keys.h b/code/framework/src/input/detail/win32_keys.h new file mode 100644 index 000000000..4e7fee0cd --- /dev/null +++ b/code/framework/src/input/detail/win32_keys.h @@ -0,0 +1,29 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include "../physical_keys.h" + +namespace Framework::Input::detail { + inline bool IsForeground() { + DWORD pid = 0; + ::GetWindowThreadProcessId(::GetForegroundWindow(), &pid); + return pid == ::GetCurrentProcessId(); + } + + inline bool ReadWin32Key(int key) { + DWORD pid = 0; + const DWORD thread = ::GetWindowThreadProcessId(::GetForegroundWindow(), &pid); + if (pid != ::GetCurrentProcessId()) { + return false; + } + const UINT code = PhysicalKeys::ToLayoutVirtualKey(static_cast(key), ::GetKeyboardLayout(thread)); + return code != 0 && (::GetAsyncKeyState(static_cast(code)) & 0x8000) != 0; + } +} // namespace Framework::Input::detail diff --git a/code/framework/src/input/input.h b/code/framework/src/input/input.h index 89f080368..314696619 100644 --- a/code/framework/src/input/input.h +++ b/code/framework/src/input/input.h @@ -8,16 +8,9 @@ #pragma once -#include - #include "input_keymap.h" namespace Framework::Input { - enum class KeyCodeSpace { - LayoutVirtualKey, - PhysicalPosition - }; - class IInput { public: virtual ~IInput() = default; @@ -32,23 +25,11 @@ namespace Framework::Input { virtual void SetInputLocked(bool locked) = 0; virtual bool IsInputLocked() const = 0; - // Opt-in: a device provider can answer held state independently of window events. - // Event-only providers cannot discover keys already held on focus reacquisition. - virtual bool ProvidesPhysicalKeyState() const { - return false; - } - - // Legacy device adapters map layout VKs to scan codes. A provider - // storing physical-position codes must declare it to avoid translation twice. - virtual KeyCodeSpace GetKeyCodeSpace() const { - return KeyCodeSpace::LayoutVirtualKey; - } - - // Device state frozen rather than idle; callers edge-detecting must hold, not latch. - virtual bool IsStateStale() const { - return false; - } + // Fresh source state is available. Edge detectors reseed after an unavailable period. + virtual bool IsAvailable() const = 0; + // Every adapter accepts FW_KEY_* codes and US physical letter/digit positions. + // Native layout and scan-code conversion belongs inside the adapter. virtual bool IsKeyDown(int key) const = 0; virtual bool IsKeyUp(int key) const = 0; virtual bool IsKeyPressed(int key) const = 0; @@ -58,7 +39,5 @@ namespace Framework::Input { virtual bool IsMouseButtonUp(int button) const = 0; virtual bool IsMouseButtonPressed(int button) const = 0; virtual bool IsMouseButtonReleased(int button) const = 0; - - virtual uint32_t MapKey(uint32_t key) const = 0; }; } // namespace Framework::Input diff --git a/code/framework/src/input/physical_key_state.h b/code/framework/src/input/physical_key_state.h deleted file mode 100644 index 1ae8d50a3..000000000 --- a/code/framework/src/input/physical_key_state.h +++ /dev/null @@ -1,58 +0,0 @@ -/* - * MafiaHub OSS license - * Copyright (c) 2026, MafiaHub. All rights reserved. - * - * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. - * See LICENSE file in the source repository for information regarding licensing. - */ - -#pragma once - -#include "button_state.h" -#include "input.h" -#include "physical_keys.h" - -namespace Framework::Input::PhysicalKeyState { - inline const IInput *Source(const IInput *input) { - return input && input->ProvidesPhysicalKeyState() ? input : nullptr; - } - - inline bool IsForeground() { - DWORD pid = 0; - ::GetWindowThreadProcessId(::GetForegroundWindow(), &pid); - return pid == ::GetCurrentProcessId(); - } - - // Key names denote US physical positions. Legacy device adapters accept - // layout VKs; adapters with physical codes explicitly declare that space. - inline bool IsDown(int key, const IInput *input = nullptr) { - if (key < 0 || key > 255) { - return false; - } - if (const auto *source = Source(input)) { - if (source->IsStateStale()) { - return false; - } - const UINT code = source->GetKeyCodeSpace() == KeyCodeSpace::PhysicalPosition ? static_cast(key) : PhysicalKeys::ToLayoutVirtualKey(static_cast(key), ::GetKeyboardLayout(0)); - return code != 0 && source->IsKeyDown(static_cast(code)); - } - DWORD pid = 0; - const DWORD thread = ::GetWindowThreadProcessId(::GetForegroundWindow(), &pid); - if (pid != ::GetCurrentProcessId()) { - return false; - } - const UINT code = PhysicalKeys::ToLayoutVirtualKey(static_cast(key), ::GetKeyboardLayout(thread)); - return code != 0 && (::GetAsyncKeyState(static_cast(code)) & 0x8000) != 0; - } - - // Passing no provider keeps UI hotkeys live while native game devices are - // paused. Calling code chooses its UI/gameplay gate separately. - inline void Update(KeySnapshot &snapshot, const IInput *input = nullptr) { - const auto *source = Source(input); - snapshot.Update( - [input](int key) { - return IsDown(key, input); - }, - IsForeground(), source && source->IsStateStale()); - } -} // namespace Framework::Input::PhysicalKeyState diff --git a/code/framework/src/input/physical_keys.h b/code/framework/src/input/physical_keys.h index d392a0c3c..ccb50d666 100644 --- a/code/framework/src/input/physical_keys.h +++ b/code/framework/src/input/physical_keys.h @@ -8,6 +8,7 @@ #pragma once +#include #include namespace Framework::Input::PhysicalKeys { @@ -90,6 +91,103 @@ namespace Framework::Input::PhysicalKeys { return MapVirtualKeyW(virtualKey, MAPVK_VK_TO_VSC_EX); } + // Native device codes are fixed by the DirectInput SDK, not by an active + // layout or MapVirtualKey's navigation/numpad interpretation. + inline UINT ToDirectInputCode(UINT key) { + for (const auto &position : detail::kPositions) { + if (position.virtualKey == key) + return position.scanCode; + } + switch (key) { + case FW_KEY_ESCAPE: return 0x01; + case FW_KEY_BACK: return 0x0E; + case FW_KEY_TAB: return 0x0F; + case FW_KEY_RETURN: return 0x1C; + case FW_KEY_CONTROL: + case FW_KEY_LCONTROL: return 0x1D; + case FW_KEY_SHIFT: + case FW_KEY_LSHIFT: return 0x2A; + case FW_KEY_RSHIFT: return 0x36; + case FW_KEY_MULTIPLY: return 0x37; + case FW_KEY_MENU: + case FW_KEY_LMENU: return 0x38; + case FW_KEY_SPACE: return 0x39; + case FW_KEY_CAPITAL: return 0x3A; + case FW_KEY_F1: return 0x3B; + case FW_KEY_F2: return 0x3C; + case FW_KEY_F3: return 0x3D; + case FW_KEY_F4: return 0x3E; + case FW_KEY_F5: return 0x3F; + case FW_KEY_F6: return 0x40; + case FW_KEY_F7: return 0x41; + case FW_KEY_F8: return 0x42; + case FW_KEY_F9: return 0x43; + case FW_KEY_F10: return 0x44; + case FW_KEY_NUMLOCK: return 0x45; + case FW_KEY_SCROLL: return 0x46; + case FW_KEY_NUMPAD7: return 0x47; + case FW_KEY_NUMPAD8: return 0x48; + case FW_KEY_NUMPAD9: return 0x49; + case FW_KEY_SUBTRACT: return 0x4A; + case FW_KEY_NUMPAD4: return 0x4B; + case FW_KEY_CLEAR: + case FW_KEY_NUMPAD5: return 0x4C; + case FW_KEY_NUMPAD6: return 0x4D; + case FW_KEY_ADD: return 0x4E; + case FW_KEY_NUMPAD1: return 0x4F; + case FW_KEY_NUMPAD2: return 0x50; + case FW_KEY_NUMPAD3: return 0x51; + case FW_KEY_NUMPAD0: return 0x52; + case FW_KEY_DECIMAL: return 0x53; + case FW_KEY_F11: return 0x57; + case FW_KEY_F12: return 0x58; + case FW_KEY_F13: return 0x64; + case FW_KEY_F14: return 0x65; + case FW_KEY_F15: return 0x66; + case FW_KEY_KANA: return 0x70; + case FW_KEY_CONVERT: return 0x79; + case FW_KEY_NONCONVERT: return 0x7B; + case FW_KEY_OEM_NEC_EQUAL: return 0x8D; + case FW_KEY_MEDIA_PREV_TRACK: return 0x90; + case FW_KEY_KANJI: return 0x94; + case FW_KEY_MEDIA_NEXT_TRACK: return 0x99; + case FW_KEY_RCONTROL: return 0x9D; + case FW_KEY_VOLUME_MUTE: return 0xA0; + case FW_KEY_MEDIA_PLAY_PAUSE: return 0xA2; + case FW_KEY_MEDIA_STOP: return 0xA4; + case FW_KEY_VOLUME_DOWN: return 0xAE; + case FW_KEY_VOLUME_UP: return 0xB0; + case FW_KEY_BROWSER_HOME: return 0xB2; + case FW_KEY_DIVIDE: return 0xB5; + case FW_KEY_SNAPSHOT: return 0xB7; + case FW_KEY_RMENU: return 0xB8; + case FW_KEY_PAUSE: return 0xC5; + case FW_KEY_HOME: return 0xC7; + case FW_KEY_UP: return 0xC8; + case FW_KEY_PRIOR: return 0xC9; + case FW_KEY_LEFT: return 0xCB; + case FW_KEY_RIGHT: return 0xCD; + case FW_KEY_END: return 0xCF; + case FW_KEY_DOWN: return 0xD0; + case FW_KEY_NEXT: return 0xD1; + case FW_KEY_INSERT: return 0xD2; + case FW_KEY_DELETE: return 0xD3; + case FW_KEY_LWIN: return 0xDB; + case FW_KEY_RWIN: return 0xDC; + case FW_KEY_APPS: return 0xDD; + case FW_KEY_SLEEP: return 0xDF; + case FW_KEY_BROWSER_SEARCH: return 0xE5; + case FW_KEY_BROWSER_FAVORITES: return 0xE6; + case FW_KEY_BROWSER_REFRESH: return 0xE7; + case FW_KEY_BROWSER_STOP: return 0xE8; + case FW_KEY_BROWSER_FORWARD: return 0xE9; + case FW_KEY_BROWSER_BACK: return 0xEA; + case FW_KEY_LAUNCH_MAIL: return 0xEC; + case FW_KEY_LAUNCH_MEDIA_SELECT: return 0xED; + default: return 0; + } + } + inline UINT FromScanCode(UINT scanCode) { for (const auto &position : detail::kPositions) { if (position.scanCode == scanCode) { diff --git a/code/framework/src/input/polling_input.h b/code/framework/src/input/polling_input.h new file mode 100644 index 000000000..0abe9f80f --- /dev/null +++ b/code/framework/src/input/polling_input.h @@ -0,0 +1,35 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include "button_state.h" +#include "win32_input.h" + +namespace Framework::Input { + // UI acquisition which stays live while a native game device is paused. + class PollingInput: public Win32Input { + public: + void Update() override { + _keys.Update( + [this](int key) { + return ReadKeyDown(key); + }, + IsAvailable()); + } + bool IsKeyPressed(int key) const override { + return IsAvailable() && _keys.IsPressed(key); + } + bool IsKeyReleased(int key) const override { + return IsAvailable() && _keys.IsReleased(key); + } + + private: + KeySnapshot _keys; + }; +} // namespace Framework::Input diff --git a/code/framework/src/input/win32_input.h b/code/framework/src/input/win32_input.h new file mode 100644 index 000000000..34d145c7d --- /dev/null +++ b/code/framework/src/input/win32_input.h @@ -0,0 +1,117 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include "detail/win32_keys.h" +#include "input.h" + +namespace Framework::Input { + // Common OS acquisition and cursor operations for window and polling backends. + // Games can override cursor/control-lock policy with their native operations. + class Win32Input: public IInput { + public: + bool IsAvailable() const override { + return HasFocus(); + } + bool IsKeyDown(int key) const override { + return ValidKey(key) && IsAvailable() && ReadKeyDown(key); + } + bool IsKeyUp(int key) const override { + return ValidKey(key) && IsAvailable() && !ReadKeyDown(key); + } + bool IsMouseButtonDown(int button) const override { + return ValidButton(button) && IsKeyDown(kMouseKeys[button]); + } + bool IsMouseButtonUp(int button) const override { + return ValidButton(button) && IsKeyUp(kMouseKeys[button]); + } + bool IsMouseButtonPressed(int button) const override { + return ValidButton(button) && IsKeyPressed(kMouseKeys[button]); + } + bool IsMouseButtonReleased(int button) const override { + return ValidButton(button) && IsKeyReleased(kMouseKeys[button]); + } + void SetWindow(HWND window) { + _window = window; + } + void GetMousePosition(int &x, int &y) const override { + POINT point {}; + ::GetCursorPos(&point); + if (_window) + ::ScreenToClient(_window, &point); + x = point.x; + y = point.y; + } + void SetMousePosition(int x, int y) override { + POINT point {x, y}; + if (_window) + ::ClientToScreen(_window, &point); + ::SetCursorPos(point.x, point.y); + } + void SetMouseVisible(bool visible) override { + if (_visible == visible) + return; + _visible = visible; + if (visible) { + while (::ShowCursor(TRUE) < 0) {} + } + else { + while (::ShowCursor(FALSE) >= 0) {} + } + } + bool IsMouseVisible() const override { + return _visible; + } + void SetMouseLocked(bool locked) override { + _mouseLocked = locked; + if (locked && _window) { + RECT rect {}; + ::GetClientRect(_window, &rect); + POINT first {rect.left, rect.top}, last {rect.right, rect.bottom}; + ::ClientToScreen(_window, &first); + ::ClientToScreen(_window, &last); + rect = {first.x, first.y, last.x, last.y}; + ::ClipCursor(&rect); + } + else { + ::ClipCursor(nullptr); + } + } + bool IsMouseLocked() const override { + return _mouseLocked; + } + void SetInputLocked(bool locked) override { + _inputLocked = locked; + } + bool IsInputLocked() const override { + return _inputLocked; + } + + protected: + virtual bool HasFocus() const { + return detail::IsForeground(); + } + virtual bool ReadKeyDown(int key) const { + return detail::ReadWin32Key(key); + } + + private: + static bool ValidKey(int key) { + return key >= 0 && key < 256; + } + static bool ValidButton(int button) { + return button >= 0 && button < 5; + } + static constexpr int kMouseKeys[] {FW_KEY_LBUTTON, FW_KEY_RBUTTON, FW_KEY_MBUTTON, FW_KEY_XBUTTON1, FW_KEY_XBUTTON2}; + HWND _window = nullptr; + bool _visible = true; + bool _mouseLocked = false; + bool _inputLocked = false; + }; +} // namespace Framework::Input diff --git a/code/framework/src/input/window_input.h b/code/framework/src/input/window_input.h index 29c37e535..a631dca65 100644 --- a/code/framework/src/input/window_input.h +++ b/code/framework/src/input/window_input.h @@ -9,13 +9,13 @@ #pragma once #include "button_state.h" -#include "input.h" #include "physical_keys.h" +#include "win32_input.h" namespace Framework::Input { // State acquisition only. Cursor presentation and native control locking // remain on the game's adapter. This does not process WM_CHAR or IME. - class WindowInput: public IInput { + class WindowInput: public Win32Input { public: void Update() override { _keys.ClearEdges(); @@ -27,32 +27,14 @@ namespace Framework::Input { y = _mouseY; } - uint32_t MapKey(uint32_t key) const override { - return key; - } - KeyCodeSpace GetKeyCodeSpace() const override { - return KeyCodeSpace::PhysicalPosition; - } - bool IsKeyDown(int key) const override { - return _keys.IsDown(key); - } - bool IsKeyUp(int key) const override { - return _keys.IsUp(key); - } bool IsKeyPressed(int key) const override { - return _keys.IsPressed(key); + return IsAvailable() && _keys.IsPressed(key); } bool IsKeyReleased(int key) const override { return _keys.IsReleased(key); } - bool IsMouseButtonDown(int button) const override { - return _buttons.IsDown(button); - } - bool IsMouseButtonUp(int button) const override { - return _buttons.IsUp(button); - } bool IsMouseButtonPressed(int button) const override { - return _buttons.IsPressed(button); + return IsAvailable() && _buttons.IsPressed(button); } bool IsMouseButtonReleased(int button) const override { return _buttons.IsReleased(button); @@ -69,44 +51,53 @@ namespace Framework::Input { // Retain the generic modifier while either side is held. UINT generic = physical; UINT side = physical; - if (physical == VK_SHIFT) { - side = ((flags >> 16) & 0xFF) == 0x36 ? VK_RSHIFT : VK_LSHIFT; + if (physical == FW_KEY_SHIFT) { + side = ((flags >> 16) & 0xFF) == 0x36 ? FW_KEY_RSHIFT : FW_KEY_LSHIFT; } - else if (physical == VK_CONTROL) { - side = (flags & (1L << 24)) != 0 ? VK_RCONTROL : VK_LCONTROL; + else if (physical == FW_KEY_CONTROL) { + side = (flags & (1L << 24)) != 0 ? FW_KEY_RCONTROL : FW_KEY_LCONTROL; } - else if (physical == VK_MENU) { - side = (flags & (1L << 24)) != 0 ? VK_RMENU : VK_LMENU; + else if (physical == FW_KEY_MENU) { + side = (flags & (1L << 24)) != 0 ? FW_KEY_RMENU : FW_KEY_LMENU; } - if (physical == VK_LSHIFT || physical == VK_RSHIFT) - generic = VK_SHIFT; - else if (physical == VK_LCONTROL || physical == VK_RCONTROL) - generic = VK_CONTROL; - else if (physical == VK_LMENU || physical == VK_RMENU) - generic = VK_MENU; + if (physical == FW_KEY_LSHIFT || physical == FW_KEY_RSHIFT) + generic = FW_KEY_SHIFT; + else if (physical == FW_KEY_LCONTROL || physical == FW_KEY_RCONTROL) + generic = FW_KEY_CONTROL; + else if (physical == FW_KEY_LMENU || physical == FW_KEY_RMENU) + generic = FW_KEY_MENU; const bool recordEdge = !down || (flags & (1L << 30)) == 0; _keys.Set(static_cast(side), down, recordEdge); if (side != generic) { - _keys.Set(static_cast(generic), _keys.IsDown(static_cast(side)) || _keys.IsDown(static_cast(side == VK_LSHIFT || side == VK_LCONTROL || side == VK_LMENU ? side + 1 : side - 1)), recordEdge); + _keys.Set(static_cast(generic), _keys.IsDown(static_cast(side)) || _keys.IsDown(static_cast(side == FW_KEY_LSHIFT || side == FW_KEY_LCONTROL || side == FW_KEY_LMENU ? side + 1 : side - 1)), recordEdge); } break; } - case WM_LBUTTONDOWN: SetButton(0, VK_LBUTTON, true); break; - case WM_LBUTTONUP: SetButton(0, VK_LBUTTON, false); break; - case WM_RBUTTONDOWN: SetButton(1, VK_RBUTTON, true); break; - case WM_RBUTTONUP: SetButton(1, VK_RBUTTON, false); break; - case WM_MBUTTONDOWN: SetButton(2, VK_MBUTTON, true); break; - case WM_MBUTTONUP: SetButton(2, VK_MBUTTON, false); break; + case WM_LBUTTONDOWN: SetButton(0, FW_KEY_LBUTTON, true); break; + case WM_LBUTTONUP: SetButton(0, FW_KEY_LBUTTON, false); break; + case WM_RBUTTONDOWN: SetButton(1, FW_KEY_RBUTTON, true); break; + case WM_RBUTTONUP: SetButton(1, FW_KEY_RBUTTON, false); break; + case WM_MBUTTONDOWN: SetButton(2, FW_KEY_MBUTTON, true); break; + case WM_MBUTTONUP: SetButton(2, FW_KEY_MBUTTON, false); break; case WM_XBUTTONDOWN: case WM_XBUTTONUP: { const bool first = HIWORD(key) == XBUTTON1; - SetButton(first ? 3 : 4, first ? VK_XBUTTON1 : VK_XBUTTON2, message == WM_XBUTTONDOWN); + SetButton(first ? 3 : 4, first ? FW_KEY_XBUTTON1 : FW_KEY_XBUTTON2, message == WM_XBUTTONDOWN); break; } case WM_MOUSEMOVE: _mouseX = static_cast(LOWORD(flags)); _mouseY = static_cast(HIWORD(flags)); break; + case WM_SETFOCUS: + Update(); + for (int index = 0; index < 256; ++index) { + _keys.Set(index, IsKeyDown(index), false); + } + for (int index = 0; index < 5; ++index) { + _buttons.Set(index, IsMouseButtonDown(index), false); + } + break; case WM_KILLFOCUS: // Discard pending presses too: a focus transfer must not fire // a toggle which has not yet been consumed by the game tick. diff --git a/code/framework/src/integrations/client/scripting/builtins/keybinds.cpp b/code/framework/src/integrations/client/scripting/builtins/keybinds.cpp index 5ddfea777..5c59ad646 100644 --- a/code/framework/src/integrations/client/scripting/builtins/keybinds.cpp +++ b/code/framework/src/integrations/client/scripting/builtins/keybinds.cpp @@ -18,7 +18,6 @@ #include #include -#include #include #include @@ -56,12 +55,9 @@ namespace Framework::Integrations::Client::Scripting::Builtins { return s; } - const Input::IInput *PhysicalKeySource() { - return Input::PhysicalKeyState::Source(CoreModules::GetInput()); - } - - bool IsPhysicallyDown(int vk) { - return Input::PhysicalKeyState::IsDown(vk, CoreModules::GetInput()); + bool IsKeyDown(int key) { + const auto *input = CoreModules::GetInput(); + return input && input->IsKeyDown(key); } } // anonymous namespace @@ -69,15 +65,15 @@ namespace Framework::Integrations::Client::Scripting::Builtins { std::mutex Keybinds::_mutex; std::function Keybinds::_activeCallback; Framework::Scripting::ResourceManager *Keybinds::_resourceManager = nullptr; - bool Keybinds::_stateWasStale = false; + bool Keybinds::_sourceWasUnavailable = false; void Keybinds::Register(v8::Isolate *isolate, v8::Local context, v8::Local target, Framework::Scripting::ResourceManager *resourceManager) { { std::scoped_lock lock(_mutex); _buckets.clear(); } - _stateWasStale = false; - _resourceManager = resourceManager; + _sourceWasUnavailable = false; + _resourceManager = resourceManager; v8::Local keyObj = v8::Object::New(isolate); @@ -192,7 +188,7 @@ namespace Framework::Integrations::Client::Scripting::Builtins { if (bucket.handlers.empty()) { // Seed from live state so a held key doesn't fire on bind. bucket.vk = vk; - bucket.prevDown = IsPhysicallyDown(vk); + bucket.prevDown = IsKeyDown(vk); } Handler entry; entry.callback.Reset(isolate, handler); @@ -281,7 +277,7 @@ namespace Framework::Integrations::Client::Scripting::Builtins { std::scoped_lock lock(_mutex); allowed = GateAllowed(); } - args.GetReturnValue().Set(allowed && IsPhysicallyDown(vk)); + args.GetReturnValue().Set(allowed && IsKeyDown(vk)); } void Keybinds::Update() { @@ -290,16 +286,15 @@ namespace Framework::Integrations::Client::Scripting::Builtins { return; } - // A frozen source reads every key up: right for dispatch, a lie to edge detection. Latching - // it would fire a phantom "down" for a key still held when the freeze lifts, so hold the - // detector and re-seed prevDown on the first fresh frame instead. - const auto *input = PhysicalKeySource(); - if (input && input->IsStateStale()) { - _stateWasStale = true; + // An unavailable source must not publish releases or manufacture presses when it + // resumes. Hold the detector and seed it from the first fresh source state. + const auto *input = CoreModules::GetInput(); + if (!input || !input->IsAvailable()) { + _sourceWasUnavailable = true; return; } - const bool resync = _stateWasStale; - _stateWasStale = false; + const bool resync = _sourceWasUnavailable; + _sourceWasUnavailable = false; // Phase 1: edge-detect without touching V8. struct Edge { @@ -314,7 +309,7 @@ namespace Framework::Integrations::Client::Scripting::Builtins { } const bool allowed = GateAllowed(); for (auto &[keyName, bucket] : _buckets) { - const bool down = IsPhysicallyDown(bucket.vk); + const bool down = IsKeyDown(bucket.vk); if (resync) { bucket.prevDown = down; continue; @@ -326,7 +321,7 @@ namespace Framework::Integrations::Client::Scripting::Builtins { // Alt+Enter / Alt+Space), not a script keystroke. Drop it WITHOUT latching prevDown so // the later release fires no stray "up" and a genuine press after Alt releases still // registers. Held keys (prevDown already set) and releases are unaffected. - if (down && bucket.vk != VK_MENU && IsPhysicallyDown(VK_MENU)) { + if (down && bucket.vk != FW_KEY_MENU && IsKeyDown(FW_KEY_MENU)) { continue; } bucket.prevDown = down; @@ -412,7 +407,7 @@ namespace Framework::Integrations::Client::Scripting::Builtins { void Keybinds::Shutdown() { std::scoped_lock lock(_mutex); _buckets.clear(); - _stateWasStale = false; + _sourceWasUnavailable = false; // NOTE: do NOT reset _activeCallback here. It is a HOST gate (installed via SetActiveCallback) // that captures host state living for the whole process — well beyond a single scripting session. // Clearing it on every Shutdown (disconnect) dropped keybind suppression after a RECONNECT (the diff --git a/code/framework/src/integrations/client/scripting/builtins/keybinds.h b/code/framework/src/integrations/client/scripting/builtins/keybinds.h index 08c89a3fb..64372e9e6 100644 --- a/code/framework/src/integrations/client/scripting/builtins/keybinds.h +++ b/code/framework/src/integrations/client/scripting/builtins/keybinds.h @@ -71,7 +71,7 @@ namespace Framework::Integrations::Client::Scripting::Builtins { static std::mutex _mutex; // Update() only. - static bool _stateWasStale; + static bool _sourceWasUnavailable; static std::function _activeCallback; diff --git a/code/framework/src/utils/key_names.cpp b/code/framework/src/utils/key_names.cpp index ab71029bc..d547df889 100644 --- a/code/framework/src/utils/key_names.cpp +++ b/code/framework/src/utils/key_names.cpp @@ -6,8 +6,7 @@ * See LICENSE file in the source repository for information regarding licensing. */ -// safe_win32 first: pulls in WinSock2 before Windows.h to avoid winsock1 conflicts. -#include "safe_win32.h" +#include #include "key_names.h" @@ -36,46 +35,46 @@ namespace Framework::Utils::KeyNames { t.emplace_back(std::string(1, c), c); } for (int i = 1; i <= 12; ++i) { - t.emplace_back("f" + std::to_string(i), VK_F1 + (i - 1)); + t.emplace_back("f" + std::to_string(i), FW_KEY_F1 + (i - 1)); } for (int i = 0; i <= 9; ++i) { - const int vk = VK_NUMPAD0 + i; + const int vk = FW_KEY_NUMPAD0 + i; t.emplace_back("numpad" + std::to_string(i), vk); t.emplace_back("num" + std::to_string(i), vk); } - t.emplace_back("space", VK_SPACE); - t.emplace_back("enter", VK_RETURN); - t.emplace_back("return", VK_RETURN); - t.emplace_back("escape", VK_ESCAPE); - t.emplace_back("esc", VK_ESCAPE); - t.emplace_back("tab", VK_TAB); - t.emplace_back("backspace", VK_BACK); - t.emplace_back("capslock", VK_CAPITAL); - t.emplace_back("shift", VK_SHIFT); - t.emplace_back("lshift", VK_LSHIFT); - t.emplace_back("rshift", VK_RSHIFT); - t.emplace_back("ctrl", VK_CONTROL); - t.emplace_back("control", VK_CONTROL); - t.emplace_back("lctrl", VK_LCONTROL); - t.emplace_back("rctrl", VK_RCONTROL); - t.emplace_back("alt", VK_MENU); - t.emplace_back("lalt", VK_LMENU); - t.emplace_back("ralt", VK_RMENU); - t.emplace_back("up", VK_UP); - t.emplace_back("down", VK_DOWN); - t.emplace_back("left", VK_LEFT); - t.emplace_back("right", VK_RIGHT); - t.emplace_back("insert", VK_INSERT); - t.emplace_back("delete", VK_DELETE); - t.emplace_back("home", VK_HOME); - t.emplace_back("end", VK_END); - t.emplace_back("pageup", VK_PRIOR); - t.emplace_back("pagedown", VK_NEXT); - t.emplace_back("mouse1", VK_LBUTTON); - t.emplace_back("mouse2", VK_RBUTTON); - t.emplace_back("mouse3", VK_MBUTTON); - t.emplace_back("mouse4", VK_XBUTTON1); - t.emplace_back("mouse5", VK_XBUTTON2); + t.emplace_back("space", FW_KEY_SPACE); + t.emplace_back("enter", FW_KEY_RETURN); + t.emplace_back("return", FW_KEY_RETURN); + t.emplace_back("escape", FW_KEY_ESCAPE); + t.emplace_back("esc", FW_KEY_ESCAPE); + t.emplace_back("tab", FW_KEY_TAB); + t.emplace_back("backspace", FW_KEY_BACK); + t.emplace_back("capslock", FW_KEY_CAPITAL); + t.emplace_back("shift", FW_KEY_SHIFT); + t.emplace_back("lshift", FW_KEY_LSHIFT); + t.emplace_back("rshift", FW_KEY_RSHIFT); + t.emplace_back("ctrl", FW_KEY_CONTROL); + t.emplace_back("control", FW_KEY_CONTROL); + t.emplace_back("lctrl", FW_KEY_LCONTROL); + t.emplace_back("rctrl", FW_KEY_RCONTROL); + t.emplace_back("alt", FW_KEY_MENU); + t.emplace_back("lalt", FW_KEY_LMENU); + t.emplace_back("ralt", FW_KEY_RMENU); + t.emplace_back("up", FW_KEY_UP); + t.emplace_back("down", FW_KEY_DOWN); + t.emplace_back("left", FW_KEY_LEFT); + t.emplace_back("right", FW_KEY_RIGHT); + t.emplace_back("insert", FW_KEY_INSERT); + t.emplace_back("delete", FW_KEY_DELETE); + t.emplace_back("home", FW_KEY_HOME); + t.emplace_back("end", FW_KEY_END); + t.emplace_back("pageup", FW_KEY_PRIOR); + t.emplace_back("pagedown", FW_KEY_NEXT); + t.emplace_back("mouse1", FW_KEY_LBUTTON); + t.emplace_back("mouse2", FW_KEY_RBUTTON); + t.emplace_back("mouse3", FW_KEY_MBUTTON); + t.emplace_back("mouse4", FW_KEY_XBUTTON1); + t.emplace_back("mouse5", FW_KEY_XBUTTON2); return t; }(); return table; diff --git a/code/framework/src/utils/key_names.h b/code/framework/src/utils/key_names.h index 8f1505015..bd746859f 100644 --- a/code/framework/src/utils/key_names.h +++ b/code/framework/src/utils/key_names.h @@ -11,7 +11,7 @@ #include #include -// Friendly key name <-> Win32 virtual-key code, shared by everything that lets a player or a +// Friendly key name <-> framework input code, shared by everything that lets a player or a // script name a key. Canonical names are lowercase and unspaced: "f5", "lshift", "mouse4". namespace Framework::Utils::KeyNames { // -1 for an unsupported name. Case-insensitive. diff --git a/code/tests/framework_ut.cpp b/code/tests/framework_ut.cpp index 4d9289b9a..8852fa582 100644 --- a/code/tests/framework_ut.cpp +++ b/code/tests/framework_ut.cpp @@ -47,6 +47,7 @@ // The hooking layer, and so the pattern table, is Windows-only. #include "modules/pattern_table_ut.h" #include "modules/physical_keys_ut.h" +#include "modules/polling_input_ut.h" #include "modules/window_input_ut.h" #endif @@ -95,6 +96,7 @@ int main() { UNIT_MODULE(pattern_table); UNIT_MODULE(physical_keys); UNIT_MODULE(window_input); + UNIT_MODULE(polling_input); #endif // Scripting tests diff --git a/code/tests/input_ut.cpp b/code/tests/input_ut.cpp index 4613f7319..3256824ee 100644 --- a/code/tests/input_ut.cpp +++ b/code/tests/input_ut.cpp @@ -13,6 +13,7 @@ #ifdef _WIN32 #include "modules/physical_keys_ut.h" #include "modules/window_input_ut.h" +#include "modules/polling_input_ut.h" #endif // clang-format on @@ -23,6 +24,7 @@ int main() { #ifdef _WIN32 UNIT_MODULE(window_input); UNIT_MODULE(physical_keys); + UNIT_MODULE(polling_input); #endif return UNIT_RUN(); } diff --git a/code/tests/modules/input_state_ut.h b/code/tests/modules/input_state_ut.h index 8427a3bc5..a3cf3f760 100644 --- a/code/tests/modules/input_state_ut.h +++ b/code/tests/modules/input_state_ut.h @@ -96,7 +96,7 @@ MODULE(input_state, { EQUALS(snapshot.IsDown('W'), true); EQUALS(snapshot.IsPressed('W'), false); }); - IT("stale device state holds the detector and resynchronizes on resume", { + IT("unavailable device state discards edges and reseeds on resume", { Framework::Input::KeySnapshot snapshot; bool held = false; const auto read = [&held](int key) { @@ -104,15 +104,15 @@ MODULE(input_state, { }; snapshot.Update(read, true); held = true; - snapshot.Update(read, true, true); + snapshot.Update(read, false); EQUALS(snapshot.IsPressed('T'), false); snapshot.Update(read, true); EQUALS(snapshot.IsDown('T'), true); EQUALS(snapshot.IsPressed('T'), false); - snapshot.Update(read, true, true); + snapshot.Update(read, false); EQUALS(snapshot.IsDown('T'), false); held = false; - snapshot.Update(read, true, true); + snapshot.Update(read, false); EQUALS(snapshot.IsReleased('T'), false); snapshot.Update(read, true); EQUALS(snapshot.IsDown('T'), false); diff --git a/code/tests/modules/physical_keys_ut.h b/code/tests/modules/physical_keys_ut.h index 81210bb82..26f56cd65 100644 --- a/code/tests/modules/physical_keys_ut.h +++ b/code/tests/modules/physical_keys_ut.h @@ -47,23 +47,29 @@ MODULE(physical_keys, { for (HKL layout : {english, russian, french}) { EQUALS(Framework::Input::PhysicalKeys::ToLayoutVirtualKey('W', layout), MapVirtualKeyExW(0x11, MAPVK_VSC_TO_VK_EX, layout)); EQUALS(Framework::Input::PhysicalKeys::ToLayoutVirtualKey('T', layout), MapVirtualKeyExW(0x14, MAPVK_VSC_TO_VK_EX, layout)); - // A layout-aware IInput provider converts a layout VK back into - // a DirectInput scan code. Preserve that existing API contract. + // Layout conversion stays inside the OS reader. Native device + // adapters map the same framework position straight to DirectInput. EQUALS(MapVirtualKeyExW(Framework::Input::PhysicalKeys::ToLayoutVirtualKey('W', layout), MAPVK_VK_TO_VSC_EX, layout), 0x11U); EQUALS(MapVirtualKeyExW(Framework::Input::PhysicalKeys::ToLayoutVirtualKey('T', layout), MAPVK_VK_TO_VSC_EX, layout), 0x14U); + ActivateKeyboardLayout(layout, 0); + EQUALS(Framework::Input::PhysicalKeys::ToDirectInputCode('W'), 0x11U); + EQUALS(Framework::Input::PhysicalKeys::ToDirectInputCode('T'), 0x14U); } EQUALS(Framework::Input::PhysicalKeys::ToLayoutVirtualKey('W', french), 'Z'); layoutRestore.Restore(); EQUALS(GetKeyboardLayout(0), original); }); - IT("adapts a layout-aware device provider without changing its native key API", { - const HKL layout = GetKeyboardLayout(0); - for (UINT key : {'W', 'T', 'A', 'Z', '1', '9'}) { - const UINT layoutKey = Framework::Input::PhysicalKeys::ToLayoutVirtualKey(key, layout); - // Match GameInput::MapKey: it uses the current thread's layout. - EQUALS(MapVirtualKeyW(layoutKey, MAPVK_VK_TO_VSC_EX), Framework::Input::PhysicalKeys::ToScanCode(key)); - } + IT("maps framework positions directly to native DirectInput codes", { + EQUALS(Framework::Input::PhysicalKeys::ToDirectInputCode('W'), 0x11U); + EQUALS(Framework::Input::PhysicalKeys::ToDirectInputCode('T'), 0x14U); + EQUALS(Framework::Input::PhysicalKeys::ToDirectInputCode(FW_KEY_LCONTROL), 0x1DU); + EQUALS(Framework::Input::PhysicalKeys::ToDirectInputCode(FW_KEY_RCONTROL), 0x9DU); + EQUALS(Framework::Input::PhysicalKeys::ToDirectInputCode(FW_KEY_UP), 0xC8U); + EQUALS(Framework::Input::PhysicalKeys::ToDirectInputCode(FW_KEY_NUMPAD8), 0x48U); + EQUALS(Framework::Input::PhysicalKeys::ToDirectInputCode(FW_KEY_NUMLOCK), 0x45U); + EQUALS(Framework::Input::PhysicalKeys::ToDirectInputCode(FW_KEY_PAUSE), 0xC5U); + EQUALS(Framework::Input::PhysicalKeys::ToDirectInputCode(256), 0U); }); IT("releases the same key after a layout change", { diff --git a/code/tests/modules/polling_input_ut.h b/code/tests/modules/polling_input_ut.h new file mode 100644 index 000000000..05c8c26f4 --- /dev/null +++ b/code/tests/modules/polling_input_ut.h @@ -0,0 +1,76 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include + +namespace InputTests { + class PollingAdapter final: public Framework::Input::PollingInput { + public: + std::array held {}; + bool foreground = true; + + protected: + bool HasFocus() const override { + return foreground; + } + bool ReadKeyDown(int key) const override { + return held[key]; + } + }; +} // namespace InputTests + +MODULE(polling_input, { + IT("uses the IInput query API for live held state and independent frame edges", { + InputTests::PollingAdapter source; + Framework::Input::IInput &input = source; + input.Update(); + source.held[FW_KEY_SPACE] = true; + EQUALS(input.IsKeyDown(FW_KEY_SPACE), true); + input.Update(); + EQUALS(input.IsKeyPressed(FW_KEY_SPACE), true); + EQUALS(input.IsKeyPressed(FW_KEY_SPACE), true); + input.Update(); + EQUALS(input.IsKeyPressed(FW_KEY_SPACE), false); + source.held[FW_KEY_SPACE] = false; + input.Update(); + EQUALS(input.IsKeyUp(FW_KEY_SPACE), true); + EQUALS(input.IsKeyReleased(FW_KEY_SPACE), true); + }); + IT("focus reacquisition seeds held state without a new press", { + InputTests::PollingAdapter input; + input.held['W'] = true; + input.Update(); + EQUALS(input.IsKeyDown('W'), true); + EQUALS(input.IsKeyPressed('W'), false); + input.foreground = false; + input.Update(); + EQUALS(input.IsKeyDown('W'), false); + EQUALS(input.IsKeyUp('W'), false); + EQUALS(input.IsKeyReleased('W'), false); + input.foreground = true; + input.Update(); + EQUALS(input.IsKeyDown('W'), true); + EQUALS(input.IsKeyPressed('W'), false); + }); + IT("mouse buttons use the same source and frame edge contract", { + InputTests::PollingAdapter input; + input.Update(); + input.held[FW_KEY_RBUTTON] = true; + input.Update(); + EQUALS(input.IsMouseButtonDown(1), true); + EQUALS(input.IsMouseButtonPressed(1), true); + EQUALS(input.IsKeyDown(FW_KEY_RBUTTON), true); + input.held[FW_KEY_RBUTTON] = false; + input.Update(); + EQUALS(input.IsMouseButtonReleased(1), true); + EQUALS(input.IsMouseButtonDown(-1), false); + EQUALS(input.IsMouseButtonUp(5), false); + }); +}); diff --git a/code/tests/modules/window_input_ut.h b/code/tests/modules/window_input_ut.h index 94d9dcc71..3c0c75f38 100644 --- a/code/tests/modules/window_input_ut.h +++ b/code/tests/modules/window_input_ut.h @@ -8,29 +8,23 @@ #pragma once -#include #include namespace InputTests { class WindowAdapter final: public Framework::Input::WindowInput { public: - bool physicalProvider = false; - bool stale = false; - Framework::Input::KeyCodeSpace codeSpace = Framework::Input::KeyCodeSpace::PhysicalPosition; - mutable int lastQuery = -1; - bool ProvidesPhysicalKeyState() const override { - return physicalProvider; - } - bool IsStateStale() const override { - return stale; - } - Framework::Input::KeyCodeSpace GetKeyCodeSpace() const override { - return codeSpace; + std::array held {}; + bool foreground = true; + + protected: + bool HasFocus() const override { + return foreground; } - bool IsKeyDown(int key) const override { - lastQuery = key; - return Framework::Input::WindowInput::IsKeyDown(key); + bool ReadKeyDown(int key) const override { + return held[key]; } + + public: void SetMousePosition(int, int) override {} void SetMouseVisible(bool) override {} bool IsMouseVisible() const override { @@ -49,54 +43,62 @@ namespace InputTests { MODULE(window_input, { IT("normalizes down and up even when the layout changes mid-hold", { InputTests::WindowAdapter input; + input.held['W'] = true; input.ProcessEvent(nullptr, WM_KEYDOWN, 'Z', 0x11L << 16); EQUALS(input.IsKeyDown('W'), true); EQUALS(input.IsKeyDown('Z'), false); input.Update(); + input.held['W'] = false; input.ProcessEvent(nullptr, WM_KEYUP, 'X', (0x11L << 16) | (1L << 30)); EQUALS(input.IsKeyReleased('W'), true); EQUALS(input.IsKeyDown('W'), false); - EQUALS(static_cast(input.GetKeyCodeSpace()), static_cast(Framework::Input::KeyCodeSpace::PhysicalPosition)); - EQUALS(input.ProvidesPhysicalKeyState(), false); }); IT("filters repeats including the first repeat after focus loss", { InputTests::WindowAdapter input; input.ProcessEvent(nullptr, WM_KEYDOWN, VK_F8, 0); - EQUALS(input.IsKeyPressed(VK_F8), true); + EQUALS(input.IsKeyPressed(FW_KEY_F8), true); input.Update(); input.ProcessEvent(nullptr, WM_KEYDOWN, VK_F8, 1L << 30); - EQUALS(input.IsKeyPressed(VK_F8), false); + EQUALS(input.IsKeyPressed(FW_KEY_F8), false); input.ProcessEvent(nullptr, WM_KILLFOCUS, 0, 0); input.Update(); + input.held[FW_KEY_F8] = true; input.ProcessEvent(nullptr, WM_KEYDOWN, VK_F8, 1L << 30); - EQUALS(input.IsKeyDown(VK_F8), true); - EQUALS(input.IsKeyPressed(VK_F8), false); + EQUALS(input.IsKeyDown(FW_KEY_F8), true); + EQUALS(input.IsKeyPressed(FW_KEY_F8), false); }); IT("mouse messages use button indices rather than modifier masks", { InputTests::WindowAdapter input; + input.held[FW_KEY_LBUTTON] = true; input.ProcessEvent(nullptr, WM_LBUTTONDOWN, MK_LBUTTON | MK_SHIFT | MK_CONTROL, 0); EQUALS(input.IsMouseButtonDown(0), true); EQUALS(input.IsMouseButtonDown(1), false); - EQUALS(input.IsKeyDown(VK_LBUTTON), true); + EQUALS(input.IsKeyDown(FW_KEY_LBUTTON), true); + input.held[FW_KEY_LBUTTON] = false; input.ProcessEvent(nullptr, WM_LBUTTONUP, MK_SHIFT, 0); EQUALS(input.IsMouseButtonReleased(0), true); - EQUALS(input.IsKeyDown(VK_LBUTTON), false); + EQUALS(input.IsKeyDown(FW_KEY_LBUTTON), false); + input.held[FW_KEY_XBUTTON2] = true; input.ProcessEvent(nullptr, WM_XBUTTONDOWN, static_cast(XBUTTON2) << 16, 0); EQUALS(input.IsMouseButtonDown(4), true); - EQUALS(input.IsKeyDown(VK_XBUTTON2), true); + EQUALS(input.IsKeyDown(FW_KEY_XBUTTON2), true); }); IT("generic modifiers stay held until both sides are up", { InputTests::WindowAdapter input; input.ProcessEvent(nullptr, WM_KEYDOWN, VK_SHIFT, 0x2AL << 16); input.ProcessEvent(nullptr, WM_KEYDOWN, VK_SHIFT, 0x36L << 16); input.Update(); + input.held[FW_KEY_RSHIFT] = true; + input.held[FW_KEY_SHIFT] = true; input.ProcessEvent(nullptr, WM_KEYUP, VK_SHIFT, 0x2AL << 16); - EQUALS(input.IsKeyDown(VK_LSHIFT), false); - EQUALS(input.IsKeyDown(VK_RSHIFT), true); - EQUALS(input.IsKeyDown(VK_SHIFT), true); - EQUALS(input.IsKeyReleased(VK_SHIFT), false); + EQUALS(input.IsKeyDown(FW_KEY_LSHIFT), false); + EQUALS(input.IsKeyDown(FW_KEY_RSHIFT), true); + EQUALS(input.IsKeyDown(FW_KEY_SHIFT), true); + EQUALS(input.IsKeyReleased(FW_KEY_SHIFT), false); + input.held[FW_KEY_RSHIFT] = false; + input.held[FW_KEY_SHIFT] = false; input.ProcessEvent(nullptr, WM_KEYUP, VK_SHIFT, 0x36L << 16); - EQUALS(input.IsKeyReleased(VK_SHIFT), true); + EQUALS(input.IsKeyReleased(FW_KEY_SHIFT), true); }); IT("focus loss releases every held key and mouse button and discards pending toggles", { InputTests::WindowAdapter input; @@ -118,20 +120,45 @@ MODULE(window_input, { EQUALS(x, -17); EQUALS(y, -42); }); - IT("physical providers are queried without a second layout conversion", { + IT("held queries work before a window message and are scoped to foreground", { InputTests::WindowAdapter input; - input.physicalProvider = true; - input.ProcessEvent(nullptr, WM_KEYDOWN, 'Z', 0x11L << 16); - EQUALS(Framework::Input::PhysicalKeyState::IsDown('W', &input), true); - EQUALS(input.lastQuery, 'W'); - input.stale = true; - EQUALS(Framework::Input::PhysicalKeyState::IsDown('W', &input), false); + input.held[FW_KEY_SPACE] = true; + EQUALS(input.IsKeyDown(FW_KEY_SPACE), true); + EQUALS(input.IsKeyPressed(FW_KEY_SPACE), false); + input.foreground = false; + EQUALS(input.IsAvailable(), false); + EQUALS(input.IsKeyDown(FW_KEY_SPACE), false); + EQUALS(input.IsKeyUp(FW_KEY_SPACE), false); + input.foreground = true; + EQUALS(input.IsKeyDown(FW_KEY_SPACE), true); + EQUALS(input.IsKeyPressed(FW_KEY_SPACE), false); }); - IT("legacy providers still receive virtual keys in their current layout", { + IT("the common query API rejects invalid keys and mouse button indices", { InputTests::WindowAdapter input; - input.physicalProvider = true; - input.codeSpace = Framework::Input::KeyCodeSpace::LayoutVirtualKey; - Framework::Input::PhysicalKeyState::IsDown('W', &input); - EQUALS(input.lastQuery, static_cast(Framework::Input::PhysicalKeys::ToLayoutVirtualKey('W', GetKeyboardLayout(0)))); + for (int key : {-1, 256}) { + EQUALS(input.IsKeyDown(key), false); + EQUALS(input.IsKeyUp(key), false); + EQUALS(input.IsKeyPressed(key), false); + EQUALS(input.IsKeyReleased(key), false); + } + for (int button : {-1, 5}) { + EQUALS(input.IsMouseButtonDown(button), false); + EQUALS(input.IsMouseButtonUp(button), false); + EQUALS(input.IsMouseButtonPressed(button), false); + EQUALS(input.IsMouseButtonReleased(button), false); + } + }); + IT("focus seeds already held modifiers before a second side is pressed", { + InputTests::WindowAdapter input; + input.held[FW_KEY_LSHIFT] = true; + input.held[FW_KEY_SHIFT] = true; + input.ProcessEvent(nullptr, WM_SETFOCUS, 0, 0); + EQUALS(input.IsKeyPressed(FW_KEY_SHIFT), false); + input.held[FW_KEY_RSHIFT] = true; + input.ProcessEvent(nullptr, WM_KEYDOWN, VK_SHIFT, 0x36L << 16); + EQUALS(input.IsKeyPressed(FW_KEY_RSHIFT), true); + EQUALS(input.IsKeyPressed(FW_KEY_SHIFT), false); + input.ProcessEvent(nullptr, WM_KEYUP, VK_SHIFT, 0x36L << 16); + EQUALS(input.IsKeyReleased(FW_KEY_SHIFT), false); }); }); diff --git a/docs/native_input.md b/docs/native_input.md index d8b68ec06..d0a70f7d9 100644 --- a/docs/native_input.md +++ b/docs/native_input.md @@ -4,43 +4,57 @@ Native multiplayer controls and scripted `Key` bindings share the framework's input primitives. Game adapters keep native device acquisition, control locking, cursor presentation and engine action-map policy. -## Window-message adapters +## One query API -Derive the game adapter from `Framework::Input::WindowInput` and implement the -remaining cursor and control-lock methods. Forward every window message to -`ProcessEvent` before a UI handler can consume it. Call `Update` once, after all -consumers have read that frame's pressed/released edges. +All native consumers and scripted `Key` bindings query an `IInput` instance: -The backend normalizes printable keys to US physical positions, filters -repeats, tracks both sides of modifiers, publishes mouse buttons by index and -as virtual keys, preserves signed client coordinates, and releases held state -on focus loss. Character and IME messages remain on the text/UI path. - -This is an event backend. It does not opt into `ProvidesPhysicalKeyState()`: -window events cannot discover keys which were already held when focus returned. -`PhysicalKeyState` uses its foreground-aware OS fallback for these adapters. - -## Physical polling and UI snapshots - -`PhysicalKeyState::IsDown(key, input)` accepts a key in the same physical -namespace as `Key`. An opted-in device provider must declare its key space: - -- `KeyCodeSpace::LayoutVirtualKey` is the default for existing device adapters - which translate layout virtual keys to native scan codes. -- `KeyCodeSpace::PhysicalPosition` means the adapter already accepts physical - positions, so the reader must not translate a second time. - -A stale provider answers false. Without an opted-in provider the helper polls -only while this process owns the foreground window, using that window's layout -to translate the requested physical position. +```cpp +input->IsKeyDown(FW_KEY_SPACE); +input->IsKeyPressed(FW_KEY_F9); +input->IsKeyDown('W'); +``` -Use `PhysicalKeyState::Update(snapshot)` for UI hotkeys which must remain live -while native gameplay devices are paused. `KeySnapshot` seeds held keys on -initial acquisition, foreground regain and stale-state recovery without firing -new press edges. Its state is unavailable while the source is unavailable or -stale. Gameplay availability remains a separate policy: use the client's -`IsLocalInputAvailable()` for gameplay hotkeys and explicit UI/editor policy for -controls which must work while a menu owns gameplay input. +Use the existing `FW_KEY_*` macros from ``. Letters and digits +use their uppercase character codes. Every adapter interprets printable codes +as US physical positions, independent of keyboard layout. Native scan-code and +layout conversions stay inside the backend. There is no public physical-key +query helper, provider opt-in or alternate key-code space. + +`IsAvailable()` reports whether the source has fresh state. Held queries return +false while focus or native acquisition is unavailable; edge detectors seed +held state when availability returns so held keys do not become new presses. +Gameplay policy is separate: use the client's `IsLocalInputAvailable()` for +controls which must stop while a menu owns gameplay input. + +## Window and UI sources + +`PollingInput` implements the query API through foreground-aware OS acquisition. +Its polled press/release snapshots are advanced by `Update()` before consumers +read them. Initial acquisition and foreground regain seed state without new +presses. It also supplies ordinary Win32 cursor operations; game adapters can +override those with their native cursor/control-lock policy. + +`WindowInput` adds window-message press/release edges, including taps shorter +than a frame. Derive the game adapter from it and forward every window message +to `ProcessEvent` before a UI handler consumes it. Call `Update()` after all +consumers have read the frame's edges. Held queries use the same live OS source, +so they also detect keys held before focus returned. Message decoding tracks +both modifier sides, stable mouse-button indices and signed client coordinates. +Focus loss clears pending presses and releases tracked event state for cleanup. +Focus acquisition seeds event state from held keys, including both modifier +sides, so subsequent messages do not manufacture an aggregate modifier press. +Character and IME messages remain on the text/UI path. + +A native device adapter implements the same `IInput` contract using physical +positions and declares itself unavailable when acquisition is paused. Use +`PhysicalKeys::ToDirectInputCode` inside DirectInput adapters. The layout +translation used by Win32 polling is an internal backend operation. + +UI controls which must remain live while gameplay devices are paused can own a +`PollingInput` source and use exactly the same query methods. M2O does this for +its debug/chat/escape hotkeys and key capture; gameplay and voice use its native +adapter. Source lifetime belongs to the feature/application, not to a query +flag or a compatibility switch. ## Resource-owned controls @@ -58,9 +72,9 @@ inherit native rebinding, priority, combat or inventory behavior. `FrameworkInputTests` has no renderer, browser, server or game dependency. Its portable suite covers transitions, repeat filtering, frame edge retention, -focus/stale resynchronization and resource ownership. Windows additionally -covers actual window messages, modifiers, mouse masks, physical key spaces and -the physical-key mapping suite. The same modules run in `FrameworkTests`. +availability resynchronization and resource ownership. Windows additionally +covers actual window messages, modifiers, mouse masks, common query behavior +and physical-to-native key mapping. The same modules run in `FrameworkTests`. Linux, using the canonical build: