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
5 changes: 4 additions & 1 deletion docs/docs/change_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ Current (0.2.2.dev)
- If the ``[pymhf].exe`` attribute is specified in the ``pymhf.toml`` as an absolute path, it will be used over the ``steam_gameid``. This is required if one wants to use the ``[pymhf].start_paused`` attribute.
- Fixed an issue with partial structs inheritence where, if a base class has a `_total_size_` attribute, then the inheriting class would have the wrong offsets for fields.
- Added the :py:class:`~pymhf.extensions.ctypes.c_enum16` type for creating enums whose value is serialized as a 16bit integer.
- Fixed an issue where the pattern cache wouldn't be able to be created under certain circumstances when running a single-file mode (`#94 <https://github.com/monkeyman192/pyMHF/issues/94>`_).
- Added the ability to specify runtime callable functions in libraries via an entry-point so that certain functions in the library may be called before mod instantiation occurs. See :doc:`here </docs/libraries/writing_libraries>` for more details.
- Fixed an issue where single-file mods which imported a library didn't load the specified internal mod directory.

0.2.1 (15/11/2025)
------------------
Expand Down Expand Up @@ -82,7 +85,7 @@ Next release set will focus on UI/UX as well as utilities, both in terms of the
--------------------

- Added the :py:func:`pymhf.gui.decorators.gui_combobox` decorator (partial work on `#15 <https://github.com/monkeyman192/pyMHF/issues/15>`_).
- Added the ability for mods to access each others' attributes and methods. (`#5 <https://github.com/monkeyman192/pyMHF/issues/5>`_). See :doc:`this page </docs/inter_mod_functionality>` for more details.
- Added the ability for mods to access each others' attributes and methods. (`#5 <https://github.com/monkeyman192/pyMHF/issues/5>`_). See :doc:`here </docs/inter_mod_functionality>` for more details.
- Fixed a few issues regarding running pyMHF. Thanks to `@Foundit3923 <https://github.com/Foundit3923>`_ for helping to figure out the issues.
- Fixed an issue where hooks of imported functions which have ``_result_`` as an argument work.
- Added :py:func:`pymhf.core.hooking.NOOP` decorator which indicates that the original game function shouldn't be called. (`#20 <https://github.com/monkeyman192/pyMHF/issues/20>`_)
Expand Down
33 changes: 33 additions & 0 deletions docs/docs/libraries/writing_libraries.rst
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,36 @@ For the above example of checking to see if the patterns are valid, we may want
pymhf cmd check --exe="NMS.exe" nmspy

The ``check`` function will receive ``['--exe="NMS.exe"']`` as an argument which we can then parse.

``pymhf_rtfunc`` entry-point
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Because of how python imports code, it is generally recommended that developers do not include any code which will be run when importing which relies on any particular state.
Because of this, we need some way to specify functions in the library which are to be run once the code has been injected into the target process, but before any mods are instantiated so that any data can be accessed as soon as possible in the mods.
To facilitate this, ``pyMHF`` has the ability to pick up functions which are defined as being run at run-time by the library.

For example, we may have some function which searches the binary for some data and then assigns it to a variable in a class like so:

.. code-block:: python
:caption: library/data.py

class Data:
x: int

def find_variable(self):
# Some code to find the value of x
x = 42

data = Data()

We can now define the following end-point:

.. code-block:: toml

[project.entry-points.pymhf_rtfunc]
data = "library.data:data.find_variable"

The syntax of this entry-point is very similar to that of normal python entry-points, but this function will be found and then run before any mods are instantiated.

.. note::
The specified function or method cannot take any arguments.
35 changes: 13 additions & 22 deletions pymhf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@
import subprocess
import sys
from enum import Enum
from importlib.metadata import PackageNotFoundError, entry_points, version
from importlib import import_module
from importlib.metadata import PackageNotFoundError, version
from typing import Optional

if sys.version_info < (3, 10):
from importlib_metadata import entry_points
else:
from importlib.metadata import entry_points

import questionary

from .core._types import FUNCDEF # noqa
Expand Down Expand Up @@ -333,24 +339,11 @@ def run():
initial_config = False

if not local:
# Get the entry points irrespective of how we are running the code.
# We do this so that if we are running a single-file mod which also uses a library we can still get
# library configuration settings and merge them with the config settings in the single-file mod.
eps = entry_points()
# This check is to ensure compatibility with multiple versions of python as the code 3.10+ isn't
# backward compatible.
if isinstance(eps, dict):
pymhf_entry_points = eps.get("pymhflib", [])
else:
pymhf_entry_points = eps.select(group="pymhflib")
if mode == ModeEnum.COMMAND:
if isinstance(eps, dict):
pymhf_commands = eps.get("pymhfcmd", [])
else:
pymhf_commands = eps.select(group="pymhfcmd")

