#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ MeowLauncher - 一个使用纯 Python 标准库实现的 Minecraft 启动器 功能特性: - 自动获取官方版本清单并下载任意版本 - 自动下载游戏依赖库 (libraries) 与原生库 (natives) - 自动下载资源文件 (assets) - 支持离线账号与正版账号 (预留 Yggdrasil 接口) - 跨平台: Windows / Linux / macOS - 零第三方依赖, 仅使用 Python 标准库 用法示例: python3 launcher.py --username Steve --version 1.20.4 python3 launcher.py --username Steve --version latest-release --max-mem 4096 python3 launcher.py --list-versions """ import argparse import hashlib import json import os import platform import re import shutil import subprocess import sys import urllib.error import urllib.request import zipfile from pathlib import Path # --------------------------------------------------------------------------- # 常量 # --------------------------------------------------------------------------- VERSION_MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json" USER_AGENT = "Mozilla/5.0 (MeowLauncher/1.0)" # 操作系统名称映射 (Mojang 规则中的 os.name) OS_NAME = {"Windows": "windows", "Linux": "linux", "Darwin": "osx"}.get(platform.system(), "linux") # 架构映射: 兼容新旧两套规则 _ARCH = platform.machine().lower() if _ARCH in ("x86_64", "amd64"): ARCH_NEW = "x86_64" # 新版 (1.13+) 使用的 arch 值 ARCH_OLD = "64" # 旧版使用的 arch 值 elif _ARCH in ("aarch64", "arm64"): ARCH_NEW = "arm64" ARCH_OLD = "arm64" else: ARCH_NEW = "x86" ARCH_OLD = "32" # 原生库分类器 (Mojang 命名) NATIVES_CLASSIFIER = { "linux": "natives-linux", "osx": "natives-osx", "windows": "natives-windows", } def log(msg: str) -> None: """简单的彩色日志输出。""" print(msg, flush=True) def sha1_of(path: Path) -> str: """计算文件的 SHA1 校验值。""" h = hashlib.sha1() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() def http_get(url: str) -> bytes: """GET 请求并返回字节内容。""" req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) with urllib.request.urlopen(req, timeout=30) as resp: return resp.read() def download_file(url: str, dest: Path, sha1: str = None, size: int = None) -> bool: """ 下载文件到指定路径。 - 若目标已存在且校验通过 (或未提供校验值), 则跳过。 - 下载后校验 SHA1, 失败则删除并抛异常。 返回 True 表示本次实际下载, False 表示已存在跳过。 """ dest.parent.mkdir(parents=True, exist_ok=True) if dest.exists(): ok = True if sha1: ok = sha1_of(dest) == sha1 if ok: return False dest.unlink() tmp = dest.with_suffix(dest.suffix + ".tmp") req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) try: with urllib.request.urlopen(req, timeout=60) as resp, open(tmp, "wb") as f: shutil.copyfileobj(resp, f, 1024 * 1024) except Exception: if tmp.exists(): tmp.unlink() raise if sha1 and sha1_of(tmp) != sha1: tmp.unlink() raise RuntimeError(f"SHA1 校验失败: {url}") if size and tmp.stat().st_size != size: tmp.unlink() raise RuntimeError(f"文件大小不符: {url}") tmp.rename(dest) return True def rule_allowed(rule: dict) -> bool: """判断 Mojang 下载规则 (rules) 是否允许当前平台。""" if rule.get("action") == "allow": default = False else: # disallow default = True os_rule = rule.get("os", {}) if "name" in os_rule and os_rule["name"] != OS_NAME: return default if "arch" in os_rule: arch = os_rule["arch"] if arch not in (ARCH_NEW, ARCH_OLD): return default return not default def library_allowed(lib: dict) -> bool: """判断某个库是否允许当前平台下载。""" rules = lib.get("rules") if not rules: return True return all(rule_allowed(r) for r in rules) # --------------------------------------------------------------------------- # Minecraft 启动器 # --------------------------------------------------------------------------- class MinecraftLauncher: def __init__( self, game_dir: str, version_id: str, username: str, java_path: str = None, min_mem: int = 1024, max_mem: int = 2048, auth: dict = None, offline: bool = True, ): self.game_dir = Path(game_dir).resolve() self.version_id = version_id self.username = username or "Steve" self.java_path = java_path self.min_mem = min_mem self.max_mem = max_mem self.auth = auth or {} # 正版登录信息: {access_token, uuid, name} self.offline = offline # 版本相关目录 self.versions_dir = self.game_dir / "versions" self.assets_dir = self.game_dir / "assets" self.libraries_dir = self.game_dir / "libraries" # 版本信息 (解析后填充) self.version_meta = None self.version_url = None self.asset_index = None self.client_jar = None # ------------------------------------------------------------------ # 版本解析 # ------------------------------------------------------------------ def fetch_version_manifest(self) -> dict: """获取官方版本清单。""" data = json.loads(http_get(VERSION_MANIFEST_URL).decode("utf-8")) return data def resolve_version(self) -> None: """ 解析版本号 -> 版本元数据。 支持: latest-release / latest-snapshot / 任意版本 id。 """ manifest = self.fetch_version_manifest() latest = manifest.get("latest", {}) versions = manifest.get("versions", []) if self.version_id == "latest-release": self.version_id = latest.get("release") elif self.version_id == "latest-snapshot": self.version_id = latest.get("snapshot") entry = next((v for v in versions if v["id"] == self.version_id), None) if not entry: raise RuntimeError(f"找不到版本: {self.version_id}") self.version_url = entry["url"] self.version_meta = json.loads(http_get(self.version_url).decode("utf-8")) log(f"[版本] 已解析: {self.version_meta.get('id')} " f"(release={self.version_meta.get('type')}, " f"java={self.version_meta.get('javaVersion', {}).get('majorVersion', '?')})") # ------------------------------------------------------------------ # 下载: 客户端 / 库 / 资源 # ------------------------------------------------------------------ def download_client(self) -> Path: """下载客户端 jar 文件。""" vdir = self.versions_dir / self.version_id vdir.mkdir(parents=True, exist_ok=True) jar = vdir / f"{self.version_id}.jar" dl = self.version_meta.get("downloads", {}).get("client") if dl: download_file(dl["url"], jar, dl.get("sha1"), dl.get("size")) else: raise RuntimeError("版本清单中缺少 client 下载项") self.client_jar = jar log(f"[客户端] {jar.name} 就绪 ({jar.stat().st_size // 1024} KB)") return jar def _download_library_artifact(self, artifact: dict, lib_path: Path) -> None: """下载单个库文件。""" if not artifact: return download_file( artifact["url"], lib_path, artifact.get("sha1"), artifact.get("size"), ) def download_libraries(self) -> list: """下载所有依赖库, 返回 classpath 列表。""" cp = [] for lib in self.version_meta.get("libraries", []): if not library_allowed(lib): continue downloads = lib.get("downloads", {}) artifact = downloads.get("artifact") # 处理原生库 (natives) classifier = NATIVES_CLASSIFIER.get(OS_NAME) native = None natives_entry = downloads.get("classifiers", {}).get(classifier) if classifier else None if artifact: path = Path(lib["name"].replace(":", "/").replace("@", "/")) path = self.libraries_dir / path self._download_library_artifact(artifact, path) cp.append(str(path)) if natives_entry: path = Path(lib["name"].replace(":", "/").replace("@", "/")) path = self.libraries_dir / path self._download_library_artifact(natives_entry, path) native = path if native: self._extract_natives(native) log(f"[库] 依赖库准备完成, classpath 共 {len(cp)} 项") return cp def _extract_natives(self, native_jar: Path) -> None: """解压原生库到 versions//natives 目录。""" natives_dir = self.versions_dir / self.version_id / "natives" natives_dir.mkdir(parents=True, exist_ok=True) ext_map = {"linux": ".so", "osx": ".dylib", "windows": ".dll"} ext = ext_map[OS_NAME] with zipfile.ZipFile(native_jar) as zf: for name in zf.namelist(): # 跳过 META-INF 与源码/javadoc if name.startswith("META-INF") or not name.endswith(ext): continue target = natives_dir / Path(name).name with zf.open(name) as src, open(target, "wb") as dst: shutil.copyfileobj(src, dst) def download_assets(self) -> None: """下载资源索引与全部资源对象。""" index_info = self.version_meta.get("assetIndex") if not index_info: log("[资源] 该版本无资源索引, 跳过") return index_file = self.assets_dir / "indexes" / f"{index_info['id']}.json" download_file(index_info["url"], index_file, index_info.get("sha1"), index_info.get("size")) index = json.loads(index_file.read_text(encoding="utf-8")) objects = index.get("objects", {}) self.asset_index = index_info["id"] total = len(objects) done = 0 for key, obj in objects.items(): h = obj["hash"] dest = self.assets_dir / "objects" / h[:2] / h url = f"https://resources.download.minecraft.net/{h[:2]}/{h}" try: download_file(url, dest, h, obj.get("size")) except Exception as e: log(f" [警告] 资源下载失败 {key}: {e}") done += 1 if done % 200 == 0: log(f" [资源] {done}/{total}") log(f"[资源] 资源文件就绪 ({total} 项)") # ------------------------------------------------------------------ # 启动参数 # ------------------------------------------------------------------ def _resolve_argument(self, arg: str) -> str: """替换启动参数中的占位符。""" uuid = self._offline_uuid() replace_map = { "${auth_player_name}": self.auth.get("name") or self.username, "${auth_session}": self.auth.get("access_token", "0"), "${auth_access_token}": self.auth.get("access_token", "0"), "${auth_uuid}": self.auth.get("uuid") or uuid, "${version_name}": self.version_id, "${version_type}": self.version_meta.get("type", "release"), "${game_directory}": str(self.game_dir), "${assets_root}": str(self.assets_dir), "${assets_index_name}": self.asset_index or self.version_id, "${user_properties}": "{}", "${user_type}": "msa" if not self.offline else "legacy", "${resolution_width}": "854", "${resolution_height}": "480", "${launcher_name}": "MeowLauncher", "${launcher_version}": "1.0", "${natives_directory}": str(self.versions_dir / self.version_id / "natives"), "${classpath}": os.pathsep.join([str(self.client_jar)] + self._cp), "${library_directory}": str(self.libraries_dir), } for k, v in replace_map.items(): arg = arg.replace(k, v) return arg def _offline_uuid(self) -> str: """根据离线用户名生成稳定的 UUID (与官方离线模式一致)。""" name = self.auth.get("name") or self.username digest = hashlib.md5(f"OfflinePlayer:{name}".encode("utf-8")).digest() digest = bytearray(digest) digest[6] = (digest[6] & 0x0F) | 0x30 # version 3 digest[8] = (digest[8] & 0x3F) | 0x80 # variant hex_str = digest.hex() return f"{hex_str[0:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}" def build_command(self, classpath: list) -> list: """构建完整的 java 启动命令。""" self._cp = classpath java = self._find_java() jvm_args, game_args = self._build_args() command = [java] command += ["-Xms{}M".format(self.min_mem), "-Xmx{}M".format(self.max_mem)] command += ["-Djava.library.path={}".format(self.versions_dir / self.version_id / "natives")] command += ["-Dminecraft.launcher.brand=MeowLauncher", "-Dminecraft.launcher.version=1.0"] command += ["-cp", os.pathsep.join([str(self.client_jar)] + classpath)] command += jvm_args command += ["net.minecraft.client.main.Main"] command += game_args return command def _build_args(self) -> tuple: """拆分并解析 jvm 参数与游戏参数 (兼容新旧版本格式)。""" jvm_args = [] game_args = [] arguments = self.version_meta.get("arguments") if arguments: # 1.13+ 新格式 for item in arguments.get("jvm", []): if isinstance(item, str): jvm_args.append(self._resolve_argument(item)) elif isinstance(item, dict): if all(rule_allowed(r) for r in item.get("rules", [])): for v in item.get("value", []): jvm_args.append(self._resolve_argument(v) if isinstance(v, str) else str(v)) for item in arguments.get("game", []): if isinstance(item, str): game_args.append(self._resolve_argument(item)) elif isinstance(item, dict): if all(rule_allowed(r) for r in item.get("rules", [])): for v in item.get("value", []): game_args.append(self._resolve_argument(v) if isinstance(v, str) else str(v)) else: # 1.12 及以前旧格式 legacy = self.version_meta.get("minecraftArguments", "") game_args = [self._resolve_argument(a) for a in legacy.split(" ")] return jvm_args, game_args def _find_java(self) -> str: """查找 Java 可执行文件: 优先用户指定, 其次 JAVA_HOME, 最后 PATH。""" if self.java_path and os.path.isfile(self.java_path): return self.java_path java_home = os.environ.get("JAVA_HOME") if java_home: cand = Path(java_home) / "bin" / ("java.exe" if OS_NAME == "windows" else "java") if cand.exists(): return str(cand) for cand in ("java", "java.exe"): found = shutil.which(cand) if found: return found raise RuntimeError( "未找到 Java, 请使用 --java 参数指定 java 可执行文件路径 " "(Minecraft 1.17+ 需要 Java 17+)" ) # ------------------------------------------------------------------ # 主流程 # ------------------------------------------------------------------ def run(self) -> int: """完整流程: 解析 -> 下载 -> 启动。""" log(f"[启动] 游戏目录: {self.game_dir}") log(f"[启动] 版本: {self.version_id} 玩家: {self.username}") self.resolve_version() self.download_client() classpath = self.download_libraries() self.download_assets() # 保存版本元数据到本地 (供后续复用) meta_file = self.versions_dir / self.version_id / f"{self.version_id}.json" meta_file.write_text(json.dumps(self.version_meta, indent=2), encoding="utf-8") command = self.build_command(classpath) log("[启动] 启动命令:") log(" " + " ".join(command[:8]) + " ...") log("[启动] 正在启动 Minecraft ...") return subprocess.call(command, cwd=str(self.game_dir)) # ------------------------------------------------------------------ # 辅助功能 # ------------------------------------------------------------------ def list_versions(self) -> None: """列出可用的发布版与快照版。""" manifest = self.fetch_version_manifest() latest = manifest.get("latest", {}) log(f"最新发布版: {latest.get('release')} 最新快照版: {latest.get('snapshot')}") log("-" * 40) for v in manifest.get("versions", []): marker = " *" if v["id"] in latest.values() else "" log(f" {v['id']:<20} {v['type']:<10}{marker}") # --------------------------------------------------------------------------- # 入口 # --------------------------------------------------------------------------- def main() -> int: parser = argparse.ArgumentParser( description="MeowLauncher - 纯 Python 实现的 Minecraft 启动器", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--version", default="latest-release", help="游戏版本, 如 1.20.4 / latest-release / latest-snapshot") parser.add_argument("--username", default="Steve", help="玩家名 (离线模式)") parser.add_argument("--game-dir", default=None, help="游戏目录 (默认 ~/.minecraft)") parser.add_argument("--java", default=None, help="Java 可执行文件路径") parser.add_argument("--min-mem", type=int, default=1024, help="最小内存 MB (默认 1024)") parser.add_argument("--max-mem", type=int, default=2048, help="最大内存 MB (默认 2048)") parser.add_argument("--list-versions", action="store_true", help="列出可用版本") args = parser.parse_args() if args.list_versions: launcher = MinecraftLauncher( game_dir=args.game_dir or str(Path.home() / ".minecraft"), version_id=args.version, username=args.username, ) launcher.list_versions() return 0 launcher = MinecraftLauncher( game_dir=args.game_dir or str(Path.home() / ".minecraft"), version_id=args.version, username=args.username, java_path=args.java, min_mem=args.min_mem, max_mem=args.max_mem, offline=True, ) try: return launcher.run() except KeyboardInterrupt: log("\n[退出] 用户中断") return 130 except Exception as e: log(f"[错误] {e}") return 1 if __name__ == "__main__": sys.exit(main())