From 51ee57e556b262f8ea5c739cff4dad6731a32681 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 10:18:26 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E5=88=9B=E5=BB=BA=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=88Python=20=E5=B1=82=EF=BC=89+=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E9=80=8F=E4=BC=A0=E6=96=B0=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ① mcfold/instances.py —— Python 提供「建实例」接口 用户定向(2026-09-26): > 这个文件夹内容也是 python 层创建的,而不是标准游戏目录。 > 即 python 提供创建实例的接口(含 mod/插件的实例,内部有对应目录, > 插件 mod 放在目录中就会被加载)。 所以实例是**我们定义的目录**,不是一份 MC 客户端安装: create_instance(name, version=, loader=, mods=[], force=False) → Path list_instances() → [实例摘要] find_content_root(version, loader) → 共享只读那层 - 实例内部:`instance.json`(自述)+ **mods/ shaderpacks/ resourcepacks/ datapacks/**(★ agent 的接口面,放进去即生效)+ config/ saves/ logs/ options.txt(MC/加载器自己写的运行时产物) - **不在实例里建 versions/libraries/assets** —— 那些属于内容根(只读共享)。 放进实例会让「同一版本多实例」变成多份副本,也把「实例私有/版本公有」 的边界糊掉。 - 实例名白名单 `^[A-Za-z0-9._-]{1,64}$`(它要进路径) - 已存在时**报错而非默默复用**,并提示「换名字 = 装不同 mod 的方式」 ★ 这与已有的三层口径一致(mcview/lib/versions.cjs:59/61): store/-/ · instances// · runtime// ## ② base.py 三个修正 **(a) cwd 缺省改为 game_dir** —— 版本隔离漏洞: 渲染进程的 cwd 原是仓库根,而 Sodium 等会往 cwd 写 config/ ⇒ 实测 config/sodium-mixins.properties 落进了 mcfold 仓库根。 这些产物属于**实例**。已改为 `cwd=self.cwd or self.game_dir`。 **(b) 命名空间按 client jar 形态自动判** —— 原先硬编码 `named`: 官方 obf jar + 加载器 ⇒ intermediary(mod 的 mixin 能解析) client-named.jar ⇒ named(仅裸启动可用) 实测:named 下 42 个 mod 的 464 处 mixin 全部找不到目标。 **(c) 新增 names_file 参数** —— 把 names.json 路径透给后端 (B 方案的 extern 参数在 Python 侧的可入口)。 --- mcfold/backends/base.py | 42 +++++++- mcfold/instances.py | 214 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 mcfold/instances.py diff --git a/mcfold/backends/base.py b/mcfold/backends/base.py index 8724e81..5333e98 100644 --- a/mcfold/backends/base.py +++ b/mcfold/backends/base.py @@ -102,6 +102,8 @@ class RenderBackend: startup_timeout: float = 240.0, cwd: str | None = None, mods_dir: str | None = None, + game_mapping_namespace: str = "", + names_file: str | None = None, ): self.java = java if classpath is None: @@ -116,6 +118,10 @@ class RenderBackend: self.cwd = cwd # 加载器后端的 mod 目录(launcher="fabric" 时经 -Dfabric.modsFolder 传入) self.mods_dir = mods_dir + # 命名空间:"" = 按 client jar 自动判;"named" / "intermediary" 可显式指定 + self.game_mapping_namespace = game_mapping_namespace + # extern 参数:反射名的次表(McfoldNames 读的 names.json) + self.names_file = names_file self.java_args = java_args or [ "-Xmx1200M", "-Xms512M", "-Djava.awt.headless=true", ] @@ -128,6 +134,20 @@ class RenderBackend: # ---------------- 启动架构(加载器) ---------------- + def _client_jar_is_official(self) -> bool: + """classpath 里的是**官方 obf jar**(而非 client-named.jar)吗。 + + 它决定运行时命名空间: + 官方 obf jar + Fabric ⇒ intermediary(mod 的 mixin 能命中) + client-named.jar ⇒ named(仅裸启动可用) + """ + if not self.classpath: + return False + for p in self.classpath.split(os.pathsep): + if "client-named" in p: + return False + return True + def _fabric_classpath(self, cp: str) -> str: """把普通 classpath 变成 **Fabric 加载器** 可用的那条。 @@ -182,7 +202,17 @@ class RenderBackend: main_class = "LoaderBoot" # ★ classpath 组装由后端负责(已实测必需的两步:去 asm + 加 Fabric 8 库) classpath = self._fabric_classpath(classpath) - java_args.append("-Dfabric.gameMappingNamespace=named") + # ★ 命名空间按 client jar 形态定: + # intermediary(官方 obf jar + 加载器)⇒ mod 的 mixin 能解析 + # named (client-named.jar) ⇒ 仅裸启动可用 + # 实测:named 下 42 个 mod 的 mixin 全部找不到目标(464 处)。 + if not self.game_mapping_namespace: + self.game_mapping_namespace = ( + "intermediary" if self._client_jar_is_official() else "named") + java_args.append(f"-Dfabric.gameMappingNamespace={self.game_mapping_namespace}") + # ★ extern 参数:反射名次表路径(B 方案) + if self.names_file: + java_args.append(f"-Dmcfold.names={self.names_file}") if self.mods_dir: java_args.append(f"-Dfabric.modsFolder={self.mods_dir}") cmd = [self.java, *java_args, "-cp", classpath, main_class] @@ -200,7 +230,7 @@ class RenderBackend: try: self._proc = subprocess.Popen( - cmd, cwd=self.cwd, env=env, + cmd, cwd=self.cwd or self.game_dir, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=False, bufsize=0, ) @@ -308,10 +338,15 @@ class RenderBackend: def set_view(self, **kw) -> dict: """视觉设置。各世代支持的项见子类 api_notes()。 - 世代无关的项:camera_type / render_distance / fov + 世代无关的项:camera_type / render_distance / fov / hide_gui 原版画质(所有世代都有):graphics_mode / ambient_occlusion / mipmap_levels / gamma / entity_shadows / clouds / particles 后处理链:post_effect(原版 PostChain,非第三方光影包) + + ★ hide_gui:关掉 HUD(血量/热键栏/教学提示…)。 + 截图场景下**应该关**:HUD 是噪声,而且它给 distinctColors + 掺一大块固定像素(实测污染判据)。 + 走的是官方选项 `options.hideGui`(= 游戏里 F1),不改渲染代码。 """ req: dict[str, Any] = {"cmd": "view"} mapping = { @@ -321,6 +356,7 @@ class RenderBackend: "gamma": "gamma", "entity_shadows": "entityShadows", "clouds": "clouds", "particles": "particles", "post_effect": "postEffect", + "hide_gui": "hideGui", } for py, js in mapping.items(): if py in kw and kw[py] is not None: diff --git a/mcfold/instances.py b/mcfold/instances.py new file mode 100644 index 0000000..84d5991 --- /dev/null +++ b/mcfold/instances.py @@ -0,0 +1,214 @@ +"""实例创建与列举 —— Python 层给 agent 的「建实例」接口。 + +## 归属(用户 2026-09-26 定向,重要) + +> 这个文件夹内容也是 python 层创建的,而不是标准游戏目录。 +> 即 python 提供创建实例的接口(含 mod/插件的实例,内部有对应目录, +> 插件 mod 放在目录中就会被加载)。 + +所以: + + - **实例是我们定义的目录**,不是「一份 MC 客户端安装」。 + MC 需要的 `versions/` `libraries/` `assets/` 都在**内容根**(只读共享), + 实例里只放**这个实例独占的东西**。 + - **谁来建**:Python(`create_instance`)。agent 调 Python 拿实例, + 把 mod 丢进 `mods/` 就会被加载 —— 不需要懂 MC 的目录规范。 + - **同一版本可有多个实例**(装不同 mod)⇒ 实例与内容根**同级**,绝不嵌套。 + +## 三层(权威口径见 mcview/lib/versions.cjs:59/61) + + BASE/ + store/-/ 内容根 共享只读:jar / libraries / assets / .mcfold 产物 + instances// 实例 独占:mods / shaderpacks / config / saves + runtime// 运行期 独占:X display / screenshots / logs + +## 实例内部(我们定义的约定) + + <实例>/ + instance.json 本实例自述(版本 / 世代 / 加载器 / mod 清单 / 创建时间) + mods/ ★ 放进去就会被加载(.jar) + shaderpacks/ ★ 光影包(Iris 等) + resourcepacks/ ★ 资源包 + datapacks/ ★ 数据包 + config/ 加载器/mod 的配置产出 + saves/ 世界存档 + options.txt 游戏选项 + logs/ 运行日志 + +★ 前四项是**我们给 agent 的接口面**(放进去即生效); + 其余是 MC/加载器自己会写的运行时产物。 +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import time +from pathlib import Path +from typing import Any + +# 实例名白名单:它要进路径,不能含分隔符或空白 +NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") + +# 实例内部布局(★ 前四项是 agent 的接口面) +INSTANCE_DIRS = ( + "mods", # ★ + "shaderpacks", # ★ + "resourcepacks", # ★ + "datapacks", # ★ + "config", + "saves", + "logs", +) + +# 内容根候选(与 instance.py 的 DEFAULT_CONTENT_ROOTS 一致) +_CONTENT_ROOTS = ( + "/home/newqqagent/mcview/store", + "/home/newqqagent/plugindev/mcfold/store", +) + + +def instance_root(root: str | os.PathLike[str] | None = None) -> Path: + """实例层的根。缺省与内容根**同级**(绝不嵌套进内容根)。""" + if root: + return Path(root) + base = os.environ.get("MCVIEW_BASE") + if base: + return Path(base) / "instances" + return Path(_CONTENT_ROOTS[0]).parent / "instances" + + +def find_content_root( + version: str | None = None, + loader: str = "vanilla", + roots: tuple[str, ...] = _CONTENT_ROOTS, +) -> Path | None: + """按 (loader, version) 定位内容根(共享只读的那一层)。""" + for r in roots: + rp = Path(r) + if not rp.is_dir(): + continue + if version: + for cand in (rp / f"{loader}-{version}", rp / f"vanilla-{version}"): + if cand.is_dir(): + return cand + subs = sorted(p for p in rp.iterdir() + if p.is_dir() and (p / "libraries").is_dir()) + if subs: + return subs[0] + return None + + +def create_instance( + name: str, + *, + version: str | None = None, + loader: str = "vanilla", + content_root: str | os.PathLike[str] | None = None, + root: str | os.PathLike[str] | None = None, + mods: list[str] | None = None, + force: bool = False, +) -> Path: + """创建一个实例,返回实例目录。**这是 Python 层给 agent 的接口。** + + 参数 + ---- + name 实例名(`^[A-Za-z0-9._-]{1,64}$`) + version MC 版本,如 "1.20.1";缺省从内容根名推断 + loader vanilla / fabric / forge / neoforge + content_root 内容根;缺省按 (loader, version) 在 store 下找 + root 实例层根;缺省 `instance_root()` + mods 要装进实例的 mod jar 路径(**放进去就会被加载**) + force 已存在时是否覆盖(缺省报错,不默默复用) + + ★ 这里**不**在实例里创建 versions/libraries/assets: + 那些属于内容根(只读共享)。放进实例会让「同一版本多实例」 + 变成多份副本,也把「实例私有 / 版本公有」的边界糊掉。 + """ + if not NAME_RE.match(name): + raise ValueError( + f"实例名不合法: {name!r}(只允许字母数字 . _ -,长度 1..64;" + f"它要进路径,不能含分隔符或空白)") + + inst_root = instance_root(root) + path = inst_root / name + if path.exists() and not force: + raise FileExistsError( + f"实例已存在: {path}\n" + f" · 要覆盖请传 force=True\n" + f" · 或换个名字 —— 同一个版本可以有多个实例," + f" 这正是「装不同 mod」的方式") + + cr = Path(content_root) if content_root else find_content_root(version, loader) + if cr is not None and not cr.is_dir(): + raise FileNotFoundError(f"内容根不存在: {cr}") + ver = version or _version_of(cr) + + path.mkdir(parents=True, exist_ok=True) + for d in INSTANCE_DIRS: + (path / d).mkdir(exist_ok=True) + + copied: list[str] = [] + if mods: + for m in mods: + src = Path(m) + if not src.is_file(): + raise FileNotFoundError(f"mod 不存在: {src}") + dst = path / "mods" / src.name + if src.resolve() != dst.resolve(): + shutil.copy2(src, dst) + copied.append(src.name) + + meta = { + "name": name, + "version": ver, + "loader": loader, + "content_root": str(cr) if cr else None, + "created_at": time.strftime("%Y-%m-%d %H:%M:%S"), + "mods": copied, + "layout": "mcfold-instance/1", + "note": "mods/ 与 shaderpacks/ 等目录放进去就会被加载;" + "versions/libraries/assets 在内容根(只读共享)", + } + (path / "instance.json").write_text( + json.dumps(meta, indent=2, ensure_ascii=False) + "\n") + (path / "options.txt").touch(exist_ok=True) + return path + + +def _version_of(content_root: Path | None) -> str | None: + """store/vanilla-1.20.1 → "1.20.1" """ + if not content_root: + return None + n = content_root.name + return n.split("-", 1)[1] if "-" in n else None + + +def list_instances(root: str | os.PathLike[str] | None = None) -> list[dict[str, Any]]: + """列出实例(给 agent 看:每个实例什么版本、装了哪些 mod)。""" + r = instance_root(root) + out: list[dict[str, Any]] = [] + if not r.is_dir(): + return out + for p in sorted(r.iterdir()): + if not p.is_dir(): + continue + meta: dict[str, Any] = {} + mf = p / "instance.json" + if mf.is_file(): + try: + meta = json.loads(mf.read_text()) + except Exception: + meta = {} + mods_dir = p / "mods" + out.append({ + "name": p.name, + "path": str(p), + "version": meta.get("version"), + "loader": meta.get("loader"), + "mods": sorted(f.name for f in mods_dir.glob("*.jar")) + if mods_dir.is_dir() else [], + "has_meta": bool(meta), + }) + return out