diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0acef5b..339459f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,13 +16,12 @@ env: # Important to pin the clang version, cause we also use it for linting CLANG_VERSION: 17 CLANG_TIDY_JOBS: 4 - # Since we use rather new c++ features, we need a rather new version of MinGW - # LLVM MinGW seems to be the newest prebuilt binaries around - LLVM_MINGW_VERSION: llvm-mingw-20230919-msvcrt-ubuntu-20.04-x86_64 - LLVM_MINGW_DOWNLOAD: https://github.com/mstorsjo/llvm-mingw/releases/download/20230919/llvm-mingw-20230919-msvcrt-ubuntu-20.04-x86_64.tar.xz + # LLVM MinGW download + LLVM_MINGW_VERSION: llvm-mingw-20231128-msvcrt-ubuntu-20.04-x86_64 + LLVM_MINGW_DOWNLOAD: https://github.com/mstorsjo/llvm-mingw/releases/download/20231128/llvm-mingw-20231128-msvcrt-ubuntu-20.04-x86_64.tar.xz # xwin settings - XWIN_VERSION: xwin-0.3.1-x86_64-unknown-linux-musl - XWIN_DOWNLOAD: https://github.com/Jake-Shadle/xwin/releases/download/0.3.1/xwin-0.3.1-x86_64-unknown-linux-musl.tar.gz + XWIN_VERSION: xwin-0.5.0-x86_64-unknown-linux-musl + XWIN_DOWNLOAD: https://github.com/Jake-Shadle/xwin/releases/download/0.5.0/xwin-0.5.0-x86_64-unknown-linux-musl.tar.gz # Python settings PYTHON_VERSION: "3.11.5" @@ -60,7 +59,7 @@ jobs: steps: - name: Restore Clang Cache - if: contains(matrix.preset, 'clang') + if: startswith(matrix.preset, 'clang') uses: actions/cache/restore@v3 with: path: C:\Program Files\LLVM @@ -68,7 +67,7 @@ jobs: fail-on-cache-miss: true - name: Add MSVC to PATH - if: contains(matrix.preset, 'msvc') + if: startswith(matrix.preset, 'msvc') uses: TheMrMilchmann/setup-msvc-dev@v2 with: arch: x64 @@ -109,14 +108,14 @@ jobs: run: python prepare_release.py ${{ matrix.preset }} --skip-install --no-bl3 --no-wl --unified - name: Prepare Release Zip (draft full) - if: inputs.new-release-tag != '' && contains(matrix.preset, 'msvc') + if: inputs.new-release-tag != '' && startswith(matrix.preset, 'msvc') run: | python prepare_release.py ${{ matrix.preset }} --skip-install mv bl3-sdk-${{ matrix.preset }}.zip bl3-sdk.zip mv wl-sdk-${{ matrix.preset }}.zip wl-sdk.zip - name: Upload Artifact - if: inputs.new-release-tag == '' || contains(matrix.preset, 'msvc') + if: inputs.new-release-tag == '' || startswith(matrix.preset, 'msvc') uses: actions/upload-artifact@v3 with: name: ${{ matrix.preset }} @@ -130,7 +129,11 @@ jobs: matrix: preset: - clang-cross-release - - mingw-release + - llvm-mingw-release + # Currently, ubuntu-latest is 22.04, whose mingw version is too old, so disabling this build + # for now + # Not sure of the exact threshold, 13.1.0 works + # - mingw-release steps: - name: Setup CMake and Ninja @@ -151,7 +154,7 @@ jobs: # Caching would also lose the +x - so we'd have to tar before caching/untar after, making it # even slower - name: Setup Clang - if: contains(matrix.preset, 'clang') + if: startswith(matrix.preset, 'clang') run: | wget https://apt.llvm.org/llvm.sh chmod +x llvm.sh @@ -173,13 +176,19 @@ jobs: /usr/bin/llvm-rc-${{ env.CLANG_VERSION }} \ 200 - - name: Setup MinGW - if: contains(matrix.preset, 'mingw') + - name: Setup LLVM MinGW + if: startswith(matrix.preset, 'llvm-mingw') run: | wget -nv ${{ env.LLVM_MINGW_DOWNLOAD }} tar -xf ${{ env.LLVM_MINGW_VERSION }}.tar.xz -C ~/ echo $(readlink -f ~/${{ env.LLVM_MINGW_VERSION }}/bin) >> $GITHUB_PATH + - name: Set up MinGW + if: startswith(matrix.preset, 'mingw') + uses: egor-tensin/setup-mingw@v2 + with: + platform: ${{ fromJSON('["x86", "x64"]')[contains(matrix.preset, 'x64')] }} + # xwin does take long enough that caching's worth it - name: Restore xwin cache if: contains(matrix.preset, 'cross') diff --git a/CMakePresets.json b/CMakePresets.json index 2ac3967..a5f96ff 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -46,6 +46,16 @@ }, "toolchainFile": "libs/pyunrealsdk/common_cmake/x86_64-w64-mingw32.cmake" }, + { + "name": "_llvm_mingw_x64", + "hidden": true, + "condition": { + "type": "notEquals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + }, + "toolchainFile": "libs/pyunrealsdk/common_cmake/llvm-x86_64-w64-mingw32.cmake" + }, { "name": "_msvc", "hidden": true, @@ -122,6 +132,22 @@ "_release" ] }, + { + "name": "llvm-mingw-debug", + "inherits": [ + "_base", + "_llvm_mingw_x64", + "_debug" + ] + }, + { + "name": "llvm-mingw-release", + "inherits": [ + "_base", + "_llvm_mingw_x64", + "_release" + ] + }, { "name": "msvc-debug", "inherits": [ diff --git a/libs/pluginloader b/libs/pluginloader index a6a9933..6370285 160000 --- a/libs/pluginloader +++ b/libs/pluginloader @@ -1 +1 @@ -Subproject commit a6a993303d79c98c4b4a4e9023a7f56497755065 +Subproject commit 6370285cbb882e39789345f212ff6255cb2f2244 diff --git a/libs/pyunrealsdk b/libs/pyunrealsdk index 82e56fe..6e79c42 160000 --- a/libs/pyunrealsdk +++ b/libs/pyunrealsdk @@ -1 +1 @@ -Subproject commit 82e56fe4435ad25b15d5a98da4670a25b416619f +Subproject commit 6e79c426bd4b7fa1831c2fa9aa1ad38d5bb252b1 diff --git a/src/__main__.py b/src/__main__.py index a62041c..c5b5bc4 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,110 +1,234 @@ import importlib +import json +import os import sys import traceback import zipfile +from collections.abc import Collection, Iterator from pathlib import Path from unrealsdk import logging -try: - import debugpy # pyright: ignore[reportMissingImports] +# If true, displays the full traceback when a mod fails to import, rather than the shortened one +FULL_TRACEBACKS: bool = False +# If true, makes debugpy wait for a client before continuing - useful for debugging errors which +# happen at import time +WAIT_FOR_CLIENT: bool = False - debugpy.listen( # pyright: ignore[reportUnknownMemberType] - ("localhost", 5678), - in_process_debug_adapter=True, - ) -except ImportError: - pass +# A json list of paths to also to import mods from - you can add your repo to keep it separated +EXTRA_FOLDERS_ENV_VAR: str = "OAK_MOD_MANAGER_EXTRA_FOLDERS" -_full_traceback = False +def init_debugpy() -> None: + """Tries to import and setup debugpy. Does nothing if unable to.""" + try: + import debugpy # pyright: ignore[reportMissingImports] -while not logging.is_console_ready(): - pass + debugpy.listen( # pyright: ignore[reportUnknownMemberType] + ("localhost", 5678), + in_process_debug_adapter=True, + ) -mods_to_import: list[str] = [] + if WAIT_FOR_CLIENT: + debugpy.wait_for_client() # pyright: ignore[reportUnknownMemberType] + debugpy.breakpoint() # pyright: ignore[reportUnknownMemberType] -for entry in Path(__file__).parent.iterdir(): - if entry.is_dir(): - if entry.name.startswith(".") or entry.name == "__pycache__": - continue - - # A lot of people accidentally extract into double nested folders - they have a - # `sdk_mods/MyCoolMod/MyCoolMod/__init__.py` instead of a `sdk_mods/MyCoolMod/__init__.py` - # Usually this silently fails - we import `MyCoolMod` but there's nothing there - # Detect this and give a proper error message - if not (entry / "__init__.py").exists() and (entry / entry.name / "__init__.py").exists(): - logging.error( - f"'{entry.name}' appears to be double nested, which may prevent it from being it" - f" from being loaded. Move the inner folder up a level.", + if "PYUNREALSDK_DEBUGPY" not in os.environ: + logging.dev_warning( + "Was able to start debugpy, but the `PYUNREALSDK_DEBUGPY` environment variable is" + " not set. This may prevent breakpoints from working properly.", ) - # Since it's a silent error, may as well continue in case it's actually what you wanted - # In the case we have a `sdk_mods/My Cool Mod v1.2`, python will try import `My Cool Mod v1` - # first, and fail when it doesn't exist. Try detect this to throw a better error. - # When this happens we're likely also double nested - `sdk_mods/My Cool Mod v1.2/MyCoolMod` - # - but we can't detect that as easily, and the problem's the same anyway - if "." in entry.name: + # Make WrappedArrays resolve the same as lists + from _pydevd_bundle.pydevd_resolver import ( # pyright: ignore[reportMissingImports] + tupleResolver, # pyright: ignore[reportUnknownVariableType] + ) + from _pydevd_bundle.pydevd_xml import ( # pyright: ignore[reportMissingImports] + _TYPE_RESOLVE_HANDLER, # pyright: ignore[reportUnknownVariableType] + ) + from unrealsdk.unreal import WrappedArray + + if not _TYPE_RESOLVE_HANDLER._initialized: # pyright: ignore[reportUnknownMemberType] + _TYPE_RESOLVE_HANDLER._initialize() # pyright: ignore[reportUnknownMemberType] + _TYPE_RESOLVE_HANDLER._default_type_map.append( # pyright: ignore[reportUnknownMemberType] + (WrappedArray, tupleResolver), + ) + + except (ImportError, AttributeError): + pass + + +def validate_mod_folder(folder: Path) -> bool: + """ + Validates a mod folder, to check if it's something we should import. + + Args: + folder: The folder to analyse. + Returns: + True if the file is a valid module to try import. + """ + if folder.name == "__pycache__": + return False + + # A lot of people accidentally extract into double nested folders - they have a + # `sdk_mods/MyCoolMod/MyCoolMod/__init__.py` instead of a `sdk_mods/MyCoolMod/__init__.py` + # Usually this silently fails - we import `MyCoolMod` but there's nothing there + # Detect this and give a proper error message + if not (folder / "__init__.py").exists() and (folder / folder.name / "__init__.py").exists(): + logging.error( + f"'{folder.name}' appears to be double nested, which may prevent it from being it from" + f" being loaded. Move the inner folder up a level.", + ) + # Since it's a silent error, may as well continue in case it's actually what you wanted + + # In the case we have a `sdk_mods/My Cool Mod v1.2`, python will try import `My Cool Mod v1` + # first, and fail when it doesn't exist. Try detect this to throw a better error. + # When this happens we're likely also double nested - `sdk_mods/My Cool Mod v1.2/MyCoolMod` + # - but we can't detect that as easily, and the problem's the same anyway + if "." in folder.name: + logging.error( + f"'{folder.name}' is not a valid python module - have you extracted the right folder?", + ) + return False + + return True + + +def validate_mod_file(file: Path) -> bool: + """ + Validates a mod file, to check if it's something we should import. + + Args: + file: The file to analyse. + Returns: + True if the file is a valid .sdkmod to try import. + """ + match file.suffix.lower(): + # Since hotfix mods can be any text file, this won't be exhaustive, but match and warn + # about what we can + # OHL often uses .url files to download the latest version of a mod, so also match that + case ".bl3hotfix" | ".wlhotfix" | ".url": logging.error( - f"'{entry.name}' is not a valid python module - have you extracted the right" - f" folder?", + f"'{file.name}' appears to be a hotfix mod, not an SDK mod. Move it to your hotfix" + f" mods folder.", ) - continue + return False - mods_to_import.append(entry.name) + case ".sdkmod": + # Handled below + pass - elif entry.is_file(): - if entry.name.startswith("."): + case _: + return False + + valid_zip: bool + try: + zip_iter = zipfile.Path(file).iterdir() + zip_entry = next(zip_iter) + valid_zip = zip_entry.name == file.stem and next(zip_iter, None) is None + except (zipfile.BadZipFile, StopIteration, OSError): + valid_zip = False + + if not valid_zip: + logging.error( + f"'{file.name}' does not appear to be valid, and has been ignored.", + ) + logging.dev_warning( + "'.sdkmod' files must be a zip, and may only contain a single root folder, which must" + " be named the same as the zip (excluding suffix).", + ) + return False + + return True + + +def iter_mod_folders() -> Iterator[Path]: + """ + Iterates through all the mod folders to try search, adding them to sys.path along the way. + + Yields: + Mod folder paths. + """ + yield Path(__file__).parent + + extra_folders: list[Path] + try: + extra_folders = [Path(x) for x in json.loads(os.environ.get(EXTRA_FOLDERS_ENV_VAR, ""))] + except (json.JSONDecodeError, TypeError): + return + + for folder in extra_folders: + if not folder.exists() or not folder.is_dir(): + logging.dev_warning(f"Extra mod folder does not exist: {folder}") continue + sys.path.append(str(folder.resolve())) - match entry.suffix.lower(): - # Since hotfix mods can be any text file, this won't be exhaustive, but match and warn - # about what we can - # OHL often uses .url files to download the latest version of a mod, so also match that - case ".bl3hotfix" | ".wlhotfix" | ".url": - logging.error( - f"'{entry.name}' appears to be a hotfix mod, not an SDK mod. Move it to your" - f" hotfix mods folder.", - ) - continue + yield folder - case ".sdkmod": - # Handled below the match - pass - case _: +def get_mods_to_import() -> Collection[str]: + """ + Sets up sys.path and gathers all the mods to try import. + + Returns: + A collection of the module names to import. + """ + mods_to_import: list[str] = [] + + # We want all .sdkmods to appear at the end of sys.path, after all of the folders, so store them + # separately for now + dot_sdkmod_sys_path_entries: list[str] = [] + + for folder in iter_mod_folders(): + for entry in folder.iterdir(): + if entry.name.startswith("."): continue - valid_zip: bool + if entry.is_dir() and validate_mod_folder(entry): + mods_to_import.append(entry.name) + + elif entry.is_file() and validate_mod_file(entry): + dot_sdkmod_sys_path_entries.append(str(entry)) + mods_to_import.append(entry.stem) + + sys.path += dot_sdkmod_sys_path_entries + + return mods_to_import + + +def import_mod_manager() -> None: + """ + Imports any mod manager modules which have specific initialization order requirements. + + Most modules are fine to get imported as a mod/by another mod, but we need to do a few manually. + """ + # Keybinds uses a native module, so will always be extracted, don't need to mess with sys.path + import keybinds # noqa: F401 # pyright: ignore[reportUnusedImport] + + +def import_mods() -> None: + """Tries to import all mods.""" + for name in get_mods_to_import(): try: - zip_iter = zipfile.Path(entry).iterdir() - zip_entry = next(zip_iter) - valid_zip = zip_entry.name == entry.stem and next(zip_iter, None) is None - except (zipfile.BadZipFile, StopIteration): - valid_zip = False + importlib.import_module(name) + except Exception as ex: # noqa: BLE001 + logging.error(f"Failed to import mod '{name}'") - if not valid_zip: - logging.error( - f"'{entry.name}' does not appear to be valid, and has been ignored.", - ) - logging.dev_warning( - "'.sdkmod' files must be a zip, and may only contain a single root folder, which" - " must be named the same as the zip (excluding suffix).", - ) - continue + tb = traceback.extract_tb(ex.__traceback__) + if not FULL_TRACEBACKS: + tb = tb[-1:] - sys.path.append(str(entry)) - mods_to_import.append(entry.stem) + logging.error("".join(traceback.format_exception_only(ex))) + logging.error("".join(traceback.format_list(tb))) -for name in mods_to_import: - try: - importlib.import_module(name) - except Exception as ex: # noqa: BLE001 - logging.error(f"Failed to import mod '{name}'") - tb = traceback.extract_tb(ex.__traceback__) - if not _full_traceback: - tb = tb[-1:] +# Don't really want to put a `__name__` check here, since it's currently just `builtins`, and that +# seems a bit unstable, like something that pybind might eventually change + +init_debugpy() + +while not logging.is_console_ready(): + pass - logging.error("".join(traceback.format_exception_only(ex))) - logging.error("".join(traceback.format_list(tb))) +import_mod_manager() +import_mods() diff --git a/src/bl3_mod_menu/dialog_box.py b/src/bl3_mod_menu/dialog_box.py index c8a60ca..35d3e0e 100644 --- a/src/bl3_mod_menu/dialog_box.py +++ b/src/bl3_mod_menu/dialog_box.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Sequence from dataclasses import InitVar, dataclass, field from typing import Any, ClassVar, Self @@ -70,7 +70,7 @@ class vars will be passed as a fallback. These actions are: CLOSED: ClassVar[DialogBoxChoice] = DialogBoxChoice("Closed", close_on_select=True) header: str - choices: list[DialogBoxChoice] + choices: Sequence[DialogBoxChoice] body: str = "" may_cancel: bool = True diff --git a/src/bl3_mod_menu/native/options_setup.cpp b/src/bl3_mod_menu/native/options_setup.cpp index a3bf2b3..c0b3428 100644 --- a/src/bl3_mod_menu/native/options_setup.cpp +++ b/src/bl3_mod_menu/native/options_setup.cpp @@ -447,7 +447,8 @@ PYBIND11_MODULE(options_setup, m) { " copying the name.\n" " description: The slider's description.", "self"_a, "name"_a, "value"_a, "slider_min"_a, "slider_max"_a, "slider_step"_a = 1.0, - "slider_is_integer"_a = false, "description_title"_a = std::nullopt, "description"_a = L""); + "slider_is_integer"_a = false, "description_title"_a = std::nullopt, + "description"_a = std::wstring{}); m.def( "add_spinner", @@ -470,7 +471,7 @@ PYBIND11_MODULE(options_setup, m) { " copying the name.\n" " description: The spinner's description.", "self"_a, "name"_a, "idx"_a, "options"_a, "wrap_enabled"_a = false, - "description_title"_a = std::nullopt, "description"_a = L""); + "description_title"_a = std::nullopt, "description"_a = std::wstring{}); m.def( "add_bool_spinner", @@ -493,7 +494,7 @@ PYBIND11_MODULE(options_setup, m) { " copying the name.\n" " description: The spinner's description.", "self"_a, "name"_a, "value"_a, "true_text"_a = std::nullopt, "false_text"_a = std::nullopt, - "description_title"_a = std::nullopt, "description"_a = L""); + "description_title"_a = std::nullopt, "description"_a = std::wstring{}); m.def( "add_dropdown", @@ -514,7 +515,7 @@ PYBIND11_MODULE(options_setup, m) { " copying the name.\n" " description: The dropdown's description.", "self"_a, "name"_a, "idx"_a, "options"_a, "description_title"_a = std::nullopt, - "description"_a = L""); + "description"_a = std::wstring{}); m.def( "add_button", @@ -531,7 +532,7 @@ PYBIND11_MODULE(options_setup, m) { " description_title: The title of the button's description. Defaults to\n" " copying the name.\n" " description: The button's description.", - "self"_a, "name"_a, "description_title"_a = std::nullopt, "description"_a = L""); + "self"_a, "name"_a, "description_title"_a = std::nullopt, "description"_a = std::wstring{}); m.def( "add_binding", @@ -550,5 +551,5 @@ PYBIND11_MODULE(options_setup, m) { " copying the name.\n" " description: The binding's description.", "self"_a, "name"_a, "display"_a, "description_title"_a = std::nullopt, - "description"_a = L""); + "description"_a = std::wstring{}); } diff --git a/src/bl3_mod_menu/outer_menu.py b/src/bl3_mod_menu/outer_menu.py index f83eb63..e0c4cc7 100644 --- a/src/bl3_mod_menu/outer_menu.py +++ b/src/bl3_mod_menu/outer_menu.py @@ -74,6 +74,12 @@ def add_menu_item_hook( always_minus_one: int, ) -> int: """Hook to inject the outermost mods option.""" + + # Add the mods option right before quit + if callback_name == "OnQuitClicked": + global last_mods_menu_idx + last_mods_menu_idx = add_menu_item(self, "MODS", "OnInviteListClearClicked", False, -1) + if callback_name in hide_menu_options and hide_menu_options[callback_name].value: # Surprisingly, just setting the return value to -1 just works, no menu item is drawn and # nothing seems to go wrong @@ -82,10 +88,6 @@ def add_menu_item_hook( # Show the item properly idx = add_menu_item(self, text, callback_name, big, always_minus_one) - if callback_name in ("OnStoreClicked", "OnPhotoModeClicked"): - global last_mods_menu_idx - last_mods_menu_idx = add_menu_item(self, "MODS", "OnInviteListClearClicked", False, -1) - return idx diff --git a/src/keybinds/__init__.py b/src/keybinds/__init__.py index 1b94f84..7e360bc 100644 --- a/src/keybinds/__init__.py +++ b/src/keybinds/__init__.py @@ -1,10 +1,20 @@ -from mods_base import EInputEvent, Game -from mods_base.mod_list import base_mod, mod_list -from mods_base.raw_keybinds import raw_keybind_callback_stack -from unrealsdk.hooks import Block -from unrealsdk.unreal import UObject +from functools import wraps +from typing import cast + +from mods_base import KeybindType +from mods_base.keybinds import KeybindCallback_Event, KeybindCallback_NoArgs +from mods_base.mod_list import base_mod +from mods_base.raw_keybinds import ( + RawKeybind, + RawKeybindCallback_EventOnly, + RawKeybindCallback_KeyAndEvent, + RawKeybindCallback_KeyOnly, + RawKeybindCallback_NoArgs, +) + +from .keybinds import deregister_keybind, register_keybind -from .keybinds import set_keybind_callback +# from mods_base.raw_keybinds import raw_keybind_callback_stack __all__: tuple[str, ...] = ( "__author__", @@ -12,84 +22,103 @@ "__version_info__", ) -__version_info__: tuple[int, int] = (1, 0) +__version_info__: tuple[int, int] = (2, 0) __version__: str = f"{__version_info__[0]}.{__version_info__[1]}" __author__: str = "bl-sdk" -def handle_raw_keybind(pc: UObject, key: str, event: EInputEvent) -> bool: - """ - Handler which calls raw keybind callbacks. - - Args: - pc: The OakPlayerController which caused the event. - key: The key which was pressed. - event: Which type of input happened. - Returns: - True if the key event should be blocked. - """ - _ = pc - - should_block = False - if len(raw_keybind_callback_stack) > 0: - for callback in raw_keybind_callback_stack[-1]: - ret = callback(key, event) - - if ret == Block or isinstance(ret, Block): - should_block = True - - return should_block - - -def handle_gameplay_keybind(pc: UObject, key: str, event: EInputEvent) -> bool: - """ - Handler which calls gameplay keybind callbacks. - - Args: - pc: The OakPlayerController which caused the event. - key: The key which was pressed. - event: Which type of input happened. - Returns: - True if the key event should be blocked. - """ - # Early exit if in a menu - if Game.get_current() == Game.WL: - # pc.IsInMenu() doesn't work in WL, since it uses a different menu system - # Haven't found the correct replacement, but just checking cursor seems to work well enough, - # even works on controller when no cursor is actually drawn - if pc.bShowMouseCursor: - return False +@wraps(KeybindType.enable) +def enable_keybind(self: KeybindType) -> None: + if self.key is None or self.callback is None: + return + + # While this is redundant, it keeps the type checking happy + if self.event_filter is None: + handle = register_keybind( + self.key, + self.event_filter, + True, + cast(KeybindCallback_Event, self.callback), + ) else: - if pc.IsInMenu(): - return False + handle = register_keybind( + self.key, + self.event_filter, + True, + cast(KeybindCallback_NoArgs, self.callback), + ) + + self._kb_handle = handle # type: ignore + + +KeybindType.enable = enable_keybind + + +@wraps(KeybindType.disable) +def disable_keybind(self: KeybindType) -> None: + handle = getattr(self, "_kb_handle", None) + if handle is None: + return + + deregister_keybind(handle) + self._kb_handle = None # type: ignore + + +KeybindType.disable = disable_keybind + + +@wraps(RawKeybind.enable) +def enable_raw_keybind(self: RawKeybind) -> None: + # Even more redundancy for type checking + # Can't use a match statement since an earlier `case None:` doesn't remove None from later cases + if self.key is None: + if self.event is None: + handle = register_keybind( + self.key, + self.event, + False, + cast(RawKeybindCallback_KeyAndEvent, self.callback), + ) + else: + handle = register_keybind( + self.key, + self.event, + False, + cast(RawKeybindCallback_KeyOnly, self.callback), + ) + else: + if self.event is None: + handle = register_keybind( + self.key, + self.event, + False, + cast(RawKeybindCallback_EventOnly, self.callback), + ) + else: + handle = register_keybind( + self.key, + self.event, + False, + cast(RawKeybindCallback_NoArgs, self.callback), + ) + + self._kb_handle = handle # type: ignore - should_block = False - for mod in mod_list: - if not mod.is_enabled: - continue - for bind in mod.keybinds: - if bind.callback is None: - continue - if bind.key != key: - continue +RawKeybind.enable = enable_raw_keybind - ret = bind.callback(event) - if ret == Block or isinstance(ret, Block): - should_block = True - return should_block +@wraps(RawKeybind.disable) +def disable_raw_keybind(self: RawKeybind) -> None: + handle = getattr(self, "_kb_handle", None) + if handle is None: + return + deregister_keybind(handle) + self._kb_handle = None # type: ignore -@set_keybind_callback -def keybind_handler(pc: UObject, key: str, event: EInputEvent) -> None | Block | type[Block]: - """General keybind handler.""" - if handle_raw_keybind(pc, key, event): - return Block - if handle_gameplay_keybind(pc, key, event): - return Block - return None +RawKeybind.disable = disable_raw_keybind base_mod.components.append(base_mod.ComponentInfo("Keybinds", __version__)) diff --git a/src/keybinds/keybinds.cpp b/src/keybinds/keybinds.cpp index e14ff87..9bf8393 100644 --- a/src/keybinds/keybinds.cpp +++ b/src/keybinds/keybinds.cpp @@ -8,28 +8,166 @@ #include "unrealsdk/memory.h" #include "unrealsdk/unreal/class_name.h" #include "unrealsdk/unreal/classes/properties/copyable_property.h" +#include "unrealsdk/unreal/classes/properties/uboolproperty.h" #include "unrealsdk/unreal/classes/uenum.h" #include "unrealsdk/unreal/classes/uobject.h" +#include "unrealsdk/unreal/classes/uobject_funcs.h" #include "unrealsdk/unreal/classes/uscriptstruct.h" #include "unrealsdk/unreal/structs/fname.h" +#include "unrealsdk/unreal/wrappers/bound_function.h" #include "unrealsdk/unreal/wrappers/wrapped_struct.h" #include "unrealsdk/unrealsdk.h" +#include + using namespace unrealsdk::memory; using namespace unrealsdk::unreal; -auto key_struct_type = - validate_type(unrealsdk::find_object(L"ScriptStruct", L"/Script/InputCore.Key")); -auto key_name_prop = key_struct_type->find_prop_and_validate(L"KeyName"_fn); +using AOakPlayerController = UObject; +using EInputEvent = uint32_t; + +namespace processing { + pyunrealsdk::StaticPyObject input_event_enum = pyunrealsdk::unreal::enum_as_py_enum( validate_type(unrealsdk::find_object(L"Enum", L"/Script/Engine.EInputEvent"))); -pyunrealsdk::StaticPyObject keybind_callback{}; +struct PY_OBJECT_VISIBILITY KeybindData { + pyunrealsdk::StaticPyObject callback; + std::optional event; + bool gameplay_bind{}; +}; -using AOakPlayerController = UObject; -using FKey = void; -using EInputEvent = uint32_t; +const FName ANY_KEY{0, 0}; +std::unordered_multimap> all_keybinds{}; + +/** + * @brief Checks if the given player controller is in a menu. + * + * @param player_controller The player controller to check. + * @return True if in a menu. + */ +bool is_in_menu(AOakPlayerController* player_controller) { + // WL and BL3 use two different menu systems, so we need to check differently on each of them. + static auto is_bl3 = + unrealsdk::utils::get_executable().filename().string() == "Borderlands3.exe"; + + if (is_bl3) { + // This is the more correct method - but it doesn't work under WL + static auto is_in_menu = validate_type( + unrealsdk::find_object(L"Function", L"/Script/OakGame.OakPlayerController:IsInMenu")); + + return BoundFunction{is_in_menu, player_controller}.call(); + + } else { // NOLINT(readability-else-after-return) + + // This is less correct, but it seems to work well enough, even works on controller when no + // cursor is actually drawn + // Since this uses a generic playercontroller property, rather than something oak-specific, + // we default to it on unknown executables + static auto show_mouse_cursor = validate_type(unrealsdk::find_object( + L"BoolProperty", L"/Script/Engine.PlayerController:bShowMouseCursor")); + + return player_controller->get(show_mouse_cursor); + } +} + +/** + * @brief Handles a key event. + * + * @param player_controller The player controller who pressed the key. + * @param key_name The key's name. + * @param input_event What type of event it was. + * @return True if to block key processing, false to allow it through. + */ +bool handle_key_event(AOakPlayerController* player_controller, + FName key_name, + EInputEvent input_event) { + // The original keybind implementation was mostly python. It caused massive lockups if you + // scrolled, even without freescroll it was relatively easy to trigger half second freezes. + + // In this implementation, we therefore try our best to keep everything as fast as possible, + // which also means touching python as little as possible + + const auto any_key_match = all_keybinds.equal_range(ANY_KEY); + const auto specific_key_match = all_keybinds.equal_range(key_name); + + const std::array, 2> both_matches{{ + std::ranges::subrange(any_key_match.first, any_key_match.second), + std::ranges::subrange(specific_key_match.first, specific_key_match.second), + }}; + auto with_matching_key = both_matches | std::views::join; + + auto with_matching_event = with_matching_key | std::views::filter([input_event](auto ittr) { + auto data = ittr.second; + return !(data->event.has_value() && data->event != input_event); + }); + + if (with_matching_event.empty()) { + return false; + } + + auto raw_binds = with_matching_event + | std::views::filter([](auto ittr) { return !ittr.second->gameplay_bind; }); + auto gameplay_binds = with_matching_event | std::views::filter([](auto ittr) { + return ittr.second->gameplay_bind; + }); + + // Checking if we're in a menu is potentially slow (it may call an unreal function), so don't + // need to do it if we don't have any gameplay binds + auto dont_run_gameplay_binds = gameplay_binds.empty() || is_in_menu(player_controller); + if (dont_run_gameplay_binds && raw_binds.empty()) { + return false; + } + + const py::gil_scoped_acquire gil{}; + + // We might be able to get away with skipping creating this enum, saves us some more time. + py::object event_as_enum{}; + + auto run_callbacks = [key_name, &event_as_enum, input_event](auto range) { + bool should_block = false; + for (const auto& ittr : range) { + auto [key, data] = ittr; + + py::list args; + if (key == ANY_KEY) { + args.append(key_name); + } + if (!data->event.has_value()) { + if (!event_as_enum) { + event_as_enum = input_event_enum(input_event); + } + args.append(event_as_enum); + } + + auto ret = data->callback(*args); + if (pyunrealsdk::hooks::is_block_sentinel(ret)) { + should_block = true; + } + } + return should_block; + }; + + if (run_callbacks(raw_binds)) { + return true; + } + if (!dont_run_gameplay_binds && run_callbacks(gameplay_binds)) { + return true; + } + + return false; +} + +} // namespace processing + +namespace hook { + +auto key_struct_type = + validate_type(unrealsdk::find_object(L"ScriptStruct", L"/Script/InputCore.Key")); +auto key_name_prop = key_struct_type->find_prop_and_validate(L"KeyName"_fn); + +using FKey = void; using oakpc_inputkey_func = uintptr_t (*)(AOakPlayerController* self, FKey* key, EInputEvent input_event, @@ -49,48 +187,92 @@ uintptr_t oakpc_inputkey_hook(AOakPlayerController* self, EInputEvent input_event, float press_duration, uint32_t gamepad_id) { - if (keybind_callback) { - try { - auto key_name = WrappedStruct{key_struct_type, key}.get(key_name_prop); + try { + auto key_name = WrappedStruct{key_struct_type, key}.get(key_name_prop); - const py::gil_scoped_acquire gil{}; - pyunrealsdk::debug_this_thread(); - - auto player_controller = pyunrealsdk::type_casters::cast(self); - auto event_enum = input_event_enum(input_event); - - auto ret = keybind_callback(player_controller, key_name, event_enum); - - if (pyunrealsdk::hooks::is_block_sentinel(ret)) { - return 0; - } - - } catch (const std::exception& ex) { - pyunrealsdk::logging::log_python_exception(ex); + if (processing::handle_key_event(self, key_name, input_event)) { + return 0; } + + } catch (const std::exception& ex) { + pyunrealsdk::logging::log_python_exception(ex); } return oakpc_inputkey_ptr(self, key, input_event, press_duration, gamepad_id); } +} // namespace hook + // NOLINTNEXTLINE(readability-identifier-length) PYBIND11_MODULE(keybinds, m) { - detour(OAKPC_INPUTKEY_PATTERN.sigscan(), oakpc_inputkey_hook, &oakpc_inputkey_ptr, - "OakPlayerController::InputKey"); + detour(hook::OAKPC_INPUTKEY_PATTERN.sigscan(), hook::oakpc_inputkey_hook, + &hook::oakpc_inputkey_ptr, "OakPlayerController::InputKey"); m.def( - "set_keybind_callback", [](const py::object& callback) { keybind_callback = callback; }, - "Sets the keybind callback.\n" + "register_keybind", + [](const std::optional& key, const std::optional& event, + bool gameplay_bind, const py::object& callback) -> void* { + auto key_name = key.has_value() ? *key : processing::ANY_KEY; + auto data = std::make_shared(callback, event, gameplay_bind); + + processing::all_keybinds.emplace(std::make_pair(key_name, data)); + return data.get(); + }, + "Registers a new keybind.\n" "\n" - "The callback takes three positional args:\n" - " pc: The OakPlayerController which caused the event.\n" - " key: The key which was pressed.\n" - " event: Which type of input happened.\n" + "If key or event are None, any key or event will be matched, and their values\n" + "will be passed back to the callback. Therefore, based on these args, the\n" + "callback is run with 0-2 arguments.\n" "\n" "The callback may return the sentinel `Block` type (or an instance thereof) in\n" "order to block normal processing of the key event.\n" "\n" "Args:\n" - " callback: The callback to use.", - "callback"_a); + " key: The key to match, or None to match any.\n" + " event: The key event to match, or None to match any.\n" + " gameplay_bind: True if this keybind should only trigger during gameplay.\n" + " callback: The callback to use.\n" + "Returns:\n" + " An opaque handle to be used in calls to deregister_keybind.", + "key"_a, "event"_a, "gameplay_bind"_a, "callback"_a); + + m.def( + "deregister_keybind", + [](void* handle) { + std::erase_if(processing::all_keybinds, [handle](const auto& entry) { + const auto& [key, data] = entry; + return data.get() == handle; + }); + }, + "Removes a previously registered keybind.\n" + "\n" + "Does nothing if the passed handle is invalid.\n" + "\n" + "Args:\n" + " handle: The handle returned from `register_keybind`.", + "handle"_a); + + m.def( + "_deregister_by_key", + [](const std::optional& key) { + auto key_to_erase = key.has_value() ? *key : processing::ANY_KEY; + std::erase_if(processing::all_keybinds, [key_to_erase](const auto& entry) { + const auto& [key_in_map, data] = entry; + return key_to_erase == key_in_map; + }); + }, + "Deregisters all keybinds matching the given key.\n" + "\n" + "Not intended for regular use, only exists for recovery during debugging, in case\n" + "a handle was lost.\n" + "\n" + "Args:\n" + " key: The key to remove all keybinds of."); + + m.def( + "_deregister_all", []() { processing::all_keybinds.clear(); }, + "Deregisters all keybinds.\n" + "\n" + "Not intended for regular use, only exists for recovery during debugging, in case\n" + "a handle was lost."); } diff --git a/src/keybinds/keybinds.pyi b/src/keybinds/keybinds.pyi index 06ed313..b09ec54 100644 --- a/src/keybinds/keybinds.pyi +++ b/src/keybinds/keybinds.pyi @@ -1,32 +1,97 @@ from __future__ import annotations from collections.abc import Callable -from typing import TypeAlias +from typing import NewType, TypeAlias, overload from mods_base import EInputEvent from unrealsdk.hooks import Block -from unrealsdk.unreal import UObject -__all__: tuple[str, ...] = ("set_keybind_callback",) +__all__: tuple[str, ...] = ( + "register_keybind", + "deregister_keybind", +) -_OakPlayerController: TypeAlias = UObject -_KeybindCallback: TypeAlias = Callable[ - [_OakPlayerController, str, EInputEvent], - None | Block | type[Block], -] +_KeybindHandle = NewType("_KeybindHandle", object) +_BlockSignal: TypeAlias = None | Block | type[Block] -def set_keybind_callback(callback: _KeybindCallback) -> None: +@overload +def register_keybind( + key: str, + event: EInputEvent, + gameplay_bind: bool, + callback: Callable[[], _BlockSignal], +) -> _KeybindHandle: ... +@overload +def register_keybind( + key: None, + event: EInputEvent, + gameplay_bind: bool, + callback: Callable[[str], _BlockSignal], +) -> _KeybindHandle: ... +@overload +def register_keybind( + key: str, + event: None, + gameplay_bind: bool, + callback: Callable[[EInputEvent], _BlockSignal], +) -> _KeybindHandle: ... +@overload +def register_keybind( + key: None, + event: None, + gameplay_bind: bool, + callback: Callable[[str, EInputEvent], _BlockSignal], +) -> _KeybindHandle: ... +def register_keybind( + key: str | None, + event: EInputEvent | None, + gameplay_bind: bool, + callback: Callable[..., _BlockSignal], +) -> _KeybindHandle: """ - Sets the keybind callback. + Registers a new keybind. - The callback takes three positional args: - pc: The OakPlayerController which caused the event. - key: The key which was pressed. - event: Which type of input happened. + If key or event are None, any key or event will be matched, and their values + will be passed back to the callback. Therefore, based on these args, the + callback is run with 0-2 arguments. The callback may return the sentinel `Block` type (or an instance thereof) in order to block normal processing of the key event. Args: + key: The key to match, or None to match any. + event: The key event to match, or None to match any. + gameplay_bind: True if this keybind should only trigger during gameplay. callback: The callback to use. + Returns: + An opaque handle to be used in calls to deregister_keybind. + """ + +def deregister_keybind(handle: _KeybindHandle) -> None: + """ + Removes a previously registered keybind. + + Does nothing if the passed handle is invalid. + + Args: + handle: The handle returned from `register_keybind`. + """ + +def _deregister_by_key(key: str | None) -> None: + """ + Deregisters all keybinds matching the given key. + + Not intended for regular use, only exists for recovery during debugging, in case + a handle was lost. + + Args: + key: The key to remove all keybinds of. + """ + +def _deregister_all() -> None: + """ + Deregisters all keybinds. + + Not intended for regular use, only exists for recovery during debugging, in case + a handle was lost. """ diff --git a/src/mods_base/__init__.py b/src/mods_base/__init__.py index 98c7a08..e469431 100644 --- a/src/mods_base/__init__.py +++ b/src/mods_base/__init__.py @@ -1,5 +1,6 @@ import tomllib from pathlib import Path +from typing import Literal, overload import unrealsdk from unrealsdk.unreal import UObject @@ -109,10 +110,27 @@ _PLAYER_CONTROLLER_PROP = _LOCAL_PLAYERS_PROP.Inner.PropertyClass._find_prop("PlayerController") +@overload def get_pc() -> UObject: + ... + + +@overload +def get_pc(*, possibly_loading: Literal[True] = True) -> UObject | None: + ... + + +def get_pc(*, possibly_loading: bool = False) -> UObject | None: # noqa: ARG001 """ Gets the main (local) player controller object. + Note that this may return None if called during a loading screen. Since hooks and keybinds + should never be able to trigger at this time, for convenience the default type hinting does not + include this possibility. If running on another thread however, this can happen, pass the + `possibly_loading` kwarg to update the type hinting. + + Args: + possibly_loading: Changes the type hinting to possibly return None. No runtime impact. Returns: The player controller. """ diff --git a/src/mods_base/hook.py b/src/mods_base/hook.py index 000a1b9..daeeba7 100644 --- a/src/mods_base/hook.py +++ b/src/mods_base/hook.py @@ -120,7 +120,7 @@ def _hook_bind(self: HookProtocol, obj: Any) -> HookProtocol: @overload def hook( hook_func: str, - hook_type: Literal[Type.PRE], + hook_type: Literal[Type.PRE] = Type.PRE, *, auto_enable: bool = False, ) -> Callable[[AnyPreHook], HookProtocol]: @@ -139,7 +139,7 @@ def hook( def hook( hook_func: str, - hook_type: Type, + hook_type: Type = Type.PRE, *, auto_enable: bool = False, hook_identifier: str | None = None, diff --git a/src/mods_base/keybinds.py b/src/mods_base/keybinds.py index 48830b3..358d67d 100644 --- a/src/mods_base/keybinds.py +++ b/src/mods_base/keybinds.py @@ -1,10 +1,10 @@ from __future__ import annotations -import functools from collections.abc import Callable from dataclasses import KW_ONLY, dataclass, field -from typing import TYPE_CHECKING, Any, TypeAlias, cast, overload +from typing import TYPE_CHECKING, Any, TypeAlias, overload +from unrealsdk import logging from unrealsdk.hooks import Block if TYPE_CHECKING: @@ -49,6 +49,7 @@ class KeybindType: description_title: The title of the description. Defaults to copying the display name. is_hidden: If true, the keybind will not be shown in the options menu. is_rebindable: If the key may be rebound. + event_filter: If not None, only runs the callback when the given event fires. Extra Attributes: default_key: What the key was originally when registered. Does not update on rebind. @@ -57,7 +58,10 @@ class KeybindType: identifier: str key: str | None - callback: KeybindCallback_Event | None = None + # If `event_filter` is None, `callback` should be `KeybindCallback_Event | None` + # If `event_filter` is not None, `callback` should be `KeybindCallback_NoArgs | None` + # The decorator uses overloads to enforce this + callback: KeybindCallback_Event | KeybindCallback_NoArgs | None = None _: KW_ONLY display_name: str = None # type: ignore @@ -65,6 +69,7 @@ class KeybindType: description_title: str = None # type: ignore is_hidden: bool = False is_rebindable: bool = True + event_filter: EInputEvent | None = EInputEvent.IE_Released default_key: str | None = field(init=False) @@ -76,6 +81,17 @@ def __post_init__(self) -> None: self.default_key = self.key + # These two functions should get replaced by the keybind implementation + # The initialization script should make sure to load it before any mods, to make sure they don't + # end up with references to these functions + def enable(self) -> None: + """Enables this keybind.""" + logging.error("No keybind implementation loaded, unable to enable binds") + + def disable(self) -> None: + """Disables this keybind.""" + logging.error("No keybind implementation loaded, unable to disable binds") + @overload def keybind( @@ -179,31 +195,18 @@ def keybind( """ def decorator(func: KeybindCallback_NoArgs | KeybindCallback_Event) -> KeybindType: - event_func: KeybindCallback_Event - if event_filter is not None: - no_arg_func = cast(KeybindCallback_NoArgs, func) - - @functools.wraps(no_arg_func) - def event_filtering_callback(event: EInputEvent) -> KeybindBlockSignal: - if event != event_filter: - return None - return no_arg_func() - - event_func = event_filtering_callback - else: - event_func = cast(KeybindCallback_Event, func) - kwargs: dict[str, Any] = { "description": description, "is_hidden": is_hidden, "is_rebindable": is_rebindable, + "event_filter": event_filter, } if display_name is not None: kwargs["display_name"] = display_name if description_title is not None: kwargs["description_title"] = description_title - return KeybindType(identifier, key, event_func, **kwargs) + return KeybindType(identifier, key, func, **kwargs) if callback is None: return decorator diff --git a/src/mods_base/mod.py b/src/mods_base/mod.py index 71cefb6..4d7a625 100644 --- a/src/mods_base/mod.py +++ b/src/mods_base/mod.py @@ -171,6 +171,8 @@ def enable(self) -> None: self.is_enabled = True + for keybind in self.keybinds: + keybind.enable() for hook in self.hooks: hook.enable() for command in self.commands: @@ -195,6 +197,8 @@ def disable(self, dont_update_setting: bool = False) -> None: self.is_enabled = False + for keybind in self.keybinds: + keybind.disable() for hook in self.hooks: hook.disable() for command in self.commands: diff --git a/src/mods_base/raw_keybinds.py b/src/mods_base/raw_keybinds.py index 491b3e8..a1878ba 100644 --- a/src/mods_base/raw_keybinds.py +++ b/src/mods_base/raw_keybinds.py @@ -1,5 +1,8 @@ from collections.abc import Callable -from typing import TypeAlias, cast, overload +from dataclasses import dataclass +from typing import TypeAlias, overload + +from unrealsdk import logging from .keybinds import EInputEvent, KeybindBlockSignal @@ -40,7 +43,26 @@ | Callable[[RawKeybindCallback_NoArgs], None] ) -raw_keybind_callback_stack: list[list[RawKeybindCallback_KeyAndEvent]] = [] + +@dataclass +class RawKeybind: + key: str | None + event: EInputEvent | None + callback: RawKeybindCallback_Any + + # These two functions should get replaced by the keybind implementation + # The initialization script should make sure to load it before any mods, to make sure they don't + # end up with references to these functions + def enable(self) -> None: + """Enables this keybind.""" + logging.error("No keybind implementation loaded, unable to enable binds") + + def disable(self) -> None: + """Disables this keybind.""" + logging.error("No keybind implementation loaded, unable to disable binds") + + +raw_keybind_callback_stack: list[list[RawKeybind]] = [] def push() -> None: @@ -50,7 +72,9 @@ def push() -> None: def pop() -> None: """Pops the current raw keybind frame.""" - raw_keybind_callback_stack.pop() + frame = raw_keybind_callback_stack.pop() + for bind in frame: + bind.disable() @overload @@ -142,43 +166,9 @@ def add( """ def decorator(callback: RawKeybindCallback_Any) -> None: - nonlocal key, event - - full_callback: RawKeybindCallback_KeyAndEvent - match key, event: - case None, None: - full_callback = cast(RawKeybindCallback_KeyAndEvent, callback) - - case None, event: - cast_callback = cast(RawKeybindCallback_KeyOnly, callback) - - def event_filter(cb_key: str, cb_event: EInputEvent) -> KeybindBlockSignal: - if cb_event != event: - return None - return cast_callback(cb_key) - - full_callback = event_filter - - case key, None: - cast_callback = cast(RawKeybindCallback_EventOnly, callback) - - def key_filter(cb_key: str, cb_event: EInputEvent) -> KeybindBlockSignal: - if cb_key != key: - return None - return cast_callback(cb_event) - - full_callback = key_filter - case key, event: - cast_callback = cast(RawKeybindCallback_NoArgs, callback) - - def key_and_event_filter(cb_key: str, cb_event: EInputEvent) -> KeybindBlockSignal: - if cb_key != key or cb_event != event: - return None - return cast_callback() - - full_callback = key_and_event_filter - - raw_keybind_callback_stack[-1].append(full_callback) + bind = RawKeybind(key, event, callback) + raw_keybind_callback_stack[-1].append(bind) + bind.enable() if callback is None: return decorator diff --git a/src/ui_utils/hud_message.py b/src/ui_utils/hud_message.py index d282584..2928adf 100644 --- a/src/ui_utils/hud_message.py +++ b/src/ui_utils/hud_message.py @@ -49,7 +49,12 @@ def cycle_next_message() -> None: title, msg, duration = queued_message queued_message = None - get_pc().DisplayRolloutNotification(title, msg, duration) + + # Since we're on a thread, the user may have started loading since we were queued + # Just drop the message if we can't find the pc + pc = get_pc(possibly_loading=True) + if pc is not None: + pc.DisplayRolloutNotification(title, msg, duration) @hook("/Script/OakGame.OakPlayerController:DisplayRolloutNotification", Type.PRE, auto_enable=True)