From 648cde6e33d3215588ace4ed43e9b5ec4b6a70da Mon Sep 17 00:00:00 2001 From: Wei Hong Date: Mon, 13 Jul 2026 03:13:17 +0800 Subject: [PATCH] Now the *launcher* can actually launch mInECraFT. --- core_lib/game/argument.py | 80 ++++++++++++- core_lib/game/library.py | 131 ++++++++++++++++++++- dialog/test.py | 242 +++++++++++++++++++++++++++++++++++++- 3 files changed, 447 insertions(+), 6 deletions(-) diff --git a/core_lib/game/argument.py b/core_lib/game/argument.py index 3b6e8f9..f75963e 100644 --- a/core_lib/game/argument.py +++ b/core_lib/game/argument.py @@ -5,7 +5,7 @@ from core_lib.game.library import get_natives_platform_info logger = logging.getLogger("Launcher.CoreLib") -def is_argument_allowed(argument: dict | str, allow_features: dict=dict) -> bool: +def is_argument_allowed(argument: dict | str, allow_features: dict | None=None) -> bool: """ Check if argument is allowed or not. :param argument: @@ -16,6 +16,9 @@ def is_argument_allowed(argument: dict | str, allow_features: dict=dict) -> bool if isinstance(argument, str): return True + if allow_features is None: + allow_features = {} + rules = argument.get('rules', {}) _, platform_rule_name, _ = get_natives_platform_info() @@ -125,7 +128,48 @@ def convert_argument_to_multiple_string(argument: str | dict) -> list[str]: raise TypeError("Argument must be of type str or dict") -def parse_and_generate_arguments(arguments: list | str, mappings: ArgumentMappings, features: dict=dict) -> list[str]: +def find_game_and_jvm_args(arguments: dict | str) -> tuple[list, list]: + game_args = [] + jvm_args = [] + + if isinstance(arguments, dict): + game = arguments.get("game", []) + jvm = arguments.get("jvm", []) + + if isinstance(game, list): + game_args.extend(game) + elif isinstance(game, str): + game_args.append(game) + + if isinstance(jvm, list): + jvm_args.extend(jvm) + elif isinstance(jvm, str): + jvm_args.append(jvm) + + if isinstance(arguments, str): + # Convert legacy argument into a list type + game_args = shlex.split(arguments) + + return game_args, jvm_args + +REMAPPING_ARGUMENTS = { + # For some (I don't know) reason, in 26.2, Mojang include some + "-Djava.library.path=${natives_directory}/java": "-Djava.library.path=${natives_directory}" +} + +def hard_remapping_special_arguments(argument: str) -> str: + for arg in REMAPPING_ARGUMENTS: + new_value = REMAPPING_ARGUMENTS[arg] + + if arg == argument: + return new_value + + return argument + +def parse_and_generate_arguments(arguments: list | str, mappings: ArgumentMappings, features: dict | None=None) -> list[str]: + if features is None: + features = {} + parsed_arguments = [] if isinstance(arguments, str): @@ -139,8 +183,11 @@ def parse_and_generate_arguments(arguments: list | str, mappings: ArgumentMappin if not is_argument_allowed(argument, allow_features=features): continue + # Replace some special arguments + new_argument = hard_remapping_special_arguments(argument) + # Each argument (section) may contain multiple items, convert it to list before replace placeholder - converted = convert_argument_to_multiple_string(argument) + converted = convert_argument_to_multiple_string(new_argument) for item in converted: parsed = replace_placeholders(item, placeholders) @@ -149,3 +196,30 @@ def parse_and_generate_arguments(arguments: list | str, mappings: ArgumentMappin return parsed_arguments +def generate_launch_command(arg_maps: ArgumentMappings, main_class: str, java_path: str, full_args: dict | str + ) -> list[str]: + cmd = [java_path] + game_args, jvm_args = find_game_and_jvm_args(full_args) + parsed_jvm_args = parse_and_generate_arguments( + jvm_args, + arg_maps + ) + parsed_game_args = parse_and_generate_arguments( + game_args, + arg_maps + ) + + # Legacy Minecraft don't have jvm arguments exist in the version library. We need to generate by ourselves + if len(parsed_jvm_args) == 0: + cmd.extend([ + f"-Djava.library.path={arg_maps.natives_directory}", + "-cp", + arg_maps.classpath, + ]) + else: + cmd.extend(parsed_jvm_args) + + cmd.append(main_class) + cmd.extend(parsed_game_args) + + return cmd \ No newline at end of file diff --git a/core_lib/game/library.py b/core_lib/game/library.py index 179a56a..6ab8f3f 100644 --- a/core_lib/game/library.py +++ b/core_lib/game/library.py @@ -13,7 +13,8 @@ from .version_info import ( NATIVE_NEW_TO_OLD_ARCH, NATIVE_OLD_TO_NEW_ARCH, find_useful_part_in_specific_version_manifest, ) -from ..common.exception import UnsupportedPlatformException, NativeResolveException, DependencyException +from ..common.exception import UnsupportedPlatformException, NativeResolveException, DependencyException, \ + VersionDataKeyNotFoundException logger = logging.getLogger("Launcher.CoreLib") @@ -513,3 +514,131 @@ def unzip_natives(filtered, libraries_dir, destination: Path): unzip(full_path, destination, exclude=exclude, flatten=flatten) logger.debug(f"Unzipped {full_path}.") + + +def check_libraries_exists(manifest: dict, libraries_dir: Path): + """ + Check if libraries are present in the libraries directory + :param manifest: + :param libraries_dir: + :return: + not_found: list + """ + libraries = manifest.get("libraries", []) + + if not libraries: + raise VersionDataKeyNotFoundException( + "Libraries not found in manifest.", + ) + + not_found = [] + + # Same as natives + _, _, os_version = get_platform_info() + platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info() + + for library in libraries: + name = library.get("name", "") + artifact_raw = library.get("downloads", {}).get("artifact", {}) + rules = library.get("rules", []) + classifiers = library.get("downloads", {}).get("classifiers", {}) + natives = library.get("natives", {}) + + # Ignore natives + if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or + any(native.startswith("natives-") for native in classifiers)): + logger.info(f"Skipping {name} because its a native library.") + continue + + if not is_library_allowed( + rules, + platform_rule_name, + platform_arch_type, + os_version, + ): + continue + + artifact = generate_formatted_artifact_with_name( + name, + artifact_raw + ) + + if not artifact: + logger.warning(f"Artifact {name} are not valid. Skipping...") + continue + + relative_path = artifact.get("path") + full_path = libraries_dir / Path(relative_path) + + if not full_path.is_file() or not full_path.exists(): + logger.debug(f"Artifact {name} missing.") + not_found.append(full_path) + + return not_found + +def generate_classpath(manifest: dict, libraries_dir: Path, core_file_path: Path): + """ + Generate classpath by using specific version's data + :param manifest: + :param libraries_dir: + :param core_file_path: + :return: + not_found: list + """ + libraries = manifest.get("libraries", []) + + if not libraries: + raise VersionDataKeyNotFoundException( + "Libraries not found in manifest.", + ) + + # Same as natives + _, _, os_version = get_platform_info() + platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info() + + separator = ";" if platform_key_name == "windows" else ":" + classes = [] + + for library in libraries: + name = library.get("name", "") + artifact_raw = library.get("downloads", {}).get("artifact", {}) + rules = library.get("rules", []) + classifiers = library.get("downloads", {}).get("classifiers", {}) + natives = library.get("natives", {}) + + # Ignore natives + if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or + any(native.startswith("natives-") for native in classifiers)): + logger.info(f"Skipping {name} because its a native library.") + continue + + if not is_library_allowed( + rules, + platform_rule_name, + platform_arch_type, + os_version, + ): + continue + + artifact = generate_formatted_artifact_with_name( + name, + artifact_raw + ) + + if not artifact: + logger.warning(f"Artifact {name} are not valid. Skipping...") + continue + + relative_path = artifact.get("path") + full_path = libraries_dir / Path(relative_path) + + if not full_path.is_file() or not full_path.exists(): + raise FileNotFoundError(f"Artifact {name} missing.") + + classes.append(full_path.as_posix()) + + # Append core file + classes.append(core_file_path.as_posix()) + + return f"{separator}".join(classes) + diff --git a/dialog/test.py b/dialog/test.py index 43b1ccb..c5a2e22 100644 --- a/dialog/test.py +++ b/dialog/test.py @@ -1,4 +1,5 @@ import json +import subprocess import textwrap import threading import time @@ -7,15 +8,18 @@ from pathlib import Path from PySide6.QtCore import Qt, QObject, Signal, Slot from PySide6.QtWidgets import QPushButton, QVBoxLayout, QDialog, QPlainTextEdit, \ - QHBoxLayout, QComboBox, QMessageBox + QHBoxLayout, QComboBox, QMessageBox, QInputDialog, QFileDialog from core_lib.common.common import download_file, FileObject, download_multiple_files from core_lib.common.exception import VersionNotSelectedException, UnsupportedPlatformException, \ NativeResolveException, DownloadException, DependencyException, VersionManifestFetchException, \ VersionManifestSaveException, NoSpecifiedVersionKeyException, AssetManifestFetchException, \ AssetManifestSaveException, AssetDataKeyNotFoundException +from core_lib.game.argument import ArgumentMappings, parse_and_generate_arguments, find_game_and_jvm_args, \ + generate_launch_command from core_lib.game.asset import fetch_and_save_asset_manifest, collect_assets_from_manifest, download_assets_and_copy -from core_lib.game.library import collect_native_artifacts, download_libraries, unzip_natives, collect_library_artifacts +from core_lib.game.library import collect_native_artifacts, download_libraries, unzip_natives, \ + collect_library_artifacts, generate_classpath from core_lib.game.version_info import (find_useful_part_in_specific_version_manifest, get_core_file_download_url_and_hash, \ @@ -55,6 +59,14 @@ class TestDialog(QDialog): self.button_5.setObjectName("ActionButton") self.button_5.clicked.connect(self.download_assets) + self.button_6 = QPushButton("Parse Arguments") + self.button_6.setObjectName("ActionButton") + self.button_6.clicked.connect(self.parse_argument) + + self.button_7 = QPushButton("Launch Game! (Offline)") + self.button_7.setObjectName("ActionButton") + self.button_7.clicked.connect(self.launch_offline_game) + self.ver_dropdown = QComboBox() self.ver_dropdown.setObjectName("Dropdown") @@ -78,6 +90,8 @@ class TestDialog(QDialog): self.right_layout.addWidget(self.button_2) self.right_layout.addWidget(self.button_3) self.right_layout.addWidget(self.button_5) + self.right_layout.addWidget(self.button_6) + self.right_layout.addWidget(self.button_7) self.right_layout.addStretch() self.right_layout.addWidget(self.ver_type_dropdown) self.right_layout.addWidget(self.ver_dropdown) @@ -171,6 +185,10 @@ class TestDialog(QDialog): self.ask_request ) + self.widget_signal.ask_for_path.connect( + self.ask_for_path + ) + self.widget_signal.get_selected_ver.connect( self.get_selected_ver ) @@ -203,6 +221,7 @@ class TestDialog(QDialog): enable_widget = Signal(object) disable_widget = Signal(object) askyesno = Signal(dict) # {"title", "targetEvent", "message", "result"} + ask_for_path = Signal(dict) get_selected_ver = Signal(dict) get_selected_type = Signal(dict) clean_output = Signal() @@ -259,6 +278,27 @@ class TestDialog(QDialog): message=message ) + @Slot(dict) + def ask_for_path(self, context: dict): + event = context.get("event") + title = context.get("title", "Select a path") + filter_raw = context.get("filter", "All files (*)") + result = context.get("result") + + path, _ = QFileDialog.getOpenFileName( + self, + title, + "", + filter_raw, + ) + + if isinstance(result, dict): + result["ok"] = bool(path) + result["path"] = path + + if isinstance(event, threading.Event): + event.set() + @staticmethod def get_progress(value): width = 30 @@ -422,6 +462,70 @@ class TestDialog(QDialog): ) thread.start() + def parse_argument(self): + self.button_6.setDisabled(True) + + self.output.clear() + + ver = self.get_selected_version() + + if not ver: + self.widget_signal.enable_widget.emit(self.button_6) + return + + thread = threading.Thread( + target=self._parse_argument, + daemon=True, + args=(ver,) + ) + thread.start() + + def launch_offline_game(self): + self.button_7.setDisabled(True) + + self.output.clear() + + ver = self.get_selected_version() + + if not ver: + self.widget_signal.enable_widget.emit(self.button_7) + return + + event = threading.Event() + result = {} + context = { + "title": "Require a java executable path to launch game.", + "event": event, + "result": result, + "filter": "Java executable (java.exe javaw.exe);;All files (*)" + } + + # Ask for a support java version. If not, use system environment java + self.ask_for_path( + context, + ) + + if not event.wait(120): + self.widget_signal.show_error_message.emit( + "Timed out while waiting for Java executable selection." + ) + return + + java_path = result.get("path") + + if java_path: + self.download_signal.log.emit(f"Use specified java executable path: {java_path}") + else: + self.download_signal.log.emit("Use system environment java.") + java_path = "java" + + thread = threading.Thread( + target=self._launch_offline_game, + daemon=True, + args=(ver, java_path,) + ) + thread.start() + def load_all_version(self): ver_type = self.ver_type_dropdown.currentText() @@ -750,6 +854,138 @@ class TestDialog(QDialog): finally: self.widget_signal.enable_widget.emit(self.button_5) + def _parse_argument(self, ver): + try: + ver_data = self.get_cached_version_manifest(ver) + + if not ver_data: + return + + new_args, asset_index, _, _, _, _, old_args = find_useful_part_in_specific_version_manifest(ver_data) + + target_args = new_args if new_args else old_args + + if not target_args: + self.widget_signal.show_error_message.emit( + "Unable to parse arguments because data are invalid.\n" + ) + return + + arg_maps = ArgumentMappings() + game_args, jvm_args = find_game_and_jvm_args(target_args) + parsed_jvm_args = parse_and_generate_arguments( + jvm_args, + arg_maps + ) + parsed_game_args = parse_and_generate_arguments( + game_args, + arg_maps + ) + self.download_signal.log.emit(f"Parsed jvm arguments: \n{parsed_jvm_args}\n{" ".join(parsed_jvm_args)}") + self.download_signal.log.emit(f"Parsed game arguments: \n{parsed_game_args}\n{" ".join(parsed_game_args)}") + except Exception as e: + self.widget_signal.show_and_print_error_message.emit( + { + "error": traceback.format_exception(e), + "title": "Parse Error", + "message": "Unable to parse arguments. An unknown error occurred." + } + ) + finally: + self.widget_signal.enable_widget.emit(self.button_6) + + def _launch_offline_game(self, ver, java_path): + try: + ver_data = self.get_cached_version_manifest(ver) + + arguments, asset_index, downloads, java_version, libraries, main_class, old_args = ( + find_useful_part_in_specific_version_manifest(ver_data) + ) + + # Vars + asset_id = asset_index.get("id") + + # Download game files (ignore if exists) + + # Client + self._download_client(ver) + self._download_libraries(ver) + self._download_assets(ver) + + # Some paths + game_dir = Path(self.app.temp_dir, "minecraft") + game_dir.mkdir(parents=True, exist_ok=True) + natives_dir = Path(self.app.work_dir, "versions", ver, "natives") + assets_dir = Path(self.app.work_dir, "assets") + libraries_dir = Path(self.app.work_dir, "libraries") + + classpath = generate_classpath( + ver_data, + Path(self.app.work_dir, "libraries"), + Path(self.app.work_dir, "versions", ver, "client.jar"), + ) + + # Mappings + arg_maps = ArgumentMappings( + player_name="Player", + version_name=ver, + game_directory=game_dir.as_posix(), + assets_root=assets_dir.as_posix(), + assets_index_name=asset_id, + auth_uuid="00000000000000000000000000000000", + auth_access_token="0", + user_type="legacy", + version_type="release", + natives_directory=natives_dir.as_posix(), + launcher_name="TestLauncher", + launcher_version="0.1", + classpath=classpath, + classpath_separator=";", + library_directory=libraries_dir.as_posix(), + ) + + target_args = arguments if arguments else old_args + + if not target_args: + self.widget_signal.show_error_message.emit( + "Unable to parse arguments because data are invalid.\n" + ) + return + + # Finally, generate launch command + cmd = generate_launch_command( + arg_maps=arg_maps, + main_class=main_class, + java_path=java_path, + full_args=target_args, + ) + + self.download_signal.log.emit(f"Launch command: {cmd}") + + # run it + process = subprocess.Popen( + cmd, + cwd=game_dir, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + ) + + for line in process.stdout: + self.download_signal.log.emit(line.rstrip()) + except Exception as e: + self.widget_signal.show_and_print_error_message.emit( + { + "error": traceback.format_exception(e), + "title": "Launch Error", + "message": "Unable to launch error. An unknown error occurred." + } + ) + finally: + self.widget_signal.enable_widget.emit(self.button_7) + def redownload_files(self, files: list[FileObject]) -> tuple[bool, list[FileObject]]: if not files: return True, [] @@ -805,3 +1041,5 @@ class TestDialog(QDialog): ) return False, pending + +