P8: scaffold —— 反混淆 + MC 渲染栈离屏初始化 + 真渲染出图

实测链路(全绿):
  1. 官方 ProGuard 映射 → SRG → SpecialSource 重映射 → client-named.jar
  2. 反编译确认 MC 渲染器挂在 Minecraft 单例上,不能独立 new
  3. 权威序列:tryDetectVersion → Bootstrap → RenderSystem → new Minecraft(cfg)
  4. 渲 3 帧 + glReadPixels → PNG,画面是 Mojang 加载屏(真渲染,非清屏)

三个坑(实测证据):
  - 漏 tryDetectVersion → Game version not set
  - FolderData 参数顺序错 → icons/ 404
  - assetIndex 用 id(5) 而非落盘名(1.20.1) → 资源全找不到

下一步:ClientLevel 构造只需 listener.registryAccess(),
ClientChunkCache.replaceWithPacketData 接收 map_chunk 原始字节
(与 chunk.py 同格式)⇒ 这就是数据喂渲染器的落点。
This commit is contained in:
JianFeeeee
2026-09-25 15:39:41 +08:00
parent 5038c7f679
commit e223be2571
6 changed files with 472 additions and 0 deletions

21
scaffold/GlProbe.java Normal file
View File

@ -0,0 +1,21 @@
import org.lwjgl.glfw.*;
import org.lwjgl.opengl.*;
import static org.lwjgl.glfw.GLFW.*;
import static org.lwjgl.opengl.GL11.*;
import static org.lwjgl.system.MemoryUtil.NULL;
public class GlProbe {
public static void main(String[] a) {
GLFWErrorCallback.createPrint(System.err).set();
if (!glfwInit()) { System.out.println("FAIL: glfwInit"); return; }
glfwDefaultWindowHints();
glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE);
long win = glfwCreateWindow(320, 240, "probe", NULL, NULL);
if (win == NULL) { System.out.println("FAIL: createWindow"); return; }
glfwMakeContextCurrent(win);
GL.createCapabilities();
System.out.println("OK gl=" + glGetString(GL_VERSION));
System.out.println("OK renderer=" + glGetString(GL_RENDERER));
glfwDestroyWindow(win); glfwTerminate();
}
}

75
scaffold/README.md Normal file
View File

