diff --git a/main.py b/main.py index eea251b..588616f 100644 --- a/main.py +++ b/main.py @@ -999,7 +999,7 @@ def download_file(url, dest_path): # ═══════════════════════════════════════════════════════════════════════════════ # § 9 MSBUILD CANDIDATE DETECTION (unchanged) -# ═══════════════════════════════════════════════════════════════════════════════ +# ═══════════════════════���═══════════════════════════════════════════════════════ def _add_msbuild_candidate(candidates, path, priority): @@ -2239,7 +2239,10 @@ def run_cli_launcher(): # ═══════════════════════════════════════════════════════════════════════════════ -def run_inprocess_launcher(memory_resident: bool = False): +def run_inprocess_launcher( + memory_resident: bool = False, + portablemc_inmemory: bool = False, +): """ Memory-resident JVM launcher — the entire game runs inside this process. @@ -2263,12 +2266,27 @@ def run_inprocess_launcher(memory_resident: bool = False): Minecraft runs. This call blocks until the game exits. No .exe files are spawned at any point in this path. + + Parameters + ---------- + memory_resident : bool + If True, load jvm.dll into memory using pythonmemorymodule instead + of the normal OS loader path. + portablemc_inmemory : bool + If True, load the _portablemc.pyd extension in-memory using + pythonmemorymodule. This is useful for environments with strict + .exe/.dll execution policies. """ print("\n" + "=" * 62) env_memory_resident = ( os.environ.get("LAUNCHER_MEMORY_JVM", "").strip().lower() in TRUTHY_ENV_VALUES ) + env_portablemc_inmemory = ( + os.environ.get("LAUNCHER_PORTABLEMC_INMEMORY", "").strip().lower() + in TRUTHY_ENV_VALUES + ) memory_resident = bool(memory_resident or env_memory_resident) + portablemc_inmemory = bool(portablemc_inmemory or env_portablemc_inmemory) mode_name = "inprocess_memory" if memory_resident else "inprocess_jpype" effective_jdk_bin = resolve_effective_launcher_jdk_bin(memory_resident=memory_resident) @@ -2357,7 +2375,8 @@ def _enable_inprocess_crash_log() -> None: print(f" server : {DEFAULT_SERVER_IP}") print(f" jdk_bin : {effective_jdk_bin}") print(f" main_dir : {BASE_DIR}") - print(f" loader : {'memory-resident' if memory_resident else 'os-loader'}") + print(f" jvm_load : {'memory-resident' if memory_resident else 'os-loader'}") + print(f" pmc_load : {'in-memory' if portablemc_inmemory else 'standard'}") print() # Build extra JVM flags: DEFAULT_JVM_OPTS tokens + auto-join server flag. @@ -2388,6 +2407,7 @@ def _enable_inprocess_crash_log() -> None: in TRUTHY_ENV_VALUES ), memory_resident=memory_resident, + portablemc_inmemory=portablemc_inmemory, ) print("\n✅ Minecraft exited normally.") return True @@ -2409,6 +2429,7 @@ def _enable_inprocess_crash_log() -> None: "version": INPROCESS_VERSION, "username": DEFAULT_USERNAME, "memory_resident": memory_resident, + "portablemc_inmemory": portablemc_inmemory, }, ) if dump: @@ -2528,12 +2549,24 @@ def main(): elif choice == "4": update_launcher_state(last_run_mode="inprocess_jpype") os.environ.pop("LAUNCHER_MEMORY_JVM", None) - success = run_inprocess_launcher(memory_resident=False) + # Enable portablemc in-memory loading for environments with strict + # .exe/.dll execution policies (can be toggled via env var) + pmc_inmem = ( + os.environ.get("LAUNCHER_PORTABLEMC_INMEMORY", "").strip().lower() + in TRUTHY_ENV_VALUES + ) + success = run_inprocess_launcher( + memory_resident=False, portablemc_inmemory=pmc_inmem + ) elif choice == "5": update_launcher_state(last_run_mode="inprocess_memory") os.environ["LAUNCHER_MEMORY_JVM"] = "1" - success = run_inprocess_launcher(memory_resident=True) + # In memory-resident mode, always enable portablemc in-memory loading + # as this mode is specifically for environments with strict exec policies + success = run_inprocess_launcher( + memory_resident=True, portablemc_inmemory=True + ) elif choice == "d": run_debug_menu() diff --git a/scripts/jvm_boot_glue.py b/scripts/jvm_boot_glue.py index cb75564..6921806 100644 --- a/scripts/jvm_boot_glue.py +++ b/scripts/jvm_boot_glue.py @@ -1,4 +1,4 @@ -""" +""" jvm_boot_glue.py ════════════════ Bridges the memory-resident JVM into JPype and launches Minecraft in-process. @@ -58,6 +58,73 @@ _SCRIPTS_DIR = Path(__file__).resolve().parent _PMC_INTERCEPT_PY = _SCRIPTS_DIR / "pmc_intercept.py" +# ── portablemc 5.x environment bootstrap ────────────────────────────────── +# This MUST be called before any portablemc import to ensure the vendored +# copy is used and (optionally) the native extension is loaded in-memory. +_PMC_BOOTSTRAP_COMPLETE = False +_PMC_INMEMORY_MODE = False + + +def _ensure_portablemc_environment(*, inmemory: bool = False) -> bool: + """ + Ensure the portablemc 5.x environment is properly initialized. + + This function: + 1. Adds the vendored portablemc tree to sys.path + 2. Optionally loads the _portablemc.pyd in-memory (for strict exec policies) + 3. Scrubs any pip-installed portablemc to avoid version conflicts + + Parameters + ---------- + inmemory : bool + If True, attempt to load _portablemc.pyd via pythonmemorymodule + instead of the normal LoadLibrary path. + + Returns True if the environment is ready for portablemc imports. + """ + global _PMC_BOOTSTRAP_COMPLETE, _PMC_INMEMORY_MODE + + if _PMC_BOOTSTRAP_COMPLETE: + return True + + # Ensure scripts dir is on path for local_portablemc import + scripts_str = str(_SCRIPTS_DIR) + if scripts_str not in sys.path: + sys.path.insert(0, scripts_str) + + try: + from local_portablemc import bootstrap, bootstrap_inmemory + + if inmemory: + log.info("Bootstrapping portablemc 5.x in in-memory mode") + result = bootstrap_inmemory(scripts_dir=_SCRIPTS_DIR) + _PMC_INMEMORY_MODE = result + _PMC_BOOTSTRAP_COMPLETE = result + if result: + log.info("portablemc 5.x in-memory bootstrap successful") + else: + log.warning( + "In-memory bootstrap failed; falling back to standard mode" + ) + # Fall back to standard bootstrap + _PMC_BOOTSTRAP_COMPLETE = bootstrap(scripts_dir=_SCRIPTS_DIR) + else: + _PMC_BOOTSTRAP_COMPLETE = bootstrap(scripts_dir=_SCRIPTS_DIR) + + return _PMC_BOOTSTRAP_COMPLETE + + except ImportError as exc: + log.warning("Could not import local_portablemc: %s", exc) + return False + except Exception as exc: + log.error("portablemc environment bootstrap failed: %s", exc) + return False + + +def is_portablemc_inmemory() -> bool: + """Check if portablemc is running in in-memory mode.""" + return _PMC_INMEMORY_MODE + # ══════════════════════════════════════════════════════════════════════════════ # § 1 Process-level utilities @@ -202,7 +269,7 @@ def _wide_path_arg_to_str(path) -> str: return "" -# ══════════════════════════════════════════════════════════════════════════════ +# ═══════════════════════════════════════��══════════════════════════════════════ # § 3 ctypes WINFUNCTYPE prototypes # ══════════════════════════════════════════════════════════════════════════════ @@ -2158,17 +2225,56 @@ def __init__( # ── public ──────────────────────────────────────────────────────────── - def resolve(self) -> tuple[list[str], list[str], str, list[str]]: - """Run the adapter and return (jvm_flags, classpath, main_class, game_args).""" + def resolve( + self, *, inmemory: bool = False + ) -> tuple[list[str], list[str], str, list[str]]: + """ + Run the adapter and return (jvm_flags, classpath, main_class, game_args). + + Parameters + ---------- + inmemory : bool + If True, attempt to load portablemc's native extension in-memory + using pythonmemorymodule. This is useful for environments with + strict .exe/.dll execution policies. + """ + # Step 1: Ensure portablemc environment is initialized + if not _ensure_portablemc_environment(inmemory=inmemory): + raise RuntimeError( + "portablemc 5.x environment initialization failed.\n" + "Ensure the vendored portablemc tree exists at:\n" + f" {_SCRIPTS_DIR / 'portablemc' / 'portablemc-py' / 'python'}\n" + "Or ensure the wheel is available at:\n" + f" {_SCRIPTS_DIR / 'portablemc' / 'target' / 'wheels'}" + ) + + # Step 2: Try the Python API try: result = self._via_python_api() - log.info("portablemc resolved via: Python API") + mode = "in-memory" if is_portablemc_inmemory() else "standard" + log.info("portablemc resolved via: Python API (%s mode)", mode) return result + except ImportError as exc: + # More specific error for import failures + raise RuntimeError( + f"portablemc 5.x module import failed: {exc}\n" + "The native extension (_portablemc.pyd) may not be compatible " + "with this Python version.\n" + "Expected location: " + f"{_SCRIPTS_DIR / 'portablemc' / 'portablemc-py' / 'python' / 'portablemc'}" + ) from exc + except AttributeError as exc: + # API mismatch - wrong portablemc version + raise RuntimeError( + f"portablemc API mismatch: {exc}\n" + "This launcher requires portablemc 5.x with PyO3 bindings.\n" + "Ensure you have the correct version of the native extension." + ) from exc except Exception as exc: raise RuntimeError( - "portablemc 5.x API resolution failed.\n" - "Ensure portablemc is importable in this interpreter " - "(Windows: py -m pip install portablemc)." + f"portablemc 5.x API resolution failed: {type(exc).__name__}: {exc}\n" + "Check that the vendored portablemc installation is complete " + "and the native extension is compatible with this Python version." ) from exc # ── path 1: portablemc 5.x PyO3 API ────────────────────────────────── @@ -2280,7 +2386,7 @@ def _via_python_api(self) -> tuple: log.warning("Could not auto-detect JDK from Python API launch args") return _ArgSplitter.split(full_args[1:]) - # ── static helpers ───────────────────────────────────────────────────── + # ── static helpers ──────────────────────────────────────��────────────── @staticmethod def _extract_args(jvm_args, game_args, main_class): @@ -2474,6 +2580,7 @@ def launch_minecraft( extra_game_args: list[str] | None = None, debug: bool = False, memory_resident: bool = False, + portablemc_inmemory: bool = False, ) -> None: """ Full in-process Minecraft launcher. No java.exe is spawned. @@ -2484,6 +2591,14 @@ def launch_minecraft( memory_resident=True (option 5) jvm.dll and all JDK deps mapped into RAM via JvmMemoryLoader. jpype.startJVM intercepted via IAT hooks. + + portablemc_inmemory=True + Load the _portablemc.pyd extension in-memory using pythonmemorymodule + instead of the normal LoadLibrary path. This is useful for environments + with strict .exe/.dll execution policies. + + Note: subprocess/os.system/external process calls are NOT used. + Everything runs within the current Python process. """ logging.basicConfig( level=logging.DEBUG if debug else logging.INFO, @@ -2497,6 +2612,7 @@ def launch_minecraft( # causes HotSpot's CRT bootstrap to abort with # "Failed setting boot class path" or a segfault inside JNI_CreateJavaVM. log.info("Resolving Minecraft version: %r", version) + log.info("portablemc mode: %s", "in-memory" if portablemc_inmemory else "standard") adapter = PortableMCGameAdapter( main_dir=main_dir, version=version, @@ -2505,7 +2621,9 @@ def launch_minecraft( access_token=access_token, extra_jvm_flags=extra_jvm_flags, ) - jvm_flags, classpath, main_class, game_args = adapter.resolve() + jvm_flags, classpath, main_class, game_args = adapter.resolve( + inmemory=portablemc_inmemory + ) # Always prefer portablemc's auto-detected JDK (e.g. java-runtime-epsilon) # over the caller's explicit jdk_bin. For option 5 the memory loader MUST diff --git a/scripts/local_portablemc.py b/scripts/local_portablemc.py index 53c230d..457ccd6 100644 --- a/scripts/local_portablemc.py +++ b/scripts/local_portablemc.py @@ -11,6 +11,8 @@ vendored Python tree, extract it from the wheel automatically (one-time). 3. Prepend the vendored tree to sys.path and strip every other portablemc path so pip-installed copies can never shadow the local version. +4. Optionally load the _portablemc.pyd binary in-memory using pythonmemorymodule + for environments with strict .exe execution policies. Call ``bootstrap()`` (or ``prepend_vendor_portablemc()``) from every entry point before any ``import portablemc`` statement. @@ -18,10 +20,19 @@ from __future__ import annotations +import importlib +import importlib.abc +import importlib.machinery +import importlib.util import logging import sys +import types import zipfile from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Sequence log = logging.getLogger(__name__) @@ -32,6 +43,10 @@ _WHEEL_SEARCH = _SCRIPTS_DIR / "portablemc" / "target" / "wheels" _PMC_PKG_DIR = _VENDOR_ROOT / "portablemc" +# Track whether the native extension has been loaded in-memory +_PYD_LOADED_INMEMORY: bool = False +_PYD_MEMORY_MODULE: object | None = None + # ── Wheel extraction ─────────────────────────────────────────────────────── @@ -192,3 +207,229 @@ def prepend_vendor_portablemc( sd = Path(project_root).resolve() / "scripts" bootstrap(scripts_dir=sd) return _VENDOR_ROOT if _VENDOR_ROOT.is_dir() else None + + +# ══════════════════════════════════════════════════════════════════════════════ +# § IN-MEMORY PYD LOADING +# ══════════════════════════════════════════════════════════════════════════════ +# +# For environments with strict .exe/.dll execution policies, load the +# _portablemc.cp314-win_amd64.pyd binary directly into memory using +# pythonmemorymodule. This bypasses normal LoadLibrary and allows the +# extension to be imported without triggering execution policy blocks. +# +# Usage: +# from local_portablemc import bootstrap_inmemory +# bootstrap_inmemory() # Must be called BEFORE any portablemc import +# import portablemc.mojang # Now works even with strict policies + + +def _find_pyd_file() -> Path | None: + """ + Locate the _portablemc.cp*-win_amd64.pyd file in the vendored tree. + Returns None if not found. + """ + if not _PMC_PKG_DIR.is_dir(): + return None + # Match any Python version's pyd file + for pyd in _PMC_PKG_DIR.glob("_portablemc*.pyd"): + return pyd + return None + + +def _load_pyd_inmemory(pyd_path: Path) -> types.ModuleType | None: + """ + Load a .pyd file into memory using pythonmemorymodule. + + Returns the loaded module object, or None on failure. + This function: + 1. Reads the .pyd binary into memory + 2. Uses pythonmemorymodule.MemoryModule to map it + 3. Creates a module object and initializes it via PyInit_* + """ + global _PYD_LOADED_INMEMORY, _PYD_MEMORY_MODULE + + if _PYD_LOADED_INMEMORY and _PYD_MEMORY_MODULE is not None: + log.debug("_portablemc pyd already loaded in-memory") + return _PYD_MEMORY_MODULE + + try: + # Ensure pythonmemorymodule is available + scripts_str = str(_SCRIPTS_DIR) + if scripts_str not in sys.path: + sys.path.insert(0, scripts_str) + + from pythonmemorymodule import MemoryModule + + log.info("Loading _portablemc.pyd in-memory from: %s", pyd_path) + pyd_data = pyd_path.read_bytes() + + # Create a MemoryModule from the pyd bytes + mem_mod = MemoryModule(data=pyd_data, debug=False) + + # Get the PyInit function address + # The function name is PyInit_ for Python 3 extensions + init_func_name = b"PyInit__portablemc" + init_func_addr = mem_mod.get_proc_addr("PyInit__portablemc") + + if not init_func_addr: + log.error("Could not find PyInit__portablemc in pyd") + return None + + # Create a ctypes function type for PyInit + import ctypes + + PyInit_func = ctypes.PYFUNCTYPE(ctypes.py_object) + init_func = PyInit_func(ctypes.cast(init_func_addr, ctypes.c_void_p).value) + + # Call the init function to get the module object + module = init_func() + + if module is None: + log.error("PyInit__portablemc returned None") + return None + + # Register the module in sys.modules + sys.modules["_portablemc"] = module + sys.modules["portablemc._portablemc"] = module + + _PYD_LOADED_INMEMORY = True + _PYD_MEMORY_MODULE = module + log.info("Successfully loaded _portablemc.pyd in-memory") + return module + + except ImportError as exc: + log.warning( + "pythonmemorymodule not available for in-memory loading: %s", exc + ) + return None + except Exception as exc: + log.error("Failed to load _portablemc.pyd in-memory: %s", exc) + return None + + +def _create_portablemc_package() -> bool: + """ + Create the portablemc package structure in sys.modules if not present. + This ensures submodules like portablemc.mojang can be imported. + """ + if "portablemc" not in sys.modules: + # Create the portablemc package module + pkg = types.ModuleType("portablemc") + pkg.__path__ = [str(_PMC_PKG_DIR)] + pkg.__file__ = str(_PMC_PKG_DIR / "__init__.py") + pkg.__package__ = "portablemc" + sys.modules["portablemc"] = pkg + log.debug("Created portablemc package in sys.modules") + return True + + +def _setup_portablemc_submodule_stubs() -> None: + """ + Create stub modules for portablemc submodules that forward to the + native extension's submodules. + + portablemc 5.x exposes: + - portablemc.mojang + - portablemc.fabric + - portablemc.forge + - portablemc.msa + + These are implemented as Rust code compiled into _portablemc.pyd + and exposed via PyO3. + """ + if "_portablemc" not in sys.modules: + log.warning("Cannot create submodule stubs: _portablemc not loaded") + return + + native_mod = sys.modules["_portablemc"] + pkg = sys.modules.get("portablemc") + + if pkg is None: + _create_portablemc_package() + pkg = sys.modules["portablemc"] + + # List of submodules exposed by the native extension + submodules = ["mojang", "fabric", "forge", "msa", "base"] + + for submod_name in submodules: + full_name = f"portablemc.{submod_name}" + if full_name in sys.modules: + continue + + # Try to get the submodule from the native extension + submod = getattr(native_mod, submod_name, None) + if submod is not None: + sys.modules[full_name] = submod + setattr(pkg, submod_name, submod) + log.debug("Registered submodule: %s", full_name) + else: + log.debug("Submodule %s not found in _portablemc", submod_name) + + +def bootstrap_inmemory(*, scripts_dir: Path | None = None) -> bool: + """ + Full in-memory bootstrap: load _portablemc.pyd via pythonmemorymodule. + + This bypasses normal LoadLibrary calls and allows the extension to work + in environments with strict .exe/.dll execution policies. + + Call this ONCE at the top of every entry-point before any portablemc import. + Returns True when the extension is ready. + + Usage: + from local_portablemc import bootstrap_inmemory + if bootstrap_inmemory(): + import portablemc.mojang + # ... use portablemc 5.x API + """ + global _SCRIPTS_DIR, _VENDOR_ROOT, _WHEEL_SEARCH, _PMC_PKG_DIR + + # Update paths if scripts_dir is provided + if scripts_dir is not None: + sd = Path(scripts_dir).resolve() + _SCRIPTS_DIR = sd + _VENDOR_ROOT = sd / "portablemc" / "portablemc-py" / "python" + _WHEEL_SEARCH = sd / "portablemc" / "target" / "wheels" + _PMC_PKG_DIR = _VENDOR_ROOT / "portablemc" + + # Ensure scripts dir is on path for pythonmemorymodule + scripts_str = str(_SCRIPTS_DIR) + if scripts_str not in sys.path: + sys.path.insert(0, scripts_str) + + # Step 1: Extract pyd from wheel if needed + _ensure_pyd() + + # Step 2: Scrub pip-installed portablemc to avoid conflicts + _scrub_pip_portablemc() + + # Step 3: Prepend vendor root for pure-Python fallback modules + _prepend_vendor() + + # Step 4: Find and load the pyd in-memory + pyd_path = _find_pyd_file() + if pyd_path is None: + log.warning("_portablemc.pyd not found in vendored tree") + return False + + module = _load_pyd_inmemory(pyd_path) + if module is None: + return False + + # Step 5: Create package structure and submodule stubs + _create_portablemc_package() + _setup_portablemc_submodule_stubs() + + log.info("In-memory portablemc 5.x bootstrap complete") + return True + + +def is_pyd_loaded_inmemory() -> bool: + """Check if the _portablemc.pyd is loaded in-memory.""" + return _PYD_LOADED_INMEMORY + + +def get_inmemory_module() -> object | None: + """Get the in-memory loaded _portablemc module, or None.""" + return _PYD_MEMORY_MODULE diff --git a/scripts/pyd_memory_loader.py b/scripts/pyd_memory_loader.py new file mode 100644 index 0000000..7cbe9b1 --- /dev/null +++ b/scripts/pyd_memory_loader.py @@ -0,0 +1,297 @@ +""" +pyd_memory_loader.py +════════════════════ +Utility module for loading Python extension modules (.pyd files) directly +into memory using pythonmemorymodule, bypassing the normal LoadLibrary path. + +This is useful for environments with strict .exe/.dll execution policies +where the OS loader would block the extension from loading. + +Usage +───── + from pyd_memory_loader import load_pyd_inmemory, PydMemoryLoader + + # Simple one-shot loading + module = load_pyd_inmemory("/path/to/_mymodule.cp314-win_amd64.pyd") + if module: + import sys + sys.modules["_mymodule"] = module + + # Or use the loader class for more control + loader = PydMemoryLoader("/path/to/_mymodule.cp314-win_amd64.pyd") + if loader.load(): + loader.register_module("_mymodule") + loader.register_module("mypackage._mymodule") + +Technical Details +───────────────── +1. Reads the .pyd binary into a bytes buffer +2. Maps it into memory using pythonmemorymodule.MemoryModule +3. Locates the PyInit_ function in the module's export table +4. Calls PyInit_ to initialize the module and get the module object +5. Registers the module in sys.modules under the specified name(s) + +The key insight is that Python extension modules are just regular DLLs with +a PyInit_ export that returns a PyObject* to the module. By mapping +the DLL into memory manually and calling this function, we can load the +extension without going through the OS loader. + +Constraints +─────────── +- Windows only (pythonmemorymodule uses Win32 APIs) +- x64 only (current implementation) +- The .pyd must be compatible with the running Python version +- TLS callbacks and complex DllMain logic may not work correctly +""" + +from __future__ import annotations + +import ctypes +import logging +import sys +import types +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pythonmemorymodule import MemoryModule + +log = logging.getLogger(__name__) + +# Track loaded modules to prevent double-loading +_LOADED_PYDS: dict[str, "PydMemoryLoader"] = {} + + +class PydMemoryLoader: + """ + Load a .pyd file into memory and expose it as a Python module. + + Example + ─────── + loader = PydMemoryLoader("path/to/_portablemc.cp314-win_amd64.pyd") + if loader.load(): + # Register under the expected import name + loader.register_module("_portablemc") + loader.register_module("portablemc._portablemc") + + # Now imports will work + import _portablemc + """ + + def __init__(self, pyd_path: str | Path) -> None: + """ + Initialize the loader with a path to a .pyd file. + + Parameters + ---------- + pyd_path : str | Path + Absolute or relative path to the .pyd file to load. + """ + self.pyd_path = Path(pyd_path).resolve() + self._memory_module: "MemoryModule | None" = None + self._module: types.ModuleType | None = None + self._init_func_name: str = "" + self._loaded = False + + @property + def is_loaded(self) -> bool: + """Check if the .pyd has been successfully loaded.""" + return self._loaded + + @property + def module(self) -> types.ModuleType | None: + """Get the loaded Python module object, or None if not loaded.""" + return self._module + + def _infer_module_name(self) -> str: + """ + Infer the module name from the .pyd filename. + + Standard naming: _modulename.cp314-win_amd64.pyd + Returns: _modulename + """ + name = self.pyd_path.stem # e.g., "_portablemc.cp314-win_amd64" + # Strip version/platform suffix + if "." in name: + name = name.split(".")[0] + return name + + def load(self) -> bool: + """ + Load the .pyd into memory and initialize it. + + Returns True on success, False on failure. + """ + if self._loaded: + log.debug("PYD already loaded: %s", self.pyd_path) + return True + + if not self.pyd_path.exists(): + log.error("PYD file not found: %s", self.pyd_path) + return False + + try: + # Import pythonmemorymodule + from pythonmemorymodule import MemoryModule + + log.info("Loading PYD in-memory: %s", self.pyd_path) + + # Read the .pyd binary + pyd_data = self.pyd_path.read_bytes() + log.debug("Read %d bytes from %s", len(pyd_data), self.pyd_path) + + # Create a MemoryModule from the bytes + self._memory_module = MemoryModule(data=pyd_data, debug=False) + + # Infer the module name and init function + module_name = self._infer_module_name() + self._init_func_name = f"PyInit_{module_name}" + + log.debug("Looking for init function: %s", self._init_func_name) + + # Get the PyInit function address + init_func_addr = self._memory_module.get_proc_addr(self._init_func_name) + + if not init_func_addr: + log.error( + "Could not find %s in %s", self._init_func_name, self.pyd_path + ) + return False + + # Create a ctypes function type for PyInit + # PyInit_* functions return PyObject* (which ctypes maps to py_object) + PyInit_func = ctypes.PYFUNCTYPE(ctypes.py_object) + raw_addr = ctypes.cast(init_func_addr, ctypes.c_void_p).value + + if not raw_addr: + log.error("Init function address is NULL") + return False + + init_func = PyInit_func(raw_addr) + + # Call the init function to get the module object + log.debug("Calling %s @ 0x%016x", self._init_func_name, raw_addr) + self._module = init_func() + + if self._module is None: + log.error("%s returned None", self._init_func_name) + return False + + self._loaded = True + _LOADED_PYDS[str(self.pyd_path)] = self + log.info( + "Successfully loaded PYD in-memory: %s -> %s", + self.pyd_path.name, + type(self._module).__name__, + ) + return True + + except ImportError as exc: + log.error("pythonmemorymodule not available: %s", exc) + return False + except OSError as exc: + log.error("OS error loading PYD: %s", exc) + return False + except Exception as exc: + log.error("Failed to load PYD in-memory: %s: %s", type(exc).__name__, exc) + return False + + def register_module(self, name: str) -> bool: + """ + Register the loaded module in sys.modules under the given name. + + Parameters + ---------- + name : str + The import name to register, e.g., "_portablemc" or + "portablemc._portablemc". + + Returns True on success. + """ + if not self._loaded or self._module is None: + log.warning("Cannot register module %r: not loaded", name) + return False + + if name in sys.modules: + log.debug("Module %r already in sys.modules, overwriting", name) + + sys.modules[name] = self._module + log.debug("Registered module: %s", name) + return True + + def get_submodule(self, submod_name: str) -> types.ModuleType | None: + """ + Get a submodule from the loaded extension module. + + Some PyO3 extensions expose multiple submodules (e.g., portablemc.mojang, + portablemc.fabric). This method retrieves them by attribute access. + + Parameters + ---------- + submod_name : str + The submodule name (without the parent package prefix). + + Returns the submodule object, or None if not found. + """ + if not self._loaded or self._module is None: + return None + return getattr(self._module, submod_name, None) + + def register_submodules(self, parent_name: str, submod_names: list[str]) -> int: + """ + Register submodules exposed by the extension into sys.modules. + + Parameters + ---------- + parent_name : str + The parent package name, e.g., "portablemc". + submod_names : list[str] + Names of submodules to register, e.g., ["mojang", "fabric", "forge"]. + + Returns the number of submodules successfully registered. + """ + count = 0 + for name in submod_names: + submod = self.get_submodule(name) + if submod is not None: + full_name = f"{parent_name}.{name}" + sys.modules[full_name] = submod + log.debug("Registered submodule: %s", full_name) + count += 1 + else: + log.debug("Submodule %s not found in extension", name) + return count + + +def load_pyd_inmemory(pyd_path: str | Path) -> types.ModuleType | None: + """ + Convenience function to load a .pyd file in-memory. + + Parameters + ---------- + pyd_path : str | Path + Path to the .pyd file. + + Returns the loaded module, or None on failure. + """ + # Check cache first + key = str(Path(pyd_path).resolve()) + if key in _LOADED_PYDS: + return _LOADED_PYDS[key].module + + loader = PydMemoryLoader(pyd_path) + if loader.load(): + return loader.module + return None + + +def is_pyd_loaded(pyd_path: str | Path) -> bool: + """Check if a .pyd file has already been loaded in-memory.""" + key = str(Path(pyd_path).resolve()) + return key in _LOADED_PYDS + + +def get_loaded_pyd(pyd_path: str | Path) -> PydMemoryLoader | None: + """Get the loader for an already-loaded .pyd file.""" + key = str(Path(pyd_path).resolve()) + return _LOADED_PYDS.get(key)