P8 完成:世界数据 → 官方渲染器 → 真实出图

不起完整客户端、不连服、不登录,只初始化 MC 渲染栈并喂入我们自己造的世界数据。

已验证(全绿):
  countRenderedChunks=29  distinct colors=1450
  画面:草地/原木/树叶/天空/HUD/英文文字(sample-world-render.png)

打通的关卡:
  - 反混淆(client-named.jar)+ GL 离屏 + new Minecraft(cfg) 不进主循环
  - 资源同步重载:22 listener(ModelManager/FontManager/Dispatcher)
    ★ 不能 join reloadResourcePacks(等主循环→死锁),直接调各 listener reload()
  - 注册表从客户端自带 data/ 加载(dimension_type=4)
  - 区块注入:LevelChunk + setBlockState → ClientChunkCache.Storage.chunks
    + LightEngine.queueSectionData(全亮)→ allChanged()
  - 每帧 mc.tick() 推进网格重建队列(不跑则 countRenderedChunks=0)

踩坑记录(10 条,README 详列):
  LoadingOverlay/ReceivingLevelScreen 遮挡、FolderData 参数序、
  assetIndex 用落盘名、reloadShaders 包私有、读像素必须绑 FBO、
  判据用 distinct colors 而非 non-black

更正附录 D:state_id=1003 → note_block 不是误映射(note_block 有 800 状态)
  —— 真客户端给完整状态,是 1.20.1 上 mod 方块语义的唯一权威来源
This commit is contained in:
JianFeeeee
2026-09-25 17:01:49 +08:00
parent e223be2571
commit 541fe232a8
8 changed files with 499 additions and 241 deletions

View File

@ -1,21 +0,0 @@
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();
}
}

View File