@ -0,0 +1,75 @@
# mcfold scaffold —— 用官方客户端渲染,但不跑客户端
**目标**(用户 2026-09-25 定向):不起完整客户端,把 MC 的渲染能力抽出来,
用我们自己的世界数据驱动它出图。
## 实测结论(2026-09-25 全绿)
客户端 jar 是 **ProGuard 全量混淆**的(`LevelRenderer` 叫 `fjv`),
所以「抽渲染器」的第一步必须是**反混淆**。三层关卡全部验证通过:
| 关卡 | 证据 | 结果 |
|---|---|---|
| 反混淆 | `client-named.jar` 25MB;`javap` 读出 `net.minecraft.client.renderer.LevelRenderer` | ✅ |
| 编译期引用 | `javac` 编译引用 `Minecraft`/`LevelRenderer` 的代码 | ✅ |
| GL 离屏 | Xvfb + llvmpipe → OpenGL 4.5 core | ✅ |
| **渲染栈初始化** | `new Minecraft(cfg)` 完成 GL/资源/渲染器初始化,**不进主循环** | ✅ |
| **真渲染出图** | 渲 3 帧 + 读像素 → PNG,画面是 Mojang 加载屏(logo 贴图 + 进度条) | ✅ |
## 为什么渲染器不能独立 new
反编译(Vineflower)给出的实测签名:
```java
LevelRenderer(Minecraft, EntityRenderDispatcher, BlockEntityRenderDispatcher, RenderBuffers)
renderLevel(PoseStack, float, long, boolean, Camera, GameRenderer, LightTexture, Matrix4f)
```
全部挂在 `Minecraft` 单例上 ⇒ **不能** `new LevelRenderer()` 独立用。
正确形态是「初始化 MC 渲染栈,但不跑主循环」。
## 权威序列(反编译 `net.minecraft.client.main.Main` 得到)
```java
SharedConstants.tryDetectVersion(); // ← 漏了会 "Game version not set"
SharedConstants.enableDataFixerOptimizations();
Bootstrap.bootStrap();
RenderSystem.initRenderThread();
RenderSystem.beginInitialization();
Minecraft mc = new Minecraft(gameConfig); // ← 构造即完成渲染栈初始化
RenderSystem.finishInitialization();
// 此处官方会起 Game thread 跑主循环 —— 我们不做
```
渲染一帧(反编译 `Minecraft.render()` 得到):
```java
RenderSystem.clear(16640, ON_OSX);
mainRenderTarget.bindWrite(true);
gameRenderer.render(partialTick, nanos, renderLevel);
mainRenderTarget.unbindWrite();
mainRenderTarget.blitToScreen(w, h);
window.updateDisplay();
```
## 踩过的三个坑(都有实测证据)
1. **漏 `tryDetectVersion()`** → `DataFixers.<clinit>` 抛 `Game version not set`
2. **`FolderData` 参数顺序**
`(gameDirectory, resourcePackDirectory, assetDirectory, assetIndex)`
—— 顺序写错会让 `icons/icon_16x16.png` 404
3. **assetIndex 名要用落盘名**
version.json 里 `assetIndex.id = "5"`,但磁盘是 `assets/indexes/1.20.1.json`
(MCLC 按 `version.number` 落盘)—— 传 `"5"` 则索引加载失败、资源全找不到
## 下一步:渲染真实世界
`ClientLevel` 构造只需 `listener.registryAccess()`(其余字段不用 listener),
且 `ClientChunkCache.replaceWithPacketData(...)` 接收的正是 **`map_chunk` 包的原始字节**
—— 与 mcfold `chunk.py` 解码器同一格式。这就是设计文档说的桥梁落点。
## 构建
```bash
./build.sh /path/to/store/vanilla-1.20.1 1.20.1
```

105
scaffold/RenderProbe.java Normal file
View File

