Skip to content

Commit eecf712

Browse files
committed
Add functionality for adding commands to in-game terminal
1 parent 773748f commit eecf712

File tree

10 files changed

+1213
-160
lines changed

10 files changed

+1213
-160
lines changed
File renamed without changes.

example_mods/instantScanning.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# /// script
2+
# dependencies = ["pymhf[gui]>=0.1.16"]
3+
#
4+
# [tool.pymhf]
5+
# exe = "NMS.exe"
6+
# steam_gameid = 275850
7+
# start_paused = false
8+
#
9+
# [tool.pymhf.gui]
10+
# always_on_top = true
11+
#
12+
# [tool.pymhf.logging]
13+
# log_dir = "."
14+
# log_level = "info"
15+
# window_name_override = "Instant scanning"
16+
# ///
17+
18+
import logging
19+
import ctypes
20+
21+
from pymhf import Mod
22+
from pymhf.core.hooking import on_key_release
23+
from pymhf.gui.decorators import BOOLEAN
24+
import nmspy.data.types as nms
25+
26+
logger = logging.getLogger("InstantScan")
27+
28+
29+
class instantScan(Mod):
30+
def __init__(self):
31+
super().__init__()
32+
self.should_be_instant = True
33+
34+
# Used to define a Boolean Type with a label in the Mod GUI, autogenerated by pyMHF.
35+
@property
36+
@BOOLEAN("Scanning is instant:")
37+
def is_instant(self):
38+
return self.should_be_instant
39+
40+
# Used to actually update the persisted value with the one input by the user in the GUI.
41+
@is_instant.setter
42+
def is_instant(self, value):
43+
self.should_be_instant = value
44+
45+
# Also set a keyboard shortcut as an option instead of the button in the UI.
46+
# When you press this button you will see that the checkboxin the UI will also change.
47+
@on_key_release("o")
48+
def toggle_instantness(self):
49+
self.should_be_instant = not self.should_be_instant
50+
51+
@nms.cGcBinoculars.UpdateScanBarProgress.before
52+
def set_no_scan(
53+
self, this: ctypes._Pointer[nms.cGcBinoculars], lfScanProgress: float
54+
):
55+
if self.should_be_instant and lfScanProgress < 1:
56+
return (this, 1)
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import ctypes
2+
import logging
3+
4+
from pymhf import Mod
5+
from pymhf.gui import no_gui
6+
from pymhf.core.hooking import hook_manager
7+
from pymhf.core._types import DetourTime, CustomTriggerProtocol
8+
import nmspy.data.types as nms
9+
import nmspy.data.basic_types as basic
10+
from nmspy.terminal_parser import TerminalCommand, generate_full_description, split_key
11+
from nmspy.decorators import terminal_command
12+
13+
logger = logging.getLogger("chat_bot")
14+
15+
16+
MOD_OPTION = "\n<TITLE>'/mod <><TITLE_BRIGHT>name option(s)<><TITLE>': configure a mod. Enter /mod <><TITLE_BRIGHT>name<><TITLE> to see options for each mod<>"
17+
MOD_LIST_OPTION = "\n<TITLE>'/modlist: display list of mods which can be configured via the terminal<>"
18+
19+
20+
@no_gui
21+
class textChatManager(Mod):
22+
@terminal_command("Say some words.", "example")
23+
def say(self, *words):
24+
logger.info(f"I am saying the words: {' '.join(words)}")
25+
26+
@nms.cGcTextChatInput.ParseTextForCommands.before
27+
def intercept_chat_message(
28+
self,
29+
this: ctypes._Pointer[nms.cGcTextChatManager],
30+
lMessageText: ctypes._Pointer[basic.cTkFixedString[0x3FF]],
31+
):
32+
"""Parse the command and do something with it.
33+
We will check for the message starting with `/mod` and recognise this as a command.
34+
Use the custom callback system in pyMHF to send these commands to anything registered and detect if
35+
nothing is registered for a given command and raise a helpful message.
36+
"""
37+
if not lMessageText:
38+
return
39+
msg = str(lMessageText.contents)
40+
if msg.startswith("/pymodlist"):
41+
# List the available mods which have commands.
42+
# Filter the keys then generate a set
43+
mod_command_keys = list(
44+
filter(
45+
lambda x: x.startswith("tc::"), hook_manager.custom_callbacks.keys()
46+
)
47+
)
48+
mod_names = list(set(split_key(x)[0] for x in mod_command_keys))
49+
mod_names.sort()
50+
desc = "<TITLE>Available mods with commands<>"
51+
for mod_name in mod_names:
52+
desc += f"\n- <TITLE>{mod_name}<>"
53+
lMessageText.contents.set(desc)
54+
elif msg.startswith("/mod"):
55+
# Get the mod name.
56+
split_msg = msg.split(" ")[1:]
57+
if len(split_msg) == 0:
58+
# Invalid...
59+
lMessageText.contents.set("Need to specify a mod!")
60+
return
61+
else:
62+
if len(split_msg) == 1:
63+
# This is just the mod name. Show the help.
64+
mod_name = split_msg[0]
65+
mod_command_keys = list(
66+
filter(
67+
lambda x: x.startswith(f"tc::{mod_name}"),
68+
hook_manager.custom_callbacks.keys(),
69+
)
70+
)
71+
if not mod_command_keys:
72+
lMessageText.contents.set(
73+
f"The mod {mod_name!r} either doesn't exist or has no "
74+
"terminal commands registered"
75+
)
76+
else:
77+
command_funcs: dict[str, CustomTriggerProtocol] = {}
78+
for key in mod_command_keys:
79+
funcs = hook_manager.custom_callbacks[key].get(
80+
DetourTime.NONE, []
81+
)
82+
if len(funcs) > 1:
83+
logger.warning(
84+
f"Multiple terminal commands have been defined with the key {key}"
85+
)
86+
return
87+
elif len(funcs) == 1:
88+
command_funcs[key] = list(funcs)[0]
89+
mod_desc = f"<TITLE>{mod_name!r} mod options:<>"
90+
for key, func in command_funcs.items():
91+
_, command = split_key(key)
92+
mod_desc += "\n" + generate_full_description(
93+
command, func._description
94+
)
95+
lMessageText.contents.set(mod_desc)
96+
return
97+
elif len(split_msg) == 2:
98+
parsed_command = TerminalCommand(split_msg[0], split_msg[1], [])
99+
elif len(split_msg) > 2:
100+
parsed_command = TerminalCommand(
101+
split_msg[0], split_msg[1], split_msg[2:]
102+
)
103+
try:
104+
# Call the custom callback with the generated key.
105+
# If it doesn't exist we raise an exception which we catch and then show a message in the
106+
# terminal.
107+
hook_manager.call_custom_callbacks(
108+
parsed_command.callback_key,
109+
args=parsed_command.args,
110+
alert_nonexist=True,
111+
)
112+
lMessageText.contents.set(
113+
f"Ran command {parsed_command.command_name!r} for mod {parsed_command.mod_name!r}"
114+
)
115+
except ValueError:
116+
lMessageText.contents.set(
117+
f"Invalid command {parsed_command.command_name!r} for mod {parsed_command.mod_name!r}"
118+
)
119+
# Let's get the list of actual commands for this mod.
120+
mod_command_keys = list(
121+
filter(
122+
lambda x: x.startswith(f"tc::{parsed_command.mod_name}"),
123+
hook_manager.custom_callbacks.keys(),
124+
)
125+
)
126+
if not mod_command_keys:
127+
lMessageText.contents.set(
128+
f"The mod {parsed_command.mod_name!r} either doesn't exist or has no "
129+
"terminal commands registered"
130+
)
131+
else:
132+
command_funcs: dict[str, CustomTriggerProtocol] = {}
133+
for key in mod_command_keys:
134+
funcs = hook_manager.custom_callbacks[key].get(
135+
DetourTime.NONE, []
136+
)
137+
if len(funcs) > 1:
138+
logger.warning(
139+
f"Multiple terminal commands have been defined with the key {key}"
140+
)
141+
return
142+
elif len(funcs) == 1:
143+
command_funcs[key] = list(funcs)[0]
144+
mod_desc = f"<TITLE>{parsed_command.mod_name!r} mod options:<>"
145+
for key, func in command_funcs.items():
146+
_, command = split_key(key)
147+
mod_desc += "\n" + generate_full_description(
148+
command, func._description
149+
)
150+
lMessageText.contents.set(mod_desc)
151+
152+
@nms.cGcTextChatManager.Say.before
153+
def say_chat_message(
154+
self,
155+
this: ctypes._Pointer[nms.cGcTextChatManager],
156+
lsMessageBody: ctypes._Pointer[basic.cTkFixedString[0x3FF]],
157+
lbSystemMessage: bool,
158+
):
159+
if lsMessageBody:
160+
msg = str(lsMessageBody.contents)
161+
# Check for the message which the game generates for an invalid command and add the mod command.
162+
if msg.startswith("<TITLE>") and msg.split("\n")[0].endswith("commands:"):
163+
msg = msg + MOD_OPTION + MOD_LIST_OPTION
164+
lsMessageBody.contents.set(msg)

