first commit

This commit is contained in:
Izalia Mae 2023-03-22 12:57:56 -04:00
parent dc9e48675f
commit 4053e6690e
44 changed files with 4946 additions and 90 deletions

View file

@ -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()

View file

@ -0,0 +1,6 @@
{
"Keybinds": {
"Toggle FPS": "F4"
},
"AutoEnable": true
}

View file

@ -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()

View file

@ -0,0 +1,3 @@
{
"AutoEnable": true
}

View file

@ -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()

View file

@ -0,0 +1,7 @@
{
"Options": {
"Drop Multiplier": 30,
"CritRequired": false
},
"AutoEnable": true
}

View file

@ -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)

176
Mods/ModMenu/HookManager.py Normal file
View file

@ -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
"<PackageName>.<ClassName>.<MethodName>".
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)

View file

@ -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", "$<Strings:WillowMenu.TitleMenu.BackBar>")
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)

436
Mods/ModMenu/MenuManager.py Normal file
View file

@ -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"<font color=\"#A1E4EF\">{mod.Version}</font>", # Make this the same colour as author
translation_context
)
status = mod.Status
if mod.Status == "Enabled":
status = "<font color=\"#00FF00\">Enabled</font>"
elif mod.Status == "Disabled":
status = "<font color=\"#FF0000\">Disabled</font>"
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)

333
Mods/ModMenu/ModObjects.py Normal file
View file

@ -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"<font color=\"#ff0000\">{inst.Name}</font>"
inst.Status = "<font color=\"#ff0000\">Incompatible</font>"
if len(inst.Description) > 0:
inst.Description += "\n\n"
inst.Description += (
f"<font color=\"#FF0000\">Incompatible with {Game.GetCurrent().name}!</font>"
)
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))

View file

@ -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)

View file

@ -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)

429
Mods/ModMenu/Options.py Normal file
View file

@ -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")"
)

View file

@ -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)

104
Mods/ModMenu/__init__.py Normal file
View file

@ -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

22
Mods/ModUtils/__init__.py Normal file
View file

@ -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()

154
Mods/ModUtils/enums.py Normal file
View file

@ -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
)

44
Mods/ModUtils/misc.py Normal file
View file

@ -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')

View file

@ -0,0 +1,2 @@
from .player import Currency, Player
from .settings import Settings

View file

@ -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

View file

@ -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

461
Mods/ModUtils/sdkmod.py Normal file
View file

@ -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']

72
Mods/WebPanel/__init__.py Normal file
View file

@ -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()

60
Mods/WebPanel/routes.py Normal file
View file

@ -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)

319
Mods/WebPanel/server.py Normal file
View file

@ -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}'

View file

@ -0,0 +1,7 @@
{
"Options": {
"Localhost": false,
"AccessLog": false
},
"AutoEnable": true
}

View file

@ -1,3 +1,3 @@
# {name}
# BL2Mods
{description}
PythonSDK mods for BL2. Check out the documentation at https://docs.barkshark.xyz/bl2mods

131
docs/build.py Executable file
View file

@ -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())

61
docs/conf.py Normal file
View file

@ -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'
# }

2
docs/index.rst Normal file
View file

@ -0,0 +1,2 @@
Welcome to PythonSDK & Borderlands 2 Documentation!
===================================================

24
docs/mods/index.rst Normal file
View file

@ -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__

20
docs/mods/modutils.rst Normal file
View file

@ -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

4
docs/requirements.txt Normal file
View file

@ -0,0 +1,4 @@
furo==2022.9.29
sphinx==5.3.0
sphinx-external-toc==0.3.1
watchdog==2.3.0

23
docs/toc.yml Normal file
View file

@ -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

View file

@ -0,0 +1,5 @@
Actor
=====
.. class: Actor
.. const:: TRACEFLAG_Bullet

4
docs/ueobjects/index.rst Normal file
View file

@ -0,0 +1,4 @@
Unreal Engine Objects
=====================
``heck``

View file

@ -0,0 +1,8 @@
Classes
=======
.. autoclass:: unrealsdk.FName
.. autoclass:: unrealsdk.FOutputDevice
.. autoclass:: unrealsdk.PyCapsule
.. autoclass:: unrealsdk.UClass
.. autoclass:: unrealsdk.UObject

View file

@ -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

2
docs/unrealsdk/index.rst Normal file
View file

@ -0,0 +1,2 @@
UnrealSDK
=========

View file

@ -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))

View file

@ -1,6 +0,0 @@
[build-system]
requires = [
"setuptools >= 38.3.0",
"wheel"
]
build-backend = "setuptools.build_meta"

View file

@ -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

106
unrealsdk.py Normal file
View file

@ -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):
...