@ -0,0 +1,105 @@
import com.mojang.blaze3d.pipeline.MainTarget;
import com.mojang.blaze3d.platform.DisplayData;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.authlib.properties.PropertyMap;
import net.minecraft.SharedConstants;
import net.minecraft.client.Minecraft;
import net.minecraft.client.User;
import net.minecraft.client.main.GameConfig;
import net.minecraft.server.Bootstrap;
import org.lwjgl.glfw.GLFW;
import org.lwjgl.opengl.GL11;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.net.Proxy;
import java.nio.ByteBuffer;
import java.util.Optional;
import java.util.OptionalInt;
/**
* mcfold render probe —— 起 MC 渲染栈,渲一帧,读像素写 PNG。
* 全程:不连服、不登录、不进主循环、不 gameplay。
*/
public class RenderProbe {
public static void main(String[] args) throws Exception {
String gameDir = arg(args, "--gameDir", "/home/newqqagent/mcview/instances/default");
String assets = arg(args, "--assets", "/home/newqqagent/mcview/store/vanilla-1.20.1/assets");
String assetIdx = arg(args, "--assetIndex", "1.20.1");
String out = arg(args, "--out", "/tmp/scaffold-frame.png");
int width = Integer.parseInt(arg(args, "--width", "854"));
int height = Integer.parseInt(arg(args, "--height", "480"));
SharedConstants.tryDetectVersion();
SharedConstants.enableDataFixerOptimizations();
Bootstrap.bootStrap();
File runDir = new File(gameDir);
User user = new User("mcfold_cam", "0".repeat(32), "mcfold_cam",
Optional.empty(), Optional.empty(), User.Type.LEGACY);
PropertyMap props = new PropertyMap();
GameConfig cfg = new GameConfig(
new GameConfig.UserData(user, props, props, Proxy.NO_PROXY),
new DisplayData(width, height, OptionalInt.empty(), OptionalInt.empty(), false),
new GameConfig.FolderData(runDir, new File(runDir, "resourcepacks"), new File(assets), assetIdx),
new GameConfig.GameData(false, "mcfold", "release", false, false),
new GameConfig.QuickPlayData(null, null, null, null)
);
RenderSystem.initRenderThread();
RenderSystem.beginInitialization();
Minecraft mc = new Minecraft(cfg);
RenderSystem.finishInitialization();
System.out.println("[probe] constructed. w=" + mc.getWindow().getWidth()
+ " h=" + mc.getWindow().getHeight());
// ★ 渲染一帧:照抄 Minecraft.render() 的实测序列(反编译 nout2/.../Minecraft.java:1110-1140)
MainTarget target = (MainTarget) mc.getMainRenderTarget();
for (int frame = 0; frame < 3; frame++) {
RenderSystem.clear(16640, Minecraft.ON_OSX);
target.bindWrite(true);
mc.gameRenderer.render(0.0F, 0L, true);
target.unbindWrite();
target.blitToScreen(mc.getWindow().getWidth(), mc.getWindow().getHeight());
mc.getWindow().updateDisplay();
System.out.println("[probe] frame " + frame + " rendered");
}
// ★ 读像素:从 GL 后台缓冲直接读(比截屏更可靠,且不依赖 X)
GL11.glReadBuffer(GL11.GL_BACK);
int w = mc.getWindow().getWidth(), h = mc.getWindow().getHeight();
ByteBuffer buf = org.lwjgl.BufferUtils.createByteBuffer(w * h * 4);
GL11.glReadPixels(0, 0, w, h, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, buf);
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
// GL 原点在左下,PNG 在左上,需翻转
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int i = ((h - 1 - y) * w + x) * 4;
int r = buf.get(i) & 0xFF, g = buf.get(i + 1) & 0xFF, b = buf.get(i + 2) & 0xFF;
img.setRGB(x, y, (r << 16) | (g << 8) | b);
}
}
ImageIO.write(img, "png", new File(out));
System.out.println("[probe] wrote " + out + " (" + w + "x" + h + ")");
// 统计非黑像素,证明真的画了东西(而非空缓冲)
long nonBlack = 0; int total = w * h;
for (int p : img.getRGB(0, 0, w, h, null, 0, w)) if ((p & 0xFFFFFF) != 0) nonBlack++;
System.out.println("[probe] non-black pixels = " + nonBlack + "/" + total
+ " (" + (100.0 * nonBlack / total) + "%)");
GLFW.glfwTerminate();
System.out.println("[probe] DONE");
System.exit(0);
}
private static String arg(String[] a, String key, String def) {
for (int i = 0; i < a.length - 1; i++) if (a[i].equals(key)) return a[i + 1];
return def;
}
}

91
scaffold/Scaffold.java Normal file
View File

