反射名的 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:
199
scaffold/gen_names.py
Normal file
199
scaffold/gen_names.py
Normal 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())
|
||||
197
scaffold/java/common/mcfold/McfoldNames.java
Normal file
197
scaffold/java/common/mcfold/McfoldNames.java
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user