nmspy/data/basic_types.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,9 @@ class cTkFixedString(ctypes.Structure):
260260
value: bytes
261261

262262
def set(self, val: str):
263-
self.value = val[: self._size].encode()
263+
"""Set the value of the string."""
264+
new_len = len(val)
265+
self.value = val[: self._size].encode() + (self._size - new_len) * b"\x00"
264266

265267
def __class_getitem__(cls: type["cTkFixedString"], key: int):
266268
_cls: type["cTkFixedString"] = types.new_class(

nmspy/data/types.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,17 @@ def LoadFromMetadata(
127127
):
128128
pass
129129

130+
@function_hook(
131+
"48 89 5C 24 ? 48 89 74 24 ? 48 89 54 24 ? 57 48 81 EC ? ? ? ? 44 8B 51"
132+
)
133+
def AddElement(
134+
self,
135+
this: "ctypes._Pointer[cGcNGuiLayer]",
136+
lpElement: "ctypes._Pointer[cGcNGuiLayer]",
137+
lbOnTheEnd: ctypes.c_int64,
138+
):
139+
pass
140+
130141

131142
@partial_struct
132143
class cGcNGui(Structure):
@@ -892,15 +903,34 @@ def AddTimedMessage(
892903
pass
893904

894905

906+
@partial_struct
895907
class cGcSky(Structure):
896908
eStormState = enums.eStormState
897909

910+
mSunDirection: Annotated[basic.Vector3f, Field(basic.Vector3f, 0x500)]
911+
898912
@function_hook("40 53 55 56 57 41 56 48 83 EC ? 4C 8B 15")
899913
def SetStormState(
900914
self, this: "ctypes._Pointer[cGcSky]", leNewState: c_enum32[eStormState]
901915
):
902916
pass
903917

918+
@function_hook("40 53 48 83 EC ? 0F 28 C1 0F 29 7C 24 ? F3 0F 5E 05")
919+
def SetSunAngle(self, this: "ctypes._Pointer[cGcSky]", lfAngle: ctypes.c_float):
920+
pass
921+
922+
@function_hook(
923+
"48 8B C4 48 89 58 ? 48 89 70 ? 55 57 41 54 41 56 41 57 48 8D A8 ? ? ? ? 48 81 EC ? ? ? ? 0F 29 70 ? 48 8B D9"
924+
)
925+
def Update(self, this: "ctypes._Pointer[cGcSky]", lfTimeStep: ctypes.c_float):
926+
pass
927+
928+
@function_hook("48 8B C4 53 48 81 EC ? ? ? ? 4C 8B 05 ? ? ? ? 48 8B D9")
929+
def UpdateSunPosition(
930+
self, this: "ctypes._Pointer[cGcSky]", lfAngle: ctypes.c_float
931+
):
932+
pass
933+
904934

905935
class sTerrainEditData(ctypes.Structure):
906936
mVoxelType: int
@@ -1028,7 +1058,7 @@ class cGcTextChatInput(Structure):
10281058
def ParseTextForCommands(
10291059
self,
10301060
this: "ctypes._Pointer[cGcTextChatInput]",
1031-
lMessageText: ctypes._Pointer[basic.cTkFixedString[0x80]],
1061+
lMessageText: ctypes._Pointer[basic.cTkFixedString[0x3FF]],
10321062
):
10331063
pass
10341064

@@ -1042,7 +1072,7 @@ def Construct(self, this: "ctypes._Pointer[cGcTextChatManager]"):
10421072
def Say(
10431073
self,
10441074
this: "ctypes._Pointer[cGcTextChatManager]",
1045-
lsMessageBody: ctypes._Pointer[basic.cTkFixedString[0x80]],
1075+
lsMessageBody: ctypes._Pointer[basic.cTkFixedString[0x3FF]],
10461076
lbSystemMessage: ctypes.c_bool,
10471077
):
10481078
pass
@@ -1315,6 +1345,12 @@ def GeneratePlanetName(
13151345
pass
13161346

13171347

1348+
class cGcCreatureComponent(Structure):
1349+
@function_hook("48 8B C4 55 56 57 48 8D A8 ? ? ? ? 48 81 EC ? ? ? ? 48 8B 51")
1350+
def Prepare(self, this: "ctypes._Pointer[cGcCreatureComponent]"):
1351+
pass
1352+
1353+
13181354
# Dummy values to copy and paste to make adding new things quicker...
13191355
# class name(Structure):
13201356
# @function_hook("")

nmspy/decorators.py

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,67 @@
1-
from pymhf.core._types import DetourTime
2-
from pymhf.core._types import HookProtocol
1+
from typing import Optional, Callable
2+
3+
from pymhf.core._types import CustomTriggerProtocol, DetourTime
34

45

56
class main_loop:
67
@staticmethod
7-
def before(func: HookProtocol):
8+
def before(func: Callable) -> CustomTriggerProtocol:
89
func._custom_trigger = "MAIN_LOOP"
910
func._hook_time = DetourTime.BEFORE
10-
return func
11+
return func # type: ignore
1112

1213
@staticmethod
13-
def after(func: HookProtocol):
14+
def after(func: Callable) -> CustomTriggerProtocol:
1415
func._custom_trigger = "MAIN_LOOP"
1516
func._hook_time = DetourTime.AFTER
16-
return func
17+
return func # type: ignore
1718

1819

19-
def on_fully_booted(func: HookProtocol):
20+
def on_fully_booted(func: Callable) -> CustomTriggerProtocol:
2021
"""
2122
Configure the decorated function to be run once the game is considered
2223
"fully booted".
2324
This occurs when the games' internal state first changes to "mode selector"
2425
(ie. just before the game mode selection screen appears).
2526
"""
2627
func._custom_trigger = "MODESELECTOR"
27-
return func
28+
return func # type: ignore
2829

2930

3031
def on_state_change(state: str):
31-
def _inner(func: HookProtocol):
32+
def _inner(func: CustomTriggerProtocol):
3233
func._custom_trigger = state
3334
return func
3435

3536
return _inner
37+
38+
39+
def terminal_command(
40+
description: Optional[str] = None,
41+
mod_override: Optional[str] = None,
42+
command_override: Optional[str] = None,
43+
):
44+
"""Mark the function as a terminal command.
45+
The mod name and terminal command will automatically be determined from the name of this function and the
46+
name of the mod it belongs to.
47+
To override the name of the command specify the ``command_override`` argument.
48+
The optional ``description`` argument will be added to the help menu to aid users."""
49+
50+
# TODO: Use inspect to automatically get arguments and types if they have any to show in the description.
51+
def _inner(func: Callable) -> CustomTriggerProtocol:
52+
func_name = func.__qualname__
53+
split_name = func_name.split(".")
54+
if len(split_name) != 2:
55+
raise ValueError(
56+
"@terminal_command can only be used as a decorator for bound methods."
57+
)
58+
mod_name, command = split_name
59+
if mod_override is not None:
60+
mod_name = mod_override
61+
if command_override is not None:
62+
command = command_override
63+
func._custom_trigger = f"tc::{mod_name.lower()}::{command.lower()}"
64+
func._description = description
65+
return func # type: ignore
66+
67+
return _inner

0 commit comments

Comments
 (0)