if not pymhf_commands:
print(
"No [project.entry-points.pymhfcmd] section found in the pyproject.toml. "
"Please create one to register and run custom commands."
)
pymhf_entry_points = eps.select(group="pymhflib")

required_lib = None
resolved_command = None
Expand All @@ -359,7 +352,7 @@ def run():
if ep.name.lower() == plugin_name.lower():
required_lib = ep
if mode == ModeEnum.COMMAND:
for cmd in pymhf_commands:
for cmd in eps.select(group="pymhfcmd") or []:
# See if the library has a check command defined.
if cmd.name.lower() == cmd_command:
resolved_command = cmd.value
Expand All @@ -377,9 +370,7 @@ def run():
if mode == ModeEnum.COMMAND:
if resolved_command is not None:
# We can use importlib here since the library has to be installed if we have made it here.
import importlib

mod = importlib.import_module(plugin_name)
mod = import_module(plugin_name)
cmd = getattr(mod, resolved_command, None)
if cmd is not None:
cmd(extras)
Expand Down
2 changes: 1 addition & 1 deletion pymhf/core/functions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import ctypes
import inspect
from functools import lru_cache
from typing import Any, Callable, NamedTuple, Optional, _AnnotatedAlias, get_args, Type
from typing import Any, Callable, NamedTuple, Optional, Type, _AnnotatedAlias, get_args

from typing_extensions import get_type_hints

Expand Down
6 changes: 6 additions & 0 deletions pymhf/core/importing.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ def _fully_unpack_ast_attr(obj: ast.Attribute) -> str:
return name


def library_path_from_name(name: str) -> Optional[str]:
if (spec := importlib.util.find_spec(name)) is not None:
if spec.origin is not None:
return op.dirname(spec.origin)


