first commit
This commit is contained in:
parent
dc9e48675f
commit
4053e6690e
41
Mods/FpsToggle/__init__.py
Normal file
41
Mods/FpsToggle/__init__.py
Normal 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()
|
6
Mods/FpsToggle/settings.json
Normal file
6
Mods/FpsToggle/settings.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"Keybinds": {
|
||||
"Toggle FPS": "F4"
|
||||
},
|
||||
"AutoEnable": true
|
||||
}
|
23
Mods/InfiniKeys/__init__.py
Normal file
23
Mods/InfiniKeys/__init__.py
Normal 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()
|
3
Mods/InfiniKeys/settings.json
Normal file
3
Mods/InfiniKeys/settings.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"AutoEnable": true
|
||||
}
|
72
Mods/LootSplosion/__init__.py
Normal file
72
Mods/LootSplosion/__init__.py
Normal 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()
|
7
Mods/LootSplosion/settings.json
Normal file
7
Mods/LootSplosion/settings.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"Options": {
|
||||
"Drop Multiplier": 30,
|
||||
"CritRequired": false
|
||||
},
|
||||
"AutoEnable": true
|
||||
}
|
64
Mods/ModMenu/DeprecationHelper.py
Normal file
64
Mods/ModMenu/DeprecationHelper.py
Normal 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
176
Mods/ModMenu/HookManager.py
Normal 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)
|
483
Mods/ModMenu/KeybindManager.py
Normal file
483
Mods/ModMenu/KeybindManager.py
Normal 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
436
Mods/ModMenu/MenuManager.py
Normal 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
333
Mods/ModMenu/ModObjects.py
Normal 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))
|
469
Mods/ModMenu/NetworkManager.py
Normal file
469
Mods/ModMenu/NetworkManager.py
Normal 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)
|
262
Mods/ModMenu/OptionManager.py
Normal file
262
Mods/ModMenu/OptionManager.py
Normal 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
429
Mods/ModMenu/Options.py
Normal 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")"
|
||||
)
|
176
Mods/ModMenu/SettingsManager.py
Normal file
176
Mods/ModMenu/SettingsManager.py
Normal 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
104
Mods/ModMenu/__init__.py
Normal 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
22
Mods/ModUtils/__init__.py
Normal 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
154
Mods/ModUtils/enums.py
Normal 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
44
Mods/ModUtils/misc.py
Normal 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')
|
2
Mods/ModUtils/objects/__init__.py
Normal file
2
Mods/ModUtils/objects/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from .player import Currency, Player
|
||||
from .settings import Settings
|
198
Mods/ModUtils/objects/player.py
Normal file
198
Mods/ModUtils/objects/player.py
Normal 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
|
110
Mods/ModUtils/objects/settings.py
Normal file
110
Mods/ModUtils/objects/settings.py
Normal 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
461
Mods/ModUtils/sdkmod.py
Normal 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
72
Mods/WebPanel/__init__.py
Normal 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
60
Mods/WebPanel/routes.py
Normal 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
319
Mods/WebPanel/server.py
Normal 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}'
|
7
Mods/WebPanel/settings.json
Normal file
7
Mods/WebPanel/settings.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"Options": {
|
||||
"Localhost": false,
|
||||
"AccessLog": false
|
||||
},
|
||||
"AutoEnable": true
|
||||
}
|
|
@ -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
131
docs/build.py
Executable 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
61
docs/conf.py
Normal 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
2
docs/index.rst
Normal file
|
@ -0,0 +1,2 @@
|
|||
Welcome to PythonSDK & Borderlands 2 Documentation!
|
||||
===================================================
|
24
docs/mods/index.rst
Normal file
24
docs/mods/index.rst
Normal 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
20
docs/mods/modutils.rst
Normal 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
4
docs/requirements.txt
Normal 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
23
docs/toc.yml
Normal 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
|
5
docs/ueobjects/engine/actor.rst
Normal file
5
docs/ueobjects/engine/actor.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
Actor
|
||||
=====
|
||||
|
||||
.. class: Actor
|
||||
.. const:: TRACEFLAG_Bullet
|
4
docs/ueobjects/index.rst
Normal file
4
docs/ueobjects/index.rst
Normal file
|
@ -0,0 +1,4 @@
|
|||
Unreal Engine Objects
|
||||
=====================
|
||||
|
||||
``heck``
|
8
docs/unrealsdk/classes.rst
Normal file
8
docs/unrealsdk/classes.rst
Normal file
|
@ -0,0 +1,8 @@
|
|||
Classes
|
||||
=======
|
||||
|
||||
.. autoclass:: unrealsdk.FName
|
||||
.. autoclass:: unrealsdk.FOutputDevice
|
||||
.. autoclass:: unrealsdk.PyCapsule
|
||||
.. autoclass:: unrealsdk.UClass
|
||||
.. autoclass:: unrealsdk.UObject
|
21
docs/unrealsdk/functions.rst
Normal file
21
docs/unrealsdk/functions.rst
Normal 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
2
docs/unrealsdk/index.rst
Normal file
|
@ -0,0 +1,2 @@
|
|||
UnrealSDK
|
||||
=========
|
37
generate.py
37
generate.py
|
@ -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))
|
|
@ -1,6 +0,0 @@
|
|||
[build-system]
|
||||
requires = [
|
||||
"setuptools >= 38.3.0",
|
||||
"wheel"
|
||||
]
|
||||
build-backend = "setuptools.build_meta"
|
45
setup.cfg
45
setup.cfg
|
@ -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
106
unrealsdk.py
Normal 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):
|
||||
...
|
Loading…
Reference in a new issue