diff --git a/Mods/FpsToggle/__init__.py b/Mods/FpsToggle/__init__.py new file mode 100644 index 0000000..0e4cc2d --- /dev/null +++ b/Mods/FpsToggle/__init__.py @@ -0,0 +1,41 @@ +from Mods.ModMenu.KeybindManager import InputEvent +from Mods.ModMenu.Options import Boolean, Slider, Spinner +from Mods.ModUtils import SdkMod, Settings + + +class FpsToggle(SdkMod): + __doc__ = 'Switch to and from 30 FPS with a hotkey' + + Name = 'FPS Toggle' + Description = __doc__ + Author = 'Izalia Mae' + Version = '1.0' + + + def __init__(self): + self.settings = Settings() + self.current = None + self.set_enable_state('settings') + self.Keybinds.new( + 'Toggle FPS', 'F4', + rebindable = True + ) + + + def handle_game_input(self, bind, event): + if event != InputEvent.Pressed: + return + + key = self.settings.get('FramerateLocking') + + if self.current == None: + self.current = int(key.CurrValue) + self.settings.set('FramerateLocking', 1) + + else: + self.settings.set('FramerateLocking', self.current or 0) + self.current = None + + +mod = FpsToggle() +mod.register() diff --git a/Mods/FpsToggle/settings.json b/Mods/FpsToggle/settings.json new file mode 100644 index 0000000..75e1873 --- /dev/null +++ b/Mods/FpsToggle/settings.json @@ -0,0 +1,6 @@ +{ + "Keybinds": { + "Toggle FPS": "F4" + }, + "AutoEnable": true +} \ No newline at end of file diff --git a/Mods/InfiniKeys/__init__.py b/Mods/InfiniKeys/__init__.py new file mode 100644 index 0000000..402a5c9 --- /dev/null +++ b/Mods/InfiniKeys/__init__.py @@ -0,0 +1,23 @@ +from Mods.ModMenu import Hook +from Mods.ModUtils import SdkMod + +class InfiniKeys(SdkMod): + __doc__ = 'Prevent golden keys from being consumed' + + Name = 'Infinite Golden Keys' + Description = __doc__ + Author = 'Izalia Mae' + Version = '1.0' + + + def __init__(self): + self.set_enable_state('settings') + + + @Hook('WillowGame.WillowPlayerController.SpendGoldenKey') + def handle_key_spend(self, caller, function, params): + return False + + +mod = InfiniKeys() +mod.register() diff --git a/Mods/InfiniKeys/settings.json b/Mods/InfiniKeys/settings.json new file mode 100644 index 0000000..3b20ac5 --- /dev/null +++ b/Mods/InfiniKeys/settings.json @@ -0,0 +1,3 @@ +{ + "AutoEnable": true +} \ No newline at end of file diff --git a/Mods/LootSplosion/__init__.py b/Mods/LootSplosion/__init__.py new file mode 100644 index 0000000..3479fd0 --- /dev/null +++ b/Mods/LootSplosion/__init__.py @@ -0,0 +1,72 @@ +from Mods.ModMenu import EnabledSaveType, Hook, SDKMod, OptionManager, RegisterMod +from Mods.ModUtils import SdkMod + + +## Enemies that MAY be upped: badass constructor, saturn, knuckle dragger, cassius +ENEMY_DROP_MULTI = { + 'PawnBalance_Anemone_Cassius': 2, # Cassius + 'PawnBalance_ConstructorBadass': 30, # Badass Constructor + 'PawnBalance_LoaderGiant': 10, # Saturn + 'PawnBalance_PrimalBeast_KnuckleDragger': 10, # Knuckle Dragger + 'PawnBalance_Uranus': 1 # Uranus +} + + +class LootSplosion(SdkMod): + __doc__ = 'Multiplies drops of enemies that are killed. The extra drops can optionally require a critical hit.' + + Name = 'Lootsplosion' + Description = 'Multiplies drops of enemies that are killed' + Author = 'Izalia Mae' + Version = '1.0' + + + def __init__(self): + self.set_enable_state('settings') + self.Options.new_slider( + 'Drop Multiplier', 'Number of times to multiply the drops', + default = 15, + min = 0, + max = 50, + increment = 1, + id = 'multiplier' + ) + + self.Options.new_boolean( + 'CritRequired', 'Require a critical hit', + default = True, + id = 'crit' + ) + + + def get_drop_multi(self, enemy): + enemy_level = ENEMY_DROP_MULTI.get(enemy, None) + drop_level = self.Options.get('multiplier').CurrentValue + + if enemy_level == None: + return self.Options.get('multiplier').CurrentValue + + return min(drop_level, enemy_level) + + + @Hook("WillowGame.WillowPawn.DropLootOnDeath") + def handle_loot_drop(self, caller, function, params): + if not caller.bWasLastDamageACriticalHit and self.Options.get('crit').CurrentValue: + return True + + enemy = caller.GetBalancedActorTypeIdentifier() + multi = self.get_drop_multi(enemy) + self.log(f'Drop Multi ({enemy}): {multi}') + + for _ in range(multi): + caller.DropLootOnDeath( + params.Killer, + params.DamageType, + params.DamageTypeDefinition + ) + + return True + + +mod = LootSplosion() +mod.register() diff --git a/Mods/LootSplosion/settings.json b/Mods/LootSplosion/settings.json new file mode 100644 index 0000000..99a04c8 --- /dev/null +++ b/Mods/LootSplosion/settings.json @@ -0,0 +1,7 @@ +{ + "Options": { + "Drop Multiplier": 30, + "CritRequired": false + }, + "AutoEnable": true +} \ No newline at end of file diff --git a/Mods/ModMenu/DeprecationHelper.py b/Mods/ModMenu/DeprecationHelper.py new file mode 100644 index 0000000..7cb6634 --- /dev/null +++ b/Mods/ModMenu/DeprecationHelper.py @@ -0,0 +1,64 @@ +import unrealsdk +import functools +from typing import Any, Callable, Dict, List, Optional, Set, Tuple + +__all__: Tuple[str, ...] = ( + "Deprecated", + "NameChangeMsg", + "PrintWarning", +) + +_printed_deprecation_warnings: Set[str] = set() + + +def PrintWarning(msg: str) -> None: + """ + Prints a warning containing the provided message. Will only happen once per message. + + Args: + msg: The message to print. + """ + if msg not in _printed_deprecation_warnings: + _printed_deprecation_warnings.add(msg) + unrealsdk.Log(f"[Warning] {msg}") + + +def NameChangeMsg(old_name: str, new_name: str) -> str: + """ + Helper returning a generic deprecation message for when something's name changed. + + Args: + old_name: The deprecated name. + new_name: The new name. + Returns: + The name change deprecation message. + """ + return f"Use of '{old_name}' is deprecated, use '{new_name}' instead." + + +def Deprecated( + msg: str, + func: Optional[Callable[..., Any]] = None +) -> Callable[..., Any]: + """ + Decorator that prints a deprecation message when it's wrapped function is called. + + Can also be called with the function as an argument. + + Args: + msg: The message to print. + func: The function to wrap. + Returns: + The wrapped function + """ + def decorator(old_func: Callable[..., Any]) -> Callable[..., Any]: + @functools.wraps(old_func) + def new_func(*args: List[Any], **kwargs: Dict[str, Any]) -> Any: + PrintWarning(msg) + return old_func(*args, **kwargs) + return new_func + + if func is None: + return decorator + else: + return decorator(func) diff --git a/Mods/ModMenu/HookManager.py b/Mods/ModMenu/HookManager.py new file mode 100644 index 0000000..9745db3 --- /dev/null +++ b/Mods/ModMenu/HookManager.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import unrealsdk +import functools +import weakref +from inspect import Parameter, signature +from typing import Any, Callable, Optional, Tuple, Union + +__all__: Tuple[str, ...] = ( + "AnyHook", + "Hook", + "HookFunction", + "HookMethod", + "RegisterHooks", + "RemoveHooks", +) + + +HookFunction = Callable[ + [unrealsdk.UObject, unrealsdk.UFunction, unrealsdk.FStruct], + Optional[bool] +] +HookMethod = Callable[ + [Any, unrealsdk.UObject, unrealsdk.UFunction, unrealsdk.FStruct], + Optional[bool] +] +AnyHook = Union[HookFunction, HookMethod] + + +def Hook(target: str, name: str = "{0}.{1}") -> Callable[[AnyHook], AnyHook]: + """ + A decorator for functions that should be invoked in response to an Unreal Engine method's + invokation. + + The function being decorated may be a standalone function, in which case its signature must + match that of `unrealsdk.RegisterHook` functions: + (caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) + + Alternatively, the function may be an instance method of any object. In this case, the hook will + be activated once `ModMenu.RegisterHooks(object)` has been called on the object. The signature + of the method must match that of `unrealsdk.RegisterHook` functions, with the addition of `self` + as the first parameter: + (self, caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) + + Upon invokation of the Unreal Engine method, the decorated function will be called. Its `caller` + argument will contain the Unreal Engine object whose method was invoked, the `function` argument + will contain the Unreal Engine function that was invoked, and the `params` argument will contain + an `FStruct` with the arguments passed to the method. + + Args: + target: + A string representing the Unreal Engine method that should be hooked, in the format + "..". + name: + A string which, when paired with the hook target, uniquely identifies this hook within + the SDK. By default, a name is generated using the function's module, qualified name, + and `id()` (in the case of mod instance method hooks, the mod instance's `id()` is used + instead). + + If a custom name is provided, it may be a simple string, or a format string with either + one or two replacement tokens. Token `{0}` will contain the function's module name and + qualified name, separated by a ".". Argument `{1}` will contain the `id()` of the + function or mod instance. + """ + def apply_hook(function: AnyHook) -> AnyHook: + # If the function has four parameters, it should be a method. + params = signature(function).parameters + is_method = (len(params) == 4) + + # Retrieve the function's dictionary of targets. If it does not yet have one, we preform + # initial setup on it now. + hook_targets = getattr(function, "HookTargets", None) + if hook_targets is None: + param_exception = ValueError( + "Hook functions must have the signature" + " ([self,] caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct)" + ) + + # If the function is an instance method, create a mutable list of the parameters and + # remove the `self` one, so we may check the remaining ones same as a non-method. + param_list = list(params.values()) + if is_method: + del param_list[0] + # If the function has neither 4 nor 3 parameters, it is invalid. + elif len(param_list) != 3: + raise param_exception + # If the functions parameters do not accept positional arguments, it is invalid. + for param in param_list: + if Parameter.POSITIONAL_ONLY != param.kind != Parameter.POSITIONAL_OR_KEYWORD: + raise param_exception + + # If the function is a method, store the name format string on it for formatting with + # future instances. If it's a simple function, format its name for use now. + function.HookName = name if is_method else name.format( # type: ignore + f"{function.__module__}.{function.__qualname__}", id(function) + ) + + # With the function now known as valid, create its set of targets. + hook_targets = function.HookTargets = set() # type: ignore + + hook_targets.add(target) + + if not is_method: + unrealsdk.RunHook(target, function.HookName, function) # type: ignore + + return function + return apply_hook + + +def _create_method_wrapper(obj_ref: weakref.ReferenceType[object], obj_function: HookMethod) -> HookFunction: + """Return a "true" function for the given bound method, passable to `unrealsdk.RegisterHook`.""" + @functools.wraps(obj_function) + def method_wrapper(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> Any: + obj = obj_ref() + method = obj_function.__get__(obj, type(obj)) + return method(caller, obj_function, params) + return method_wrapper + + +def RegisterHooks(obj: object) -> None: + """ + Registers all `@Hook` decorated methods for the object. Said methods will subsequently be called + in response to the hooked Unreal Engine methods. + + Args: + obj: The object for which to register method hooks. + """ + + # Create a weak reference to the object which we may use in attributes on it without creating + # cyclical references. Before destruction, `RemoveHooks` should be called on the object to + # ensure there are no remaining hooks that reference it. + obj_ref = weakref.ref(obj, RemoveHooks) + + # Iterate over each attribute on the object's class that contains a function. + for attribute_name, function in type(obj).__dict__.items(): + if not callable(function): + continue + + # Attempt to get the set of hook targets from the function. If it doesn't have one, or if + # its signature doesn't have 4 parameters, it is not a hook method. + hook_targets = getattr(function, "HookTargets", None) + if hook_targets is None or len(signature(function).parameters) != 4: + continue + + # Create a wrapper to replace the descriptor of the attribute, "binding" the function to the + # mod's weak reference, in a function that can be passed to `unrealsdk.RunHook`. + method_wrapper = _create_method_wrapper(obj_ref, function) + setattr(obj, attribute_name, method_wrapper) + + # Format the provided hook name. + method_wrapper.HookName = function.HookName.format( # type: ignore + f"{function.__module__}.{function.__qualname__}", id(obj) + ) + + for target in hook_targets: + unrealsdk.RunHook(target, method_wrapper.HookName, method_wrapper) # type: ignore + + +def RemoveHooks(obj: object) -> None: + """ + Unregisters all `@Hook` decorated methods for the object. Said methods will no longer be called + in response to the hooked Unreal Engine methods. + + Args: + obj: The object for which to unregister method hooks. + """ + for function in obj.__dict__.values(): + if not callable(function): + continue + + hook_targets = getattr(function, "HookTargets", None) + if hook_targets is None: + continue + + for target in hook_targets: + unrealsdk.RemoveHook(target, function.HookName) diff --git a/Mods/ModMenu/KeybindManager.py b/Mods/ModMenu/KeybindManager.py new file mode 100644 index 0000000..65fb3a2 --- /dev/null +++ b/Mods/ModMenu/KeybindManager.py @@ -0,0 +1,483 @@ +import unrealsdk +import functools +import inspect +from dataclasses import dataclass, field +from enum import IntEnum +from typing import Callable, ClassVar, Dict, Optional, Tuple, Union + +from . import DeprecationHelper as dh +from . import MenuManager, ModObjects, SettingsManager + +__all__: Tuple[str, ...] = ( + "InputEvent", + "Keybind", + "KeybindCallback", +) + + +class InputEvent(IntEnum): + """ + Any enum holding the various input event types. + + Sourced from https://docs.unrealengine.com/en-US/API/Runtime/Engine/Engine/EInputEvent/index.html + """ + Pressed = 0 + Released = 1 + Repeat = 2 + DoubleClick = 3 + Axis = 4 + + +KeybindCallback = Union[Callable[[], None], Callable[[InputEvent], None]] + + +@dataclass +class Keybind: + """ + A simple dataclass representing a keybind. + + Attributes: + Name: The name of the keybind. + Key: + The key this bind is currently bound to. See the following link for reference. + https://api.unrealengine.com/udk/Three/KeyBinds.html#Mappable%20keys + IsRebindable: + If this bind can be rebound. If not, it also won't be placed in the settings file. Note + that this does not prevent changing the value on this object manually. + IsHidden: If the keybind should be hidden from the keybinds menu. + + OnPress: + A callback for when the key is pressed. If provided, it will be called instead of the + mod's `GameInputPressed`. If it accepts an argument, it will get passed an `InputEvent` + value, and be called on any event. If it doesn't, it will only be called on `Pressed` + events. + + DefaultKey: The original value of Key. You do not provide this, it is set automatically. + """ + Name: str + Key: str = "None" + IsRebindable: bool = True + IsHidden: bool = False + + OnPress: Optional[KeybindCallback] = None + + DefaultKey: str = field(default=Key, init=False) + + _list_deprecation_warning: ClassVar[str] = ( + "Using lists for keybinds is deprecated, use 'ModMenu.Keybind' instances instead." + ) + + def __post_init__(self) -> None: + self.DefaultKey = self.Key + + @dh.Deprecated(_list_deprecation_warning) + def __getitem__(self, i: int) -> str: + if not isinstance(i, int): + raise TypeError(f"list indices must be integers or slices, not {type(i)}") + if i == 0: + return self.Name + elif i == 1: + return self.Key + else: + raise IndexError("list index out of range") + + @dh.Deprecated(_list_deprecation_warning) + def __setitem__(self, i: int, val: str) -> None: + if not isinstance(i, int): + raise TypeError(f"list indices must be integers or slices, not {type(i)}") + if i == 0: + self.Name = val + elif i == 1: + self.Key = val + else: + raise IndexError("list index out of range") + + +""" +We put all keybinds into their own "MODDED KEYBINDS" menu. +It's important to realize however that, internally, it's still the exact same menu as the one for + the normal keybinds. +We have one keybinds menu object with one keybind list, we just, based on which menu item you + select, filter dowc the keybinds that you can actually see. +""" + + +_NORMAL_CONTROLLER_NAME = unrealsdk.GetEngine().Localize("KeyBindings", "Caption", "WillowMenu") +_MODDED_CONTROLLER_NAME = "Modded " + _NORMAL_CONTROLLER_NAME + +_NORMAL_KEYBINDS_EVENT_ID: int = 1000 +_MODDED_EVENT_ID: int = 1417 +_MODDED_KEYBINDS_CAPTION: str = _MODDED_CONTROLLER_NAME.upper() + +_TAG_MODDED: str = "unrealsdk" +_TAG_SEPERATOR: str = f"{_TAG_MODDED}.seperator" +_TAG_UNREBINDABLE: str = f"{_TAG_MODDED}.unrebindable" +_TAG_INPUT: str = f"{_TAG_MODDED}.input" + +_INDENT: int = 4 + +# Need a dict because normal binds always take the first bunch of indexes +_modded_keybind_map: Dict[int, Keybind] = {} +_is_modded_keybind_menu: bool = False + + +def _get_fixed_localized_key_name(provider: unrealsdk.UObject, key: str) -> str: + """ + A wrapper around `GetLocalizedKeyName` that fills a few gaps left by it. + + Args: + provider: The keyboard/mouse menu data provider. + key: The key to get the name of. + Returns: + The localized key name. + """ + + custom_name_map = { + "None": "", + "Quote": "'", + "Backslash": "\\ " # The space is important for some reason + } + + if key.strip() == "" or key is None: + return "" + elif key in custom_name_map: + return custom_name_map[key] + + name = provider.GetLocalizedKeyName(key) + if name is None: + return "" + # TODO: Work out what is causing this to be returned and patch that instead + elif name.startswith("?INT?"): + return key + else: + return str(name) + + +def _KeyboardMouseOptionsPopulate(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called to create the kb/m settings menu. We use it to inject our own + "MODDED KEYBINDS" menu. + """ + # If we have no modded binds, disable the menu + disabled = True + for mod in ModObjects.Mods: + if not mod.IsEnabled: + continue + for input in mod.Keybinds: + if isinstance(input, Keybind) and input.IsHidden: + continue + disabled = False + break + if not disabled: + break + + def AddListItem(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called every time an item is added to *any* menu list - we obviously can't + use a generic hook. + Using it cause it simplifies the code to add our own entry. + """ + if params.Caption != "$WillowMenu.MenuOptionDisplayNames.KeyBinds": + return True + + # Want ours to display after the normal keybinds option + unrealsdk.DoInjectedCallNext() + caller.AddListItem(params.EventID, params.Caption, params.bDisabled, params.bNew) + + caller.AddListItem(_MODDED_EVENT_ID, _MODDED_KEYBINDS_CAPTION, disabled, False) + return False + + unrealsdk.RunHook("WillowGame.WillowScrollingList.AddListItem", "ModMenu.KeybindManager", AddListItem) + + unrealsdk.DoInjectedCallNext() + caller.Populate(params.TheList) + caller.AddDescription(_MODDED_EVENT_ID, "$WillowMenu.MenuOptionDisplayNames.KeyBindsDesc") + + unrealsdk.RemoveHook("WillowGame.WillowScrollingList.AddListItem", "ModMenu.KeybindManager") + + return False + + +def _extOnPopulateKeys(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called to load the list of keybinds. We add our own binds onto the end of the + list after it's called. + """ + global _modded_keybind_map + + unrealsdk.DoInjectedCallNext() + caller.extOnPopulateKeys() + + _modded_keybind_map = {} + for mod in MenuManager.GetOrderedModList(): + if not mod.IsEnabled: + continue + + if all(isinstance(k, Keybind) and k.IsHidden for k in mod.Keybinds): + continue + + tag = f"{_TAG_SEPERATOR}.{mod.Name}" + idx = caller.AddKeyBindEntry(tag, tag, mod.Name) + caller.KeyBinds[idx].CurrentKey = "None" + + for input in mod.Keybinds: + name: str + key: str + rebindable: bool + if isinstance(input, Keybind): + if input.IsHidden: + continue + name = input.Name + key = input.Key + rebindable = input.IsRebindable + else: + dh.PrintWarning(Keybind._list_deprecation_warning) + name = input[0] + key = input[1] + rebindable = True + + tag = (_TAG_INPUT if rebindable else _TAG_UNREBINDABLE) + f".{mod.Name}.{name}" + idx = caller.AddKeyBindEntry(tag, tag, " " * _INDENT + name) + + _modded_keybind_map[idx] = input + + if not rebindable: + key = "[ ]" if key == "None" else f"[ {key} ]" + caller.KeyBinds[idx].CurrentKey = key + + return False + + +def _HandleSelectionChangeRollover(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + The two functions with this hook get called when your menu selection changes. We use them to + detect when you open each keybinds menu, and filter them down accordingly. + """ + global _is_modded_keybind_menu + + if params.EventID == _MODDED_EVENT_ID: + _is_modded_keybind_menu = True + caller.ControllerMappingClip.GetObject("controller").SetText(_MODDED_CONTROLLER_NAME) + elif params.EventID == _NORMAL_KEYBINDS_EVENT_ID: + _is_modded_keybind_menu = False + caller.ControllerMappingClip.GetObject("controller").SetText(_NORMAL_CONTROLLER_NAME) + else: + _is_modded_keybind_menu = False + return True + + # Recreate the visual keybind list, filtered down to just the relevant binds + caller.ControllerMappingClip.EmptyKeyData() + for bind in caller.KeyBinds: + if _is_modded_keybind_menu: + if not bind.Tag.startswith(_TAG_MODDED): + continue + else: + if bind.Tag.startswith(_TAG_MODDED): + continue + + bind.Object = caller.ControllerMappingClip.AddKeyData( + bind.Tag, bind.Caption, _get_fixed_localized_key_name(caller, bind.CurrentKey) + ) + caller.ControllerMappingClip.InvalidateKeyData() + + # Conveniently, instantly turning this off + on again will re-show the animation + caller.ShowControllerMapping(False) + caller.ShowControllerMapping(True) + + caller.UpdateDescriptionText(params.EventID, params.TheList) + return False + + +def _DoBind(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called when you start rebinding a key, blocking it on seperators and uneditable + keybinds. + """ + tag = caller.KeyBinds[caller.CurrentKeyBindSelection].Tag + if tag.startswith(_TAG_SEPERATOR) or tag.startswith(_TAG_UNREBINDABLE): + return False + return True + + +def _BindCurrentSelection(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called when a key is rebound. We basically entirely rewrite it, making sure to + update modded binds, as well as adding the ability to unbind modded keys. + + Unbinding default binds won't save, so doing so has been disabled. + If you want to look into it more, it's probably caused by how it gets saved to your profile, + follow the trail from `OnPop()`. + """ + selected_idx = caller.CurrentKeyBindSelection + selected_bind = caller.KeyBinds[selected_idx] + + translation_context = unrealsdk.GetEngine().GamePlayers[0].GetTranslationContext() + + if selected_bind.Tag.startswith(_TAG_SEPERATOR): + return False + + key = params.Key + if selected_bind.CurrentKey == key: + # Don't allow unbinding defaults + if selected_idx not in _modded_keybind_map: + return False + key = "None" + + if selected_idx in _modded_keybind_map: + input = _modded_keybind_map[selected_idx] + if isinstance(input, Keybind): + input.Key = key + else: + dh.PrintWarning(Keybind._list_deprecation_warning) + input[1] = key + + # Find if we have to swap the bind with anything + for idx, bind in enumerate(caller.KeyBinds): + if bind.CurrentKey != params.Key: + continue + if bind == selected_bind: + continue + + # Allow multiple "None" binds + # Using continue rather than a break so that it falls into the else + if key == "None": + continue + + # If you would swap a default bind to None + if selected_bind.CurrentKey == "None" and idx not in _modded_keybind_map: + # Show a small explanatory dialog. + dialog = caller.WPCOwner.GFxUIManager.ShowDialog() + + title = dialog.Localize("dlgKeyBindSwap", "Caption", "WillowMenu") + msg = ( + f"Unable to bind \"{selected_bind.Caption}\" to \"{key}\".\n" + f"\n" + f"Doing so would cause the default bind \"{bind.Caption}\" to become unbound." + ) + dialog.SetText(title, msg) + dialog.SetVariableString("tooltips.text", "$") + dialog.ApplyLayout() + + def HandleInputKey(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called on any key event on any `WillowGFxDialogBox`. Only using it + to replicate adding `HandleKeySwapDialog` as a delegate, sdk can't quite do so yet. + """ + if caller != dialog: # noqa: B023 + return True + + if ( + params.uevent == InputEvent.Released + and params.ukey in ("Escape", "XboxTypeS_B", "XboxTypeS_Back") + ): + dialog.Close() # noqa: B023 + unrealsdk.RemoveHook("WillowGame.WillowGFxDialogBox.HandleInputKey", "ModMenu.KeybindManager") + return True + + unrealsdk.RunHook("WillowGame.WillowGFxDialogBox.HandleInputKey", "ModMenu.KeybindManager", HandleInputKey) + + return False + + if idx in _modded_keybind_map: + input = _modded_keybind_map[idx] + if isinstance(input, Keybind): + input.Key = selected_bind.CurrentKey + else: + dh.PrintWarning(Keybind._list_deprecation_warning) + input[1] = selected_bind.CurrentKey + + unrealsdk.DoInjectedCallNext() + caller.BindCurrentSelection(key) + + bind.Object.SetString( + "value", + _get_fixed_localized_key_name(caller, bind.CurrentKey), + translation_context + ) + else: + caller.bNeedsToSaveKeyBinds = True + selected_bind.CurrentKey = key + + selected_bind.Object.SetString( + "value", + _get_fixed_localized_key_name(caller, selected_bind.CurrentKey), + translation_context + ) + + caller.ControllerMappingClip.InvalidateKeyData() + + return False + + +def _OnResetKeyBindsButtonClicked(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called when the user resets all keybinds - unsuprisingly we do the same. + + There is no easy way to store default binds for legacy mods that still provide them as lists, + so they will not be reset. + """ + unrealsdk.DoInjectedCallNext() + caller.OnResetKeyBindsButtonClicked(params.Dlg, params.ControllerId) + + if params.Dlg.DialogResult != "Yes": + return False + + for input in _modded_keybind_map.values(): + if isinstance(input, Keybind): + input.Key = input.DefaultKey + + return False + + +def _KeyboardMouseOptionsOnPop(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called upon leaving the keyboard/mouse settings menu. We save all mod settings + (including keybinds) when this happens. + """ + SettingsManager.SaveAllModSettings() + return True + + +def _InputKey(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called on basically all player input, while ingame. Using it for our callbacks. + """ + for mod in ModObjects.Mods: + if not mod.IsEnabled: + continue + + for input in mod.Keybinds: + returned_input: Keybind + if isinstance(input, Keybind): + returned_input = input + else: + dh.PrintWarning(Keybind._list_deprecation_warning) + returned_input = Keybind(*input) + + if returned_input.Key != params.Key: + continue + + # Prefer the callback + function = ( + returned_input.OnPress or functools.partial(mod.GameInputPressed, returned_input) + ) + + if len(inspect.signature(function).parameters) == 0: + if params.Event == InputEvent.Pressed: + function() + return False + else: + function(InputEvent(params.Event)) + return False + return True + + +unrealsdk.RunHook("WillowGame.WillowScrollingListDataProviderKeyboardMouseOptions.Populate", "ModMenu.KeybindManager", _KeyboardMouseOptionsPopulate) +unrealsdk.RunHook("WillowGame.WillowScrollingListDataProviderKeyboardMouseOptions.extOnPopulateKeys", "ModMenu.KeybindManager", _extOnPopulateKeys) +unrealsdk.RunHook("WillowGame.WillowScrollingListDataProviderKeyboardMouseOptions.HandleSelectionChange", "ModMenu.KeybindManager", _HandleSelectionChangeRollover) +unrealsdk.RunHook("WillowGame.WillowScrollingListDataProviderKeyboardMouseOptions.HandleSelectionRollover", "ModMenu.KeybindManager", _HandleSelectionChangeRollover) +unrealsdk.RunHook("WillowGame.WillowScrollingListDataProviderKeyboardMouseOptions.DoBind", "ModMenu.KeybindManager", _DoBind) +unrealsdk.RunHook("WillowGame.WillowScrollingListDataProviderKeyboardMouseOptions.BindCurrentSelection", "ModMenu.KeybindManager", _BindCurrentSelection) +unrealsdk.RunHook("WillowGame.WillowScrollingListDataProviderKeyboardMouseOptions.OnResetKeyBindsButtonClicked", "ModMenu.KeybindManager", _OnResetKeyBindsButtonClicked) +unrealsdk.RunHook("WillowGame.WillowScrollingListDataProviderKeyboardMouseOptions.OnPop", "ModMenu.KeybindManager", _KeyboardMouseOptionsOnPop) +unrealsdk.RunHook("WillowGame.WillowUIInteraction.InputKey", "ModMenu.KeybindManager", _InputKey) diff --git a/Mods/ModMenu/MenuManager.py b/Mods/ModMenu/MenuManager.py new file mode 100644 index 0000000..e565d80 --- /dev/null +++ b/Mods/ModMenu/MenuManager.py @@ -0,0 +1,436 @@ +import unrealsdk +import json +import os +import re +import sys +import webbrowser +from functools import cmp_to_key +from typing import Dict, List, Set, Tuple + +from . import VERSION_MAJOR, VERSION_MINOR +from . import DeprecationHelper as dh +from . import KeybindManager, ModObjects + +__all__: Tuple[str, ...] = () + + +_MODS_EVENT_ID: int = 1417 +_MODS_MENU_NAME: str = "MODS" +_FAVOURITES_FILE: str = os.path.join(os.path.dirname(os.path.realpath(__file__)), "favourites.json") + +_current_mod_list: List[ModObjects.SDKMod] = [] +_favourite_mods: Set[str] = set() + + +def GetOrderedModList() -> List[ModObjects.SDKMod]: + """ + Gets the list of mods properly ordered by favourite status, priority, and name. + + The first section of the list contains the favourited mods, the second all others. Each section + is sorted by priority first, with higher priority appearing earlier in the list, and + alphabetically by name second. + + Returns: + The ordered list of mods. + """ + def cmp(a: ModObjects.SDKMod, b: ModObjects.SDKMod) -> int: + if a.Name in _favourite_mods and b.Name not in _favourite_mods: + return -1 + elif a.Name not in _favourite_mods and b.Name in _favourite_mods: + return 1 + + if a.Priority == b.Priority: + # Do some basic html stripping, so recolouring a name doesn't send the mod to the top + # You can definitely fool this but it should be good enough + a_stripped = re.sub("<.+?>", '', a.Name) + b_stripped = re.sub("<.+?>", '', b.Name) + if a_stripped < b_stripped: + return -1 + elif a_stripped > b_stripped: + return 1 + return 0 + + return b.Priority - a.Priority + + return sorted(ModObjects.Mods, key=cmp_to_key(cmp)) + + +class _General(ModObjects.SDKMod): + Name: str = "General" + Author: str = "PythonSDK" + Description: str = ( + "Welcome to the Borderlands 2 SDK Mod Manager\n" + "\n" + "See below for options.\n" + ) + Version: str = f"{VERSION_MAJOR}.{VERSION_MINOR}" + + SupportedGames: ModObjects.Game = ( + ModObjects.Game.BL2 | ModObjects.Game.TPS | ModObjects.Game.AoDK + ) + Types: ModObjects.ModTypes = ModObjects.ModTypes.All + + Status: str = "" + SettingsInputs: Dict[str, str] = { + "H": "Help", + "O": "Open Mods Folder", + } + + def SettingsInputPressed(self, action: str) -> None: + if action == "Help": + webbrowser.open("http://bl-sdk.github.io/") + elif action == "Open Mods Folder": + os.startfile(os.path.join(os.path.dirname(sys.executable), "Mods")) + + +_general_instance = _General() + + +def _save_favourite_mods() -> None: + """ Saves all favourited mods to disk. """ + if len(_favourite_mods) == 0: + os.remove(_FAVOURITES_FILE) + else: + with open(_FAVOURITES_FILE, "w") as file: + json.dump(list(_favourite_mods), file, indent=4) + + +def _load_favourite_mods() -> None: + """ Loads all favourited mods from disk. """ + global _favourite_mods + + try: + _favourite_mods = set() + with open(_FAVOURITES_FILE) as file: + names = json.load(file) + # This looks redundant, but it makes sure only loaded mods are in the list + for mod in ModObjects.Mods: + if mod.Name in names: + _favourite_mods.add(mod.Name) + + except (FileNotFoundError, json.JSONDecodeError): + pass + + +def _update_mod_list_item( + mod: ModObjects.SDKMod, + item: unrealsdk.UObject, + movie: unrealsdk.UObject, + translation_context: unrealsdk.UObject +) -> None: + """ Updates a mod GFxObject to use all the latest fields from it's SDKMod. """ + item.SetString(movie.Prop_contentTitleText, mod.Name, translation_context) + item.SetString(movie.Prop_costText, "By " + mod.Author, translation_context) + item.SetString(movie.Prop_descriptionText, mod.Description, translation_context) + item.SetString( + movie.Prop_statusText, + f"{mod.Version}", # Make this the same colour as author + translation_context + ) + + status = mod.Status + if mod.Status == "Enabled": + status = "Enabled" + elif mod.Status == "Disabled": + status = "Disabled" + item.SetString(movie.Prop_messageText, status, translation_context) + + if isinstance(mod.Types, list): + dh.PrintWarning( + "Using lists for mod types is deprecated, combine types with bitwise or instead." + ) + + # For some odd reason these take in floats, but treat them as bools + item.SetFloat(movie.Prop_isCompatibility, float(ModObjects.ModTypes.Utility in mod.Types)) + item.SetFloat(movie.Prop_isAddOn, float(ModObjects.ModTypes.Content in mod.Types)) + item.SetFloat(movie.Prop_isSeasonPass, float(ModObjects.ModTypes.Gameplay in mod.Types)) + item.SetFloat(movie.Prop_isMisc, float(ModObjects.ModTypes.Library in mod.Types)) + item.SetFloat(movie.Prop_isNewOffer, float(mod.Name in _favourite_mods or mod == _general_instance)) + + +def _FrontEndPopulate(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called to create the front end menu. We use it to replace the DLC caption and + event id. + """ + def AddListItem(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called every time an item is added to *any* menu list - we obviously can't + use a generic hook. + Using it cause it simplifies the code to replace the caption. + """ + if params.Caption == "$WillowMenu.WillowScrollingListDataProviderFrontEnd.DLC": + return False + + inject_now = False + if unrealsdk.GetEngine().GetCurrentWorldInfo().NetMode == 3: # NM_Client + inject_now = params.Caption == "$WillowMenu.WillowScrollingListDataProviderFrontEnd.Disconnect" + else: + inject_now = params.Caption == "$WillowMenu.WillowScrollingListDataProviderFrontEnd.Quit" + + if inject_now: + caller.AddListItem(_MODS_EVENT_ID, _MODS_MENU_NAME, False, False) + + return True + + unrealsdk.RunHook("WillowGame.WillowScrollingList.AddListItem", "ModMenu.MenuManager", AddListItem) + + unrealsdk.DoInjectedCallNext() + caller.Populate(params.TheList) + + unrealsdk.RemoveHook("WillowGame.WillowScrollingList.AddListItem", "ModMenu.MenuManager") + return False + + +def _RefreshDLC(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called to refresh the DLC menu. We're mostly interested in it because Gearbox + has amazing code, and calls `OnDownloadableContentListRead` twice if you're offline. + + This happens right at the end of the function, so it's easiest to just recreate it. + + We can ignore the filter stuff, `OnDownloadableContentListRead` overwrites it right after, and + we can also remove the small meaningless loading message that some people get hung up on. + """ + if caller.bDelegateFired: + caller.ShowMarketplaceElements(False) + caller.SetShoppingTooltips(False, False, False, False, True) + caller.SetContentData() + caller.bDelegateFired = False + + # Here's the issue: earlier they setup a delegate for this call, but when it fails they + # also manually call the delgate again - we just don't + caller.WPCOwner.OnlineSub.ContentInterface.ObjectPointer.ReadDownloadableContentList( + caller.WPCOwner.GetMyControllerId() + ) + return False + + +def _OnDownloadableContentListRead(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ This function is called to fill in the dlc menu, we overwrite it with our mods instead. """ + global _current_mod_list + + _load_favourite_mods() + + caller.ClearFilters() + caller.SetFilterFromString("compatibility", "Utility Mods", "isCompatibility:1") + caller.SetFilterFromString("addon", "Content Mods", "isAddOn:1") + caller.SetFilterFromString("seasonpass", "Gameplay Mods", "isSeasonPass:1") + caller.SetFilterFromString("misc", "Libraries", "isMisc:1") + caller.SetFilterFromStringAndSortNew("all", "All Mods", "") + + caller.SetStoreHeader("Mods", False, "By Abahbob", "Mod Manager") + + _current_mod_list = [_general_instance] + GetOrderedModList() # type: ignore + translation_context = unrealsdk.GetEngine().GamePlayers[0].GetTranslationContext() + + for idx, mod in enumerate(_current_mod_list): + # This is weird and crashes if you don't have the second arg, but also crashes most the time + # when you try to access something on it - seems like a garbage pointer + item, _ = caller.CreateMarketplaceItem() + + item.SetString(caller.Prop_offeringId, str(idx), translation_context) + _update_mod_list_item(mod, item, caller, translation_context) + + caller.AddContentData(item) + + caller.PostContentLoaded(True) + return False + + +def _ShopInputKey(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called on pretty much all input in the dlc menu. We use it to add our own + custom binds, and to refresh mods after running them. + """ + global _filter_idx + key = params.ukey + event = params.uevent + + controller_key_map = { + "Gamepad_LeftStick_Up": "Up", + "Gamepad_LeftStick_Down": "Down", + "XboxTypeS_A": "Enter", + "XboxTypeS_B": "Escape", + "XboxTypeS_Y": "Q", + "XboxTypeS_LeftTrigger": "PageUp", + "XboxTypeS_RightTrigger": "PageDown" + } + if key in controller_key_map: # noqa: SIM908 + key = controller_key_map[key] + + if key in ("Escape", "Up", "Down", "W", "S"): + return True + + if event in (KeybindManager.InputEvent.Pressed, KeybindManager.InputEvent.Repeat): + # These two are bugged on gearbox's end, we manually fix them + if key == "PageUp": + caller.ScrollDescription(True) + return False + elif key == "PageDown": + caller.ScrollDescription(False) + return False + + item = caller.GetSelectedObject() + mod = _current_mod_list[int(item.GetString(caller.Prop_offeringId))] + + def update_item() -> None: + _update_mod_list_item( + mod, + item, + caller, + unrealsdk.GetEngine().GamePlayers[0].GetTranslationContext() + ) + # Unfortuantly it doesn't seem like there's a way to easily scroll back down to where you + # were, but we have to call this to actually update the visuals + # This also resets filters, and for some reason none of the filter functions want to work + # right now either :/ + _RefreshDLC(caller, None, None) + + if key == "Q": + if event == KeybindManager.InputEvent.Released: + if mod == _general_instance: + caller.CycleFilter() + else: + if mod.Name in _favourite_mods: + _favourite_mods.remove(mod.Name) + else: + _favourite_mods.add(mod.Name) + _save_favourite_mods() + update_item() + + return False + + elif key in mod.SettingsInputs: + if event == KeybindManager.InputEvent.Released: + mod.SettingsInputPressed(mod.SettingsInputs[key]) + update_item() + return False + + # These keys would try open the store if we let them continue + elif key in ("Enter", "E"): + return False + + return True + + +def _extOnOfferingChanged(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called when the currently selected mod is changed. We use it to update the + settings input tooltips. + """ + caller.PlayUISound('VerticalMovement') + + try: + idx = int(params.Data.GetString(caller.Prop_offeringId)) + # This seems to sometimes be called as we're refreshig the menu, making `GetString` return None + except TypeError: + return False + + mod = _current_mod_list[idx] + + on_left_column = False + left_column = "" + right_column = "" + + if mod == _general_instance: + left_column = "[Q] Filter\n" + elif mod.Name in _favourite_mods: + left_column = "[Q] Unfavourite\n" + else: + left_column = "[Q] Favourite\n" + + for key, action in mod.SettingsInputs.items(): + entry = f"[{key}] {action}\n" + if on_left_column: + left_column += entry + else: + right_column += entry + on_left_column = not on_left_column + + caller.SetTooltips(left_column, right_column) + return False + + +def _FrontEndHandleClick(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called when you click on any option on the main menu. We use it to open the dlc + menu, even if you're offline or on epic. + """ + if params.EventID == _MODS_EVENT_ID: + movie = params.TheList.MyOwnerMovie + if not movie.IsOverlayMenuOpen(): + movie.CheckDownloadableContentListCompleted(movie.WPCOwner.GetMyControllerId(), True) + return False + return True + + +def _SharedHandleInputKey(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called on pretty much all key input events on the main menu. We use it to open + the dlc menu when you press "M". + """ + if ( + params.ukey == "M" + and params.uevent == KeybindManager.InputEvent.Released + and not caller.IsOverlayMenuOpen() + ): + caller.CheckDownloadableContentListCompleted(caller.WPCOwner.GetMyControllerId(), True) + return True + + +def _FrontEndUpdateTooltips(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called to update the tooltips in the bottom right corner on the main menu. We + replace it with our own message, adding character/network mode for bl2, and mentioning our new + `M` for mods menu bind. + + Unfortuantly doing so requires recreating most of the function, there's no nice var we can read + from - `SetVariableString` adds a bunch of extra formatting that's awkward to parse though if + you tried using `GetVariableString`. + """ + tooltip = caller.TooltipSpacing + caller.SelectTooltip + + cancel = caller.CancelString + if caller.WPCOwner.WorldInfo.NetMode == 3: + # There's no easy len() :/ + count = 0 + if caller.TheList is not None: + for _ in caller.TheList.DataProviderStack: + count += 1 + if count <= 1: + cancel = caller.DisconnectString + tooltip += caller.TooltipSpacing + caller.CancelTooltip.replace("%PLAYER1", cancel) + + tooltip += "\n" + + if caller.CanShowSpectatorControls(): + tooltip += caller.TooltipSpacing + caller.SpectatorTooltip + + if caller.CanShowCharacterSelect(-1): + tooltip += caller.TooltipSpacing + caller.CharacterSelectTooltip + + if caller.WPCOwner.WorldInfo.NetMode != 3: + tooltip += caller.TooltipSpacing + caller.NetworkOptionsTooltip + + # Only show on the main menu, not also the pause menu + if caller.Class.Name == "FrontendGFxMovie": + tooltip += caller.TooltipSpacing + "[M] Mods" + + if caller.MyFrontendDefinition is not None: + caller.SetVariableString( + caller.MyFrontendDefinition.TooltipPath, + caller.ResolveDataStoreMarkup(tooltip) + ) + + return False + + +unrealsdk.RunHook("WillowGame.WillowScrollingListDataProviderFrontEnd.Populate", "ModMenu.MenuManager", _FrontEndPopulate) +unrealsdk.RunHook("WillowGame.MarketplaceGFxMovie.RefreshDLC", "ModMenu.MenuManager", _RefreshDLC) +unrealsdk.RunHook("WillowGame.MarketplaceGFxMovie.OnDownloadableContentListRead", "ModMenu.MenuManager", _OnDownloadableContentListRead) +unrealsdk.RunHook("WillowGame.MarketplaceGFxMovie.ShopInputKey", "ModMenu.MenuManager", _ShopInputKey) +unrealsdk.RunHook("WillowGame.MarketplaceGFxMovie.extOnOfferingChanged", "ModMenu.MenuManager", _extOnOfferingChanged) +unrealsdk.RunHook("WillowGame.WillowScrollingListDataProviderFrontEnd.HandleClick", "ModMenu.MenuManager", _FrontEndHandleClick) +unrealsdk.RunHook("WillowGame.FrontendGFxMovie.SharedHandleInputKey", "ModMenu.MenuManager", _SharedHandleInputKey) +unrealsdk.RunHook("WillowGame.FrontendGFxMovie.UpdateTooltips", "ModMenu.MenuManager", _FrontEndUpdateTooltips) diff --git a/Mods/ModMenu/ModObjects.py b/Mods/ModMenu/ModObjects.py new file mode 100644 index 0000000..650c4a6 --- /dev/null +++ b/Mods/ModMenu/ModObjects.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import copy +import enum +import json +import sys +from abc import ABCMeta +from functools import lru_cache +from os import path +from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, cast + +from . import HookManager, KeybindManager, NetworkManager, OptionManager, SettingsManager + +__all__: Tuple[str, ...] = ( + "EnabledSaveType", + "Game", + "ModPriorities", + "Mods", + "ModTypes", + "RegisterMod", + "SDKMod", +) + + +Mods: List[SDKMod] = [] + + +def RegisterMod(mod: SDKMod) -> None: + """ + Adds the provided mod to the mods list and loads all it's settings. + + Args: + mod: The mod to register. + """ + Mods.append(mod) + SettingsManager.LoadModSettings(mod) + + +class ModPriorities(enum.IntEnum): + """ Predefined mod priorities. These just provide standarization, any int value can be used. """ + High = 10 + Standard = 0 + Low = -10 + Library = Low + + +class ModTypes(enum.Flag): + """ Various categories your mod might fit into, used for filtering in the mod menu. """ + NONE = 0 + Utility = enum.auto() + Content = enum.auto() + Gameplay = enum.auto() + Library = enum.auto() + All = Utility | Content | Gameplay | Library + + +class EnabledSaveType(enum.Enum): + """ + The different ways a mod's enabled state may (or may not) be saved. + + Note that you must call `LoadModSettings` for the state to properly be loaded. + + If the state is saved as disabled, nothing happens. If it's saved as enabled, it's enabled by + calling `SettingsInputPressed("Enable")`, as long as `IsEnabled` is still False. + + Values: + NotSaved: The enabled state is not saved. + LoadWithSettings: + The enabled state is saved, and the mod is enabled when the mod settings are loaded. + LoadOnMainMenu: + The enabled state is saved, and the mod is enabled upon reaching the main menu - after + hotfixes are all setup and all the normal packages are loaded. + """ + NotSaved = enum.auto() + LoadWithSettings = enum.auto() + LoadOnMainMenu = enum.auto() + + +class Game(enum.Flag): + BL2 = enum.auto() + TPS = enum.auto() + AoDK = enum.auto() + + @staticmethod + @lru_cache(None) + def GetCurrent() -> Game: + return Game.BL2 + + +class _ModMeta(ABCMeta): + """ + Metaclass used to ensure that SDKMod subclasses get copies of attributes rather than references. + + It's a rather easy to mistakenly edit these references, and affect all other mods, so it's + probably better to prevent it happening in the first place. + """ + Attributes: Tuple[str, ...] = ( + "Author", + "Description", + "Version", + "SupportedGames", + "Types", + "Priority", + "SaveEnabledState", + "Status", + "SettingsInputs", + "Options", + "Keybinds", + "_server_functions", + "_client_functions", + "_is_enabled", + ) + + def __init__(cls, name: str, bases: Tuple[type, ...], attrs: Dict[str, Any]) -> None: + super().__init__(name, bases, attrs) + + for name in _ModMeta.Attributes: + setattr(cls, name, copy.copy(getattr(cls, name))) + + functions = (attribute for attribute in cls.__dict__.values() if callable(attribute)) + for function in functions: + method_sender = NetworkManager._find_method_sender(function) + if method_sender is not None: + if method_sender._is_server: # type: ignore + cls._server_functions.add(method_sender) # type: ignore + if method_sender._is_client: # type: ignore + cls._client_functions.add(method_sender) # type: ignore + + +class SDKMod(metaclass=_ModMeta): + """ + The base class any SDK mod should inherit from. Describes an entry in the mod menu. + + Attributes: + Name: The mod's name. + Author: The mod's author(s). + Description: A short description of the mod. + Version: + A string holding the mod's version. This is purely informational, no version checking is + performed. + + SupportedGames: + The games this mod supports - see the `Game` enum. When loaded in an unsupported one, a + warning will be displayed and the mod will be blocked from enabling. + Types: A list specifing all `ModTypes` the mod fits into. + Priority: The priority of the mod in the mod list. See the `ModPriorities` enum. + SaveEnabledState: + If the mod's enabled state is saved across launches. See the `EnabledSaveType` enum. + + Status: + A string holding the mod's current status. The default `SettingsInputPressed` sets it to + "Enabled"/"Disabled" - these exact strings will automatically be coloured green/red. + SettingsInputs: + A dictionary mapping keys to the action the mod performs when that key is pressed in the + mods menu. This does *not* bind in game actions, use `Keybinds` for that. + Options: A sequence of the mod's options. These are only displayed while the mod is enabled. + Keybinds: + A sequence of the mod's in game keybinds. These are only displayed, and the callback + will only be called, while the mod is enabled. + + IsEnabled: + A bool that is True if the mod is currently enabled. For compatibility reasons, by + default this returns if the status is currently "Enabled". Once overwritten, it will + return whatever value it was set to. + """ + Name: str + Author: str = "Unknown" + Description: str = "" + Version: str = "Unknown Version" + + SupportedGames: Game = Game.BL2 | Game.TPS | Game.AoDK + Types: ModTypes = ModTypes.NONE + Priority: int = ModPriorities.Standard + SaveEnabledState: EnabledSaveType = EnabledSaveType.NotSaved + + Status: str = "Disabled" + SettingsInputs: Dict[str, str] = {"Enter": "Enable"} + Options: Sequence[OptionManager.Options.Base] = [] + Keybinds: Sequence[KeybindManager.Keybind] = [] + + _server_functions: Set[Callable[..., None]] = set() + _client_functions: Set[Callable[..., None]] = set() + + _is_enabled: Optional[bool] = None + + @property + def IsEnabled(self) -> bool: + if self._is_enabled is None: + return self.Status == "Enabled" + return self._is_enabled + + @IsEnabled.setter + def IsEnabled(self, val: bool) -> None: + self._is_enabled = val + if self.SaveEnabledState != EnabledSaveType.NotSaved: + SettingsManager.SaveModSettings(self) + + def __new__(cls, *args: List[Any], **kwargs: Dict[str, Any]) -> Any: + """ + Check if you're running in a compatible game. Do this here rather than in `__init__()` + because it's easy to forget to also call it on the superclass when you overwrite it. + """ + inst = super().__new__(cls) + if Game.GetCurrent() not in inst.SupportedGames: + try: + del inst.SettingsInputs["Enter"] + except KeyError: + pass + inst.Name = f"{inst.Name}" + inst.Status = "Incompatible" + + if len(inst.Description) > 0: + inst.Description += "\n\n" + inst.Description += ( + f"Incompatible with {Game.GetCurrent().name}!" + ) + + return inst + + def Enable(self) -> None: + """ + Called by the mod manager to enable the mod. The default implementation calls + ModMenu.RegisterHooks(self) and ModMenu.RegisterNetworkMethods(self) on the mod. + """ + HookManager.RegisterHooks(self) + NetworkManager.RegisterNetworkMethods(self) + + def Disable(self) -> None: + """ + Called by the mod manager to disable the mod. The default implementation calls + ModMenu.UnregisterHooks(self) and ModMenu.UnregisterNetworkMethods(self) on the mod. + """ + HookManager.RemoveHooks(self) + NetworkManager.UnregisterNetworkMethods(self) + + def SettingsInputPressed(self, action: str) -> None: + """ + Called by the mod manager when one of the actions in `SettingsInputs` is invoked via its key. + + Mods may should overwrite this method when they add custom actions, the base implementation + only deals with enabling/disabling the mod. + + All arguments are provided positionally, mods can rename them as they please. + + Args: + action: The name of the action. + """ + # Even though we removed these from `SettingsInputs`, need this check for auto enable + if Game.GetCurrent() not in self.SupportedGames and action in ("Enable", "Disable"): + return + + if action == "Enable": + if not self.IsEnabled: + self.Enable() + self.IsEnabled = True + + """ + Relying on these calls here should be considered deprecated. + Unfortuantly there's no easy way to detect and print a warning where this happens. + """ + for option in self.Options: + if isinstance(option, OptionManager.Options.Value): + self.ModOptionChanged(option, option.CurrentValue) + self.Status = "Enabled" + self.SettingsInputs["Enter"] = "Disable" + + elif action == "Disable": + if self.IsEnabled: + self.Disable() + self.IsEnabled = False + self.Status = "Disabled" + self.SettingsInputs["Enter"] = "Enable" + + def GameInputPressed(self, bind: KeybindManager.Keybind, event: KeybindManager.InputEvent) -> None: + """ + Called by the mod manager on any key event associated with one of the mod's keybindings. + + For compatibility reasons, you may define this funtion with just the first positional `bind` + argument. Doing so will only call it for pressed events. + + All arguments are provided positionally, mods can rename them as they please. + + Arguments: + bind: The keybind object associated with the key that was pressed. + event: The input event type - see the `InputEvent` enum. + """ + pass + + def ModOptionChanged(self, option: OptionManager.Options.Base, new_value: Any) -> None: + """ + Called by the mod manager when one of the mod's options gets changed. + + Called before the option's value is updated - i.e. `option.CurrentValue` will still be the + old value, while `new_value` is the new one. + + All arguments are provided positionally, mods can rename them as they please. + + Arguments: + option: The option who's value was changed. + new_value: The new value which `option.CurrentValue` will be updated to. + """ + pass + + @staticmethod + def NetworkSerialize(arguments: NetworkManager.NetworkArgsDict) -> str: + """ + Called when instances of this class invoke methods decorated with `@ModMenu.ServerMethod` + or `@ModMenu.ClientMethod`, performing the serialization of any arguments passed to said + methods. The default implementation uses `json.dumps()`. + + Arguments: + arguments: + The arguments that need to be serialized. The top-level object passed will be a + `dict` keyed with `str`, containing a `list` as well as another `dict`. + Returns: + The arguments serialized into a text string. + """ + return json.dumps(arguments) + + @staticmethod + def NetworkDeserialize(serialized: str) -> NetworkManager.NetworkArgsDict: + """ + Called when instances of this class receive requests for methods decorated with + `@ModMenu.ServerMethod` or `@ModMenu.ClientMethod`, performing the deserialization of any + arguments passed to said methods. The default implementation uses `json.loads()`. + + Arguments: + serialized: + The string containing the serialized arguments as returned by 'NetworkSerialize'. + Returns: + The deserialized arguments in the same format as they were passed to `NetworkSerialize`. + """ + return cast(NetworkManager.NetworkArgsDict, json.loads(serialized)) diff --git a/Mods/ModMenu/NetworkManager.py b/Mods/ModMenu/NetworkManager.py new file mode 100644 index 0000000..cdc1477 --- /dev/null +++ b/Mods/ModMenu/NetworkManager.py @@ -0,0 +1,469 @@ +from __future__ import annotations + +import unrealsdk +import functools +import inspect +import traceback +from collections import deque +from time import time +from typing import Any, Callable, Deque, Dict, Optional, Set, Tuple, Union + +from . import ModObjects + +__all__: Tuple[str, ...] = ( + "ClientMethod", + "NetworkArgsDict", + "RegisterNetworkMethods", + "ServerMethod", + "UnregisterNetworkMethods", +) + +NetworkArgsDict = Dict[str, Union[Dict[str, Any], Tuple[Any, ...]]] + + +class _Message(): + """ + A simple class that tracks the various pieces of information involved in, and facilitates the + # sending of, a network message. + + Attributes: + ID: The unique ID of the message. + PC: The player controller the message will be sent through. + message_type: The message type string. + arguments: The serialized argument string. + server: `True` if the message is destined to a server, `False` if destined to a client. + timeout: `None` if the message has been sent, otherwise a float representing the real time + it will time out. + """ + ID: str + PC: unrealsdk.UObject + message_type: str + arguments: str + server: bool + timeout: Optional[float] + + def __init__(self, PC: unrealsdk.UObject, message_type: str, arguments: str, server: bool) -> None: + """ + Create a new message. + + Args: + PC: The player controller to send the message through. + message_type: The message type string. + arguments: The serialized argument string. + server: `True` if the message is destined to a server, `False` if destined to a client. + """ + self.ID = str(id(self)) + self.PC = PC + self.message_type = message_type + self.arguments = f"{self.ID}:{arguments}" + self.server = server + self.timeout = None + + def send(self) -> None: + """Send the message.""" + if self.server: + self.PC.ServerSpeech(self.message_type, 0, self.arguments) + else: + self.PC.ClientMessage(self.arguments, self.message_type) + # Set our timeout to be 2x (for leeway) the ping in each direction from the receiving end. + self.timeout = time() + self.PC.PlayerReplicationInfo.ExactPing * 4 + + +_message_queue: Deque[_Message] = deque() + + +def _PlayerTick(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + timeout = _message_queue[0].timeout + if timeout is None: + _message_queue[0].send() + elif timeout < time(): + _dequeue_message() + return True + + +def _Logout(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + global _message_queue + # If there are no queued messages, we have nothing to do. + if len(_message_queue) == 0: + return True + + # Filter the messages destined to the logged out player out of our message queue. + purged_queue = deque(message for message in _message_queue if message.PC is not params.Exiting) + + # If there are no more messages left in the queue, we may cease observing message timeouts. + if len(purged_queue) == 0: + unrealsdk.RemoveHook("Engine.PlayerController.PlayerTick", "ModMenu.NetworkManager") + + # If the first message in the filtered queue is different from the previous one, send it. + elif purged_queue[0] is not _message_queue[0]: + purged_queue[0].send() + + _message_queue = purged_queue + return True + + +def _GameSessionEnded(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + global _message_queue + # If there are no queued messages, we have nothing to do. + if len(_message_queue) == 0: + return True + # Cease observing message timeouts and empty the message queue. + unrealsdk.RemoveHook("Engine.PlayerController.PlayerTick", "ModMenu.NetworkManager") + _message_queue = deque() + return True + + +def _enqueue_message(message: _Message) -> None: + """ Add a message to the message queue, sending it if message queue is empty. """ + _message_queue.append(message) + + # If this was the first message to be added to the queue, send it now, and register our tick + # hook to observe for its timeout. + if len(_message_queue) == 1: + message.send() + unrealsdk.RunHook("Engine.PlayerController.PlayerTick", "ModMenu.NetworkManager", _PlayerTick) + + +def _dequeue_message() -> None: + """ Remove the frontmost message from the message queue, sending the following one, if any. """ + _message_queue.popleft() + + # If the queue is not empty, send the now-frontmost message. + if len(_message_queue) > 0: + _message_queue[0].send() + # If this was the last message in the queue, we may cease observing message timeouts. + else: + unrealsdk.RemoveHook("Engine.PlayerController.PlayerTick", "ModMenu.NetworkManager") + + +_method_senders = set() + + +def _find_method_sender(function: Callable[..., Any]) -> Callable[..., Any]: + """ + Searches through arbitrarily nested decorator functions to attempt to find a method sender, + returning it, or `None` if one isn't found. + + This assumes the decorators all follow the convention and use `@functools.wraps`, so we can + follow the nesting via `__wrapped__`. + """ + while function is not None: + if function in _method_senders: + break + function = getattr(function, "__wrapped__", None) # type: ignore + return function + + +def _create_method_sender(function: Callable[..., None]) -> Optional[Callable[..., None]]: + """ + Create a new function that will replace ones decorated as network methods, intercepting their + local calls, and instead transmitting a request to remote copies of the mod. + """ + # Format the unique message type we will use to identify requests sent and received for this + # method, using the module and hierarchy within the module it was defined in. + message_type = f"unrealsdk.{function.__module__}.{function.__qualname__}" + + signature = inspect.signature(function) + parameters = list(signature.parameters.values()) + if len(parameters) < 1: + unrealsdk.Log(f"Unable to register network method <{message_type}>.") + unrealsdk.Log(" @ServerMethod and @ClientMethod decorated methods must be mod instance methods") + return None + + # To be able to correctly map arguments to their parameters, remove the first (`self`) + # parameters from the signature. + del parameters[0] + signature = signature.replace(parameters=parameters) + + # Record whether or not this function includes a parameter for specifying a player controller. + specifies_pc = (signature.parameters.get("PC") is not None) + + # "Wrap" the original function. Among other things, this assigns the original function to the + # __wrapped__ attribute of the new one. + @functools.wraps(function) + def method_sender(self: ModObjects.SDKMod, *args: Any, **kwargs: Any) -> None: + # Get the current world info, and from that, the current list of replicated players. + world_info = unrealsdk.GetEngine().GetCurrentWorldInfo() + PRIs = list(world_info.GRI.PRIArray) + + # Determine whether this message should be sent to a server and whether it should be sent to + # client(s). If neither, we have nothing to send. + # ENetMode.NM_Client == 3 + send_server = (method_sender._is_server and world_info.NetMode == 3) # type: ignore + send_client = ( + method_sender._is_client and world_info.NetMode != 3 and len(PRIs) > 1 # type: ignore + ) + if not (send_server or send_client): + return + + # Use the inspect module to correctly map the received arguments to their parameters. + bindings = signature.bind(*args, **kwargs) + # If the arguments include one specifying a player controller we will be messaging, retrieve + # which one, and purge it. + remote_pc = bindings.arguments.get("PC", None) + if specifies_pc: + bindings.arguments["PC"] = None + # Serialize the arguments we were called with using the class's serializer function. + arguments = type(self).NetworkSerialize({"args": bindings.args, "kwargs": bindings.kwargs}) + + # Retrieve our own player controller. + local_pc = unrealsdk.GetEngine().GamePlayers[0].Actor + + # If we're sending a message to the server, send it using our own player controller. + if send_server: + _enqueue_message(_Message(local_pc, message_type, arguments, True)) + + # If we're sending to a client, and the mapped arguments do specify a specific player + # controller to message, we will spend this message to it. + elif send_client and remote_pc is not None: + if type(remote_pc) is unrealsdk.UObject and remote_pc.Class.Name == "WillowPlayerController": + _enqueue_message(_Message(remote_pc, message_type, arguments, False)) + else: + raise TypeError( + f"Invalid player controller specified for {message_type}. Expected" + f" unrealsdk.UObject of UClass WillowPlayerController, received {remote_pc}." + ) + + # If no player controller was specified, send a message to each replicated player that has a + # player controller that is not our own. + elif send_client: + for PRI in PRIs: + if PRI.Owner is not None and PRI.Owner is not local_pc: + _enqueue_message(_Message(PRI.Owner, message_type, arguments, False)) + + # Assign the server and client attributes to identify this method's role. + method_sender._message_type = message_type # type: ignore + method_sender._is_server = False # type: ignore + method_sender._is_client = False # type: ignore + + _method_senders.add(method_sender) + return method_sender + + +def ServerMethod(function: Callable[..., None]) -> Callable[..., None]: + """ + A decorator function for mods' instance methods that should be invoked on server copies of the + mod rather than the local copy. + + The decorated function must be an instance method (have `self` as the first parameter). + Additionally it may contain any parameters, so long as the values passed through them are + serializable through the mod class's `NetworkSerialize` and `NetworkDeserialize`. + + The decorated function may optionally include a parameter named `PC`. If it does, upon + invokation on the server, its value will contain the `unrealsdk.UObject` + `WillowPlayerController` for the client who requested the method. + + Args: + function: The function to decorate. + """ + + # Check if the function already has a method sender. If it doesn't, create one now. + method_sender = _find_method_sender(function) + if method_sender is None: + method_sender = _create_method_sender(function) + if method_sender is None: + return function + + method_sender._is_server = True # type: ignore + return method_sender + + +def ClientMethod(function: Callable[..., None]) -> Callable[..., None]: + """ + A decorator function for mods' instance methods that should be invoked on client copies of the + mod rather than the local copy. + + The decorated function must be an instance method (have `self` as the first parameter). + Additionally it may contain any parameters, so long as the values passed through them are + serializable through the mod class's `NetworkSerialize` and `NetworkDeserialize`. + + The decorated function may optionally include a parameter named `PC`. If it does, and if an + `unrealsdk.UObject` `WillowPlayerController` associated with a given client is specified when + calling this method on the server, the invokation will be sent to that client. In the absense of + a specified client, the request is sent to all clients. + + Args: + function: The function to decorate. + """ + + # Check if the function already has a method sender. If it doesn't, create one now. + method_sender = _find_method_sender(function) + if method_sender is None: + method_sender = _create_method_sender(function) + if method_sender is None: + return function + + method_sender._is_client = True # type: ignore + return method_sender + + +_server_message_types: Dict[str, Set[Callable[..., None]]] = {} +_client_message_types: Dict[str, Set[Callable[..., None]]] = {} + + +def RegisterNetworkMethods(mod: ModObjects.SDKMod) -> None: + """ + Enables a mod's interaction with server and client copies of itself. Methods for the mod that + are decorated with @ModMenu.ServerMethod and @ModMenu.ClientMethod subsequently send requests + to servers and clients when invoked locally. In addition, said methods have ther original + implementations invoked locally when requests are received from servers and clients. + + Args: + mod: The instance of the mod to register for network method handling. + """ + cls = type(mod) + + # For each network method in this mod's class, create a bound version for this mod instance, and + # add it to the set associated with the method's message type, creating one if necessary. + for function in cls._server_functions: + method = function.__wrapped__.__get__(mod, cls) # type: ignore + _server_message_types.setdefault(function._message_type, set()).add(method) # type: ignore + + for function in cls._client_functions: + method = function.__wrapped__.__get__(mod, cls) # type: ignore + _client_message_types.setdefault(function._message_type, set()).add(method) # type: ignore + + +def UnregisterNetworkMethods(mod: ModObjects.SDKMod) -> None: + """ + Disables a mod's interaction with server and client copies of itself. Requests will no longer be + sent when @ModMenu.ServerMethod and @ModMenu.ClientMethod methods are invoked locally, and + incoming requests for said methods will be ignored. + + Args: + mod: The instance of the mod to unregister for network method handling. + """ + cls = type(mod) + + # For each network method in this mod's class, find the set of methods associated with the + # method's message type. Discard any method that matches one bound to this mod instance. If none + # remain in the set, remove it as well. + for function in cls._server_functions: + methods = _server_message_types.get(function._message_type) # type: ignore + if methods is not None: + methods.discard(function.__wrapped__.__get__(mod, cls)) # type: ignore + if len(methods) == 0: + del _server_message_types[function._message_type] # type: ignore + + for function in cls._client_functions: + methods = _client_message_types.get(function._message_type) # type: ignore + if methods is not None: + methods.discard(function.__wrapped__.__get__(mod, cls)) # type: ignore + if len(methods) == 0: + del _client_message_types[function._message_type] # type: ignore + + +def _server_speech(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + message = params.Callsign + message_type = params.Type + if message_type is None or not message_type.startswith("unrealsdk."): + return True + + # Check if the message type indicates an acknowledgement from a client for the previous message + # we had sent. If so, and its ID matches that of our last message, dequeue it and we are done. + if message_type == "unrealsdk.__clientack__": + if len(_message_queue) > 0 and _message_queue[0].ID == message: + _dequeue_message() + return False + + # This message's ID and serialized arguments should be separated by a ":". If not, ignore it. + message_components = message.split(":", 1) + if len(message_components) != 2: + return False + message_id = message_components[0] + + # Get the list of methods registered to respond to this message type. If none are, we're done. + methods = _server_message_types.get(message_type) + if methods is not None and len(methods) > 0: + # All of the methods in this set are known to be identical functions, just bound to + # different instances. Retrieve any one of them, and get the mod's class from it. + sample_method = next(iter(methods)) + cls = type(sample_method.__self__) # type: ignore + + # Attempt to use the class's deserializer callable to deserialize the message's arguments. + arguments = None + try: + arguments = cls.NetworkDeserialize(message_components[1]) + except Exception: + unrealsdk.Log(f"Unable to deserialize arguments for '{message_type}'") + tb = traceback.format_exc().split('\n') + unrealsdk.Log(f" {tb[-4].strip()}") + unrealsdk.Log(f" {tb[-3].strip()}") + unrealsdk.Log(f" {tb[-2].strip()}") + + if arguments is not None: + # Use the inspect module to correctly map the received arguments to their parameters. + bindings = inspect.signature(sample_method).bind( + *arguments["args"], **arguments["kwargs"] + ) + # If this method has a parameter through which to pass a player controller, assign the + # caller to it. + if bindings.signature.parameters.get("PC") is not None: + bindings.arguments["PC"] = caller + + # Invoke each registered method with the mapped arguments. + for method in methods: + try: + method(*bindings.args, **bindings.kwargs) + except Exception: + unrealsdk.Log(f"Unable to call remotely requested {method}.") + tb = traceback.format_exc().split('\n') + unrealsdk.Log(f" {tb[-4].strip()}") + unrealsdk.Log(f" {tb[-3].strip()}") + unrealsdk.Log(f" {tb[-2].strip()}") + + # Send acknowledgement of the message back to the client. + caller.ClientMessage("unrealsdk.__serverack__", message_id) + return False + + +def _client_message(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + message = params.S + message_type = params.Type + if message_type is None or not message_type.startswith("unrealsdk."): + return True + + if message_type == "unrealsdk.__serverack__": + if len(_message_queue) > 0 and _message_queue[0].ID == message: + _dequeue_message() + return False + + message_components = message.split(":", 1) + if len(message_components) != 2: + return False + message_id = message_components[0] + + methods = _client_message_types.get(message_type) + if methods is not None and len(methods) > 0: + sample_method = next(iter(methods)) + cls = type(sample_method.__self__) # type: ignore + + arguments = None + try: + arguments = cls.NetworkDeserialize(message_components[1]) + except Exception: + unrealsdk.Log(f"Unable to deserialize arguments for '{message_type}'") + tb = traceback.format_exc().split('\n') + unrealsdk.Log(f" {tb[-4].strip()}") + unrealsdk.Log(f" {tb[-3].strip()}") + unrealsdk.Log(f" {tb[-2].strip()}") + + if arguments is not None: + for method in methods: + try: + method(*arguments["args"], **arguments["kwargs"]) + except Exception: + unrealsdk.Log(f"Unable to call remotely requested {method}.") + tb = traceback.format_exc().split('\n') + unrealsdk.Log(f" {tb[-4].strip()}") + unrealsdk.Log(f" {tb[-3].strip()}") + unrealsdk.Log(f" {tb[-2].strip()}") + + caller.ServerSpeech(message_id, 0, "unrealsdk.__clientack__") + return False + + +unrealsdk.RunHook("Engine.PlayerController.ServerSpeech", "ModMenu.NetworkManager", _server_speech) +unrealsdk.RunHook("WillowGame.WillowPlayerController.ClientMessage", "ModMenu.NetworkManager", _client_message) +unrealsdk.RunHook("Engine.GameInfo.Logout", "ModMenu.NetworkManager", _Logout) +unrealsdk.RunHook("Engine.GameViewportClient.GameSessionEnded", "ModMenu.NetworkManager", _GameSessionEnded) diff --git a/Mods/ModMenu/OptionManager.py b/Mods/ModMenu/OptionManager.py new file mode 100644 index 0000000..23da6eb --- /dev/null +++ b/Mods/ModMenu/OptionManager.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import unrealsdk +from typing import Any, List, Sequence, Tuple + +from . import MenuManager, ModObjects, Options, SettingsManager + +__all__: Tuple[str, ...] = () + + +_modded_data_provider_stack: List[unrealsdk.UObject] = [] +_nested_options_stack: List[Options.Nested] = [] + +_MOD_OPTIONS_EVENT_ID: int = 1417 +_MOD_OPTIONS_MENU_NAME: str = "MODS" + +_INDENT: int = 2 + + +class _ModHeader(Options.Field): + def __init__(self, Caption: str) -> None: + self.Caption = Caption + self.Description = "" + self.IsHidden = False + + +def _create_data_provider(name: str) -> unrealsdk.UObject: + """ + Helper function that creates a new data provider and adds it to the stack. + + Args: + name: The menu name to give the new data provider. + Returns: + The data provider. + """ + provider = unrealsdk.ConstructObject( + Class=unrealsdk.FindClass("WillowScrollingListDataProviderOptionsBase") + ) + # See bl-sdk/PythonSDK#45 + unrealsdk.GetEngine().GamePlayers[0].Actor.ServerRCon( + f"set {provider.PathName(provider)} MenuDisplayName {name}" + ) + _modded_data_provider_stack.append(provider) + return provider + + +def _is_anything_shown(option_list: Sequence[Options.Base]) -> bool: + """ + Helper function that recursively checks if anything in the provided option list is shown. + + Args: + option_list: The list of options to check. + Returns: + True if at least one of the options in the list, or in any nested lists, is shown. + """ + for option in option_list: + if option.IsHidden: + continue + if isinstance(option, Options.Nested): + if _is_anything_shown(option.Children): + return True + else: + return True + return False + + +def _TopLevelOptionsPopulate(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ This function is called to create the options menu. We use it to inject our `MODS` menu. """ + # If not mods have accessable options, we want to disable the mods entry + disabled = True + for mod in ModObjects.Mods: + if not mod.IsEnabled: + continue + if _is_anything_shown(mod.Options): + disabled = False + break + + def AddListItem(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called every time an item is added to *any* menu list - we obviously can't + use a generic hook. + Using it cause it simplifies the code to add our own entry. + """ + if params.Caption == "$WillowGame.WillowScrollingList.BackCaption": + caller.AddListItem(_MOD_OPTIONS_EVENT_ID, _MOD_OPTIONS_MENU_NAME, disabled, False) + + return True + + unrealsdk.RunHook("WillowGame.WillowScrollingList.AddListItem", "ModMenu.OptionManager", AddListItem) + + unrealsdk.DoInjectedCallNext() + caller.Populate(params.TheList) + + unrealsdk.RemoveHook("WillowGame.WillowScrollingList.AddListItem", "ModMenu.OptionManager") + return False + + +def _WillowScrollingListOnClikEvent(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called on a few different events to do with these scrolling lists. We're + interested in it to detect when you open up one of our modded menus. + """ + global isMenuPluginMenu + + if params.Data.Type != "itemClick": + return True + + # For some reason `caller.GetCurrentDataProvider()` returns a null object? + provider = None + for obj in caller.DataProviderStack: + provider = obj.DataProvider.ObjectPointer + if provider is None: + return True + + if provider in _modded_data_provider_stack: + # If you pressed the back button + if params.Data.Index == len(_nested_options_stack[-1].Children): + return True + + option = _nested_options_stack[-1].Children[params.Data.Index] + if isinstance(option, Options.Nested): + _nested_options_stack.append(option) + caller.MyOwnerMovie.PlayUISound("MenuOpen") + caller.PushDataProvider(_create_data_provider(option.Caption)) + return False + elif isinstance(option, Options.Field): + return False + + elif ( + provider.Class.Name == "WillowScrollingListDataProviderTopLevelOptions" + and caller.IndexToEventId[params.Data.Index] == _MOD_OPTIONS_EVENT_ID + ): + caller.MyOwnerMovie.PlayUISound("MenuOpen") + caller.PushDataProvider(_create_data_provider(_MOD_OPTIONS_MENU_NAME)) + return False + + return True + + +def _DataProviderOptionsBasePopulate(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called to fill in a few of a scrolling lists. Our custom data providers are of + this type, so we use it to populate the lists ourselves. + """ + if caller not in _modded_data_provider_stack: + return True + + # If we're on the first level we need to setup the inital list + if len(_nested_options_stack) == 0: + all_options: List[Options.Base] = [] + for mod in MenuManager.GetOrderedModList(): + if not mod.IsEnabled: + continue + + one_shown = False + for option in mod.Options: + if option.IsHidden: + continue + if not one_shown: + one_shown = True + all_options.append(_ModHeader(mod.Name)) + all_options.append(option) + + _nested_options_stack.append(Options.Nested(_MOD_OPTIONS_MENU_NAME, "", all_options)) + + first_level = len(_nested_options_stack) == 1 + for idx, option in enumerate(_nested_options_stack[-1].Children): + if option.IsHidden: + continue + + indent = " " * _INDENT if first_level and not isinstance(option, _ModHeader) else "" + + if isinstance(option, Options.Spinner): + spinner_idx: int + if isinstance(option, Options.Boolean): + spinner_idx = int(option.CurrentValue) + else: + spinner_idx = option.Choices.index(option.CurrentValue) + + params.TheList.AddSpinnerListItem( + idx, indent + option.Caption, False, spinner_idx, option.Choices + ) + elif isinstance(option, Options.Slider): + params.TheList.AddSliderListItem( + idx, + indent + option.Caption, + False, + option.CurrentValue, + option.MinValue, + option.MaxValue, + option.Increment, + ) + elif isinstance(option, Options.Field): + disabled = False + if isinstance(option, Options.Nested): + disabled = not _is_anything_shown(option.Children) + params.TheList.AddListItem(idx, indent + option.Caption, disabled, False) + + caller.AddDescription(idx, option.Description) + + return False + + +def _DataProviderOptionsBaseOnPop(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called when the data provider is popped off the stack, when you leave the menu. + Unsuprisingly, we do the same with our stacks. We can also use it to save settings when you + leave the outermost menu. + """ + if caller in _modded_data_provider_stack: + _modded_data_provider_stack.pop() + _nested_options_stack.pop() + if len(_modded_data_provider_stack) == 0: + SettingsManager.SaveAllModSettings() + + return True + + +def _HandleSpinnerSliderChange(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + The two functions that have this hook get called when a spinner or slider changes value. We use + the hook to update our version of the objects. + """ + if caller not in _modded_data_provider_stack: + return True + + changed_option = _nested_options_stack[-1].Children[params.EventID] + + new_value: Any + if isinstance(changed_option, Options.Slider): + new_value = int(params.NewSliderValue) + elif isinstance(changed_option, Options.Boolean): + new_value = bool(params.NewChoiceIndex) + elif isinstance(changed_option, Options.Spinner): + new_value = changed_option.Choices[params.NewChoiceIndex] + else: + raise RuntimeError(f"Option of bad type '{type(changed_option)}' somehow changed value.") + + def in_option_list(option_list: Sequence[Options.Base]) -> bool: + return any( + in_option_list(option.Children) + if isinstance(option, Options.Nested) + else option == changed_option + for option in option_list + ) + + for mod in ModObjects.Mods: + if in_option_list(mod.Options): + # Calling this before updating the value + mod.ModOptionChanged(changed_option, new_value) + changed_option.CurrentValue = new_value + break + + return True + + +unrealsdk.RunHook("WillowGame.WillowScrollingListDataProviderTopLevelOptions.Populate", "ModMenu.OptionManager", _TopLevelOptionsPopulate) +unrealsdk.RunHook("WillowGame.WillowScrollingList.OnClikEvent", "ModMenu.OptionManager", _WillowScrollingListOnClikEvent) +unrealsdk.RunHook("WillowGame.WillowScrollingListDataProviderOptionsBase.Populate", "ModMenu.OptionManager", _DataProviderOptionsBasePopulate) +unrealsdk.RunHook("WillowGame.WillowScrollingListDataProviderOptionsBase.OnPop", "ModMenu.OptionManager", _DataProviderOptionsBaseOnPop) +unrealsdk.RunHook("WillowGame.WillowScrollingListDataProviderOptionsBase.HandleSpinnerChange", "ModMenu.OptionManager", _HandleSpinnerSliderChange) +unrealsdk.RunHook("WillowGame.WillowScrollingListDataProviderOptionsBase.HandleSliderChange", "ModMenu.OptionManager", _HandleSpinnerSliderChange) diff --git a/Mods/ModMenu/Options.py b/Mods/ModMenu/Options.py new file mode 100644 index 0000000..9b4a4f3 --- /dev/null +++ b/Mods/ModMenu/Options.py @@ -0,0 +1,429 @@ +from abc import ABC, abstractmethod +from reprlib import recursive_repr +from typing import Any, Generic, Optional, Sequence, Tuple, TypeVar + +from . import DeprecationHelper as dh + +__all__: Tuple[str, ...] = ( + "Base", + "Boolean", + "Field", + "Hidden", + "Nested", + "Slider", + "Spinner", + "Value", +) + +T = TypeVar("T") + + +class Base(ABC): + """ + The abstract base class all options inherit from. + + Attributes: + Caption: The name of the option. + Description: A short description of the option to show when hovering over it in the menu. + IsHidden: If the option is hidden from the options menu. + """ + Caption: str + Description: str + IsHidden: bool + + @abstractmethod + def __init__( + self, + Caption: str, + Description: str = "", + *, + IsHidden: bool = True + ) -> None: + raise NotImplementedError + + +class Value(Base, Generic[T]): + """ + The abstract base class for all options that store a value. + + Attributes: + Caption: The name of the option. + Description: A short description of the option to show when hovering over it in the menu. + + CurrentValue: The current value of the option. + StartingValue: The default value of the option. + + IsHidden: If the option is hidden from the options menu. + """ + CurrentValue: T + StartingValue: T + + @abstractmethod + def __init__( + self, + Caption: str, + Description: str, + StartingValue: T, + *, + IsHidden: bool = True + ) -> None: + raise NotImplementedError + + +class Hidden(Value[T]): + """ + A hidden option that never displays in the menu but stores an arbitrary (json serializable) + value to the settings file. + + Attributes: + Caption: The name of the option. + Description: + A short description of the option to show when hovering over it in the menu. This is + inherited, it is useless. + CurrentValue: The current value of the option. + StartingValue: The default value of the option. + IsHidden: If the option is hidden from the options menu. This is forced to True. + """ + + def __init__( + self, + Caption: str, + Description: str = "", + StartingValue: T = None, # type: ignore + *, + IsHidden: bool = True + ) -> None: + """ + Creates the option. + + Args: + Caption: The name of the option. + Description: + A short description of the option to show when hovering over it in the menu. This is + inherited, it is useless. + StartingValue: The default value of the option. + + IsHidden (keyword only): + If the option is hidden from the options menu. This is forced to True. + """ + self.Caption = Caption + self.Description = Description + self.CurrentValue = StartingValue + self.StartingValue = StartingValue + self.IsHidden = IsHidden + + @property # type: ignore + def IsHidden(self) -> bool: # type: ignore + return True + + @IsHidden.setter + def IsHidden(self, val: bool) -> None: + pass + + @recursive_repr() + def __repr__(self) -> str: + return ( + f"Hidden(" + f"Caption={repr(self.Caption)}," + f"Description={repr(self.Description)}," + f"*,IsHidden={repr(self.IsHidden)}" + f")" + ) + + +class Slider(Value[int]): + """ + An option which allows users to select a value along a slider. + + Note that, while you can give this float inputs, the game will only return integers. + + Attributes: + Caption: The name of the option. + Description: A short description of the option to show when hovering over it in the menu. + CurrentValue: The current value of the option. + StartingValue: The default value of the option. + + MinValue: The minimum selectable value on the slider. + MaxValue: The maximum selectable value on the slider. + Increment: The minimum amount a value on the slider can be changed by. + + IsHidden: If the option is hidden from the options menu. + """ + CurrentValue: int + StartingValue: int + MinValue: int + MaxValue: int + Increment: int + + def __init__( + self, + Caption: str, + Description: str, + StartingValue: int, + MinValue: int, + MaxValue: int, + Increment: int, + *, + IsHidden: bool = False + ): + """ + Creates the option. + + Args: + Caption: The name of the option. + Description: + A short description of the option to show when hovering over it in the menu. + StartingValue: The default value of the option. + + MinValue: The minimum selectable value on the slider. + MaxValue: The maximum selectable value on the slider. + Increment: The minimum amount a value on the slider can be changed by. + + IsHidden (keyword only): If the option is hidden from the options menu. + """ + self.Caption = Caption + self.Description = Description + self.CurrentValue = StartingValue + self.StartingValue = StartingValue + self.MinValue = MinValue + self.MaxValue = MaxValue + self.Increment = Increment + self.IsHidden = IsHidden + + @recursive_repr() + def __repr__(self) -> str: + return ( + f"Slider(" + f"Caption={repr(self.Caption)}," + f"Description={repr(self.Description)}," + f"CurrentValue={repr(self.CurrentValue)}," + f"StartingValue={repr(self.StartingValue)}," + f"MinValue={repr(self.MinValue)}," + f"MaxValue={repr(self.MaxValue)}," + f"Increment={repr(self.Increment)}," + f"*,IsHidden={repr(self.IsHidden)}" + f")" + ) + + +class Spinner(Value[str]): + """ + An option which allows users to select one value from a sequence of strings. + + Attributes: + Caption: The name of the option. + Description: A short description of the option to show when hovering over it in the menu. + CurrentValue: The currently selected string. + StartingValue: The string selected by default. + + Choices: A sequence of strings to be used as the choices. + + IsHidden: If the option is hidden from the options menu. + """ + CurrentValue: str + StartingValue: str + Choices: Sequence[str] + + def __init__( + self, + Caption: str, + Description: str, + StartingValue: Optional[str] = None, + Choices: Optional[Sequence[str]] = None, + *, + IsHidden: bool = False, + StartingChoice: Optional[str] = None + ): + """ + Creates the option. + + Args: + Caption: The name of the option. + Description: + A short description of the option to show when hovering over it in the menu. + StartingValue: The string selected by default. + + Choices: A sequence of strings to be used as the choices. + + IsHidden (keyword only): If the option is hidden from the options menu. + """ + self.Caption = Caption + self.Description = Description + self.IsHidden = IsHidden + + if StartingValue is not None: + self.StartingValue = StartingValue + self.CurrentValue = StartingValue + elif StartingChoice is not None: + dh.PrintWarning(dh.NameChangeMsg("Spinner.StartingChoice", "Spinner.StartingValue")) + self.StartingValue = StartingChoice + self.CurrentValue = StartingChoice + else: + raise TypeError("__init__() missing 1 required positional argument: 'StartingValue'") + + if Choices is None: + raise TypeError("__init__() missing 1 required positional argument: 'Choices'") + else: + self.Choices = Choices + + if self.StartingValue not in self.Choices: + raise ValueError( + f"Provided starting value '{self.StartingValue}' is not in the list of choices." + ) + + @recursive_repr() + def __repr__(self) -> str: + return ( + f"Spinner(" + f"Caption={repr(self.Caption)}," + f"Description={repr(self.Description)}," + f"CurrentValue={repr(self.CurrentValue)}," + f"StartingValue={repr(self.StartingValue)}," + f"Choices={repr(self.Choices)}," + f"*,IsHidden={repr(self.IsHidden)}" + f")" + ) + + +class Boolean(Spinner, Value[bool]): + """ + A special form of a spinner, with two options representing boolean values. + + Attributes: + Caption: The name of the option. + Description: A short description of the option to show when hovering over it in the menu. + CurrentValue: The currently value, as a boolean. + StartingValue: The default value, as a boolean. + + Choices: A tuple of two strings to be used as the choices. + + IsHidden: If the option is hidden from the options menu. + """ + StartingValue: bool # type: ignore + Choices: Tuple[str, str] + + _current_value: bool + + def __init__( + self, + Caption: str, + Description: str, + StartingValue: bool, + Choices: Tuple[str, str] = ("Off", "On"), + *, + IsHidden: bool = False + ): + """ + Creates the option. + + Args: + Caption: The name of the option. + Description: + A short description of the option to show when hovering over it in the menu. + StartingValue: The string selected by default. + + Choices: A sequence of strings to be used as the choices. + + IsHidden (keyword only): If the option is hidden from the options menu. + """ + self.Caption = Caption + self.Description = Description + self.StartingValue = StartingValue + self.IsHidden = IsHidden + + self.Choices = Choices + self.CurrentValue = StartingValue + + if len(self.Choices) != 2: + raise ValueError( + f"Invalid amount of choices passed to boolean option, expected 2 not" + f" {len(self.Choices)}." + ) + + @property # type: ignore + def CurrentValue(self) -> bool: # type: ignore + return self._current_value + + @CurrentValue.setter + def CurrentValue(self, val: Any) -> None: + if val in self.Choices: + self._current_value = bool(self.Choices.index(val)) + else: + self._current_value = bool(val) + + @recursive_repr() + def __repr__(self) -> str: + return ( + f"Boolean(" + f"Caption={repr(self.Caption)}," + f"Description={repr(self.Description)}," + f"CurrentValue={repr(self.CurrentValue)}," + f"StartingValue={repr(self.StartingValue)}," + f"Choices={repr(self.Choices)}," + f"*,IsHidden={repr(self.IsHidden)}" + f")" + ) + + +class Field(Base): + """ + A field which displays in the options list but holds no value. + + Attributes: + Caption: The name of the field. + Description: A short description of the field to show when hovering over it in the menu. + IsHidden: If the field is hidden from the options menu. + """ + + +class Nested(Field): + """ + A field which when clicked opens up a nested menu with more options. + + Note that these fields will be disabled if all child options are either hidden or other disabled + nested fields. + + Attributes: + Caption: The name of the field. + Description: A short description of the field to show when hovering over it in the menu. + + Children: A sequence of child options to display in the nested menu. + + IsHidden: If the field is hidden from the options menu. + """ + Children: Sequence[Base] + + def __init__( + self, + Caption: str, + Description: str, + Children: Sequence[Base], + *, + IsHidden: bool = False + ) -> None: + """ + Creates the option. + + Args: + Caption: The name of the option. + Description: A short description of the option to show when hovering over it in the menu. + + Children: A sequence of child options to display in the nested menu. + + IsHidden (keyword only): If the value is hidden from the options menu. + """ + self.Caption = Caption + self.Description = Description + self.Children = Children + self.IsHidden = IsHidden + + @recursive_repr() + def __repr__(self) -> str: + return ( + f"Nested(" + f"Caption={repr(self.Caption)}," + f"Description={repr(self.Description)}," + f"Children={repr(self.Children)}," + f"*,IsHidden={repr(self.IsHidden)}" + f")" + ) diff --git a/Mods/ModMenu/SettingsManager.py b/Mods/ModMenu/SettingsManager.py new file mode 100644 index 0000000..5c23cc8 --- /dev/null +++ b/Mods/ModMenu/SettingsManager.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import unrealsdk +import inspect +import json +import traceback +from os import path +from typing import Any, Dict, Sequence, Set, Tuple + +from . import DeprecationHelper as dh +from . import KeybindManager, ModObjects, Options + +__all__: Tuple[str, ...] = ( + "GetSettingsFilePath", + "SaveModSettings", + "SaveAllModSettings", + "LoadModSettings", +) + + +_OPTIONS_CATEGORY_NAME = "Options" +_KEYBINDS_CATEGORY_NAME = "Keybinds" +_ENABLED_CATEGORY_NAME = "AutoEnable" +_SETTINGS_FILE_NAME = "settings.json" + +_mods_to_enable_on_main_menu: Set[ModObjects.SDKMod] = set() + + +def GetSettingsFilePath(mod: ModObjects.SDKMod) -> str: + """ + Gets the path of a mod's settings file. + + Args: + mod: The instance of the mod whose settings file path should be retrived. + Returns: + The path to the file, which is in the same folder as the file defining the mod class. + """ + return path.join(path.dirname(inspect.getfile(mod.__class__)), _SETTINGS_FILE_NAME) + + +def SaveModSettings(mod: ModObjects.SDKMod) -> None: + """ + Saves the options, keybinds, and enabled state of a mod, where applicable. + + Args: + mod: The instance of the mod whose settings should be saved. + """ + mod_settings: Dict[str, Any] = {} + + def create_options_dict(options: Sequence[Options.Base]) -> Dict[str, Any]: + settings = {} + for option in options: + if isinstance(option, Options.Value): + settings[option.Caption] = option.CurrentValue + elif isinstance(option, Options.Nested): + settings[option.Caption] = create_options_dict(option.Children) + return settings + + options_dict = create_options_dict(mod.Options) + + if len(options_dict) > 0: + mod_settings[_OPTIONS_CATEGORY_NAME] = options_dict + + keybinds_dict = {} + for input in mod.Keybinds: + if isinstance(input, KeybindManager.Keybind): + if not input.IsRebindable: + continue + keybinds_dict[input.Name] = input.Key + else: + dh.PrintWarning(KeybindManager.Keybind._list_deprecation_warning) + keybinds_dict[input[0]] = input[1] + + if len(keybinds_dict) > 0: + mod_settings[_KEYBINDS_CATEGORY_NAME] = keybinds_dict + + if mod.SaveEnabledState != ModObjects.EnabledSaveType.NotSaved: + mod_settings[_ENABLED_CATEGORY_NAME] = mod.IsEnabled + + if len(mod_settings.keys()) > 0: + with open(GetSettingsFilePath(mod), "w") as file: + json.dump(mod_settings, file, indent=4) + + +def SaveAllModSettings() -> None: + """ Saves the options, keybinds, and enabled state of all loaded mods, where applicable. """ + for mod in ModObjects.Mods: + try: + SaveModSettings(mod) + except Exception: + unrealsdk.Log(f"Unable to save settings for '{mod.Name}'") + tb = traceback.format_exc().split('\n') + unrealsdk.Log(f" {tb[-4].strip()}") + unrealsdk.Log(f" {tb[-3].strip()}") + unrealsdk.Log(f" {tb[-2].strip()}") + + +def LoadModSettings(mod: ModObjects.SDKMod) -> None: + """ + Loads the options, keybinds, and enabled state of a mod back from disk. + + Args: + mod: The instance of the mod to load settings onto. + """ + settings: Dict[str, Any] + try: + with open(GetSettingsFilePath(mod)) as file: + settings = json.load(file) + except (FileNotFoundError, json.JSONDecodeError): + return + + def load_options_dict(options: Sequence[Options.Base], settings: Dict[str, Any]) -> None: + for option in options: + if option.Caption not in settings: + continue + + value = settings[option.Caption] + + if isinstance(option, Options.Boolean): + if isinstance(value, str): + if value in option.Choices: + option.CurrentValue = bool(option.Choices.index(value)) + elif value.lower() == "true": + option.CurrentValue = True + elif value.lower() == "false": + option.CurrentValue = False + else: + option.CurrentValue = bool(value) + elif isinstance(option, Options.Spinner): + if value in option.Choices: + option.CurrentValue = str(value) + else: + option.CurrentValue = option.StartingValue + elif isinstance(option, Options.Slider): + option.CurrentValue = max(option.MinValue, min(option.MaxValue, int(value))) + elif isinstance(option, Options.Hidden): + option.CurrentValue = value + + elif isinstance(option, Options.Nested): + load_options_dict(option.Children, value) + + load_options_dict(mod.Options, settings.get(_OPTIONS_CATEGORY_NAME, {})) + + saved_keybinds = settings.get(_KEYBINDS_CATEGORY_NAME, {}) + for input in mod.Keybinds: + if isinstance(input, KeybindManager.Keybind): + if input.Name in saved_keybinds: + input.Key = saved_keybinds[input.Name] + else: + dh.PrintWarning(KeybindManager.Keybind._list_deprecation_warning) + if input[0] in saved_keybinds: + input[1] = saved_keybinds[input[0]] + + if settings.get(_ENABLED_CATEGORY_NAME, False): + if mod.SaveEnabledState == ModObjects.EnabledSaveType.LoadWithSettings: + if not mod.IsEnabled: + mod.SettingsInputPressed("Enable") + elif mod.SaveEnabledState == ModObjects.EnabledSaveType.LoadOnMainMenu: + _mods_to_enable_on_main_menu.add(mod) + + +def _FrontendGFxMovieStart(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + """ + This function is called upon reaching the main menu, after hotfix objects already exist and all + the main packages are loaded. We use it to enable all `LoadOnMainMenu` mods. + """ + for mod in _mods_to_enable_on_main_menu: + if not mod.IsEnabled: + mod.SettingsInputPressed("Enable") + + _mods_to_enable_on_main_menu.clear() + + return True + + +unrealsdk.RunHook("WillowGame.FrontendGFxMovie.Start", "ModMenu.SettingsManager", _FrontendGFxMovieStart) diff --git a/Mods/ModMenu/__init__.py b/Mods/ModMenu/__init__.py new file mode 100644 index 0000000..4286ba4 --- /dev/null +++ b/Mods/ModMenu/__init__.py @@ -0,0 +1,104 @@ +import unrealsdk +import sys +from typing import Tuple + +__all__: Tuple[str, ...] = ( + "AnyHook", + "ClientMethod", + "Deprecated", + "EnabledSaveType", + "Game", + "GetOrderedModList", + "GetSettingsFilePath", + "Hook", + "HookFunction", + "HookMethod", + "InputEvent", + "Keybind", + "KeybindCallback", + "LoadModSettings", + "ModPriorities", + "Mods", + "ModTypes", + "NameChangeMsg", + "Options", + "PrintWarning", + "RegisterHooks", + "RegisterMod", + "RegisterNetworkMethods", + "RemoveHooks", + "SaveAllModSettings", + "SaveModSettings", + "SDKMod", + "ServerMethod", + "UnregisterNetworkMethods", +) + + +# Need to define these up here so that they're accessable when importing the other files +VERSION_MAJOR = 2 +VERSION_MINOR = 5 + +unrealsdk.Log(f"[ModMenu] Version: {VERSION_MAJOR}.{VERSION_MINOR}") + +from . import DeprecationHelper as dh # noqa: E402 +from . import OptionManager, Options, SettingsManager # noqa: E402 +from .DeprecationHelper import Deprecated, NameChangeMsg, PrintWarning # noqa: E402 +from .HookManager import (AnyHook, Hook, HookFunction, HookMethod, RegisterHooks, # noqa: E402 + RemoveHooks) +from .KeybindManager import InputEvent, Keybind, KeybindCallback # noqa: E402 +from .MenuManager import GetOrderedModList # noqa: E402 +from .ModObjects import (EnabledSaveType, Game, ModPriorities, Mods, ModTypes, # noqa: E402 + RegisterMod, SDKMod) +from .NetworkManager import (ClientMethod, RegisterNetworkMethods, ServerMethod, # noqa: E402 + UnregisterNetworkMethods) +from .SettingsManager import (GetSettingsFilePath, LoadModSettings, # noqa: E402 + SaveAllModSettings, SaveModSettings) + +from . import ModObjects # noqa: E402 # isort: skip # Avoid circular import + +""" +From this point on this file defines just aliases, most of which should be considered deprecated. + + +When enabling a mod, the default `SettingsInputPressed` calls `ModOptionChanged` on every option. +This behaviour should be considered deprecated, move it into your `Enable` if you need it. +Unfortuantly there's no easy way to detect mods that rely on this to print a warning, and it's not + something that can be aliased, so this text warning will have to do. +""" + + +sys.modules["bl2sdk"] = unrealsdk +sys.modules["Mods.ModManager"] = ModObjects +sys.modules["Mods.OptionManager"] = OptionManager +sys.modules["Mods.SaveManager"] = SettingsManager + +unrealsdk.PythonManagerVersion = VERSION_MAJOR + +ModObjects.BL2MOD = ModObjects.SDKMod # type: ignore +unrealsdk.BL2MOD = ModObjects.SDKMod + +unrealsdk.Mods = ModObjects.Mods +unrealsdk.ModTypes = ModObjects.ModTypes +unrealsdk.RegisterMod = ModObjects.RegisterMod + + +OptionManager.Options = Options +unrealsdk.Options = Options + +# When removing this, also make sure to edit `Spinner.__init__()` +_msg = dh.NameChangeMsg("Spinner.StartingChoice", "Spinner.StartingValue") +Options.Spinner.StartingChoice = property( # type: ignore + dh.Deprecated(_msg, lambda self: self.StartingValue), + dh.Deprecated(_msg, lambda self, val: self.__setattr__("StartingValue", val)) +) +_msg = dh.NameChangeMsg("Boolean.StartingChoiceIndex", "Boolean.StartingValue") +Options.Boolean.StartingChoiceIndex = property( # type: ignore + dh.Deprecated(_msg, lambda self: self.StartingValue), + dh.Deprecated(_msg, lambda self, val: self.__setattr__("StartingValue", val)) +) +del _msg + + +SettingsManager.storeModSettings = SettingsManager.SaveAllModSettings # type: ignore +storeModSettings = SettingsManager.SaveAllModSettings # noqa: N816 diff --git a/Mods/ModUtils/__init__.py b/Mods/ModUtils/__init__.py new file mode 100644 index 0000000..5d80814 --- /dev/null +++ b/Mods/ModUtils/__init__.py @@ -0,0 +1,22 @@ +from Mods.ModMenu import ModTypes + +from .misc import clsname, combine_flags, is_in_game +from .objects import Currency, Player, Settings +from .sdkmod import SdkMod + + +class ModUtils(SdkMod): + ''' + Various useful classes and functions for mod devs. See :class:`Mods.ModUtils.ModUtils` for + API usage. + ''' + + Name = 'Mod Utilities' + Description = 'Various useful classes and functions for mod devs' + Author = 'Izalia Mae' + Version = '0.1' + Types = ModTypes.Library + + +mod = ModUtils() +mod.register() diff --git a/Mods/ModUtils/enums.py b/Mods/ModUtils/enums.py new file mode 100644 index 0000000..9785bf4 --- /dev/null +++ b/Mods/ModUtils/enums.py @@ -0,0 +1,154 @@ +from enum import IntEnum, auto +import enum + + +class StrEnum(str, enum.Enum): + def __new__(cls, value): + return str.__new__(cls, value) + + + def __str__(self): + return self.value + + + @classmethod + def parse(cls, value): + try: + if value in cls: + return cls[value] + + return cls(value) + + except ValueError: + pass + + for item in cls: + if value.upper() in (item.value.upper(), item.name.upper()): + return item + + raise KeyError(value) + + +class IntEnum(enum.IntEnum): + @classmethod + def parse(cls, value): + try: + if value in cls: + return cls[value] + + return cls(value) + + except ValueError: + pass + + if isinstance(value, str): + for item in cls: + if value.upper() == item.name: + return item + + raise KeyError(value) + + +class CurrencyType(IntEnum): + CREDITS = 0 + ERIDIUM = 1 + SERAPH = 2 + GOLDKEYS = 3 + RESERVED_B = 4 + RESERVED_C = 5 + RESERVED_D = 6 + RESERVED_E = 7 + RESERVED_F = 8 + RESERVED_G = 9 + RESERVED_H = 10 + RESERVED_I = 11 + RESERVED_J = 12 + MAX = 13 + + # alias + MOONSTONE = CREDITS + + +class FrameRate(IntEnum): + SMOOTH = 0 + CAP30 = 1 + CAP50 = 2 + CAP60 = 3 + CAP72 = 4 + CAP120 = 5 + UNLIMITED = 6 + + +class KeyboardInput(StrEnum): + # Letters + A = 'A' + B = 'B' + C = 'C' + D = 'D' + E = 'E' + F = 'F' + G = 'G' + H = 'H' + I = 'I' + J = 'J' + K = 'K' + L = 'L' + M = 'M' + N = 'N' + O = 'O' + P = 'P' + Q = 'Q' + R = 'R' + S = 'S' + T = 'T' + U = 'U' + V = 'V' + W = 'W' + X = 'X' + Y = 'Y' + Z = 'Z' + + # Numbers + ZERO = 'zero' + ONE = 'one' + TWO = 'two' + THREE = 'three' + FOUR = 'four' + FIVE = 'five' + SIX = 'siz' + SEVEN = 'seven' + EIGHT = 'eight' + NINE = 'nine' + + # Numpad Numbers + NUMPAD_ZERO = 'NumPadZero' + NUMPAD_ONE = 'NumPadOne' + NUMPAD_TWO = 'NumPadTwo' + NUMPAD_THREE = 'NumPadThree' + NUMPAD_FOUR = 'NumPadFour' + NUMPAD_FIVE = 'NumPadFive' + NUMPAD_SIX = 'NumPadSix' + NUMPAD_SEVEN = 'NumPadSeven' + NUMPAD_EIGHT = 'NumPadEight' + NUMPAD_NINE = 'NumPadNine' + + # F-keys + F1 = 'F1' + F2 = 'F2' + F3 = 'F3' + F4 = 'F4' + F5 = 'F5' + F6 = 'F6' + F7 = 'F7' + F8 = 'F8' + F9 = 'F9' + F10 = 'F10' + F11 = 'F11' + F12 = 'F12' + + @classmethod + def numbers(cls): + return tuple( + cls.ZERO, cls.ONE, cls.TWO, cls.THREE, cls.FOUR, + cls.FIVE, cls.SIX, cls.SEVEN, cls.EIGHT, cls.NINE + ) diff --git a/Mods/ModUtils/misc.py b/Mods/ModUtils/misc.py new file mode 100644 index 0000000..64307d2 --- /dev/null +++ b/Mods/ModUtils/misc.py @@ -0,0 +1,44 @@ +import enum +import functools +import operator +import sys +import typing + +from os.path import basename + + +def clsname(obj) -> str: + ''' + Get the class name of an object + + :param typing.Union[type, object] obj: Object or type to get the name of + ''' + try: + return obj.__class__.__name__ + + except AttributeError: + return obj.__name__ + + +# might not use this +def manifest_to_flag(enum: 'enum.Flag', values: 'list[str]'): + items = tuple(getattr(enum, item) for item in values) + return combine_flags(items) + + +def combine_flags(*items) -> enum.Flag: + ''' + Combine multiple :class:`enum.Flag` objects into one + + :param list[str,enum.Flag] items: + ''' + if len(items) > 1: + return functools.reduce(operator.or_, items) + + elif len(items) == 1: + return items[0] + + +def is_in_game() -> bool: + 'Return ``True`` if Python is currently running within a Borderlands game' + return not basename(sys.executable).lower().startswith('python') diff --git a/Mods/ModUtils/objects/__init__.py b/Mods/ModUtils/objects/__init__.py new file mode 100644 index 0000000..8ffa273 --- /dev/null +++ b/Mods/ModUtils/objects/__init__.py @@ -0,0 +1,2 @@ +from .player import Currency, Player +from .settings import Settings diff --git a/Mods/ModUtils/objects/player.py b/Mods/ModUtils/objects/player.py new file mode 100644 index 0000000..e1dc915 --- /dev/null +++ b/Mods/ModUtils/objects/player.py @@ -0,0 +1,198 @@ +import unrealsdk + +from ..enums import CurrencyType + + +class Player: + 'Represents a player' + + def __init__(self, player): + self._player = player + self._player.bHasSeenGoldenKeyMessageThisSession = True + + + @classmethod + def new_from_id(cls, pid): + return cls(unrealsdk.GetEngine().GamePlayers[pid]) + + + @classmethod + def default(cls): + return cls.new_from_id(0) + + + ## player objects + @property + def controller(self): + return self._player.Actor + + + @property + def currency(self): + return Currency(self._player.Actor.PlayerReplicationInfo) + + + @property + def pawn(self): + data = self.controller.MyWillowPawn + + if not data: + raise AttributeError('No Pawn object. Are you on the main menu?') + + return data + + + ## useful player properties + @property + def character_name(self): + return self.controller.GetPlayerUINamePreference() + + + @property + def classmod_name(self): + return self.controller.PlayerReplicationInfo.GetClassModName() + + + @property + def level(self): + self.pawn.GetExpLevel() + + + @level.setter + def level(self, value): + if 1 > level > 80: + raise ValueError('Level must be anywhere from 1 to 80') + + self.pawn.SetExpLevel(value) + + + @property + def shield_amount(self): + return self.pawn.GetShieldStrength() + + + @shield_amount.setter + def shield_amount(self, value): + max = self.shield_amount_max + + if 0 > value > max: + raise ValueError(f'Shield amount must be between 0 and {max}') + + self.pawn.SetShieldStrength(value) + + + @property + def shield_amount_max(self): + return self.pawn.GetMaxShieldStrength() + + + @property + def user_name(self): + return self._player.GetNickname() + + + def get_net_speed(self): + player = self.controller.Player + + return { + 'internet': player.ConfiguredInternetSpeed, + 'lan': player.ConfiguredLanSpeed + } + + + def golden_key_add(self, value): + self.controller.AddGoldenKeysFromSource(0, value) + + + def golden_key_remove(self, value): + controller = self.controller + + for _ in range(0, value - 1): + controller.SpendGoldenKey() + + + def console_command(self, *cmd): + command = ' '.join(str(c) for c in cmd) + self.controller.ConsoleCommand(command) + + + def refill_life(self): + self.pawn.FullyReplenishLife() + + + def refill_shields(self): + self.pawn.FullyReplenishShields() + + + def remove_status_effects(self): + self.pawn.RemoveAllStatusEffects() + + + def set_net_speed(self, lan=None, internet=None): + player = self.controller.Player + + if lan == internet == None: + raise ValueError('Must provide a value for `lan` and/or `internet`') + + if lan != None: + player.ConfiguredLanSpeed = lan + + if internet != None: + player.ConfiguredInternetSpeed = internet + + + +class Currency: + 'Manage a player\'s currency' + + def __init__(self, repinfo): + self._repinfo = repinfo + + + def __getattr__(self, key): + try: + get = object.__getattribute__(self, 'get') + return get(CurrencyType.parse(key)) + + except KeyError: + pass + + object.__getattribute__(self, key) + + + def add(self, type, value): + if not isinstance(type, CurrencyType): + type = CurrencyType[type.upper()] + + return self._repinfo.AddCurrencyOnHand(type.value) + + + def get(self, type): + if not isinstance(type, CurrencyType): + type = CurrencyType[type.upper()] + + return self._repinfo.GetCurrencyOnHand(type.value) + + + def set(self, type, value): + if not isinstance(type, CurrencyType): + type = CurrencyType[type.upper()] + + return self._repinfo.SetCurrencyOnHand(type.value, value) + + + def to_dict(self): + values = self._repinfo.GetAllCurrencyOnHand() + data = {} + + for idx, type in enumerate(CurrencyType): + data[type] = values[idx] + + return data + + +class SkillTree: + 'View and/or manage a character\'s skill tree' + def __init__(self, player): + self._player = player + self._tree = player.controller.PlayerSkillTree diff --git a/Mods/ModUtils/objects/settings.py b/Mods/ModUtils/objects/settings.py new file mode 100644 index 0000000..a6a97fc --- /dev/null +++ b/Mods/ModUtils/objects/settings.py @@ -0,0 +1,110 @@ +import unrealsdk + +from ..misc import is_in_game + + +SETTINGS = [ + 'AmbientOcclusion', + 'AudioFocus', + 'bNVIDIA3d', + 'DepthOfField', + 'FramerateLocking', + 'FoliageDistance', + 'FXAA', + 'GameDetail', + 'LensFlares', + 'Fullscreen', + 'MaxAnisotropy', + 'NumberOfDecals', + 'PhysXLevel', + 'TextureFade', + 'TextureQuality', + 'ViewDistance', + 'VSync' +] + + +class Item: + def __init__(self, name, value, *options): + self.name = name + self.value = value + self.options = options + + + def __repr__(self): + return f'Item("{self.name}", "{self.value_string}")' + + + def __str__(self): + return self.value_string + + + @classmethod + def new_from_option(cls, option): + return cls( + option.Name, + option.CurrValue, + *tuple(v for v in option.ValueStrings) + ) + + + @property + def value_string(self): + return self.options[self.value] + + +class Settings: + def __init__(self): + self._set = unrealsdk.FindObject('WillowSystemSettings', 'WillowGame.Default__WillowSystemSettings') + + if is_in_game(): + self._set.LoadSystemSettings(False) + + + def __getitem__(self, key): + return self.get(key).value + + + def __setitem__(self, key, value): + self.set(key, value) + + + def __iter__(self): + for key in self.keys(): + yield key + + + def get(self, key): + for value in self._set.SystemOptions: + if key == value.Name: + return Item.new_from_option(value) + + raise KeyError(key) + + + def items(self): + for key in self.keys(): + yield key, self.get(key).value + + + def keys(self): + return tuple(SETTINGS) + + + def set(self, key, new_value): + if isinstance(new_value, str): + new_value = self.get(key).options.index(new_value) + + self._set.UpdateSystemOption(key, new_value) + + + def to_dict(self, strings=False): + if not strings: + return {k: v for k, v in self.items()} + + return {k: str(self.get(k)) for k in self.keys()} + + + def values(self): + for key in self.keys(): + yield self.get(key).value diff --git a/Mods/ModUtils/sdkmod.py b/Mods/ModUtils/sdkmod.py new file mode 100644 index 0000000..cca16e9 --- /dev/null +++ b/Mods/ModUtils/sdkmod.py @@ -0,0 +1,461 @@ +from Mods.ModMenu import ( + KeybindManager as KBM, + Options +) + +from Mods.ModMenu.ModObjects import EnabledSaveType, Game, ModPriorities, ModTypes, RegisterMod, SDKMod +from Mods.ModMenu.Options import Boolean, Slider, Spinner +from dataclasses import dataclass, field +from unrealsdk import Log + +from .misc import combine_flags, is_in_game, manifest_to_flag +from .objects import Player + + +MOD_VALUES = { + 'Name': 'name', + 'Author': 'author', + 'Description': 'description', + 'Version': 'version', + 'SupportedGames': 'supported_games', + 'Types': 'types', + 'Priority': 'priority', + 'SaveEnabledState': 'save_enabled_state', + 'Status': 'status', + 'SettingsInput': 'settings_input', + 'Options': 'options', + 'Keybinds': 'keybinds' +} + +SAVE_STATES = { + 'menu': EnabledSaveType.LoadOnMainMenu, + 'settings': EnabledSaveType.LoadWithSettings, + 'never': EnabledSaveType.NotSaved +} + + +class SdkMod(SDKMod): + def __new__(cls, *args, **kwargs): + instance = SDKMod.__new__(cls, *args, **kwargs) + instance.Keybinds = KeybindList() + instance.Options = OptionsList() + instance.SettingsInputs = SettingsInputDict() + + return instance + + + def __getattr__(self, key): + if key == 'player': + return Player.default() + + elif key == 'world': + return World.default() + + return SDKMod.__getattribute__(self, key) + + + def get_enable_state(self): + return self.SaveEnabledState + + + def get_games(self): + return self.SupportedGames + + + def get_priority(self): + return self.Priority + + + def get_types(self): + return self.Types + + + def log(self, *data): + message = ' '.join(str(msg) for msg in data) + Log(f'[{self.Name}] {message}') + + + def register(self): + if is_in_game(): + RegisterMod(self) + + + def set_enable_state(self, state): + if isinstance(state, str): + state = SAVE_STATES.get(state) + + if not state: + state = EnabledSaveType[str] + + elif not isinstance(state, EnabledSaveType): + raise TypeError(f'Mod enable state must be a str or EnabledSaveType, not {type(priority)}') + + self.SaveEnabledState = state + + + def set_games(self, *values): + items = [] + + for value in values: + if isinstance(value, str): + value = Game[value] + + elif not isinstance(value, Game): + raise TypeError(f'Game type must be a str or Game, not {(type(value))}') + + items.append(value) + + self.SupportedGames = combine_flags(*items) + + + def set_priority(self, priority): + if not instance(priority, (int, ModPriorities)): + raise TypeError(f'Priority must be an int or ModPriorities, not {type(priority)}') + + self.Priority = priority + + + def set_types(self, *types): + items = [] + + for value in types: + if isinstance(value, str): + value = ModTypes[value] + + elif not isinstance(value, ModTypes): + raise TypeError(f'Mod type must be a str or ModTypes, not {(type(value))}') + + items.append(value) + + self.Types = combine_flags(*items) + + + ## Overriding functions and events to add handler functions for easier sub-classing + @classmethod + def NetworkDeserialize(cls, data): + args = cls.handle_network_deserialize(data) + + if not args or isinstance(args, str): + return SDKMod.NetworkDeserialize(args) + + return args + + + @classmethod + def NetworkSerialise(cls, args): + data = cls.handle_network_serialize(args) + + if not data or isinstance(data, dict): + return SDKMod.NetworkSerialise(data) + + return data + + + def Disable(self): + SDKMod.Disable(self) + self.handle_disable() + + + def Enable(self): + SDKMod.Enable(self) + self.handle_enable() + + + def GameInputPressed(self, bind, event): + self.handle_game_input(bind, event) + + + def ModOptionChanged(self, option, value): + self.handle_option_change(option, value) + + + def SettingsInputPressed(self, action): + SDKMod.SettingsInputPressed(self, action) + self.handle_settings_input(action) + + + ## New handler methods for built-in events + def handle_disable(self): + pass + + + def handle_enable(self): + pass + + + def handle_game_input(self, bind, event): + pass + + + def handle_network_deserialize(self, data: str): + pass + + + def handle_network_serialize(self, args: dict): + pass + + + def handle_option_change(self, option, value): + pass + + + def handle_settings_input(self, action): + pass + + +class KeybindList(list): + def __init__(self, *keybinds): + list.__init__(self) + + for keybind in keybinds: + self.add(keybind) + + + def add(self, keybind): + ''' + Add an existing Keybind object to the keybind list + ''' + if not isinstance(keybind, KBM.Keybind): + raise TypeError('Keybind is not a ModMenu.Keybind object') + + if self.get(keybind.Name): + raise ValueError(f'Keybind with id already exists: {keybind.Name}') + + self.append(keybind) + + + def get(self, name): + ''' + Get an in-game keybind + ''' + for key in self: + if name in {keybind.Name, keybind.Name.replace(' ', '')}: + return key + + + def new(self, name, key, rebindable=True, hidden=False, handler=None): + ''' + Create a new in-game keybind for an action + ''' + keybind = KBM.Keybind( + Name = name, + Key = key, + IsRebindable = rebindable, + IsHidden = hidden, + OnPress = handler + ) + + self.add(keybind) + return key + + +class OptionsList(list): + def __init__(self, *options): + list.__init__(self) + + for opt in options: + self.add(opt) + + + def add(self, opt, id=None): + if not isinstance(opt, Options.Value): + raise TypeError('Option must be a ModUtils.BaseValue and ModMenu.Options.Value object') + + opt.id = id or opt.Caption.replace(' ', '') + self.append(opt) + + + def get(self, id): + for opt in self: + if id in {opt.id, opt.Caption}: + return opt + + + def new_boolean(self, name, description, default=False, true_value='On', false_value='Off', id=None): + self.add(Options.Boolean( + Caption = name, + Description = description, + StartingValue = default, + Choices = (false_value, true_value) + ), id) + + + def new_slider(self, name, description, default=1, min=0, max=100, increment=1, id=None): + self.add(Options.Slider( + Caption = name, + Description = description, + StartingValue = default, + MinValue = min, + MaxValue = max, + Increment = increment + ), id) + + + def new_spinner(self, name, descrption, choices: list, default=None, id=None): + self.add(Options.Spinner( + Caption = name, + Description = description, + StartingValue = default or choices[0] + ), id) + + + def set(self, id, value=None): + opt = self.get(id) + opt.CurrentValue = value if value != None else opt.StartingValue + + + def to_dict(self): + return {opt.id: opt.CurrentValue for opt in self} + + +class SettingsInputDict(dict): + def __init__(self, **binds): + dict.__init__(self, {'Enter': 'Enable'}) + + for keybind, name in binds.items(): + self.new(name, keybind) + + + def get_by_name(self, name): + for keybind, keybind_name in self.items(): + if keybind_name == name: + return keybind + + raise KeyError(name) + + + def new(self, name, keybind): + if keybind in self: + raise ValueError(f'Settings keybind already exists: {keybind}') + + self[keybind] = name + + +class BaseValue: + id = None + + def __new__(cls, *args, **kwargs): + id = kwargs.pop('id') + + if issubclass(cls, Options.Boolean): + value = Options.Boolean.__new__(cls, *args, **kwargs) + + elif issubclass(cls, Options.Slider): + value = Options.Slider.__new__(cls, *args, **kwargs) + + elif issubclass(cls, Options.Spinner): + value = Options.Spinner.__new__(cls, *args, **kwargs) + + value.id = id or value.Caption.replace(' ', '') + return value + + + def get(self): + return self.CurrentValue + + + def default(self): + self.CurrentValue = self.StartingValue + + + def set(self, value): + self.CurrentValue = value + + +class Boolean(BaseValue, Options.Boolean): + def set(self, value): + if not isinstance(value, bool): + value = bool(value) + + BaseValue.set(self, value) + + +class Slider(BaseValue, Options.Slider): + def set(self, value): + if not isinstance(value, int): + value = int(value) + + if self.MinValue > value > self.MaxValue: + raise ValueError(f'Value out of range: {self.MinValue}-{self.MaxValue}') + + BaseValue.set(self, value) + + +class Spinner(BaseValue, Options.Slider): + def set(self, value): + if value not in self.Choices: + raise ValueError(f'Invalid choice: {value}') + + BaseValue.set(self, value) + + +# class SdkManifestMod(SDKMod): +# def __init__(self, dirname): +# with Path(__file__).parent.joinpath(f'../{dirname}/manifest.json').open('r') as manifest: +# self.manifest = json.load(manifest) +# +# +# def __getattr__(self, key): +# if key in MOD_VALUES: +# key = MOD_VALUES[key] +# +# return object.__getattribute__(self, key) +# +# +# def __setattr__(self, key, value): +# if key in MOD_VALUES: +# key = MOD_VALUES[key] +# +# object.__setattr__(self, key, value) +# +# +# def __delattr__(self, key): +# if key in MOD_VALUES: +# key = MOD_VALUES[key] +# +# return object.__delattr__(self, key) +# +# +# @property +# def Author(self): +# return self.manifest['authors'] +# +# +# @property +# def Description(self): +# return self.manifest.get('tagline', self.manifest['description']) +# +# +# @property +# def Name(self): +# return self.manifest['name'] +# +# +# @property +# def Priority(self): +# value = self.manifest['modinfo']['priority'] +# +# if isinstance(value, str): +# return ModPriorities[value.title()] +# +# return value +# +# +# @property +# def SaveEnabledState(self): +# return EnabledSaveType[self.manifest['modinfo']['save_state']] +# +# +# @property +# def SupportedGames(self): +# return manifest_to_flag(Game, self.manifest['supports']) +# +# +# @property +# def Types(self): +# return manifest_to_flag(ModTypes, self.manifest['types']) +# +# +# @property +# def Version(self): +# return self.manifest['latest'] diff --git a/Mods/WebPanel/__init__.py b/Mods/WebPanel/__init__.py new file mode 100644 index 0000000..0514c47 --- /dev/null +++ b/Mods/WebPanel/__init__.py @@ -0,0 +1,72 @@ +from Mods.ModMenu import EnabledSaveType +from Mods.ModUtils import SdkMod +from unrealsdk import Log + +from . import routes +from .server import Server + + +class WebPanel(SdkMod): + 'Web-based control panel and API for borderlands' + + Name = 'Web Panel' + Description = 'Web-based control panel for Borderlands' + Author = 'Izalia Mae' + Version = '0.1' + + + def __init__(self): + self.set_enable_state('settings') + self.set_games('BL2', 'TPS', 'AoDK') + self.set_types('Utility') + + self._server = Server(self) + + self.Options.new_boolean( + 'Localhost', 'Only listen on localhost', + default = False, + id = 'localhost' + ) + + self.Options.new_boolean( + 'AccessLog', 'Log server accesses to the conolse', + default = False, + id = 'access_log' + ) + + + def __getattr__(self, key): + if key == 'host': + localhost = object.__getattribute__(self, 'Options')[0].CurrentValue + return '127.0.0.1' if localhost else '0.0.0.0' + + elif key == 'port': + return 8100 + + elif key == 'server': + return self._server + + elif key == 'access_log': + access_log = object.__getattribute__(self, 'Options')[1] + return access_log.CurrentValue + + return SdkMod.__getattr__(self, key) + + + def handle_enable(self): + self.server.start() + + + def handle_disable(self): + self.server.stop() + + + def handle_option_change(self, option, new_value): + localhost = self.Options.get('localhost') + + if option == localhost and localhost.CurrentValue != new_value and self.server.running: + self.server.stop() + self.server.start() + + +WebPanel().register() diff --git a/Mods/WebPanel/routes.py b/Mods/WebPanel/routes.py new file mode 100644 index 0000000..73ec816 --- /dev/null +++ b/Mods/WebPanel/routes.py @@ -0,0 +1,60 @@ +import json +import unrealsdk + +from Mods.ModMenu import Mods +from Mods.ModUtils import Player +from traceback import format_exc + +from .server import Response, route + + +@route('/') +def home(request): + return Response('UvU') + + +@route('/api/v1/mods') +def mods(request): + data = [] + + for mod in sorted(Mods, key=lambda mod: mod.Name): + data.append({ + 'name': mod.Name, + 'author': mod.Author, + 'description': mod.Description, + 'enabled': mod.IsEnabled + }) + + return Response(data) + + +@route('/api/v1/console', 'POST') +def console(request): + data = request.read().decode('utf-8') + player = Player.default() + player.console_command(data) + + return Response({'msg': 'Command executed'}) + + +@route('/api/v1/eval', 'POST') +def eval_python(request): + data = request.read() + + if data: + try: + exec(data, globals(), locals()) + resp_data = globals().pop('resp_data') + + if isinstance(resp_data, Response): + return resp_data + + elif isinstance(resp_data, (list, set, tuple, dict)): + resp_data = json.dumps(resp_data, indent=4) + + return Response(resp_data if resp_data != None else 'UvU') + + except Exception: + return Response(format_exc()) + + return Response('Failed to provide python code', status=400) diff --git a/Mods/WebPanel/server.py b/Mods/WebPanel/server.py new file mode 100644 index 0000000..cfea30b --- /dev/null +++ b/Mods/WebPanel/server.py @@ -0,0 +1,319 @@ +import json +import sys +import threading +import time +import traceback + +from Mods.ModUtils.misc import clsname +from http import HTTPStatus +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +from socketserver import ThreadingMixIn + + +ROUTES = {} + + +def convert_to_bytes(value, encoding='utf-8'): + if isinstance(value, bytes): + return value + + try: + return convert_to_string(value).encode(encoding) + + except RuntimeError: + raise RuntimeError(f'Cannot convert "{clsname(value)}" into bytes') + + +def convert_to_string(value, encoding='utf-8'): + if isinstance(value, bytes): + return value.decode(encoding) + + elif isinstance(value, bool): + return str(value) + + elif isinstance(value, str): + return value + + elif isinstance(value, (dict, list, tuple, set)): + return json.dumps(value) + + elif isinstance(value, (int, float)): + return str(value) + + elif value == None: + return '' + + raise RuntimeError(f'Cannot convert "{clsname(value)}" into a string') + + +def get_route(path, method): + try: rpath = ROUTES[path] + except KeyError: raise HttpError(404) + + try: return rpath[method.upper()] + except KeyError: raise HttpError(405) + + +def route(path, method='GET'): + def wrapper(handler): + ROUTES[path] = {method: handler} + return wrapper + + +class Request(BaseHTTPRequestHandler): + default_request_version = 'HTTP/1.1' + protocol_version = 'HTTP/1.1' + responded = False + app = None + + def __init__(self, app, request, client_addr, server_obj): + self.app = app + BaseHTTPRequestHandler.__init__(self, request, client_addr, server_obj) + + + @property + def length(self): + return int(self.headers.get('Length', 0)) + + + @property + def method(self): + return self.command + + + @property + def mod(self): + return self.app.mod + + + @property + def remote(self): + return self.headers.get('X-Real-Ip', self.client_address[0]) + + + @property + def useragent(self): + return self.headers.get('User-Agent', 'n/a') + + + def log_request(self, code='-', size='-'): + message = f'{self.remote} "{self.method} {self.path}" {code} {size} "{self.useragent}"' + self.app.mod.log(message + '\n') + + + def read(self, length=None): + return self.rfile.read(length or self.length) + + + def respond(self, body=b'', status=HTTPStatus.OK, headers=None, content_type='text/plain', new_line=True): + if self.responded: + raise RuntimeError('Response already sent') + + if not isinstance(status, HTTPStatus): + try: + status = HTTPStatus(status) + except ValueError: + return self.respond('Server Error', 500) + + body = convert_to_bytes(body) + resp_headers = self.app.default_headers.copy() + resp_headers.update(headers or {}) + resp_headers = {key.title(): value for key, value in resp_headers.items()} + resp_headers.pop('Server', None) + + self.send_response_only(status) + self.send_header('Server', 'BL2Panel') + self.send_header('Date', resp_headers.pop('Date', self.date_time_string())) + self.send_header('Content-Type', resp_headers.pop('Content-Type', content_type)) + self.send_header('Length', resp_headers.pop('Length', len(body))) + + for key, value in resp_headers.items(): + self.send_header(key.title(), value) + + self.end_headers() + + if self.app.mod.access_log: + self.log_request(status, len(body)) + + self.write(body) + + if new_line and not body.endswith(b'\n'): + self.write(b'\n') + + self.wfile.flush() + self.responded = True + + + def write(self, data, encoding='utf-8'): + if self.wfile.closed: + raise IOError('Client connection closed') + + self.wfile.write(convert_to_bytes(data, encoding)) + + + def handle(self): + try: + self.raw_requestline = self.rfile.readline(65537) + + if len(self.raw_requestline) > 65536: + self.requestline = '' + self.request_version = '' + self.command = '' + self.send_error(HTTPStatus.REQUEST_URI_TOO_LONG) + return + + if not self.raw_requestline: + self.close_connection = True + return + + if not self.parse_request(): + # An error code has been sent, just exit + return + + self.handle_request() + self.wfile.flush() #actually send the response if not already done. + + except TimeoutError as e: + self.log_error("Request timed out: %r", e) + self.close_connection = True + return + + + def handle_request(self): + try: + handler = get_route(self.path, self.method) + response = handler(self) + + if not response: + raise HttpError(500, 'Empty response') + + elif not isinstance(response, Response): + raise HttpError(500, 'Invalid response') + + return self.respond( + response.body, + response.status, + response.headers, + response.content_type + ) + + except HttpError as e: + return self.respond(e.body, e.status, e.headers, e.content_type) + + except Exception: + return self.respond(traceback.format_exc(), status=500) + + +class Server: + def __init__(self, mod): + self.mod = mod + self.default_headers = {} + + self._server = None + self._stop = threading.Event() + + + def __enter__(self): + self.start() + return self + + + def __exit__(self, *args): + self.stop() + + + @property + def running(self): + return self._server != None + + + def run(self): + with self: + while not self._stop.is_set(): + time.sleep(0.25) + + + def start(self): + if self._server: + return + + self._stop.clear() + self._server = ThreadingHTTPServer((self.mod.host, self.mod.port), self.handle_request) + self._server.allow_reuse_address = True + + thread = threading.Thread(target=self._server.serve_forever) + thread.start() + + self.mod.log(f'Started server @ {self.mod.host}:{self.mod.port}') + + + def stop(self): + if not self._server: + return + + self._stop.set() + self._server.shutdown() + self._server = None + + self.mod.log('Stopped server') + + + def handle_request(self, request, client_addr, server): + return Request(self, request, client_addr, server) + + +class Response: + def __init__(self, body=b'', status=200, headers=None, content_type='text/html'): + self.status = HTTPStatus(status) + self.body = body + self.headers = headers or {} + self.content_type = content_type + + + def __repr__(self): + props = [] + + for key, value in self.to_dict().items(): + if key == 'status': + value = value.value + + props.append(f'{key}={repr(value)}') + + propstr = ', '.join(props) + return f'{self.__class__.__name__}({propstr})' + + + def __str__(self): + return f'{self.status.value} {self.status.phrase}' + + + @property + def reason(self): + return self.status.phrase + + + @property + def statuscode(self): + return self.status.value + + + def to_dict(self): + return dict( + status = self.status, + body = self.body, + headers = self.headers, + content_type = self.content_type + ) + + +class HttpError(Response, Exception): + def __init__(self, status=500, body=b'', headers=None, content_type='text/html'): + Response.__init__(self, body, status, headers, content_type) + Exception.__init__(self, str(self)) + + if not self.body: + self.body = str(self) + + + def __str__(self): + return f'{self.statuscode} {self.reason}' diff --git a/Mods/WebPanel/settings.json b/Mods/WebPanel/settings.json new file mode 100644 index 0000000..3ad706d --- /dev/null +++ b/Mods/WebPanel/settings.json @@ -0,0 +1,7 @@ +{ + "Options": { + "Localhost": false, + "AccessLog": false + }, + "AutoEnable": true +} \ No newline at end of file diff --git a/README.md b/README.md index a3d30b0..c726b40 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# {name} +# BL2Mods -{description} +PythonSDK mods for BL2. Check out the documentation at https://docs.barkshark.xyz/bl2mods diff --git a/docs/build.py b/docs/build.py new file mode 100755 index 0000000..6226574 --- /dev/null +++ b/docs/build.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +import subprocess +import sys +import time + +from datetime import datetime +from http.server import HTTPServer, ThreadingHTTPServer, SimpleHTTPRequestHandler +from pathlib import Path +from watchdog.events import FileSystemEventHandler +from watchdog.observers.polling import PollingObserver + +docpath = Path(__file__).resolve().parent + + +class DocWatcher(FileSystemEventHandler): + proc = None + last_restart = 0 + + def __init__(self, path): + self.path = str(path) + + + def on_any_event(self, event): + if event.event_type not in ['modified', 'created', 'deleted']: + return + + path = Path(event.src_path) + directory = str(path.parent).replace(self.path, '') + + if not path.suffix or not directory: + return + + if directory.startswith('/docs/_') or str(path).endswith('/docs/build.py'): + return + + if path.suffix[1:] not in {'py', 'rst', 'yml'}: + return + + self.run_proc() + + + def run_proc(self): + timestamp = datetime.timestamp(datetime.now()) + + if self.last_restart != None and timestamp - 3 < self.last_restart: + print(timestamp - 3 < self.last_restart, timestamp -3, self.last_restart) + return + + self.last_restart = timestamp + build_docs(False) + + +class ServerHandler(SimpleHTTPRequestHandler): + def send_response(self, code, message=None): + if any(map(self.path.endswith, ['/', '.html'])): + agent = self.headers.get('User-Agent', 'n/a') + sys.stdout.write(f'\n{self.address_string()} {self.path} {code} "{agent}"') + sys.stdout.flush() + + self.send_response_only(code, message) + self.send_header('Server', self.version_string()) + self.send_header('Date', self.date_time_string()) + + +class Server(ThreadingHTTPServer): + def __init__(self, host, port, directory): + ThreadingHTTPServer.__init__(self, (host, port), SimpleHTTPRequestHandler) + self.directory = directory + + + def finish_request(self, request, client_address): + ServerHandler(request, client_address, self, directory=self.directory) + + + def run(self): + with self as httpd: + host, port = httpd.socket.getsockname()[:2] + print(f'Serving HTTP on {host} port {port} (http://{host}:{port}/)') + + try: + httpd.serve_forever() + + except KeyboardInterrupt: + print('\nKeyboard interrupt received, exiting.') + return 0 + + except Exception as e: + print(f'\nError running server: {e.__class__.__name__}: {e}') + + print('Bye! :3') + + +def build_docs(wait=False): + proc = subprocess.Popen([sys.executable, '-m', 'sphinx', '-M', 'html', str(docpath), docpath.joinpath('_build')]) + + if wait: + while proc.poll() == None: + time.sleep(0.1) + + return proc + + +def main(): + if len(sys.argv) < 2: + print('Must include an option: build, serve') + return 1 + + arg = sys.argv[1] + + if arg == 'serve': + build_docs(False) + s = Server('0.0.0.0', 8080, docpath.joinpath('_build/html')) + + watcher = PollingObserver() + watcher.schedule(DocWatcher(docpath.parent), docpath.parent, recursive=True) + watcher.start() + + s.run() + watcher.stop() + return 0 + + elif arg == 'build': + return build_docs().returncode + + print(f'Invalid option: {arg}') + print('Valid options: build, serve') + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..fbda54f --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,61 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + + +import sys +from pathlib import Path + +## Make sure aputils is in sys.path +docpath = Path(__file__).resolve().parent +sys.path.insert(0, str(docpath.parent)) + +## Get the major and minor Python version numbers to link to the proper documentation for Python classes +pyversion = '.'.join(str(v) for v in sys.version_info[:2]) + + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'Izalia\'s PythonSDK Mods' +copyright = '2022, Izalia Mae' +author = 'Izalia Mae' +release = '0.1' + + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.viewcode', + 'sphinx_external_toc' +] + +autodoc_typehints = "description" +autodoc_class_signature = "separated" +autodoc_member_order = "bysource" + +external_toc_path = str(docpath.joinpath('toc.yml')) +external_toc_exclude_missing = True + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', '.pyvenv'] +intersphinx_mapping = { + 'python': (f'https://docs.python.org/{pyversion}', None) +} + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +#html_theme = 'sphinx_rtd_theme' +html_theme = 'furo' +html_static_path = ['_static'] +# latex_elements = { +# 'papersize': 'letterpaper', +# 'pointsize': '10pt', +# 'preamble': '', +# 'figure_align': 'htbp' +# } diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..9d1659c --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,2 @@ +Welcome to PythonSDK & Borderlands 2 Documentation! +=================================================== diff --git a/docs/mods/index.rst b/docs/mods/index.rst new file mode 100644 index 0000000..3efedd7 --- /dev/null +++ b/docs/mods/index.rst @@ -0,0 +1,24 @@ +Mods by Izalia Mae +================== + + +.. autoclass:: Mods.FpsToggle.FpsToggle + :members: + :exclude-members: __init__, __new__ + +.. autoclass:: Mods.InfiniKeys.InfiniKeys + :members: + :exclude-members: __init__, __new__ + +.. autoclass:: Mods.LootSplosion.LootSplosion + :members: + :exclude-members: __init__, __new__ + +.. autoclass:: Mods.ModUtils.ModUtils + :members: + :exclude-members: __init__, __new__ + :noindex: + +.. autoclass:: Mods.WebPanel.WebPanel + :members: + :exclude-members: __init__, __new__ diff --git a/docs/mods/modutils.rst b/docs/mods/modutils.rst new file mode 100644 index 0000000..a1e93d7 --- /dev/null +++ b/docs/mods/modutils.rst @@ -0,0 +1,20 @@ +Mod Utilities +============= + +.. autoclass:: Mods.ModUtils.ModUtils + :exclude-members: __init__, __new__ + + +Classes +~~~~~~~ + +.. autoclass:: Mods.ModUtils.Player + :members: + + +Functions +~~~~~~~~~ + +.. autofunction:: Mods.ModUtils.clsname +.. autofunction:: Mods.ModUtils.combine_flags +.. autofunction:: Mods.ModUtils.is_in_game diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..f347d80 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,4 @@ +furo==2022.9.29 +sphinx==5.3.0 +sphinx-external-toc==0.3.1 +watchdog==2.3.0 diff --git a/docs/toc.yml b/docs/toc.yml new file mode 100644 index 0000000..be11e0a --- /dev/null +++ b/docs/toc.yml @@ -0,0 +1,23 @@ +root: index.rst +subtrees: +- entries: + - file: mods/index.rst + title: Mods + subtrees: + - entries: + - file: mods/modutils.rst + - file: unrealsdk/index.rst + title: UnrealSDK + subtrees: + - entries: + - file: unrealsdk/functions.rst + - file: unrealsdk/classes.rst + - file: ueobjects/index.rst + title: Unreal Objects + subtrees: + - entries: + - file: ueobjects/engine/actor.rst + - url: https://git.barkshark.xyz/izaliamae/bl2mods + title: Git Repo + - url: https://barkshark.xyz/@izalia + title: Mastodon diff --git a/docs/ueobjects/engine/actor.rst b/docs/ueobjects/engine/actor.rst new file mode 100644 index 0000000..20a4795 --- /dev/null +++ b/docs/ueobjects/engine/actor.rst @@ -0,0 +1,5 @@ +Actor +===== + +.. class: Actor + .. const:: TRACEFLAG_Bullet diff --git a/docs/ueobjects/index.rst b/docs/ueobjects/index.rst new file mode 100644 index 0000000..276e645 --- /dev/null +++ b/docs/ueobjects/index.rst @@ -0,0 +1,4 @@ +Unreal Engine Objects +===================== + +``heck`` diff --git a/docs/unrealsdk/classes.rst b/docs/unrealsdk/classes.rst new file mode 100644 index 0000000..43104f7 --- /dev/null +++ b/docs/unrealsdk/classes.rst @@ -0,0 +1,8 @@ +Classes +======= + +.. autoclass:: unrealsdk.FName +.. autoclass:: unrealsdk.FOutputDevice +.. autoclass:: unrealsdk.PyCapsule +.. autoclass:: unrealsdk.UClass +.. autoclass:: unrealsdk.UObject diff --git a/docs/unrealsdk/functions.rst b/docs/unrealsdk/functions.rst new file mode 100644 index 0000000..ae87fd8 --- /dev/null +++ b/docs/unrealsdk/functions.rst @@ -0,0 +1,21 @@ +Functions +========= + +.. autofunction:: unrealsdk.CallPostEdit +.. autofunction:: unrealsdk.ConstructObject +.. autofunction:: unrealsdk.DoInjectedCallNext +.. autofunction:: unrealsdk.FindClass +.. autofunction:: unrealsdk.FindAll +.. autofunction:: unrealsdk.FindObject +.. autofunction:: unrealsdk.GetEngine +.. autofunction:: unrealsdk.GetPackageObject +.. autofunction:: unrealsdk.GetVersion +.. autofunction:: unrealsdk.KeepAlive +.. autofunction:: unrealsdk.LoadObject +.. autofunction:: unrealsdk.LoadPackage +.. autofunction:: unrealsdk.Log +.. autofunction:: unrealsdk.LogAllCalls +.. autofunction:: unrealsdk.RegisterHook +.. autofunction:: unrealsdk.RemoveHook +.. autofunction:: unrealsdk.RunHook +.. autofunction:: unrealsdk.SetLoggingLevel diff --git a/docs/unrealsdk/index.rst b/docs/unrealsdk/index.rst new file mode 100644 index 0000000..67fce1d --- /dev/null +++ b/docs/unrealsdk/index.rst @@ -0,0 +1,2 @@ +UnrealSDK +========= diff --git a/generate.py b/generate.py deleted file mode 100644 index dbccb39..0000000 --- a/generate.py +++ /dev/null @@ -1,37 +0,0 @@ -import os -import sys - -from pathlib import Path - - -readme = Path('README.md') -setup = Path('setup.cfg') - - -def main(args): - try: - name = args[1] - - except IndexError: - print('Missing name') - return 1 - - try: - description = ' '.join(args[2:]) - - except IndexError: - print('Missing description') - return 1 - - for file in {readme, setup}: - file.write_text(file.read_text().format( - name = name, - description = description or 'na' - )) - - Path('generate.py').unlink() - - return 0 - - -sys.exit(main(sys.argv)) diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 11d6cbb..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,6 +0,0 @@ -[build-system] -requires = [ - "setuptools >= 38.3.0", - "wheel" -] -build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index e7d6dbd..0000000 --- a/setup.cfg +++ /dev/null @@ -1,45 +0,0 @@ -[metadata] -name = {name} -version = 0.0.1 -author = Izalia Mae -author_email = izalia@barkshark.xyz -url = https://git.barkshark.xyz/barkshark/{name} -description = {description} -license = CNPL 7+ -license_file = LICENSE.md -platform = any -keywords = python module -classifiers = - Development Status :: 3 - Alpha - Intended Audience :: Developers - Operating System :: OS Independent - Programming Language :: Python - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Topic :: Software Development :: Libraries :: Python Modules -project_urls = - Bug Tracker = https://git.barkshark.xyz/barkshark/{name}/issues - Documentation = https://git.barkshark.xyz/barkshark/{name}/wiki - Source Code = https://git.barkshark.xyz/barkshark/{name} - -[options] -include_package_data = true -python_requires = >= 3.7, <3.11 -packages = - {name} -setup_requires = - setuptools >= 38.3.0 - -[options.extras_require] -extra1 = - some-module >= 1.0 -extra2 = - another-module >= 1.2 - -[bdist_wheel] -universal = false - -[sdist] -formats = zip, gztar diff --git a/unrealsdk.py b/unrealsdk.py new file mode 100644 index 0000000..4e20a66 --- /dev/null +++ b/unrealsdk.py @@ -0,0 +1,106 @@ +from typing import Callable, Union + + +## Classes +class FName(object): + ... + + +class FOutputDevice(object): + ... + + +class FStruct(object): + ... + + +class PyCapsule(object): + ... + + +class UClass(object): + ... + + +class UFunction(object): + ... + + +class UObject(object): + ... + + +class Engine: + def __getattr__(self, key): + return lambda *args: 'e' + + +## Functions +def CallPostEdit(arg0: bool) -> None: + ... + +def ConstructObject( + Class: UClass, + Outer: UObject = 'Package Transient', + Name: FName = 'None', + SetFlags: int = 1, + InternalSetFlags: int = 0, + Template: UObject = None, + Error: FOutputDevice = None, + InstanceGraph: PyCapsule = None, + bAssumeTemplateIsArchtype: int = 0 + ) -> UObject: + ... + +#todo +def DoInjectedCallNext(): + ... + +def FindClass(ClassName: str) -> UClass: + ... + +def FindAll(InStr: str, IncludeSubclasses: bool = False) -> tuple[UObject]: + ... + +def FindObject(ClassName: Union[str,UClass], ObjectFullName: str) -> UObject: + ... + +def GetEngine() -> UObject: + return Engine() + +#todo +def GetPackageObject() -> UObject: + ... + +def GetVersion() -> tuple[int,int,int]: + ... + +#todo +def KeepAlive(): + ... + +def LoadObject(ClassName: Union[str,UClass], ObjectFullName: str): + ... + +def LoadPackage(filename: str, flags: int = 0, force: bool = False): + ... + +def Log(*args: tuple[str]): + ... + +#todo +def LogAllCalls(): + ... + +##todo +def RegisterHook(): + ... + +def RemoveHook(funcName: str, hookName: str): + ... + +def RunHook(funcName: str, hookName: str, funcHook: Callable): + ... + +def SetLoggingLevel(Level: str): + ...