def parse_file_for_mod(data: str) -> bool:
"""Parse the provided data and determine if there is at least one mod class in it."""
tree = ast.parse(data)
Expand Down
4 changes: 4 additions & 0 deletions pymhf/gui/hexview.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ def _select_coord(self, i: int, j: int):
"_select_bytes_uint32",
str(int.from_bytes(self._selected_bytes, byteorder="little", signed=True)),
)
dpg.set_value("_select_bytes_float32", str(struct.unpack_from("<f", self._selected_bytes)))
elif selection_size == 8:
dpg.set_value(
"_select_bytes_int64",
Expand Down Expand Up @@ -501,6 +502,7 @@ def _setup(self):
dpg.add_string_value(tag="_select_bytes_uint16", default_value="N/A")
dpg.add_string_value(tag="_select_bytes_int32", default_value="N/A")
dpg.add_string_value(tag="_select_bytes_uint32", default_value="N/A")
dpg.add_string_value(tag="_select_bytes_float32", default_value="N/A")
dpg.add_string_value(tag="_select_bytes_int64", default_value="N/A")
dpg.add_string_value(tag="_select_bytes_uint64", default_value="N/A")

Expand Down Expand Up @@ -591,6 +593,8 @@ def _setup(self):
dpg.add_text(source="_select_bytes_int32")
dpg.add_text("uint32:")
dpg.add_text(source="_select_bytes_uint32")
dpg.add_text("float32:")
dpg.add_text(source="_select_bytes_float32")
with dpg.group(parent=self.parent, horizontal=True, tag="size_8_values", show=False):
dpg.add_text("int64:")
dpg.add_text(source="_select_bytes_int64")
Expand Down
24 changes: 24 additions & 0 deletions pymhf/injected.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,18 @@
import logging.handlers
import os
import os.path as op
import sys
import time
import traceback
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from typing import Optional

if sys.version_info < (3, 10):
from importlib_metadata import entry_points
else:
from importlib.metadata import entry_points

import pymem
import pymem.process

Expand Down Expand Up @@ -221,6 +227,24 @@ def top_globals(limit: Optional[int] = 10):
if not cache.offset_cache.loaded:
cache.offset_cache.load()

# Before loading any mods, let's call any registered runtime function callbacks from any loaded
# libraries.
# These functions will be registered as an entrypoint of the library under
# [project.entry-points.pymhf_rtfunc].
from pymhf.utils.imports import get_callable_obj

eps = entry_points()
rtfunc_entry_points = eps.select(group="pymhf_rtfunc")
for ep in rtfunc_entry_points:
name = ep.name
value = ep.value
obj = get_callable_obj(value)
rootLogger.debug(f"Calling runtime function {name} -> {value} ({obj})")
try:
obj()
except Exception:
rootLogger.exception(f"There was an issue running the runtime function {name} ({value})")

mod_manager.hook_manager = hook_manager
# First, load our internal mod before anything else.
if internal_mod_folder is not None:
Expand Down
58 changes: 50 additions & 8 deletions pymhf/main.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import asyncio
import concurrent.futures
import glob
import importlib.resources as impres
import os
import os.path as op
import subprocess
import sys
import time
import webbrowser
from functools import partial
from signal import SIGTERM
from threading import Event
from typing import Optional

if sys.version_info < (3, 10):
from importlib_metadata import entry_points
else:
from importlib.metadata import entry_points

import psutil
import pymem
import pymem.exception
Expand All @@ -20,11 +27,11 @@

from pymhf.core._types import LoadTypeEnum, pymhfConfig
from pymhf.core.hashing import hash_bytes_from_file, hash_bytes_from_memory
from pymhf.core.importing import parse_file_for_mod
from pymhf.core.importing import library_path_from_name, parse_file_for_mod
from pymhf.core.log_handling import open_log_console
from pymhf.core.process import start_process
from pymhf.core.protocols import ESCAPE_SEQUENCE, TerminalProtocol
from pymhf.utils.config import canonicalize_setting
from pymhf.utils.config import canonicalize_setting, canonicalize_settings_inline, merge_configs
from pymhf.utils.parse_toml import read_pymhf_settings
from pymhf.utils.winapi import get_exe_path_from_pid

Expand Down Expand Up @@ -142,7 +149,7 @@ def get_process_when_ready(
return None, None


def load_mod_file(filepath: str, config_overrides: Optional[pymhfConfig] = None):
def load_mod_file(filepath: str, config_overrides: Optional[dict] = None):
"""Load an individual file as a mod.

Parameters
Expand Down Expand Up @@ -195,13 +202,38 @@ def run_module(
config_dir
The local config directory. This will only be provided if we are running a library.
"""
load_type = LoadTypeEnum.LIBRARY
if plugin_name is None:
if op.isfile(module_path):
load_type = LoadTypeEnum.SINGLE_FILE
if op.exists(op.join(module_path, "pymhf.toml")):
load_type = LoadTypeEnum.MOD_FOLDER
else:
load_type = LoadTypeEnum.LIBRARY

# Ensure the module path is absolute so as some later logic will rely on this.
if not op.isabs(module_path):
module_path = op.abspath(module_path)

# Before parsing the config, check if any of the installed libraires have any extra config info that
# needs to be merged in.
final_config: pymhfConfig = {}
pymhf_libraries = [ep.name for ep in entry_points().select(group="pymhflib")]
for library in pymhf_libraries:
if impres.is_resource(library, "pymhf.toml"):
with impres.path(library, "pymhf.toml") as pymhf_toml:
lib_config = read_pymhf_settings(pymhf_toml)
if (library_path := library_path_from_name(library)) is not None:
canonicalize_settings_inline(
lib_config,
library,
library_path,
None,
None,
["{CURR_DIR}", "{USER_DIR}"],
)
merge_configs(lib_config, final_config)
merge_configs(config, final_config)

config = final_config

binary_path = None
injected = None
Expand Down Expand Up @@ -232,7 +264,12 @@ def run_module(
start_paused = config.get("start_paused", False)

if config_dir is None:
cache_dir = op.join(module_path, ".cache")
# If we have a single-file mod, use the cache_dir as a folder up from the mod.
if load_type == LoadTypeEnum.SINGLE_FILE:
mod_dir, mod_fname = op.split(module_path)
cache_dir = op.join(mod_dir, f".{mod_fname}.cache")
else:
cache_dir = op.join(module_path, ".cache")
else:
cache_dir = op.join(config_dir, ".cache")

Expand Down Expand Up @@ -417,6 +454,13 @@ def close_callback(x):
assem_name = list(offset_map.keys())[0]
binary_base = offset_map[assem_name][0]
binary_size = offset_map[assem_name][1]
else:
binary_base = 0
binary_size = 0

# Some data for the injection process.
injected_data_list = []
sentinel_addr = 0

try:
cwd = CWD.replace("\\", "\\\\")
Expand Down Expand Up @@ -469,8 +513,6 @@ def close_callback(x):
break
saved_path = [x.replace("\\", "\\\\") for x in _path]

injected_data_list = []

# Inject the new path
sys_path_str = f"""
import sys
Expand Down
Loading