@ -0,0 +1,91 @@
import com.mojang.blaze3d.platform.DisplayData;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.authlib.properties.PropertyMap;
import net.minecraft.SharedConstants;
import net.minecraft.client.Minecraft;
import net.minecraft.client.User;
import net.minecraft.client.main.GameConfig;
import net.minecraft.server.Bootstrap;
import java.io.File;
import java.net.Proxy;
import java.util.OptionalInt;
/**
* mcfold scaffold —— 只起 MC 的**渲染栈**,不进游戏主循环。
*
* 依据(反编译 net.minecraft.client.main.Main 的实测序列):
* Bootstrap.bootStrap()
* RenderSystem.initRenderThread(); beginInitialization();
* new Minecraft(gameConfig) ← 构造即完成 GL/资源/渲染器初始化
* RenderSystem.finishInitialization()
* 之后 Main 才起 Game thread 跑主循环 —— 我们**不**走那步。
*/
public class Scaffold {
public static void main(String[] args) throws Exception {
String gameDir = arg(args, "--gameDir", "/home/newqqagent/mcview/instances/default");
String assets = arg(args, "--assets", "/home/newqqagent/mcview/store/vanilla-1.20.1/assets");
// ★ 索引名必须是 assets/indexes/<name>.json 中真实存在的那个。
// version.json 的 assetIndex.id 是 "5",但 MCLC 落盘用 version.number(1.20.1)
// —— 传 "5" 会导致索引加载失败,所有资源/icons 全部找不到(实测踩过)。
String assetIdx = arg(args, "--assetIndex", "1.20.1");
int width = Integer.parseInt(arg(args, "--width", "1280"));
int height = Integer.parseInt(arg(args, "--height", "720"));
System.out.println("[scaffold] bootstrap...");
// ★ 官方 Main 的实测序列:tryDetectVersion 必须在 bootStrap 之前,
// 否则 DataFixers.<clinit> 抛 "Game version not set"
SharedConstants.tryDetectVersion();
SharedConstants.enableDataFixerOptimizations();
Bootstrap.bootStrap();
System.out.println("[scaffold] bootstrap ok, mcVersion=" + SharedConstants.getCurrentVersion().getName());
File runDir = new File(gameDir);
File assetsDir = new File(assets);
User user = new User("mcfold_cam", "0".repeat(32), "mcfold_cam",
java.util.Optional.empty(), java.util.Optional.empty(), User.Type.LEGACY);
PropertyMap props = new PropertyMap();
GameConfig cfg = new GameConfig(
new GameConfig.UserData(user, props, props, Proxy.NO_PROXY),
new DisplayData(width, height, OptionalInt.empty(), OptionalInt.empty(), false),
new GameConfig.FolderData(
// ★ 顺序是 (gameDirectory, resourcePackDirectory, assetDirectory, assetIndex)
// 反着写会把 assets 当游戏目录,导致资源全找不到(实测踩过:icons/icon_16x16.png 404)
runDir, new File(runDir, "resourcepacks"), assetsDir, assetIdx),
new GameConfig.GameData(
false, // demo
"mcfold", // version
"release", // versionType
false, // disableMultiplayer
false), // disableChat
new GameConfig.QuickPlayData(null, null, null, null)
);
System.out.println("[scaffold] RenderSystem init...");
RenderSystem.initRenderThread();
RenderSystem.beginInitialization();
System.out.println("[scaffold] new Minecraft(cfg) ...");
Minecraft mc = new Minecraft(cfg);
RenderSystem.finishInitialization();
System.out.println("[scaffold] OK Minecraft constructed");
// 检查渲染栈关键对象是否就绪(不启主循环)
System.out.println("[scaffold] window=" + mc.getWindow().getWidth() + "x" + mc.getWindow().getHeight());
System.out.println("[scaffold] levelRenderer=" + (mc.levelRenderer != null ? "present" : "null"));
System.out.println("[scaffold] gameRenderer=" + (mc.gameRenderer != null ? "present" : "null"));
System.out.println("[scaffold] level=" + (mc.level != null ? "present" : "null (не connected)"));
System.out.println("[scaffold] DONE (no main loop)");
}
private static String arg(String[] a, String key, String def) {
for (int i = 0; i < a.length - 1; i++)
if (a[i].equals(key)) return a[i + 1];
return def;
}
}

76
scaffold/build.sh Executable file
View File