@ -6,67 +6,102 @@
## 实测结论(2026-09-25 全绿)
客户端 jar 是 **ProGuard 全量混淆**的(`LevelRenderer` 叫 `fjv`),
所以「抽渲染器」的第一步必须是**反混淆**。三层关卡全部验证通过:
所以「抽渲染器」的第一步必须是**反混淆**。全部关卡已验证:
| 关卡 | 证据 | 结果 |
|---|---|---|
| 反混淆 | `client-named.jar` 25MB;`javap` 读出 `net.minecraft.client.renderer.LevelRenderer` | ✅ |
| 编译期引用 | `javac` 编译引用 `Minecraft`/`LevelRenderer` 的代码 | ✅ |
| 反混淆 | `client-named.jar`;`javap` 读出 `LevelRenderer` | ✅ |
| GL 离屏 | Xvfb + llvmpipe → OpenGL 4.5 core | ✅ |
| **渲染栈初始化** | `new Minecraft(cfg)` 完成 GL/资源/渲染器初始化,**不进主循环** | ✅ |
| **真渲染出图** | 渲 3 帧 + 读像素 → PNG,画面是 Mojang 加载屏(logo 贴图 + 进度条) | ✅ |
| 渲染栈初始化 | `new Minecraft(cfg)` 完成初始化,**不进主循环** | ✅ |
| 资源重载 | 22 个 listener;ModelManager/FontManager/Dispatcher 全 ok | ✅ |
| 注册表 | 客户端自带 `data/` → dimension_type=4 | ✅ |
| 区块注入 | 81 chunks → `countRenderedChunks=29` | ✅ |
| **真实世界渲染** | 草地+原木+树叶+天空+HUD 文字,`distinct colors=1450` | ✅ |
见 `sample-world-render.png`。
## 为什么渲染器不能独立 new
反编译(Vineflower)给出的实测签名:
反编译(Vineflower)实测签名:
```java
LevelRenderer(Minecraft, EntityRenderDispatcher, BlockEntityRenderDispatcher, RenderBuffers)
renderLevel(PoseStack, float, long, boolean, Camera, GameRenderer, LightTexture, Matrix4f)
```
全部挂在 `Minecraft` 单例上 ⇒ **不能** `new LevelRenderer()` 独立用。
全挂在 `Minecraft` 单例上 ⇒ **不能** `new LevelRenderer()` 独立用。
正确形态是「初始化 MC 渲染栈,但不跑主循环」。
## 权威序列(反编译 `net.minecraft.client.main.Main` 得到)
## 权威序列
初始化(反编译 `net.minecraft.client.main.Main`):
```java
SharedConstants.tryDetectVersion(); // ← 漏了会 "Game version not set"
SharedConstants.tryDetectVersion(); // 漏了 → "Game version not set"
SharedConstants.enableDataFixerOptimizations();
Bootstrap.bootStrap();
RenderSystem.initRenderThread();
RenderSystem.beginInitialization();
Minecraft mc = new Minecraft(gameConfig); // ← 构造即完成渲染栈初始化
Minecraft mc = new Minecraft(gameConfig);
RenderSystem.finishInitialization();
// 此处官方会起 Game thread 跑主循环 —— 我们不做
```
渲染一帧(反编译 `Minecraft.render()` 得到):
渲染一帧(反编译 `Minecraft.render()`):
```java
RenderSystem.clear(16640, ON_OSX);
mainRenderTarget.bindWrite(true);
gameRenderer.render(partialTick, nanos, renderLevel);
gameRenderer.render(partialTick, nanos, true);
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"` 则索引加载失败、资源全找不到
2. **`FolderData` 参数顺序** = `(gameDirectory, resourcePackDirectory, assetDirectory, assetIndex)`
—— 写错则 `icons/icon_16x16.png` 404
3. **assetIndex 要用落盘名**(`1.20.1`)而非 `version.json` 的 `assetIndex.id`(`5`)
—— MCLC 按 `version.number` 落盘
4. **`LoadingOverlay` 必须摘** → 否则永远渲出 Mojang 加载屏
5. **`ReceivingLevelScreen`("Joining world...")必须清** → 否则客户端认为未进世界
6. **必须跑 `mc.tick()`** → 区块网格重建队列靠 tick 推进(不跑则 `countRenderedChunks=0`)
7. **`reloadResourcePacks()` 不可 join**(等主循环 → 死锁);
正解是**直接调各 listener 的 public `reload()`**,barrier 同步返回、
apply 用 `Runnable::run`(主线程即渲染线程)
8. **`GameRenderer.reloadShaders` 包私有** → 同包 bridge 调用
9. **读像素必须绑 FBO**(`glBindFramebuffer(target.frameBufferId)` + `GL_COLOR_ATTACHMENT0`);
`bindRead()` 路径实测读到的是残留缓冲
10. **判据要锐利**:`non-black=100%` 会被红底加载屏骗过;
`distinct colors` 才区分得出「加载屏(199) vs 世界(1450)」
## 下一步:渲染真实世界
## 注入世界数据
`ClientLevel` 构造只需 `listener.registryAccess()`(其余字段不用 listener),
且 `ClientChunkCache.replaceWithPacketData(...)` 接收的正是 **`map_chunk` 包的原始字节**
—— 与 mcfold `chunk.py` 解码器同一格式。这就是设计文档说的桥梁落点。
```java
LevelChunk chunk = new LevelChunk(level, new ChunkPos(cx, cz));
chunk.setBlockState(new BlockPos(lx, y, lz), Blocks.GRASS_BLOCK.defaultBlockState(), false);
// ★ 放进 ClientChunkCache.Storage.chunks(反射 getIndex)
// ★ 光照:LightEngine.queueSectionData(SKY/BLOCK, SectionPos, DataLayer 全15)
// ★ 触发重建:levelRenderer.allChanged() + 每帧 mc.tick()
```
**与 mcfold Python 客户端的接口**:`ClientChunkCache.replaceWithPacketData(...)`
接收的正是 `map_chunk` 包原始字节 —— **Python 客户端收到的就是它**,
可零转换直通;或用 Python 解码后的方块写入 `LevelChunk`。
## 注册表:state id ↔ 方块名(mod 支持的关键)
```java
BuiltInRegistries.BLOCK.size() // 1003
Block.BLOCK_STATE_REGISTRY.size() // 24135
Block.stateById(1003) // note_block[instrument=xylophone,note=7,powered=false]
BuiltInRegistries.BLOCK.getKey(state.getBlock())
```
★ **更正附录 D 的一个错误结论**:`1003 → note_block` **不是**静默误映射
—— note_block 有 800 个状态,1003 真的落在其范围内。真客户端给的是**完整状态**
(`instrument/note/powered`),这正是 1.20.1 上 mod 方块语义的唯一权威来源。
## 构建

View File

