Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-19: Add ability to run individual files as mods #29

Merged
merged 4 commits into from
Nov 5, 2024
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
11 changes: 10 additions & 1 deletion docs/change_log.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
# Change Log

## Current
## Current (0.1.8)

- Add ability for single-file mods to be run by pymhf. ([gh-19](https://github.com/monkeyman192/pyMHF/issues/19))
- Changed the config system to use toml files.

### 0.1.7 (10/10/2024)

- Implement ability to call overloaded functions which have patterns.
- Improve safety of hooking functions and keyboard bindings as well as GUI reload fix.
- Added functions to set the main window active ([gh-6](https://github.com/monkeyman192/pyMHF/issues/6)) - Contributed by `Foundit3923`

### 0.1.6 (08/09/2024)

Expand Down
4 changes: 3 additions & 1 deletion docs/settings.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# pyMHF settings file
# pyMHF settings file [OBSOLETE]

**TODO: Update these settings**

*pyMHF* contains a file called `pymhf.cfg` which (currently) must be situated within the root directory of the modding library (cf. [here](../writing_libraries.md))
This file has a number of properties in different sections. Some are required and others are not:
Expand Down
67 changes: 67 additions & 0 deletions docs/single_file_mods.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Single-file mods

While `pymhf` is designed to be used to create libraries upon which mods can be made, for smaller scale projects or small tests, this is not convenient.
To accomodate this, `pymhf` can run a single file containing a mod.

This file **MAY** have the following attributes defined in it to simplify the defintions:

`__pymhf_func_binary__` (`str`): The name of the binary all the function offsets/patterns are to be found relative to/in. If provided this will be the default for all functions, however any `manual_hook` with a different value provided will supersede this value.
`__pymhf_func_offsets__` (`dict[str, Union[int, dict[str, int]]]`): A lookup containing the function names and offsets. Generally the keys will be the function names that you want to call the function by, and the values will simply be the offset relative to the start of the binary. For overloads the value can be another dictionary with keys being some identifier for the overload (eg. the argument types), and the values being the offsets.
`__pymhf_func_call_sigs__` (`dict[str, Union[FUNCDEF, dict[str, FUNCDEF]]]`): A lookup containing the function names and function call signatures. The keys will be the function names that you want to call the function by, and the values will either be the `FUNCDEF` itself, or a dictionary similar to the `__pymhf_func_offsets__` mapping.
`__pymhf_func_patterns__` (`dict[str, Union[str, dict[str, str]]]`): A lookup containing the function names and byte patterns to find the functions at. The keys will be the function names that you want to call the function by, and the values will be either a string containing the pattern, or a dictionary similar to the `__pymhf_func_offsets__` mapping.

Note that none of these are mandatory, however they do simplify the code in the file a decent amount and keep the file more easy to maintain.

Below is an example single-file mod for the game No Man's Sky:

```py
# /// script
# dependencies = ["pymhf"]
#
# [tool.pymhf]
# exe = "NMS.exe"
# steam_gameid = 275850
# start_paused = false
#
# [tool.pymhf.logging]
# log_dir = "."
# log_level = "info"
# window_name_override = "NMS test mod"
# ///

from logging import getLogger

from pymhf import Mod, load_mod_file
from pymhf.core.hooking import on_key_pressed

logger = getLogger("testmod")


class MyMod(Mod):
@on_key_pressed("p")
def press_p(self):
logger.info("Pressed P now!")


if __name__ == "__main__":
load_mod_file(__file__)
```

This mod clearly has very little functionality, but the above highlights the minimum requirements to make a single-file mod using `pymhf`.

To run this mod it is **strongly** recommended to use [uv](https://github.com/astral-sh/uv) as it has full support for inline script metadata.

The steps to run the above script from scratch (ie. with no `uv` installed) are as follows:

```
python -m pip install uv
uv run script.py
```

In the above ensure that the `python` command runs a python version between 3.9 and 3.11 INCLUSIVE.
Replace `script.py` with the name of the script as it was saved.

To modify the above script, the only values that really need to be changed are the `steam_gameid` and `exe` values.

If the game is being ran via steam, replace `steam_gameid` with the value found in steam, and set `exe` to be the name of the game binary.
If the game or program is not run through steam, remove the `steam_gameid` value and instead set `exe` and the absolute path to the binary.
55 changes: 28 additions & 27 deletions pymhf/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import argparse
import configparser
import os
import os.path as op
import shutil
Expand All @@ -10,7 +9,7 @@
from .core._types import FUNCDEF # noqa
from .core.hooking import FuncHook # noqa
from .core.mod_loader import Mod, ModState # noqa
from .main import load_module # noqa
from .main import load_mod_file, load_module # noqa

try:
__version__ = version("pymhf")
Expand Down Expand Up @@ -48,13 +47,14 @@ def _is_int(val: str) -> bool:
],
)

CFG_FILENAME = "pymhf.toml"


# TODO:
# Need to support the following commands:
# --config -> will configure the library
def run():
"""Main entrypoint which can be used to run programs with pymhf.
This will take the first argument as the name of a module which has been installed."""
This will take the first argument as the name of a module which has been installed.
"""
from .utils.parse_toml import read_pymhf_settings, write_pymhf_settings

parser = argparse.ArgumentParser(
prog="pyMHF program runner",
Expand All @@ -72,6 +72,15 @@ def run():

plugin_name: str = args.plugin_name
is_config_mode: bool = args.config
standalone = False

if op.isfile(plugin_name) and op.exists(plugin_name):
# In this case we are running in stand-alone mode
standalone = True

if standalone:
load_mod_file(plugin_name)
return

# Get the location of app data, then construct the expected folder name.
appdata_data = os.environ.get("APPDATA", op.expanduser("~"))
Expand Down Expand Up @@ -116,7 +125,7 @@ def run():

module_dir = op.dirname(loaded_lib.__file__)

cfg_file = op.join(module_dir, "pymhf.cfg")
cfg_file = op.join(module_dir, CFG_FILENAME)
config_progress_file = op.join(cfg_folder, ".config_in_progress")
if not op.exists(cfg_file):
print(
Expand All @@ -125,7 +134,7 @@ def run():
)
return
else:
dst = op.join(cfg_folder, "pymhf.cfg")
dst = op.join(cfg_folder, CFG_FILENAME)
if not op.exists(dst) or op.exists(config_progress_file):
# In this case we can prompt the user to enter the config values which need to be changed.
initial_config = True
Expand All @@ -136,51 +145,44 @@ def run():
# Write the file which indicates we are in progress.
with open(config_progress_file, "w") as f:
f.write("")
config = configparser.ConfigParser()
if not config.read(dst):
print("Cannot read config file for some reason... Exiting")
return
pymhf_settings = read_pymhf_settings(cfg_file)

# Modify some of the values in the config file, allowing the user to enter the values they want.

if (mod_dir := MOD_DIR_Q.ask()) is not None:
config.set("binary", "mod_dir", mod_dir)
pymhf_settings["mod_dir"] = mod_dir
else:
return

# Write the config back and then delete the temporary file only once everything is ok.
with open(dst, "w") as f:
config.write(f)
write_pymhf_settings(pymhf_settings, dst)
os.remove(config_progress_file)
initial_config = False
elif is_config_mode:
config = configparser.ConfigParser()
if not config.read(dst):
print("Cannot read config file for some reason... Exiting")
return
pymhf_settings = read_pymhf_settings(dst)
keep_going = True
while keep_going:
config_choice = CONFIG_SELECT_Q.ask()
if config_choice == CFG_OPT_BIN_PATH:
if (exe_path := EXE_PATH_Q.ask()) is not None:
config.set("binary", "path", exe_path)
config.remove_option("binary", "steam_gameid")
pymhf_settings["exe_path"] = exe_path
del pymhf_settings["steam_gameid"]
else:
return
elif config_choice == CFG_OPT_STEAM_ID:
if (steam_id := STEAM_ID_Q.ask()) is not None:
config.set("binary", "steam_gameid", steam_id)
config.remove_option("binary", "path")
pymhf_settings["steam_gameid"] = steam_id
del pymhf_settings["exe_path"]
else:
return
elif config_choice == CFG_OPT_MOD_PATH:
if (mod_dir := MOD_DIR_Q.ask()) is not None:
config.set("binary", "mod_dir", mod_dir)
pymhf_settings["mod_dir"] = mod_dir
else:
return
elif config_choice == CFG_OPT_START_PAUSED:
if (start_paused := START_PAUSED.ask()) is not None:
config.set("binary", "start_paused", str(start_paused))
pymhf_settings["start_paused"] = start_paused
else:
return
elif config_choice is None:
Expand All @@ -189,8 +191,7 @@ def run():
keep_going = CONTINUE_CONFIGURING_Q.ask()
if keep_going is None:
return
with open(dst, "w") as f:
config.write(f)
write_pymhf_settings(dst, pymhf_settings)

if not RUN_GAME.ask():
return
Expand Down
8 changes: 7 additions & 1 deletion pymhf/core/_internal.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ctypes
from concurrent.futures import ThreadPoolExecutor

CWD: str = ""
Expand All @@ -8,8 +9,11 @@
BINARY_HASH: str = ""
BASE_ADDRESS: int = -1
SIZE_OF_IMAGE: int = -1
CFG_DIR: str = ""
CONFIG: dict = {}
EXE_NAME: str = ""
BINARY_PATH: str = ""
SINGLE_FILE_MOD: bool = False
MOD_SAVE_DIR: str = ""

_executor: ThreadPoolExecutor = None # type: ignore

Expand All @@ -30,3 +34,5 @@ def game_loaded(self, val: bool):


GameState: _GameState = _GameState()

imports: dict[str, dict[str, ctypes._CFuncPtr]] = {}
6 changes: 6 additions & 0 deletions pymhf/core/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class KeyPressProtocol(Protocol):
class HookProtocol(Protocol):
_is_funchook: bool
_is_manual_hook: bool
_is_imported_func_hook: bool
_has__result_: bool
_hook_func_name: str
_hook_time: DetourTime
Expand All @@ -33,3 +34,8 @@ class ManualHookProtocol(HookProtocol):
_hook_pattern: Optional[str]
_hook_binary: Optional[str]
_hook_func_def: FUNCDEF


class ImportedHookProtocol(HookProtocol):
_dll_name: str
_hook_func_def: FUNCDEF
67 changes: 47 additions & 20 deletions pymhf/core/calling.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,23 @@

import pymhf.core._internal as _internal
from pymhf.core._types import FUNCDEF
from pymhf.core.memutils import find_pattern_in_binary
from pymhf.core.errors import UnknownFunctionError
from pymhf.core.memutils import _get_binary_info, find_pattern_in_binary
from pymhf.core.module_data import module_data
from pymhf.core.utils import saferun_decorator

calling_logger = getLogger("CallingManager")


@saferun_decorator
def call_function(
name: str,
*args,
overload: Optional[str] = None,
pattern: Optional[str] = None,
func_def: Optional[FUNCDEF] = None,
offset: Optional[int] = None,
binary: Optional[str] = None,
) -> Any:
"""Call a named function.

Expand All @@ -31,32 +36,43 @@ def call_function(
pattern
The pattern which can be used to find where the function is.
If provided this will be used instead of the offset as determined by the name.
offset
The offset relative to the binary.
If provided it will take precedence over the `pattern` argument.
binary
The name of the binary to search for the pattern within, or to find the offset relative to.
If not provided, will fallback to the name of the binary as provided by the `exe` config value.
"""
if func_def is not None:
_sig = func_def
else:
_sig = module_data.FUNC_CALL_SIGS[name]
if pattern:
offset = find_pattern_in_binary(pattern, False)
else:
if (_pattern := module_data.FUNC_PATTERNS.get(name)) is not None:
if isinstance(_pattern, str):
offset = find_pattern_in_binary(_pattern, False)
else:
if (opattern := _pattern.get(overload)) is not None:
offset = find_pattern_in_binary(opattern, False)
else:
first = list(_pattern.items())[0]
calling_logger.warning(f"No pattern overload was provided for {name}. ")
calling_logger.warning(f"Falling back to the first overload ({first[0]})")
offset = find_pattern_in_binary(first[1], False)
# If we have a binary defined in module_data, use it.
binary = binary or module_data.FUNC_BINARY
if offset is None:
if pattern:
offset = find_pattern_in_binary(pattern, False, binary)
else:
offset = module_data.FUNC_OFFSETS.get(name)
if offset is None:
raise NameError(f"Cannot find function {name}")
if (_pattern := module_data.FUNC_PATTERNS.get(name)) is not None:
if isinstance(_pattern, str):
offset = find_pattern_in_binary(_pattern, False, binary)
else:
if (opattern := _pattern.get(overload)) is not None:
offset = find_pattern_in_binary(opattern, False, binary)
else:
first = list(_pattern.items())[0]
calling_logger.warning(f"No pattern overload was provided for {name}. ")
calling_logger.warning(f"Falling back to the first overload ({first[0]})")
offset = find_pattern_in_binary(first[1], False, binary)
else:
offset = module_data.FUNC_OFFSETS.get(name)
if offset is None:
raise UnknownFunctionError(f"Cannot find function {name}")

if isinstance(_sig, FUNCDEF):
sig = CFUNCTYPE(_sig.restype, *_sig.argtypes)
else:
elif isinstance(_sig, dict):
# TODO: Check to see if _sig is actually a dict. If it's not then we should raise an error.
# Look up the overload:
if (osig := _sig.get(overload)) is not None: # type: ignore
sig = CFUNCTYPE(osig.restype, *osig.argtypes)
Expand All @@ -68,6 +84,11 @@ def call_function(
calling_logger.warning(f"No function arguments overload was provided for {name}. ")
calling_logger.warning(f"Falling back to the first overload ({first[0]})")
sig = CFUNCTYPE(first[1].restype, *first[1].argtypes)
else:
raise ValueError(
f"Invalid data type provided for `sig` argument: {type(_sig)}. Must be one of FUNCDEF or a "
"dictionary containing a mapping of FUNCDEF objects representing overloads."
)
if isinstance(offset, dict):
# Handle overloads
if (_offset := offset.get(overload)) is not None: # type: ignore
Expand All @@ -77,6 +98,12 @@ def call_function(
calling_logger.warning(f"No function arguments overload was provided for {name}. ")
calling_logger.warning(f"Falling back to the first overload ({_offset[0]})")
offset = _offset[1]
binary_base = _internal.BASE_ADDRESS
# TODO: This is inefficient to look it up every time. This should be optimised at some point.
if binary is not None:
if (hm := _get_binary_info(binary)) is not None:
_, module = hm
binary_base = module.lpBaseOfDll

cfunc = sig(_internal.BASE_ADDRESS + offset)
cfunc = sig(binary_base + offset)
return cfunc(*args)
8 changes: 0 additions & 8 deletions pymhf/core/common.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
import os
import os.path as op
from concurrent.futures import ThreadPoolExecutor

import pymhf.core._internal as _internal

# TODO: Move somewhere else? Not sure where but this doesn't really fit here...
executor: ThreadPoolExecutor = None # type: ignore

mod_save_dir = op.join(_internal.CFG_DIR, "MOD_SAVES")
if not op.exists(mod_save_dir):
os.makedirs(mod_save_dir)
Loading
Loading