@ -0,0 +1,76 @@
#!/usr/bin/env bash
# mcfold scaffold 构建脚本 —— 把「官方客户端 jar」变成「可直接调用的渲染库」
#
# 实测链路(2026-09-25 全绿):
# 1. 下官方 ProGuard 映射(client_mappings.txt)
# 2. 转成 SRG(SpecialSource 只吃 SRG)
# 3. 重映射 client.jar → client-named.jar(类名可读,javap/javac 可用)
# 4. 编译脚手架
#
# 用法: ./build.sh [实例store目录] [MC版本]
set -euo pipefail
STORE="${1:-/home/newqqagent/mcview/store/vanilla-1.20.1}"
MCVER="${2:-1.20.1}"
HERE="$(cd "$(dirname "$0")" && pwd)"
WORK="${MCFOLD_SCAFFOLD_WORK:-/tmp/mcfold-scaffold}"
mkdir -p "$WORK"
cd "$WORK"
CLIENT_JAR="$STORE/versions/$MCVER/$MCVER.jar"
[ -f "$CLIENT_JAR" ] || { echo "找不到客户端 jar: $CLIENT_JAR"; exit 1; }
# ── 1. 映射文件 ────────────────────────────────────────────────
MAPPINGS_URL=$(python3 - "$STORE/versions/$MCVER/$MCVER.json" <<'PY'
import json,sys
d=json.load(open(sys.argv[1]))
print(d['downloads']['client_mappings']['url'])
PY
)
if [ ! -s client_mappings.txt ]; then
echo "[1/4] 下载官方映射..."
curl -sL -o client_mappings.txt "$MAPPINGS_URL"
fi
echo "[1/4] 映射: $(wc -l < client_mappings.txt) 行"
# ── 2. ProGuard → SRG ─────────────────────────────────────────
echo "[2/4] 转换映射格式..."
python3 "$HERE/pg2srg.py" client_mappings.txt mappings.srg
# ── 3. 重映射 jar ─────────────────────────────────────────────
if [ ! -f client-named.jar ] || [ "$CLIENT_JAR" -nt client-named.jar ]; then
echo "[3/4] 重映射客户端 jar(约 1-2 分钟)..."
SS=$(find /home/minecraft/libraries -name "SpecialSource-*.jar" 2>/dev/null | head -1)
[ -n "$SS" ] || { echo "找不到 SpecialSource"; exit 1; }
GUAVA=$(find /home/minecraft/libraries -name "guava-*.jar" ! -name "*sources*" 2>/dev/null | head -1)
for a in asm asm-commons asm-tree; do
[ -f "$a.jar" ] || curl -sL -o "$a.jar" "https://repo1.maven.org/maven2/org/ow2/asm/$a/9.7/$a-9.7.jar"
done
[ -f joptsimple.jar ] || curl -sL -o joptsimple.jar \
"https://repo1.maven.org/maven2/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"
java -Xmx3G -cp "$SS:joptsimple.jar:asm.jar:asm-commons.jar:asm-tree.jar:$GUAVA" \
net.md_5.specialsource.SpecialSource \
-i "$CLIENT_JAR" -o client-named.jar -m mappings.srg
else
echo "[3/4] client-named.jar 已是最新"
fi
ls -la client-named.jar
# ── classpath ─────────────────────────────────────────────────
find "$STORE/libraries" -name "*.jar" ! -name "*-sources.jar" ! -name "*natives*" 2>/dev/null \
| grep -vE "minecraftforge|neoforge" > cp.txt
CP="$(paste -sd: cp.txt):$WORK/client-named.jar"
# ── 4. 编译脚手架 ─────────────────────────────────────────────
echo "[4/4] 编译脚手架..."
mkdir -p classes
javac -nowarn -proc:none -cp "$CP" -d classes "$HERE"/*.java
echo
echo "✅ 构建完成"
echo " named jar : $WORK/client-named.jar"
echo " classes : $WORK/classes"
echo
echo "运行(无头,需 Xvfb):"
echo " find $STORE/libraries -name '*natives-linux*.jar' | while read j; do unzip -o -q \$j -d $WORK/nativ; done"
echo " DISPLAY=:98 java -cp \"$CP:$WORK/classes\" -Djava.library.path=$WORK/nativ RenderProbe"

104
scaffold/pg2srg.py Normal file
View File

@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""ProGuard mappings (Mojang官方) -> SRG (SpecialSource 可读)
ProGuard 格式:
net.minecraft.client.Minecraft -> enn:
187:307:void render(...) -> a
org.slf4j.Logger LOGGER -> c
SRG 格式:
CL: enn net/minecraft/client/Minecraft
MD: enn/a (FJZ)V net/minecraft/client/Minecraft/render (FJZ)V
FD: fjv/c net/minecraft/client/renderer/LevelRenderer/LOGGER
"""
import re, sys
PRIM = {'void':'V','boolean':'Z','byte':'B','char':'C','short':'S',
'int':'I','long':'J','float':'F','double':'D'}
def main(pg_path, out_path):
classes = {} # named -> obf
class_lines = [] # (obf, named)
members = [] # ('MD'|'FD', obfOwner, obfName, namedOwner, namedName, [namedDesc])
cur_named = cur_obf = None
class_re = re.compile(r'^(\S+) -> (\S+):$')
meth_re = re.compile(r'^\s*\d+:\d+:(.*) -> (\S+)$')
field_re = re.compile(r'^\s+(\S+)\s+(\S+) -> (\S+)$')
with open(pg_path, encoding='utf-8') as f:
for line in f:
line = line.rstrip('\n')
if not line or line.startswith('#'):
continue
m = class_re.match(line)
if m:
cur_named, cur_obf = m.group(1), m.group(2).rstrip(':')
classes[cur_named] = cur_obf
class_lines.append((cur_obf, cur_named))
continue
if cur_obf is None:
continue
# ★ ProGuard 的方法行「可以没有」行号前缀(如 `Foo acquire() -> a`),
# 所以判据是「-> 左侧以 ) 结尾」而不是「行首有 N:M:」。靠行号判会漏成字段。
m = re.match(r'^\s*(?:\d+:\d+:)?(.*?)\s+->\s+(\S+)$', line)
if not m:
continue
sig, obf_name = m.group(1).strip(), m.group(2)
if sig.endswith(')'):
members.append(('MD', cur_obf, obf_name, cur_named, sig))
else:
# 字段:"type name"
parts = sig.rsplit(' ', 1)
if len(parts) != 2:
continue
ftype, fname = parts[0], parts[1]
members.append(('FD', cur_obf, obf_name, cur_named, ftype + ' ' + fname))
def to_obf_desc(t):
"""named 类型 -> obf JVM 描述符"""
t = t.strip()
if t in PRIM:
return PRIM[t]
if t.endswith('[]'):
return '[' + to_obf_desc(t[:-2])
# 内部类在 ProGuard 里用 $ 写,obf 侧也是 $ 形式(已包含在 classes 里)
obf = classes.get(t)
if obf is None:
# 可能是混淆后的内部类写法差异,或非 MC 类(java/*, netty 等)-> 转 /
return 'L' + t.replace('.', '/') + ';'
return 'L' + obf + ';'
def split_params(s):
"""按顶层逗号切分(泛型里不会有逗号,ProGuard 已擦除泛型)"""
if not s.strip():
return []
return [p.strip() for p in s.split(',')]
with open(out_path, 'w', encoding='utf-8') as out:
for obf, named in class_lines:
out.write(f'CL: {obf} {named.replace(".", "/")}\n')
for kind, obf_owner, obf_name, named_owner, payload in members:
named_owner_s = named_owner.replace('.', '/')
if kind == 'FD':
ftype, fname = payload.split(' ', 1)
out.write(f'FD: {obf_owner}/{obf_name} {named_owner_s}/{fname}\n')
else:
# payload = "retType name(args)"
mm = re.match(r'^(.*?)\s+([\w$<>]+)\((.*)\)$', payload)
if not mm:
continue
ret, name, argstr = mm.group(1), mm.group(2), mm.group(3)
try:
params = split_params(argstr)
obf_desc = '(' + ''.join(to_obf_desc(p) for p in params) + ')' + to_obf_desc(ret)
except Exception:
continue
out.write(f'MD: {obf_owner}/{obf_name} {obf_desc} '
f'{named_owner_s}/{name} {obf_desc}\n')
sys.stderr.write(f'CL={len(class_lines)} members={len(members)}\n')
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])