From c7dfc4a6d33a11032ad664d390baa2600c6ddf4d Mon Sep 17 00:00:00 2001 From: apple1417 Date: Sun, 9 Jun 2024 17:30:54 +1200 Subject: [PATCH 1/4] move raw keybinds to keybinds implementation after trying it out, this interface doesn't really make sense in willow luckily it's not really a used interface yet --- src/bl3_mod_menu/keybinds.py | 3 +- src/console_mod_menu/screens/keybind.py | 118 +++++++++++--------- src/keybinds/__init__.py | 60 ---------- src/{mods_base => keybinds}/raw_keybinds.py | 70 ++++++++++-- src/mods_base/__init__.py | 2 - src/mods_base/keybinds.py | 6 +- 6 files changed, 134 insertions(+), 125 deletions(-) rename src/{mods_base => keybinds}/raw_keybinds.py (67%) diff --git a/src/bl3_mod_menu/keybinds.py b/src/bl3_mod_menu/keybinds.py index 39b1ca3..e9dc651 100644 --- a/src/bl3_mod_menu/keybinds.py +++ b/src/bl3_mod_menu/keybinds.py @@ -1,7 +1,8 @@ from enum import StrEnum import unrealsdk -from mods_base import BoolOption, DropdownOption, EInputEvent, KeybindOption, get_pc, raw_keybinds +from keybinds import raw_keybinds +from mods_base import BoolOption, DropdownOption, EInputEvent, KeybindOption, get_pc from unrealsdk.unreal import UObject from .dialog_box import DialogBox diff --git a/src/console_mod_menu/screens/keybind.py b/src/console_mod_menu/screens/keybind.py index db3c1de..93a988e 100644 --- a/src/console_mod_menu/screens/keybind.py +++ b/src/console_mod_menu/screens/keybind.py @@ -5,15 +5,8 @@ from mods_base import ( EInputEvent, KeybindOption, - raw_keybinds, remove_next_console_line_capture, ) - -try: - from ui_utils import show_hud_message -except ImportError: - show_hud_message = None - from unrealsdk.hooks import Block from console_mod_menu.draw import draw @@ -29,6 +22,18 @@ ) from .option import OptionScreen +# We would like to have a screen which binds a key from an actual key press. This is relies on some +# external modules, it's not truly game agnostic. +# Gracefully degrade if we can't import everything +try: + from keybinds import raw_keybinds +except ImportError: + raw_keybinds = None +try: + from ui_utils import show_hud_message +except ImportError: + show_hud_message = None + @dataclass class InvalidNameScreen(AbstractScreen): @@ -93,56 +98,65 @@ def handle_input(self, line: str) -> bool: # noqa: D102 return True -@dataclass -class RebindPressScreen(AbstractScreen): - name: str = field(init=False) - parent: KeybindOptionScreen +if raw_keybinds is not None: - is_bind_active: bool = field(default=False, init=False) + @dataclass + class RebindPressScreen(AbstractScreen): + name: str = field(init=False) + parent: KeybindOptionScreen - def __post_init__(self) -> None: - self.name = self.parent.option.display_name + is_bind_active: bool = field(default=False, init=False) - def draw(self) -> None: # noqa: D102 - draw( - "Close console, then press the key you want to bind to. This screen will automatically" - " close after being bound.", - ) - draw_standard_commands() + def __post_init__(self) -> None: + self.name = self.parent.option.display_name - if self.is_bind_active: - raw_keybinds.pop() - raw_keybinds.push() + def draw(self) -> None: # noqa: D102 + draw( + "Close console, then press the key you want to bind to. This screen will" + " automatically close after being bound.", + ) + draw_standard_commands() - # Closing console only triggers a release event - @raw_keybinds.add(None, EInputEvent.IE_Pressed) - def key_handler(key: str) -> type[Block]: # pyright: ignore[reportUnusedFunction] - self.parent.update_value(key) + # pyright thinks there's a chance this could change by the time we get to the function + # call, so doesn't propegate the above None check + assert raw_keybinds is not None - self.is_bind_active = False - raw_keybinds.pop() + if self.is_bind_active: + raw_keybinds.pop() + raw_keybinds.push() - # Try show a notification that we caught the press, if we were able to import it - if show_hud_message is not None: - show_hud_message( - "Console Mod Menu", - f"'{self.parent.option.display_name}' bound to '{key}'", - ) + # Closing console only triggers a release event + @raw_keybinds.add(None, EInputEvent.IE_Pressed) + def key_handler(key: str) -> type[Block]: # pyright: ignore[reportUnusedFunction] + self.parent.update_value(key) - # Bit of hackery to inject back into the menu loop - # Submit a B to close this menu - remove_next_console_line_capture() - _handle_interactive_input("B") + self.is_bind_active = False - return Block + assert raw_keybinds is not None + raw_keybinds.pop() - def handle_input(self, line: str) -> bool: # noqa: D102 - return handle_standard_command_input(line) + # Try show a notification that we caught the press, if we were able to import it + if show_hud_message is not None: + show_hud_message( + "Console Mod Menu", + f"'{self.parent.option.display_name}' bound to '{key}'", + ) + + # Bit of hackery to inject back into the menu loop + # Submit a B to close this menu + remove_next_console_line_capture() + _handle_interactive_input("B") - def on_close(self) -> None: # noqa: D102 - if self.is_bind_active: - self.is_bind_active = False - raw_keybinds.pop() + return Block + + def handle_input(self, line: str) -> bool: # noqa: D102 + return handle_standard_command_input(line) + + def on_close(self) -> None: # noqa: D102 + if self.is_bind_active: + self.is_bind_active = False + assert raw_keybinds is not None + raw_keybinds.pop() @dataclass @@ -151,8 +165,12 @@ def draw_option(self) -> None: # noqa: D102 if self.option.is_rebindable: draw("[1] Rebind by key name") draw("[2] List known key names", indent=1) - draw("[3] Rebind using key press") - draw("[4] Unbind") + + if raw_keybinds is None: + draw("[3] Unbind") + else: + draw("[3] Rebind using key press") + draw("[4] Unbind") draw_standard_commands() @@ -168,9 +186,9 @@ def handle_input(self, line: str) -> bool: # noqa: D102 draw("Known Keys:") draw(", ".join(sorted(KNOWN_KEYS))) draw("") - elif line == "3": + elif line == "3" and raw_keybinds is not None: push_screen(RebindPressScreen(self)) - elif line == "4": + elif line == ("3" if raw_keybinds is None else "4"): self.update_value(None) else: return False diff --git a/src/keybinds/__init__.py b/src/keybinds/__init__.py index 690bd3e..3b6bc0d 100644 --- a/src/keybinds/__init__.py +++ b/src/keybinds/__init__.py @@ -4,13 +4,6 @@ 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 @@ -83,57 +76,4 @@ def rebind_keybind(self: KeybindType, new_key: str | None) -> None: KeybindType._rebind = rebind_keybind # pyright: ignore[reportPrivateUsage] -@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), - ) - elif 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 - - -RawKeybind.enable = enable_raw_keybind - - -@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 - - -RawKeybind.disable = disable_raw_keybind - - base_mod.components.append(base_mod.ComponentInfo("Keybinds", __version__)) diff --git a/src/mods_base/raw_keybinds.py b/src/keybinds/raw_keybinds.py similarity index 67% rename from src/mods_base/raw_keybinds.py rename to src/keybinds/raw_keybinds.py index c076c66..cbbc054 100644 --- a/src/mods_base/raw_keybinds.py +++ b/src/keybinds/raw_keybinds.py @@ -1,10 +1,15 @@ +from __future__ import annotations + from collections.abc import Callable from dataclasses import dataclass -from typing import overload +from typing import TYPE_CHECKING, cast, overload + +from mods_base.keybinds import EInputEvent, KeybindBlockSignal -from unrealsdk import logging +from .keybinds import deregister_keybind, register_keybind -from .keybinds import EInputEvent, KeybindBlockSignal +if TYPE_CHECKING: + from .keybinds import _KeybindHandle # pyright: ignore[reportPrivateUsage] __all__: tuple[str, ...] = ( "add", @@ -50,16 +55,51 @@ class RawKeybind: 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 + _handle: _KeybindHandle | None = None + def enable(self) -> None: """Enables this keybind.""" - logging.error("No keybind implementation loaded, unable to enable binds") + if self._handle is not None: + self.disable() + + # Redundancy for type checking + if self.key is None: + if self.event is None: + self._handle = register_keybind( + self.key, + self.event, + False, + cast(RawKeybindCallback_KeyAndEvent, self.callback), + ) + else: + self._handle = register_keybind( + self.key, + self.event, + False, + cast(RawKeybindCallback_KeyOnly, self.callback), + ) + elif self.event is None: + self._handle = register_keybind( + self.key, + self.event, + False, + cast(RawKeybindCallback_EventOnly, self.callback), + ) + else: + self._handle = register_keybind( + self.key, + self.event, + False, + cast(RawKeybindCallback_NoArgs, self.callback), + ) def disable(self) -> None: """Disables this keybind.""" - logging.error("No keybind implementation loaded, unable to disable binds") + if self._handle is None: + return + + deregister_keybind(self._handle) + self._handle = None raw_keybind_callback_stack: list[list[RawKeybind]] = [] @@ -67,15 +107,25 @@ def disable(self) -> None: def push() -> None: """Pushes a new raw keybind frame.""" + if raw_keybind_callback_stack: + old_frame = raw_keybind_callback_stack[-1] + for bind in old_frame: + bind.disable() + raw_keybind_callback_stack.append([]) def pop() -> None: """Pops the current raw keybind frame.""" - frame = raw_keybind_callback_stack.pop() - for bind in frame: + old_frame = raw_keybind_callback_stack.pop() + for bind in old_frame: bind.disable() + if raw_keybind_callback_stack: + new_frame = raw_keybind_callback_stack[-1] + for bind in new_frame: + bind.enable() + @overload def add( diff --git a/src/mods_base/__init__.py b/src/mods_base/__init__.py index 48f5a6c..23b5ab5 100644 --- a/src/mods_base/__init__.py +++ b/src/mods_base/__init__.py @@ -21,7 +21,6 @@ ) del _mod_dir -from . import raw_keybinds from .command import ( AbstractCommand, ArgParseCommand, @@ -88,7 +87,6 @@ "ModType", "NestedOption", "open_in_mod_dir", - "raw_keybinds", "register_mod", "remove_next_console_line_capture", "SETTINGS_DIR", diff --git a/src/mods_base/keybinds.py b/src/mods_base/keybinds.py index 04e0518..c78b876 100644 --- a/src/mods_base/keybinds.py +++ b/src/mods_base/keybinds.py @@ -35,8 +35,10 @@ class KeybindType: """ Represents a single keybind. - The input callback takes no args, and may return the Block sentinel to prevent passing the input - back into the game. Standard blocking logic applies when multiple keybinds use the same key. + The input callback takes no args, and may return the Block sentinel to try prevent passing the + input back into the game. Note that this depends on the keybinds implementation, implementations + may not necessarily implement blocking. If it is implemented, standard blocking logic applies + when multiple keybinds use the same key. Args: identifier: The keybind's identifier. From 9744640f002e19ded08878163f689f9209c3d3af Mon Sep 17 00:00:00 2001 From: apple1417 Date: Sun, 9 Jun 2024 17:49:29 +1200 Subject: [PATCH 2/4] pull in init script tweaks from willow hook up the warning system, found a new proton bug, rename the extra folders env var for consistency --- src/__main__.py | 84 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 16 deletions(-) diff --git a/src/__main__.py b/src/__main__.py index 9b9e16f..897c0ae 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -17,10 +17,16 @@ import os import sys import traceback +import warnings import zipfile from collections.abc import Collection +from functools import wraps from pathlib import Path +from typing import TextIO +# Note we try to import as few third party modules as possible before the console is ready, in case +# any of them cause errors we'd like to have logged +# Trusting that we can keep all the above standard library modules without issue import unrealsdk from unrealsdk import logging @@ -31,7 +37,7 @@ WAIT_FOR_CLIENT: bool = False # 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" +EXTRA_FOLDERS_ENV_VAR: str = "MOD_MANAGER_EXTRA_FOLDERS" def init_debugpy() -> None: @@ -236,14 +242,40 @@ def import_mods(mods_to_import: Collection[str]) -> None: logging.error("".join(traceback.format_list(tb))) -def proton_null_exception_check() -> None: - """ - Tries to detect and warn if we're running under a version of Proton which has the exception bug. +def hookup_warnings() -> None: + """Hooks up the Python warnings system to the dev warning log type.""" + + original_show_warning = warnings.showwarning + dev_warn_logger = logging.Logger(logging.Level.DEV_WARNING) + + @wraps(warnings.showwarning) + def showwarning( + message: Warning | str, + category: type[Warning], + filename: str, + lineno: int, + file: TextIO | None = None, + line: str | None = None, + ) -> None: + if file is None: + # Typeshed has this as a TextIO, but the implementation only actually uses `.write` + file = dev_warn_logger # type: ignore + original_show_warning(message, category, filename, lineno, file, line) + + warnings.showwarning = showwarning + warnings.resetwarnings() # Reset filters, show all warnings + - For context, usually pybind detects exceptions using a catch all, which eventually calls through - to `std::current_exception` to get the exact exception, and then runs a bunch of translators on - it to convert it to a Python exception. When running under a bad Proton version, this call - fails, and returns an empty exception pointer, so pybind is unable to translate it. +def check_proton_bugs() -> None: + """Tries to detect and warn about various known proton issues.""" + + """ + The exception bug + ----------------- + Usually pybind detects exceptions using a catch all, which eventually calls through to + `std::current_exception` to get the exact exception, and then runs a bunch of translators on it + to convert it to a Python exception. When running under a bad Proton version, this call fails, + and returns an empty exception pointer, so pybind is unable to translate it. This means Python throws a: ``` @@ -251,14 +283,13 @@ def proton_null_exception_check() -> None: ``` This is primarily a problem for `StopIteration`. """ # noqa: E501 - cls = unrealsdk.find_class("Object") try: # Cause an attribute error _ = cls._check_for_proton_null_exception_bug except AttributeError: # Working properly - return + pass except SystemError: # Have the bug logging.error( @@ -268,13 +299,33 @@ def proton_null_exception_check() -> None: logging.error( "\n" "Some particular Proton versions cause this, try switch to another one.\n" - "Alternatively, the nightly release has builds from other compilers, which may also" - " prevent it.\n" + "Alternatively, the nightly release has builds from other compilers, which may\n" + "also prevent it.\n" "\n" "Will attempt to import mods, but they'll likely break with a similar error.\n" "===============================================================================", ) + """ + Env vars not propagating + ------------------------ + We set various env vars in `unrealsdk.env`, which unrealsdk sets via `SetEnvironmentVariableA`. + On some proton versions this does not get propagated through to Python - despite clearly having + worked for pyunrealsdk, if we're able to run this script. Some of these are used by Python, and + may cause issues if we cannot find them. + """ + if "PYUNREALSDK_INIT_SCRIPT" not in os.environ: + logging.error( + "===============================================================================\n" + "Some environment variables don't seem to have propagated into Python. This may\n" + "cause issues in parts of the mod manager or individual mods which expect them.\n" + "\n" + "Some particular Proton versions cause this, try switch to another one.\n" + "Alternatively, the nightly release has builds from other compilers, which may\n" + "also prevent it.\n" + "===============================================================================", + ) + # 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 @@ -288,14 +339,15 @@ def proton_null_exception_check() -> None: while not logging.is_console_ready(): pass -# Now that the console's ready, show errors for any non-existent mod folders +# Now that the console's ready, hook up the warnings system, and show some other warnings users may +# be interested in +hookup_warnings() + +check_proton_bugs() for folder in mod_folders: if not folder.exists() or not folder.is_dir(): logging.dev_warning(f"Extra mod folder does not exist: {folder}") -# And check for the proton null exception bug, if present we also want to print -proton_null_exception_check() - mods_to_import = find_mods_to_import(mod_folders) import_mod_manager() From 8220625d806d12111e9aef8c43596c5c5f8cfac9 Mon Sep 17 00:00:00 2001 From: apple1417 Date: Sun, 9 Jun 2024 18:34:32 +1200 Subject: [PATCH 3/4] bump versions and update changelog --- changelog.md | 53 ++++++++++++++++++++++++++++++-- manager_pyproject.toml | 2 +- src/console_mod_menu/__init__.py | 2 +- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/changelog.md b/changelog.md index d0b2530..61120ac 100644 --- a/changelog.md +++ b/changelog.md @@ -1,12 +1,52 @@ # Changelog -## Upcoming +## v1.3 (Upcoming) + +### General +- Added a warning for when Proton fails to propagate environment variables, which mods or the mod + manager may have been expecting. + + An example consequence of this is that the base mod may end up as version "Unknown Version". + + [f420d77b](https://github.com/bl-sdk/oak-mod-manager/commit/f420d77b) + +### General - Developer +- When developing third party native modules, you can now include this repo as a submodule to + automatically keep the Python version in sync. There was a bit of build system restructuring to + allow our `CMakeLists.txt` to define this. + + [6f9a8717](https://github.com/bl-sdk/oak-mod-manager/commit/6f9a8717) + +- Changed the `OAK_MOD_MANAGER_EXTRA_FOLDERS` env var to read from `MOD_MANAGER_EXTRA_FOLDERS` + instead, for consistency. + + [f420d77b](https://github.com/bl-sdk/oak-mod-manager/commit/f420d77b) + +- Python warnings are now hooked up to the logging system. + + [f420d77b](https://github.com/bl-sdk/oak-mod-manager/commit/f420d77b) ### BL3 Mod Menu v1.2 - Updated type hinting to use 3.12 syntax. - [dfb72a92](https://github.com/bl-sdk/oak-mod-manager/commit/dfb72a92) + [dfb72a92](https://github.com/bl-sdk/oak-mod-manager/commit/dfb72a92), + [95cc37eb](https://github.com/bl-sdk/oak-mod-manager/commit/95cc37eb) + +### Console Mod Menu v1.2 + +- Updated type hinting to use 3.12 syntax. + + [95cc37eb](https://github.com/bl-sdk/oak-mod-manager/commit/95cc37eb) + +- Changed strict keybind and ui utils dependencies to be soft dependencies. This is of no + consequence to this project, but it makes the mod menu more game-agnostic for other ones. + + These dependencies were only used for the "Rebind using key press" screen, this functionality will + now gracefully degrade based on what's available. + + [9ab26173](https://github.com/bl-sdk/oak-mod-manager/commit/9ab26173), + [216a739d](https://github.com/bl-sdk/oak-mod-manager/commit/216a739d) ### Keybinds v2.2 @@ -14,11 +54,20 @@ [dfb72a92](https://github.com/bl-sdk/oak-mod-manager/commit/dfb72a92) +- Moved `raw_keybinds` out of mods base, into keybinds. + + [216a739d](https://github.com/bl-sdk/oak-mod-manager/commit/216a739d) + ### Mods Base v1.3 - Updated type hinting to use 3.12 syntax. [dfb72a92](https://github.com/bl-sdk/oak-mod-manager/commit/dfb72a92) + [95cc37eb](https://github.com/bl-sdk/oak-mod-manager/commit/95cc37eb) + +- Moved `raw_keybinds` out of mods base, into keybinds. + + [216a739d](https://github.com/bl-sdk/oak-mod-manager/commit/216a739d) ## v1.2 diff --git a/manager_pyproject.toml b/manager_pyproject.toml index 6de6d6c..659a120 100644 --- a/manager_pyproject.toml +++ b/manager_pyproject.toml @@ -5,7 +5,7 @@ [project] name = "oak_mod_manager" -version = "1.2" +version = "1.3" authors = [{ name = "bl-sdk" }] [tool.sdkmod] diff --git a/src/console_mod_menu/__init__.py b/src/console_mod_menu/__init__.py index 59fd9b9..1a43274 100644 --- a/src/console_mod_menu/__init__.py +++ b/src/console_mod_menu/__init__.py @@ -11,7 +11,7 @@ "__version_info__", ) -__version_info__: tuple[int, int] = (1, 1) +__version_info__: tuple[int, int] = (1, 2) __version__: str = f"{__version_info__[0]}.{__version_info__[1]}" __author__: str = "bl-sdk" From c6e4369884b9e2a885ae3da0b4cbe1f5bde477d8 Mon Sep 17 00:00:00 2001 From: apple1417 Date: Sun, 9 Jun 2024 20:19:10 +1200 Subject: [PATCH 4/4] swap ci to use latest pyright only using pylance latest was causing issues in dependencies of this repo pyunrealsdk was already using pyright latest, so we really ought to copy it --- .github/workflows/build.yml | 1 - libs/pyunrealsdk | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 03fad1e..2bca6dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -364,7 +364,6 @@ jobs: - name: Run pyright uses: jakebailey/pyright-action@v2 with: - pylance-version: latest-release working-directory: "./src/" ruff: diff --git a/libs/pyunrealsdk b/libs/pyunrealsdk index fe675b6..b628dba 160000 --- a/libs/pyunrealsdk +++ b/libs/pyunrealsdk @@ -1 +1 @@ -Subproject commit fe675b6b0e8c9b94e0a6c9fc7b93c6e319b70107 +Subproject commit b628dbad80d15b3cba647dd6019afdfef44735d4