反射名的 extern 机制(B 方案):19 处字符串反射 → 0

## 问题

Java 后端要访问 MC 的**私有字段/方法**(Minecraft.resourceManager 等),
只能走反射。而反射参数是**字符串**:

    Minecraft.class.getDeclaredField("resourceManager")

remap 工具只改常量池里的**符号引用**,**改不了字符串字面量** ⇒
把产物从 named remap 到 intermediary 时,`Xxx.class` 都对了,
`"resourceManager"` 原样留着 ⇒ 运行期 NoSuchFieldException(实测撞到过)。

## 做法:语义名 → 物理名,表由 extern 参数传入

源码写**语义路径**(跨命名空间稳定):
    McfoldNames.field(Minecraft.class, "net/minecraft/client/Minecraft/resourceManager")

- named 模式(无表):退化为取末段 ⇒ "resourceManager" ✅
- intermediary 模式:表里查 ⇒ "field_1745" ✅

⇒ **同一份 class 在两种命名空间下都能跑**,切换不必重编。
   这是「mod 要 intermediary」与「我们的产物本来是 named」能共存的**前提**。

## 表的来源(scaffold/gen_names.py)

扫源码里的语义引用 → 经两份映射复合解析:
    named ──(mappings.srg 反查)──> official ──(intermediary tiny)──> intermediary

运行时按此序找表:
  1. -Dmcfold.names=<path>          ★ 显式 extern 参数(调用方只传路径)
  2. <class 同级>/names.json
  3. 都无 ⇒ named 模式

★ 调用方(Python)**不需要知道任何 MC 名字**,只传一个路径 ——
  命名空间知识全部留在 jar 与构建产物里。这就是「接口统一、非通用数据走 extern」。

## 实测踩坑(都在 gen_names.py 里记了原因)

① 查表必须**带描述符**:同一个 obf 名(如 fjq/b)有十几个重载,
   只按 (kind, owner, name) 会取到第一个重载 ——
   症状:reloadShaders(Lala;)V 被解成 method_3207()V(无参),运行期 NoSuchMethodException。
② 字段要单建一张 (owner,name) 表:tiny 的 FIELD 行带字段类型描述符,
   而 srg 的 FD 行没有 ⇒ 拿 None 去查永远不命中(7 个字段全漏)。
③ **不能靠首字母大小猜字段/方法**,必须在 srg 里实查 kind。
④ MC 里存在**同名字段与方法**(如 ClientPacketListener.registryAccess
   既有 FD fex/B 又有 MD fex/r)⇒ 用 McfoldNames.field/method 的**调用形式**
   判定 kind,与运行期用法一致。

## 验证

- 名字表 11/11 解析成功(gen_1_20)
- 两世代后端字符串反射归零(gen_1_20 10 处、gen_1_21 9 处)
- 加载器路线端到端跑通(见后续提交)
This commit is contained in:
JianFeeeee
2026-09-26 10:17:29 +08:00
parent 652a460405
commit d4ba586ac7
2 changed files with 396 additions and 0 deletions

199
scaffold/gen_names.py Normal file
View File

@ -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=<path>`),
不需要知道任何 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: <obfOwner>/<name> <offDesc> <namedOwner>/<name> <namedDesc> (两个 desc 都是 official 形式)
mem[("METHOD", p[3], p[2])] = p[1]
elif p[0] == "FD:" and len(p) == 3:
# FD: <obfOwner>/<name> <namedPath>
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 <offOwner> <desc> <name> <intName>
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())

View File

@ -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=<path>` ← ★ 显式 extern 参数(调用方传,只传路径)
* 2. `<类自身的 code source 同级>/names.json`
* 3. 都没有 ⇒ 退化为 named 模式(末段)
*
* ★ 调用方(Python)**不需要知道任何 MC 名字** —— 它只传一个路径。
* 命名空间的知识全部留在 jar 与构建产物里。这就是「接口统一、非通用数据走 extern」。
*/
public final class McfoldNames {
private McfoldNames() {}
/** 语义路径 → 物理简单名。空表 = named 模式。 */
private static final Map<String, String> TABLE = load();
private static Map<String, String> load() {
Map<String, String> 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<String, String> 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);
}
}