Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions Mods/ModMenu/NetworkManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,45 @@ def send(self) -> None:

def _PlayerTick(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool:
timeout = _message_queue[0].timeout
if timeout is not None and timeout < time():
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)
Expand Down Expand Up @@ -328,7 +362,7 @@ def _server_speech(caller: unrealsdk.UObject, function: unrealsdk.UFunction, par
# 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 message == _message_queue[0].ID:
if len(_message_queue) > 0 and _message_queue[0].ID == message:
_dequeue_message()
return False

Expand Down Expand Up @@ -390,7 +424,7 @@ def _client_message(caller: unrealsdk.UObject, function: unrealsdk.UFunction, pa
return True

if message_type == "unrealsdk.__serverack__":
if message == _message_queue[0].ID:
if len(_message_queue) > 0 and _message_queue[0].ID == message:
_dequeue_message()
return False

Expand Down Expand Up @@ -431,3 +465,5 @@ def _client_message(caller: unrealsdk.UObject, function: unrealsdk.UFunction, pa

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)
22 changes: 16 additions & 6 deletions Mods/ModMenu/OptionManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@
_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:
"""
Expand Down Expand Up @@ -149,15 +158,18 @@ def _DataProviderOptionsBasePopulate(caller: unrealsdk.UObject, function: unreal
continue
if not one_shown:
one_shown = True
all_options.append(Options.Field(mod.Name))
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):
Expand All @@ -166,12 +178,12 @@ def _DataProviderOptionsBasePopulate(caller: unrealsdk.UObject, function: unreal
spinner_idx = option.Choices.index(option.CurrentValue)

params.TheList.AddSpinnerListItem(
idx, option.Caption, False, spinner_idx, option.Choices
idx, indent + option.Caption, False, spinner_idx, option.Choices
)
elif isinstance(option, Options.Slider):
params.TheList.AddSliderListItem(
idx,
option.Caption,
indent + option.Caption,
False,
option.CurrentValue,
option.MinValue,
Expand All @@ -180,11 +192,9 @@ def _DataProviderOptionsBasePopulate(caller: unrealsdk.UObject, function: unreal
)
elif isinstance(option, Options.Field):
disabled = False
new = False
if isinstance(option, Options.Nested):
disabled = not _is_anything_shown(option.Children)
new = True
params.TheList.AddListItem(idx, option.Caption, disabled, new)
params.TheList.AddListItem(idx, indent + option.Caption, disabled, False)

caller.AddDescription(idx, option.Description)

Expand Down
112 changes: 78 additions & 34 deletions Mods/ModMenu/Options.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
from typing import Any, Optional, Sequence, Tuple
from reprlib import recursive_repr
from typing import Any, Generic, Optional, Sequence, Tuple, TypeVar

from . import DeprecationHelper as dh

Expand All @@ -14,6 +15,8 @@
"Value",
)

T = TypeVar("T")


class Base(ABC):
"""
Expand All @@ -39,7 +42,7 @@ def __init__(
raise NotImplementedError


class Value(Base):
class Value(Base, Generic[T]):
"""
The abstract base class for all options that store a value.

Expand All @@ -52,22 +55,22 @@ class Value(Base):

IsHidden: If the option is hidden from the options menu.
"""
CurrentValue: Any
StartingValue: Any
CurrentValue: T
StartingValue: T

@abstractmethod
def __init__(
self,
Caption: str,
Description: str,
StartingValue: Any,
StartingValue: T,
*,
IsHidden: bool = True
) -> None:
raise NotImplementedError


class Hidden(Value):
class Hidden(Value[T]):
"""
A hidden option that never displays in the menu but stores an arbitrary (json serializable)
value to the settings file.
Expand All @@ -81,11 +84,12 @@ class Hidden(Value):
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: Any = None,
StartingValue: T = None, # type: ignore
*,
IsHidden: bool = True
) -> None:
Expand Down Expand Up @@ -116,8 +120,18 @@ def IsHidden(self) -> bool: # type: ignore
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):

class Slider(Value[int]):
"""
An option which allows users to select a value along a slider.

Expand Down Expand Up @@ -176,8 +190,23 @@ def __init__(
self.Increment = Increment
self.IsHidden = IsHidden


class Spinner(Value):
@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.

Expand Down Expand Up @@ -242,8 +271,21 @@ def __init__(
f"Provided starting value '{self.StartingValue}' is not in the list of choices."
)


class Boolean(Spinner):
@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.

Expand Down Expand Up @@ -309,6 +351,19 @@ def CurrentValue(self, val: Any) -> None:
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):
"""
Expand All @@ -320,33 +375,11 @@ class Field(Base):
IsHidden: If the field is hidden from the options menu.
"""

def __init__(
self,
Caption: str,
Description: str = "",
*,
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.
IsHidden (keyword only): If the value is hidden from the options menu.
"""
self.Caption = Caption
self.Description = Description
self.IsHidden = IsHidden


class Nested(Field):
"""
A field which when clicked opens up a nested menu with more options.

These are distinguished from normal fields by having the "new" exclaimation mark to the side of
it, but you should probably still give it a meaningful description.

Note that these fields will be disabled if all child options are either hidden or other disabled
nested fields.

Expand Down Expand Up @@ -383,3 +416,14 @@ def __init__(
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")"
)
2 changes: 1 addition & 1 deletion Mods/SkillRandomizer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import os

from ..ModManager import BL2MOD, RegisterMod
from Mods.ModMenu import Game
from Mods.ModMenu import Game, Hook


class CrossSkillRandomizer(BL2MOD):
Expand Down
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,29 @@ An UnrealEngine Plugin enabling using Python to write plugins that interact dire

## Installation

Begin by [downloading the latest version of `PythonSDK.zip` here](https://github.com/Matt-Hurd/BL2-SDK/releases).
1. [Download the latest version of `PythonSDK.zip`.](https://github.com/Matt-Hurd/BL2-SDK/releases) Ensure you download `PythonSDK.zip` from that page:

![PythonSDK Download Page](https://i.imgur.com/tBlidGi.png)

For PythonSDK to be able to interact with the game, you must add a few things to the game's Win32 folder. If you already have PluginLoader installed, you'll need to overwrite some files.
2. Open `PythonSDK.zip`. It should contain 4 items:

1. Quit the game if it is running.
![PythonSDK.zip Contents](https://i.imgur.com/jd77dnB.png)

2. Extract all of the contents of the folder in the PythonSDK.zip into your `Borderlands 2\Binaries\Win32` directory, overwriting files if necessary.
You want there to be a file `Win32\ddraw.dll`, *not* `Win32\PythonSDK\ddraw.dll`.
3. Locate your game's files. In Steam, this can be done by right-clicking on the game in your library, selecting "Properties," then in the "Local Files" section, clicking "Browse":

3. If you have installed an older version of the SDK, delete any extra old files that weren't overwritten.
![Steam Contextual Menu](https://i.imgur.com/eyfn3ht.png) ![Steam Local Files Properties](https://i.imgur.com/wok2ZUA.png)

4. Download and install [this](https://aka.ms/vs/16/release/vc_redist.x86.exe) Mircosoft Visual C++ Redistributable. Most of the time this will already be installed.
4. In the game's files, navigate to the `Binaries`, then the `Win32` folder. This folder should contain the `.exe` for your game (i.e. `Borderlands2.exe` or `BorderlandsPreSequel.exe`).

5. Copy the 4 items from `PythonSDK.zip` **exactly as they** are to the `Win32` folder. Note that `pythonXX.zip` should *not* be un-zipped:

![Win32 Folder Contents](https://i.imgur.com/hIvNi7w.png)

6. If you had previously installed an older version of the SDK, delete any old files that weren't overwritten by the ones in the latest `PythonSDK.zip`.

7. You are done, and may launch the game (if it is running, relaunch it now). You should see a "Mods" menu in the main menu!

8. If the SDK fails to run with the files correctly in place as described above, you may need to [download and install Mircosoft Visual C++ Redistributable](https://aka.ms/vs/16/release/vc_redist.x86.exe).

### Linux (SteamPlay/Proton and Wine)

Expand Down