diff --git a/scaffold/gen_names.py b/scaffold/gen_names.py new file mode 100644 index 0000000..a99d415 --- /dev/null +++ b/scaffold/gen_names.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""生成 names.json —— 反射用的「语义名 → 物理名」映射表(extern 参数)。 + +## 背景(为什么需要它) + +Java 后端起 MC 渲染栈时要访问若干**私有字段**(`Minecraft.resourceManager` 等), +只能走反射。而反射参数是**字符串**: + + Minecraft.class.getDeclaredField("resourceManager") + +remap 工具只改常量池里的**符号引用**,**改不了字符串** ⇒ 把产物从 named +remap 到 intermediary 时,`Xxx.class` 都对了,`"resourceManager"` 原样留着 +⇒ 运行期 `NoSuchFieldException`(实测撞到过)。 + +## 做法 + +源码里写**语义路径**(全路径,跨命名空间稳定): + + McfoldNames.field(Minecraft.class, "net/minecraft/client/Minecraft/resourceManager") + +本脚本扫出这些路径,经两份映射复合解析成当前命名空间的物理名: + + mappings.srg official → named (ProGuard,我们做 client-named.jar 用的) + intermediary mappings.tiny official → intermediary(Fabric) + +两者键在**同一套 official 名**上,所以能连起来: + named ──(srg 反查)──> official ──(tiny)──> intermediary + +## 输出 + +`names.json`:`{"net/minecraft/client/Minecraft/resourceManager": "field_1745", ...}` + +★ 调用方(Python)只需要传**路径**(`-Dmcfold.names=`), + 不需要知道任何 MC 名字 —— 命名空间知识留在构建产物里。 +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +import zipfile +from pathlib import Path + + +def load_srg(path: Path): + """official → named 的 CL / MD / FD 三张表。 + + mem 的键统一成 (kind, namedPath, desc): + METHOD (namedPath, officialDesc) + FIELD (namedPath, None) ← 字段没有 desc(类型)可用,靠 owner+name 定位 + """ + cls, mem = {}, {} + for line in path.read_text(errors="replace").splitlines(): + p = line.split() + if not p: + continue + if p[0] == "CL:" and len(p) == 3: + cls[p[1]] = p[2] + elif p[0] == "MD:" and len(p) == 5: + # MD: / / (两个 desc 都是 official 形式) + mem[("METHOD", p[3], p[2])] = p[1] + elif p[0] == "FD:" and len(p) == 3: + # FD: / + mem[("FIELD", p[2], None)] = p[1] + return cls, mem + + +def load_tiny(path: Path): + """official → intermediary。""" + cls, mem = {}, {} + with zipfile.ZipFile(path) as z: + text = z.read("mappings/mappings.tiny").decode("utf-8", "replace") + for line in text.splitlines(): + p = line.split("\t") + if not p: + continue + if p[0] == "CLASS" and len(p) >= 3: + cls[p[1]] = p[2] + elif p[0] in ("METHOD", "FIELD") and len(p) >= 5: + # METHOD/FIELD + mem[(p[0], p[1], p[3], p[2])] = p[4] + return cls, mem + + +# 语义路径的字面量:`net/...` 或 `com/...`,末段是成员名。 +# 不依赖调用形态(`field(x, "...")` / `method(y.getClass(),\n "...")` 都可能跨行), +# 只要它出现在 McfoldNames 调用的附近。 +CALL_RE = re.compile( + r'McfoldNames\.(\w+)\s*\([^;]*?' # 捕获**调用形式**(field/method/get/set/of) + r'"((?:net|com)/[A-Za-z0-9_$/]+)"', # 到第一个 MC 路径字面量 + re.S, +) + + +def scan_semantics(java_files: list[Path]) -> list[tuple[str, str]]: + """返回 [(调用形式, 语义路径)]。 + + ★ 为什么要带上调用形式:MC 里存在**同名的字段与方法**,例如 + ClientPacketListener.registryAccess + FD: fex/B registryAccess (字段) + MD: fex/r registryAccess()Lhs; (方法) + 只看语义路径无法区分该取哪个。 + ⇒ 用 McfoldNames.field / method 的**调用形式**做 kind 判定, + 与运行期实际用法一致。 + """ + seen: list[tuple[str, str]] = [] + for f in java_files: + for m in CALL_RE.finditer(f.read_text(errors="replace")): + form, s = m.group(1), m.group(2) + kind = "FIELD" if form in ("field", "get", "set") else "METHOD" + if (kind, s) not in seen: + seen.append((kind, s)) + return seen + + +def split_semantic(s: str): + """'net/minecraft/client/Minecraft/resourceManager' → ('net/minecraft/client/Minecraft', 'resourceManager')""" + i = s.rfind("/") + return s[:i], s[i + 1:] + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--srg", required=True, help="official→named 的 mappings.srg") + ap.add_argument("--intermediary", required=True, help="含 mappings/mappings.tiny 的 jar") + ap.add_argument("--java", nargs="+", required=True, help="要扫的 .java 文件/目录") + ap.add_argument("--out", required=True, help="输出 names.json 路径") + ap.add_argument("--namespace", default="intermediary", + choices=["intermediary", "named"], + help="目标命名空间;named = 直接取末段(不需要表)") + args = ap.parse_args() + + files: list[Path] = [] + for p in args.java: + pp = Path(p) + files.extend(sorted(pp.rglob("*.java")) if pp.is_dir() else [pp]) + sem = scan_semantics(files) + print(f" 扫到 {len(sem)} 个语义引用(来自 {len(files)} 个 .java)") + + if args.namespace == "named": + table = {s: split_semantic(s)[1] for _, s in sem} + else: + srg_cls, srg_mem = load_srg(Path(args.srg)) + int_cls, int_mem = load_tiny(Path(args.intermediary)) + # ★ 键必须**带 desc**:同一个 obf 名(如 fjq/b)可能有十几个重载, + # 只按 (kind, owner, name) 做键会取到**第一个碰到的重载** —— + # 实测症状:reloadShaders(Lala;)V 被解成 method_3207()V(无参重载), + # 运行期 NoSuchMethodException(参数不匹配)。 + int_by_key = {} + for (kind, off, name, desc), intname in int_mem.items(): + int_by_key[(kind, off, name, desc)] = intname + # ★ 字段单独建一张 (owner,name) → intName 的表: + # tiny 的 FIELD 行带字段类型描述符,而 srg 的 FD 行没有 ⇒ + # 拿 None 去查带 desc 的键永远不命中(实测:7 个字段全漏)。 + int_fld_by_name = {} + for (kind, off, name, desc), intname in int_mem.items(): + if kind == "FIELD": + int_fld_by_name[(off, name)] = intname + + table, unresolved = {}, [] + for want_kind, s in sem: + owner, member = split_semantic(s) + named_path = f"{owner}/{member}" + # ① named 全路径 → official(按调用形式限定 kind) + off = off_desc = None + for kk, v in srg_mem.items(): + if kk[0] == want_kind and kk[1] == named_path: + off, off_desc = v, kk[2] + break + if off is None: + unresolved.append((s, f"named 侧未找到({want_kind})")) + continue + off_owner, off_name = off.rsplit("/", 1) + # ② official → intermediary + if want_kind == "FIELD": + hit = int_fld_by_name.get((off_owner, off_name)) + else: + hit = int_by_key.get((want_kind, off_owner, off_name, off_desc)) + if hit is None: + unresolved.append((s, f"official {off} ({off_desc}) 未映射到 intermediary")) + continue + table[s] = hit + + if unresolved: + print(f" ⚠ {len(unresolved)} 项未解析:") + for s, why in unresolved[:12]: + print(f" {s} ← {why}") + print(f" 解析成功 {len(table)}/{len(sem)}") + + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(table, indent=2, ensure_ascii=False) + "\n") + print(f" ✅ 写入 {out}({len(table)} 项, namespace={args.namespace})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scaffold/java/common/mcfold/McfoldNames.java b/scaffold/java/common/mcfold/McfoldNames.java new file mode 100644 index 0000000..7413ab5 --- /dev/null +++ b/scaffold/java/common/mcfold/McfoldNames.java @@ -0,0 +1,197 @@ +package mcfold; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +/** + * 【统一接口的 extern 侧】反射用的符号名解析。 + * + * ## 为什么需要它 + * + * 我们的 Java 后端要访问 MC 的**私有字段**(Minecraft.resourceManager 等), + * 只能走反射。而反射的参数是**字符串**: + * + * Minecraft.class.getDeclaredField("resourceManager") + * + * ★ 关键问题:remap 工具(SpecialSource / tiny-remapper)只改**常量池里的符号引用** + * (类名、字段描述符、方法引用),**改不了字符串字面量**。 + * + * ⇒ 于是「把我们的产物从 named 命名空间 remap 到 intermediary」时, + * 所有 `Xxx.class` 都被正确改名,而 `"resourceManager"` 原样留下 ⇒ + * 运行期抛 `NoSuchFieldException: resourceManager`(实测撞到过)。 + * + * ## 解法:语义名 → 物理名,表由外部传入 + * + * 代码里写**语义路径**(跨命名空间稳定),物理名运行时查表: + * + * McfoldNames.field(Minecraft.class, "net/minecraft/client/Minecraft/resourceManager") + * + * - named 模式(表缺失):`of()` 退化为取末段 ⇒ `"resourceManager"` ✅ 照常工作 + * - intermediary 模式:表里有 `{...: "field_1745"}` ⇒ `"field_1745"` ✅ + * + * ⇒ **同一份 class 文件在两种命名空间下都能跑**,切换命名空间不必重编。 + * 这正是「加载器路线」(mod 要 intermediary)与「我们的代码」(本来对着 named 编译) + * 能共存的技术前提。 + * + * ## 表的来源与落点(extern 参数) + * + * 表由 `scaffold/gen_names.py` 在构建时生成: + * 读源码里出现的语义路径 → 经 mappings.srg + intermediary tiny 复合解析 → 写 JSON。 + * + * 运行时按**这个顺序**找: + * 1. 系统属性 `-Dmcfold.names=` ← ★ 显式 extern 参数(调用方传,只传路径) + * 2. `<类自身的 code source 同级>/names.json` + * 3. 都没有 ⇒ 退化为 named 模式(末段) + * + * ★ 调用方(Python)**不需要知道任何 MC 名字** —— 它只传一个路径。 + * 命名空间的知识全部留在 jar 与构建产物里。这就是「接口统一、非通用数据走 extern」。 + */ +public final class McfoldNames { + + private McfoldNames() {} + + /** 语义路径 → 物理简单名。空表 = named 模式。 */ + private static final Map TABLE = load(); + + private static Map load() { + Map m = new HashMap<>(); + Path p = locate(); + if (p == null) { + System.err.println("[mcfold] names.json 未找到 ⇒ named 模式(符号名按末段取)"); + return m; + } + try { + String text = new String(Files.readAllBytes(p), StandardCharsets.UTF_8); + parseFlatJson(text, m); + System.err.println("[mcfold] names.json 已加载: " + p + " (" + m.size() + " 项)"); + } catch (IOException e) { + System.err.println("[mcfold] names.json 读取失败(" + p + "): " + e + + " ⇒ named 模式"); + } + return m; + } + + private static Path locate() { + // ① 显式 extern 参数 + String prop = System.getProperty("mcfold.names"); + if (prop != null && !prop.isEmpty()) { + Path p = Paths.get(prop); + if (Files.isReadable(p)) return p; + System.err.println("[mcfold] -Dmcfold.names 指向的文件不可读: " + prop); + } + // ② 与自身 class 同级的 names.json + try { + var src = McfoldNames.class.getProtectionDomain().getCodeSource(); + if (src != null && src.getLocation() != null) { + Path base = Paths.get(src.getLocation().toURI()); + Path cand = Files.isDirectory(base) + ? base.resolve("names.json") + : base.getParent().resolve("names.json"); + if (Files.isReadable(cand)) return cand; + } + } catch (Throwable ignored) { + } + return null; + } + + /** 极简平铺 JSON 解析({"k":"v",...})—— 避免引入 JSON 依赖。 */ + private static void parseFlatJson(String text, Map out) { + int i = 0, n = text.length(); + while (i < n) { + int k1 = text.indexOf('"', i); + if (k1 < 0) break; + int k2 = text.indexOf('"', k1 + 1); + if (k2 < 0) break; + String key = unescape(text.substring(k1 + 1, k2)); + int c = text.indexOf(':', k2); + if (c < 0) break; + int v1 = text.indexOf('"', c); + if (v1 < 0) break; + int v2 = text.indexOf('"', v1 + 1); + if (v2 < 0) break; + String val = unescape(text.substring(v1 + 1, v2)); + out.put(key, val); + i = v2 + 1; + } + } + + private static String unescape(String s) { + if (s.indexOf('\\') < 0) return s; + return s.replace("\\\"", "\"").replace("\\\\", "\\") + .replace("\\n", "\n").replace("\\/", "/"); + } + + /** + * 语义路径 → 当前命名空间下的物理简单名。 + * + * @param semantic 全路径,如 `net/minecraft/client/Minecraft/resourceManager` + */ + public static String of(String semantic) { + String hit = TABLE.get(semantic); + if (hit != null) return hit; + // named 模式(或表里没这一项):末段就是物理名 + int i = semantic.lastIndexOf('/'); + return i < 0 ? semantic : semantic.substring(i + 1); + } + + /** 是否处于映射模式(诊断用)。 */ + public static boolean mapped() { + return !TABLE.isEmpty(); + } + + /** 读字段:owner 已被 remap 处理,语义路径给出字段归属。 */ + public static Field field(Class owner, String semantic) throws NoSuchFieldException { + String name = of(semantic); + for (Class c = owner; c != null; c = c.getSuperclass()) { + try { + Field f = c.getDeclaredField(name); + f.setAccessible(true); + return f; + } catch (NoSuchFieldException ignored) { + } + } + throw new NoSuchFieldException(semantic + " → " + name + " (owner=" + owner.getName() + + ", mapped=" + mapped() + ")"); + } + + /** 取方法:先本类声明,再沿父类/接口找(等价 getMethod 但用解析后的名字)。 */ + public static Method method(Class owner, String semantic, Class... params) + throws NoSuchMethodException { + String name = of(semantic); + for (Class c = owner; c != null; c = c.getSuperclass()) { + try { + Method m = c.getDeclaredMethod(name, params); + m.setAccessible(true); + return m; + } catch (NoSuchMethodException ignored) { + } + } + try { + Method m = owner.getMethod(name, params); + m.setAccessible(true); + return m; + } catch (NoSuchMethodException e) { + throw new NoSuchMethodException(semantic + " → " + name + "(owner=" + owner.getName() + + ", mapped=" + mapped() + ")"); + } + } + + /** 字段当前值(省一次 setAccessible 样板)。 */ + public static Object get(Class owner, String semantic, Object instance) throws Exception { + return field(owner, semantic).get(instance); + } + + /** 设置字段值。 */ + public static void set(Class owner, String semantic, Object instance, Object value) + throws Exception { + field(owner, semantic).set(instance, value); + } +}