@ -0,0 +1,82 @@
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.core.registries.BuiltInRegistries;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
import java.io.File;
import java.net.Proxy;
import java.util.Optional;
import java.util.OptionalInt;
/**
* 证明:真客户端的注册表能给出 state id ↔ 方块名的权威映射。
*
* 这正是不起完整客户端、只用客户端注册表的价值所在:
* 1.20.1 不从网络下发方块表(附录D),mod 方块的 state id 只有客户端注册表知道。
*/
public class RegistryProbe {
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");
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(854, 480, 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("[reg] Minecraft constructed");
// ★ 核心:客户端的方块注册表
int blockCount = BuiltInRegistries.BLOCK.size();
int stateCount = Block.BLOCK_STATE_REGISTRY.size();
System.out.println("[reg] BLOCK registry size = " + blockCount);
System.out.println("[reg] BLOCK_STATE registry size = " + stateCount);
// ★ 关键验证:mineflayer 会静默误映射的那些越界 id,客户端怎么答
int[] probes = {0, 1, 1002, 1003, 1502, 30000, stateCount - 1};
for (int id : probes) {
BlockState st = Block.stateById(id);
ResourceLocation key = BuiltInRegistries.BLOCK.getKey(st.getBlock());
System.out.println("[reg] state_id=" + id + " -> " + key + " [" + st + "]");
}
// ★ 列出几个方块名(证明可读)
System.out.println("[reg] sample blocks:");
for (int i = 0; i < 5; i++) {
ResourceLocation k = BuiltInRegistries.BLOCK.getKey(BuiltInRegistries.BLOCK.byId(i));
System.out.println(" id=" + i + " -> " + k);
}
System.out.println("[reg] 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;
}
}

View File

@ -1,105 +0,0 @@
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;
}
}

View File

@ -1,91 +0,0 @@
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;
}
}

344
scaffold/WorldRender.java Normal file
View File

