diff --git a/code/framework/src/input/button_state.h b/code/framework/src/input/button_state.h new file mode 100644 index 000000000..cfabbfe17 --- /dev/null +++ b/code/framework/src/input/button_state.h @@ -0,0 +1,106 @@ +/* + * 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. Unavailable input does not publish release edges. + class KeySnapshot final { + public: + template + void Update(Reader &&read, bool available) { + _keys.ClearEdges(); + _available = available; + if (!available) { + _keys.ReleaseAll(); + _keys.ClearEdges(); + _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/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 694fa026e..314696619 100644 --- a/code/framework/src/input/input.h +++ b/code/framework/src/input/input.h @@ -8,17 +8,15 @@ #pragma once -#include - #include "input_keymap.h" namespace Framework::Input { 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,17 +25,11 @@ 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). - virtual bool ProvidesPhysicalKeyState() const { - return false; - } - - // 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; @@ -47,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_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/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/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 new file mode 100644 index 000000000..a631dca65 --- /dev/null +++ b/code/framework/src/input/window_input.h @@ -0,0 +1,122 @@ +/* + * 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 "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 Win32Input { + public: + void Update() override { + _keys.ClearEdges(); + _buttons.ClearEdges(); + } + + void GetMousePosition(int &x, int &y) const override { + x = _mouseX; + y = _mouseY; + } + + bool IsKeyPressed(int key) const override { + return IsAvailable() && _keys.IsPressed(key); + } + bool IsKeyReleased(int key) const override { + return _keys.IsReleased(key); + } + bool IsMouseButtonPressed(int button) const override { + return IsAvailable() && _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 == FW_KEY_SHIFT) { + side = ((flags >> 16) & 0xFF) == 0x36 ? FW_KEY_RSHIFT : FW_KEY_LSHIFT; + } + else if (physical == FW_KEY_CONTROL) { + side = (flags & (1L << 24)) != 0 ? FW_KEY_RCONTROL : FW_KEY_LCONTROL; + } + else if (physical == FW_KEY_MENU) { + side = (flags & (1L << 24)) != 0 ? FW_KEY_RMENU : FW_KEY_LMENU; + } + 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 == FW_KEY_LSHIFT || side == FW_KEY_LCONTROL || side == FW_KEY_LMENU ? side + 1 : side - 1)), recordEdge); + } + 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 ? 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. + 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..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,31 +55,9 @@ namespace Framework::Integrations::Client::Scripting::Builtins { return s; } - const Input::IInput *PhysicalKeySource() { + bool IsKeyDown(int key) { const auto *input = CoreModules::GetInput(); - return input && input->ProvidesPhysicalKeyState() ? input : nullptr; - } - - 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 && input->IsKeyDown(key); } } // anonymous namespace @@ -88,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); @@ -211,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); @@ -300,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() { @@ -309,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 { @@ -333,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; @@ -345,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; @@ -431,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/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..8852fa582 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,8 @@ // 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 // Scripting tests @@ -70,6 +73,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 +95,8 @@ int main() { #ifdef _WIN32 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 new file mode 100644 index 000000000..3256824ee --- /dev/null +++ b/code/tests/input_ut.cpp @@ -0,0 +1,30 @@ +/* + * 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" +#include "modules/polling_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); + 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 new file mode 100644 index 000000000..a3cf3f760 --- /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("unavailable device state discards edges and reseeds 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, false); + EQUALS(snapshot.IsPressed('T'), false); + snapshot.Update(read, true); + EQUALS(snapshot.IsDown('T'), true); + EQUALS(snapshot.IsPressed('T'), false); + snapshot.Update(read, false); + EQUALS(snapshot.IsDown('T'), false); + held = false; + snapshot.Update(read, false); + 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/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 new file mode 100644 index 000000000..3c0c75f38 --- /dev/null +++ b/code/tests/modules/window_input_ut.h @@ -0,0 +1,164 @@ +/* + * 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 WindowAdapter final: public Framework::Input::WindowInput { + public: + std::array held {}; + bool foreground = true; + + protected: + bool HasFocus() const override { + return foreground; + } + bool ReadKeyDown(int key) const override { + return held[key]; + } + + public: + 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.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); + }); + IT("filters repeats including the first repeat after focus loss", { + InputTests::WindowAdapter input; + input.ProcessEvent(nullptr, WM_KEYDOWN, VK_F8, 0); + EQUALS(input.IsKeyPressed(FW_KEY_F8), true); + input.Update(); + input.ProcessEvent(nullptr, WM_KEYDOWN, VK_F8, 1L << 30); + 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(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(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(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(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(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(FW_KEY_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("held queries work before a window message and are scoped to foreground", { + InputTests::WindowAdapter input; + 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("the common query API rejects invalid keys and mouse button indices", { + InputTests::WindowAdapter input; + 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 new file mode 100644 index 000000000..d0a70f7d9 --- /dev/null +++ b/docs/native_input.md @@ -0,0 +1,91 @@ +# 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. + +## One query API + +All native consumers and scripted `Key` bindings query an `IInput` instance: + +```cpp +input->IsKeyDown(FW_KEY_SPACE); +input->IsKeyPressed(FW_KEY_F9); +input->IsKeyDown('W'); +``` + +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 + +`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, +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: + +```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 +```