@ -0,0 +1,344 @@
import com.mojang.blaze3d.pipeline.MainTarget;
import com.mojang.blaze3d.platform.DisplayData;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.authlib.GameProfile;
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.client.multiplayer.ClientLevel;
import net.minecraft.client.multiplayer.ClientPacketListener;
import net.minecraft.client.telemetry.TelemetryEventSender;
import net.minecraft.client.telemetry.WorldSessionTelemetryManager;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.Connection;
import net.minecraft.network.protocol.PacketFlow;
import net.minecraft.resources.RegistryDataLoader;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.util.profiling.InactiveProfiler;
import net.minecraft.world.Difficulty;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.dimension.DimensionType;
import org.lwjgl.opengl.GL11;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.net.Proxy;
import java.time.Duration;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.UUID;
/**
* 世界渲染器 —— 把世界数据喂给真客户端渲染器并出图。
*
* 实测要点(2026-09-25):
* 1. 不跑主循环 ⇒ 渲染栈手动初始化(tryDetectVersion/Bootstrap/RenderSystem/new Minecraft)
* 2. 注册表从客户端自带 data/ 加载(RegistryDataLoader + vanilla pack)
* 3. shader 需显式编译(GameRenderer.reloadShaders 包私有 → 同包 bridge)
* 4. LoadingOverlay 必须摘掉,否则永远渲出加载屏
* 5. 区块靠 LevelChunk + setBlockState 注入 + setSectionDirty 触发网格重建
* 6. 读像素必须绑 FBO(glBindFramebuffer(frameBufferId)),GL_BACK 读到的是残留
*/
public class WorldRender {
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-world.png");
int W = Integer.parseInt(arg(args, "--width", "854"));
int H = 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(W, H, 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("[wr] Minecraft ok");
// ── 资源:字体/贴图/模型 ──
// createReload 的 future 不该在主线程 join(会死锁),但字体加载是同步的,
// 这里用官方 FontManager.reload 直接同步加载字体(修「文字成方框」)
// ★ 资源重载:modelManager/fontManager/textureManager 全是 reload listener,
// 不跑重载则方块模型/字体/贴图全空(地形渲不出来)。
// 驱动方式照抄 LoadingOverlay:轮询 isDone(),不能 join(会死锁)。
reloadResources(mc);
System.out.println("[wr] resources reloaded");
// ── listener ──
Connection conn = new Connection(PacketFlow.CLIENTBOUND);
GameProfile profile = new GameProfile(UUID.randomUUID(), "mcfold_cam");
WorldSessionTelemetryManager telemetry = new WorldSessionTelemetryManager(
TelemetryEventSender.DISABLED, false, Duration.ZERO, "");
ClientPacketListener listener = new ClientPacketListener(mc, null, conn, null, profile, telemetry);
// ── 注册表:从客户端自带 data/ 加载 ──
var packs = net.minecraft.server.packs.repository.ServerPacksSource.createPackRepository(
new File(runDir, "datapacks").toPath());
packs.reload();
ResourceManager dataRm = new net.minecraft.server.packs.resources.MultiPackResourceManager(
net.minecraft.server.packs.PackType.SERVER_DATA,
java.util.List.of(packs.getPack("vanilla").open()));
java.util.List<RegistryDataLoader.RegistryData<?>> all = new java.util.ArrayList<>();
all.addAll(RegistryDataLoader.WORLDGEN_REGISTRIES);
all.addAll(RegistryDataLoader.DIMENSION_REGISTRIES);
RegistryAccess.Frozen loaded = RegistryDataLoader.load(dataRm, listener.registryAccess(), all);
var layered = net.minecraft.client.multiplayer.ClientRegistryLayer.createRegistryAccess()
.replaceFrom(net.minecraft.client.multiplayer.ClientRegistryLayer.REMOTE,
new RegistryAccess.Frozen[]{loaded});
java.lang.reflect.Field rf = ClientPacketListener.class.getDeclaredField("registryAccess");
rf.setAccessible(true);
rf.set(listener, layered);
System.out.println("[wr] registries loaded: dimension_type="
+ loaded.registryOrThrow(Registries.DIMENSION_TYPE).size());
Holder<DimensionType> dimHolder = loaded.registryOrThrow(Registries.DIMENSION_TYPE)
.getHolderOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, new ResourceLocation("overworld")));
// ── ClientLevel ──
var levelData = new ClientLevel.ClientLevelData(Difficulty.NORMAL, false, false);
ClientLevel level = new ClientLevel(listener, levelData, Level.OVERWORLD, dimHolder,
8, 8, () -> InactiveProfiler.INSTANCE, mc.levelRenderer, false, 0L);
mc.setLevel(level);
var player = new net.minecraft.client.player.LocalPlayer(mc, level, listener,
new net.minecraft.stats.StatsCounter(), new net.minecraft.client.ClientRecipeBook(), false, false);
// 站在区块中心上方
player.setPos(8.0, 70.0, 8.0);
mc.player = player;
mc.setCameraEntity(player);
mc.gameMode = new net.minecraft.client.multiplayer.MultiPlayerGameMode(mc, listener);
System.out.println("[wr] level + player ok");
// ── shader 编译(包私有,走同包 bridge)──
net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager());
System.out.println("[wr] shaders ok: " + (RenderSystem.getShader() != null));
// ── LoadingOverlay 必须摘掉 ──
mc.setOverlay(null);
// ★ "Joining world..." 是 ReceivingLevelScreen;不清它客户端始终认为未进世界
mc.setScreen(null);
// ── 注入区块 ──
int injected = injectWorld(level, mc);
System.out.println("[wr] injected chunks=" + injected);
// ── 第三人称(跳过 renderItemInHand)──
mc.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK);
// ── 渲染(每帧先 tick:区块网格重建队列靠 tick 推进)──
MainTarget target = (MainTarget) mc.getMainRenderTarget();
for (int f = 0; f < 60; f++) {
try { mc.tick(); } catch (Throwable t) { if (f == 0) System.out.println("[wr] tick err: " + t); }
RenderSystem.clear(16640, Minecraft.ON_OSX);
target.bindWrite(true);
mc.gameRenderer.render(0.0F, (long) f * 16_000_000L, true);
target.unbindWrite();
target.blitToScreen(mc.getWindow().getWidth(), mc.getWindow().getHeight());
mc.getWindow().updateDisplay();
}
System.out.println("[wr] rendered 20 frames");
// ── 诊断:区块与网格状态 ──
System.out.println("[wr] hasRenderedAllChunks=" + mc.levelRenderer.hasRenderedAllChunks()
+ " countRenderedChunks=" + mc.levelRenderer.countRenderedChunks());
var got = level.getChunkSource().getChunk(0, 0, net.minecraft.world.level.chunk.ChunkStatus.FULL, false);
System.out.println("[wr] getChunk(0,0)=" + (got != null ? got.getClass().getSimpleName() : "null")
+ " block(0,64,0)=" + (got != null ? got.getBlockState(new BlockPos(0,64,0)) : "-"));
System.out.println("[wr] isChunkCompiled(0,64,0)=" + mc.levelRenderer.isChunkCompiled(new BlockPos(0,64,0)));
save(target, W, H, out);
System.exit(0);
}
/**
* 同步驱动各 reload listener。
*
* 为什么不用 Minecraft.reloadResourcePacks():
* 它的 future 靠主循环 tick 推进(LoadingOverlay),我们不跑主循环 ⇒ 死锁。
* 为什么不用 createReload():
* prepare 阶段仍走真后台池,实测 120s 超时。
* 正解:直接调各 listener 的 public reload(),barrier 同步返回,
* 且 apply 用 Runnable::run 保证在渲染线程执行。
*/
private static void reloadResources(Minecraft mc) throws Exception {
var rmF = Minecraft.class.getDeclaredField("resourceManager");
rmF.setAccessible(true);
var rm = (net.minecraft.server.packs.resources.ReloadableResourceManager) rmF.get(mc);
var packs = mc.getResourcePackRepository();
packs.reload();
// 同步 barrier:prepare 完成后立刻交出,不排队
var barrier = new net.minecraft.server.packs.resources.PreparableReloadListener.PreparationBarrier() {
@Override public <T> java.util.concurrent.CompletableFuture<T> wait(T t) {
return java.util.concurrent.CompletableFuture.completedFuture(t);
}
};
var prof = InactiveProfiler.INSTANCE;
// prepare 用当前线程(同步跑),apply 也用当前线程(渲染线程)
java.util.concurrent.Executor sync = Runnable::run;
var all = reloadListeners(mc);
System.out.println("[wr] listeners=" + all.size());
for (var listener : all) {
String name = listener.getName();
System.out.println("[wr] -> " + name + " ...");
try {
var fut = listener.reload(barrier, rm, prof, prof, sync, sync);
boolean done = false;
long dl = System.currentTimeMillis() + 30_000;
while (!done && System.currentTimeMillis() < dl) {
done = fut.isDone();
if (!done) Thread.sleep(10);
}
if (!done) { System.out.println("[wr] TIMEOUT " + name); continue; }
fut.join();
System.out.println("[wr] ok: " + name);
} catch (Throwable e) {
System.out.println("[wr] FAILED " + name + ": " + e);
}
}
}
/** 取出注册过的 listener(private 字段) */
@SuppressWarnings("unchecked")
private static java.util.List<net.minecraft.server.packs.resources.PreparableReloadListener> reloadListeners(Minecraft mc) throws Exception {
var rmF = Minecraft.class.getDeclaredField("resourceManager");
rmF.setAccessible(true);
var rm = rmF.get(mc);
for (var f : rm.getClass().getDeclaredFields()) {
if (java.util.List.class.isAssignableFrom(f.getType())) {
f.setAccessible(true);
Object v = f.get(rm);
if (v instanceof java.util.List<?> l && !l.isEmpty()
&& l.get(0) instanceof net.minecraft.server.packs.resources.PreparableReloadListener) {
return (java.util.List<net.minecraft.server.packs.resources.PreparableReloadListener>) l;
}
}
}
throw new IllegalStateException("reload listeners not found");
}
/** 造地形并注入:区块 + 光照 + 网格重建 */
private static int injectWorld(ClientLevel level, Minecraft mc) throws Exception {
int radius = 4; // 9x9 区块 = 144x144 格
int yBottom = level.getMinBuildHeight();
var cache = (net.minecraft.client.multiplayer.ClientChunkCache) level.getChunkSource();
var light = cache.getLightEngine();
// 反射拿 Storage.chunks / getIndex(包私有)
var storageF = net.minecraft.client.multiplayer.ClientChunkCache.class.getDeclaredField("storage");
storageF.setAccessible(true);
Object storage = storageF.get(cache);
var chunksF = storage.getClass().getDeclaredField("chunks");
chunksF.setAccessible(true);
var chunks = (java.util.concurrent.atomic.AtomicReferenceArray<LevelChunk>) chunksF.get(storage);
var getIndex = storage.getClass().getDeclaredMethod("getIndex", int.class, int.class);
getIndex.setAccessible(true);
System.out.println("[wr] storage chunks.length=" + chunks.length());
int count = 0;
for (int cx = -radius; cx <= radius; cx++) {
for (int cz = -radius; cz <= radius; cz++) {
LevelChunk chunk = new LevelChunk(level, new ChunkPos(cx, cz));
for (int lx = 0; lx < 16; lx++) {
for (int lz = 0; lz < 16; lz++) {
int wx = cx * 16 + lx, wz = cz * 16 + lz;
for (int y = yBottom; y < 63; y++)
chunk.setBlockState(new BlockPos(lx, y, lz), Blocks.STONE.defaultBlockState(), false);
chunk.setBlockState(new BlockPos(lx, 63, lz), Blocks.DIRT.defaultBlockState(), false);
chunk.setBlockState(new BlockPos(lx, 64, lz), Blocks.GRASS_BLOCK.defaultBlockState(), false);
if ((wx % 7 == 0 && wz % 7 == 0) && (wx != 0 || wz != 0)) {
for (int y = 65; y < 72; y++)
chunk.setBlockState(new BlockPos(lx, y, lz), Blocks.OAK_LOG.defaultBlockState(), false);
for (int dx = -2; dx <= 2; dx++)
for (int dz = -2; dz <= 2; dz++)
if (lx+dx >= 0 && lx+dx < 16 && lz+dz >= 0 && lz+dz < 16
&& Math.abs(dx) + Math.abs(dz) <= 2)
chunk.setBlockState(new BlockPos(lx+dx, 72, lz+dz),
Blocks.OAK_LEAVES.defaultBlockState(), false);
}
}
}
// ★ 核心:直接放进 ClientChunkCache.Storage.chunks
int idx = (int) getIndex.invoke(storage, cx, cz);
chunks.set(idx, chunk);
count++;
// ★ 光照:全亮(否则地形全黑)。section y 0..19 覆盖 build height
for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) {
var sky = new net.minecraft.world.level.chunk.DataLayer();
for (int x = 0; x < 16; x++)
for (int y = 0; y < 16; y++)
for (int z = 0; z < 16; z++) sky.set(x, y, z, 15);
light.queueSectionData(net.minecraft.world.level.LightLayer.SKY,
net.minecraft.core.SectionPos.of(cx, sy, cz), sky);
light.queueSectionData(net.minecraft.world.level.LightLayer.BLOCK,
net.minecraft.core.SectionPos.of(cx, sy, cz), sky);
}
light.setLightEnabled(new ChunkPos(cx, cz), true);
chunk.setLightCorrect(true);
}
}
int upd = light.runLightUpdates();
System.out.println("[wr] light updates=" + upd);
// 触发区块网格重建
mc.levelRenderer.allChanged();
System.out.println("[wr] allChanged done");
return count;
}
private static void save(MainTarget target, int W, int H, String out) throws Exception {
org.lwjgl.opengl.GL30.glBindFramebuffer(org.lwjgl.opengl.GL30.GL_FRAMEBUFFER, target.frameBufferId);
GL11.glReadBuffer(org.lwjgl.opengl.GL30.GL_COLOR_ATTACHMENT0);
var buf = org.lwjgl.BufferUtils.createByteBuffer(W * H * 4);
GL11.glReadPixels(0, 0, W, H, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, buf);
org.lwjgl.opengl.GL30.glBindFramebuffer(org.lwjgl.opengl.GL30.GL_FRAMEBUFFER, 0);
BufferedImage img = new BufferedImage(W, H, BufferedImage.TYPE_INT_RGB);
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++) {
int i = ((H - 1 - y) * W + x) * 4;
img.setRGB(x, y, ((buf.get(i) & 0xFF) << 16) | ((buf.get(i+1) & 0xFF) << 8) | (buf.get(i+2) & 0xFF));
}
ImageIO.write(img, "png", new File(out));
java.util.Set<Integer> colors = new java.util.HashSet<>();
for (int p : img.getRGB(0, 0, W, H, null, 0, W)) colors.add(p & 0xFFFFFF);
System.out.println("[wr] wrote " + out + " distinct colors=" + colors.size());
}
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;
}
}

View File

@ -0,0 +1,14 @@
package net.minecraft.client.renderer;
import net.minecraft.server.packs.resources.ResourceProvider;
/**
* 包私有成员桥(GameRenderer.reloadShaders 是 package-private)。
* 官方由匿名内部类 GameRenderer$1 在资源重载时调用;
* 我们不跑主循环 ⇒ 用同包类直接调用,跳过整个 reload 基础设施。
*/
public class McfoldBridge {
public static void reloadShaders(GameRenderer gr, ResourceProvider provider) {
gr.reloadShaders(provider);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB