用户定向:在 python 层写 switch,调用不同 java 后端(而非一个文件里 switch)。
新增:
mcfold/backends/{__init__,base,gen_1_20,gen_1_21}.py 后端体系与分派
scaffold/java/gen_1_20/ 对 1.20.x client jar 编译
scaffold/java/gen_1_21/ 对 1.21.1 client jar 编译(✅ 实测编译通过)
scaffold/java/VERSION-DIFFS.md 实测差异全表
判据:DeltaTracker 类是否存在(1.21 引入,正是 render 签名变化的根源)
⇒ 比硬编码版本号稳
实测差异共 11 处(21 个检查点里 13 个跨世代完全一致):
setLevel / GameRenderer.render / countRendered* / createPackRepository /
ResourceLocation 构造 / enableDataFixerOptimizations(移除) / User 构造 /
ChunkStatus 包路径 / ClientPacketListener 构造(参数个数语义都变) /
loadEffect(变 private)
为什么必须分后端:DeltaTracker 在 1.20.x jar 中不存在,
两代调用无法共存于一份源码(实测编译报 cannot find symbol)。
1.21.1 反混淆已验证(29MB named jar,DeltaTracker 3 处)
677 lines
34 KiB
Java
677 lines
34 KiB
Java
import com.google.gson.Gson;
|
||
import com.google.gson.JsonArray;
|
||
import com.google.gson.JsonElement;
|
||
import com.google.gson.JsonObject;
|
||
import com.google.gson.JsonParser;
|
||
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.FriendlyByteBuf;
|
||
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.LightLayer;
|
||
import net.minecraft.world.level.block.Block;
|
||
import net.minecraft.world.level.block.state.BlockState;
|
||
import net.minecraft.world.level.chunk.DataLayer;
|
||
import net.minecraft.world.level.dimension.DimensionType;
|
||
import org.lwjgl.opengl.GL11;
|
||
|
||
import javax.imageio.ImageIO;
|
||
import java.awt.image.BufferedImage;
|
||
import java.io.BufferedReader;
|
||
import java.io.ByteArrayOutputStream;
|
||
import java.io.File;
|
||
import java.io.InputStreamReader;
|
||
import java.io.PrintStream;
|
||
import java.net.Proxy;
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.time.Duration;
|
||
import java.util.Base64;
|
||
import java.util.Optional;
|
||
import java.util.OptionalInt;
|
||
import java.util.UUID;
|
||
|
||
/**
|
||
* mcfold 渲染服务:常驻进程,NDJSON(行分隔 JSON) 协议,由 Python 侧驱动。
|
||
*
|
||
* 设计约束(用户 2026-09-25):
|
||
* - 不起完整客户端:不跑主循环、不连服、不登录、不作玩家
|
||
* - 我们的世界数据喂给官方渲染器出图
|
||
*
|
||
* 协议(stdin/stdout 各一行一个 JSON):
|
||
* → {"id":1,"cmd":"chunk","cx":0,"cz":0,"data":"<b64>","heightmaps":null}
|
||
* → {"id":2,"cmd":"camera","x":8,"y":70,"z":8,"yaw":0,"pitch":0}
|
||
* → {"id":3,"cmd":"render","frames":60,"out":"/tmp/a.png"}
|
||
* ← {"id":3,"ok":true,"width":854,"height":480,"chunksRendered":29,
|
||
* "distinctColors":1450,"image":"<b64 png>"}
|
||
*
|
||
* 启动就绪时主动发 {"event":"ready"}。
|
||
*
|
||
* ★ 关键实现点(全部实测得来,详见 scaffold/README.md):
|
||
* 1. tryDetectVersion 必须先于 Bootstrap.bootStrap
|
||
* 2. FolderData 顺序 (gameDir, resourcePackDir, assets, assetIndex),assetIndex 用落盘名
|
||
* 3. 资源必须手工同步重载(各 listener 的 public reload()),不能 join Minecraft 的
|
||
* 4. LoadingOverlay + ReceivingLevelScreen 都要清,否则渲出加载屏
|
||
* 5. 每帧 mc.tick() 才能推进区块网格重建队列
|
||
* 6. 读像素必须绑 FBO
|
||
* 7. chunk 数据走官方 replaceWithPacketData(与网络包同路径)
|
||
*/
|
||
public class McfoldRenderServer {
|
||
|
||
static final Gson GSON = new Gson();
|
||
static final PrintStream OUT = new PrintStream(
|
||
new java.io.FileOutputStream(java.io.FileDescriptor.out), true, StandardCharsets.UTF_8);
|
||
|
||
static Minecraft mc;
|
||
static ClientLevel level;
|
||
static int width = 854, height = 480;
|
||
|
||
public static void main(String[] args) throws Exception {
|
||
// stdout 只放协议 JSON,MC 的 INFO/WARN 全部赶到 stderr
|
||
System.setOut(new PrintStream(new java.io.FileOutputStream(java.io.FileDescriptor.err), true));
|
||
|
||
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");
|
||
width = Integer.parseInt(arg(args, "--width", "854"));
|
||
height = Integer.parseInt(arg(args, "--height", "480"));
|
||
|
||
try {
|
||
boot(gameDir, assets, assetIdx);
|
||
} catch (Throwable t) {
|
||
emitError(null, "启动失败: " + t);
|
||
t.printStackTrace(System.err);
|
||
System.exit(1);
|
||
}
|
||
event("ready", null);
|
||
|
||
BufferedReader in = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
|
||
String line;
|
||
while ((line = in.readLine()) != null) {
|
||
line = line.trim();
|
||
if (line.isEmpty()) continue;
|
||
JsonObject req;
|
||
try {
|
||
req = JsonParser.parseString(line).getAsJsonObject();
|
||
} catch (Exception e) {
|
||
emitError(null, "非法 JSON: " + e.getMessage());
|
||
continue;
|
||
}
|
||
Integer id = req.has("id") ? req.get("id").getAsInt() : null;
|
||
try {
|
||
handle(id, req);
|
||
} catch (Throwable t) {
|
||
emitError(id, t.toString());
|
||
t.printStackTrace(System.err);
|
||
}
|
||
}
|
||
System.exit(0);
|
||
}
|
||
|
||
// ---------------- 协议处理 ----------------
|
||
|
||
static void handle(Integer id, JsonObject req) throws Exception {
|
||
String cmd = req.get("cmd").getAsString();
|
||
switch (cmd) {
|
||
case "shutdown": {
|
||
JsonObject r = new JsonObject(); r.addProperty("ok", true);
|
||
emit(id, r);
|
||
System.exit(0);
|
||
}
|
||
case "chunk": {
|
||
int cx = req.get("cx").getAsInt(), cz = req.get("cz").getAsInt();
|
||
byte[] data = decode(req.get("data").getAsString());
|
||
var resp = applyChunk(cx, cz, data);
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
r.add("result", resp);
|
||
emit(id, r);
|
||
break;
|
||
}
|
||
case "chunks": {
|
||
JsonArray items = req.getAsJsonArray("items");
|
||
int ok = 0, failed = 0;
|
||
String firstErr = null;
|
||
for (JsonElement el : items) {
|
||
JsonObject it = el.getAsJsonObject();
|
||
try {
|
||
applyChunk(it.get("cx").getAsInt(), it.get("cz").getAsInt(),
|
||
decode(it.get("data").getAsString()));
|
||
ok++;
|
||
} catch (Throwable t) {
|
||
failed++;
|
||
if (firstErr == null) {
|
||
firstErr = it.get("cx") + "," + it.get("cz") + ": " + t;
|
||
}
|
||
}
|
||
}
|
||
levelRendererAllChanged();
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
r.addProperty("injected", ok);
|
||
r.addProperty("failed", failed);
|
||
if (firstErr != null) r.addProperty("firstError", firstErr);
|
||
var cache2 = (net.minecraft.client.multiplayer.ClientChunkCache) level.getChunkSource();
|
||
r.addProperty("loadedChunks", cache2.getLoadedChunksCount());
|
||
emit(id, r);
|
||
break;
|
||
}
|
||
case "camera": {
|
||
double x = req.get("x").getAsDouble(), y = req.get("y").getAsDouble(), z = req.get("z").getAsDouble();
|
||
float yaw = req.has("yaw") ? req.get("yaw").getAsFloat() : 0f;
|
||
float pitch = req.has("pitch") ? req.get("pitch").getAsFloat() : 0f;
|
||
// ★ 必须 setPosRaw + 手动更新 chunk 追踪,否则实体仍属旧区块
|
||
mc.player.setPosRaw(x, y, z);
|
||
mc.player.xo = x; mc.player.yo = y; mc.player.zo = z;
|
||
mc.player.setYRot(yaw);
|
||
mc.player.setXRot(pitch);
|
||
mc.player.yRotO = yaw;
|
||
mc.player.xRotO = pitch;
|
||
mc.setCameraEntity(mc.player);
|
||
// 视野中心跟着相机走,否则目标区域区块被 ClientChunkCache 丢弃
|
||
level.getChunkSource().updateViewCenter((int)Math.floor(x) >> 4, (int)Math.floor(z) >> 4);
|
||
mc.levelRenderer.allChanged();
|
||
tickFrames(5);
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
JsonArray c = new JsonArray(); c.add(x); c.add(y); c.add(z);
|
||
r.add("camera", c);
|
||
emit(id, r);
|
||
break;
|
||
}
|
||
case "view": {
|
||
if (req.has("cameraType")) {
|
||
mc.options.setCameraType(net.minecraft.client.CameraType.valueOf(req.get("cameraType").getAsString()));
|
||
}
|
||
if (req.has("renderDistance")) {
|
||
int rd = req.get("renderDistance").getAsInt();
|
||
// ★ 两处都要设:renderDistance 是本地设置;
|
||
// serverRenderDistance 模拟服务端下发的视距上限
|
||
// (getEffectiveRenderDistance = min(两者))
|
||
mc.options.renderDistance().set(rd);
|
||
mc.options.setServerRenderDistance(rd);
|
||
// ★ 必须同步 ClientChunkCache 的 storage 半径,
|
||
// 否则区块被 inRange 丢弃(官方靠服务端包触发 updateViewRadius)
|
||
level.getChunkSource().updateViewRadius(rd);
|
||
mc.levelRenderer.allChanged();
|
||
}
|
||
if (req.has("fov")) {
|
||
mc.options.fov().set(req.get("fov").getAsInt());
|
||
}
|
||
// ── 原版画质选项(光影的基础层)──
|
||
if (req.has("graphicsMode")) {
|
||
mc.options.graphicsMode().set(net.minecraft.client.GraphicsStatus
|
||
.valueOf(req.get("graphicsMode").getAsString()));
|
||
mc.levelRenderer.allChanged(); // FANCY 会启用更多渲染层
|
||
}
|
||
if (req.has("ambientOcclusion")) {
|
||
mc.options.ambientOcclusion().set(req.get("ambientOcclusion").getAsBoolean());
|
||
mc.levelRenderer.allChanged();
|
||
}
|
||
if (req.has("mipmapLevels")) {
|
||
mc.options.mipmapLevels().set(req.get("mipmapLevels").getAsInt());
|
||
}
|
||
if (req.has("gamma")) {
|
||
mc.options.gamma().set(req.get("gamma").getAsDouble());
|
||
}
|
||
if (req.has("entityShadows")) {
|
||
mc.options.entityShadows().set(req.get("entityShadows").getAsBoolean());
|
||
}
|
||
if (req.has("clouds")) {
|
||
mc.options.cloudStatus().set(net.minecraft.client.CloudStatus
|
||
.valueOf(req.get("clouds").getAsString()));
|
||
}
|
||
if (req.has("particles")) {
|
||
mc.options.particles().set(net.minecraft.client.ParticleStatus
|
||
.valueOf(req.get("particles").getAsString()));
|
||
}
|
||
// ── 原版后处理链(PostChain 基础设施)──
|
||
if (req.has("postEffect")) {
|
||
String eff = req.get("postEffect").getAsString();
|
||
if (eff.isEmpty() || eff.equals("none")) {
|
||
mc.gameRenderer.shutdownEffect();
|
||
} else {
|
||
// loadEffect 是包私有 → 走同包 bridge
|
||
net.minecraft.client.renderer.McfoldBridge.loadEffect(
|
||
mc.gameRenderer, ResourceLocation.parse(eff));
|
||
}
|
||
}
|
||
// 回包带上真实生效值,便于验证
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
JsonObject cur = new JsonObject();
|
||
try {
|
||
java.lang.reflect.Field pf = net.minecraft.client.renderer.GameRenderer.class
|
||
.getDeclaredField("postEffect");
|
||
pf.setAccessible(true);
|
||
cur.addProperty("postEffect", pf.get(mc.gameRenderer) != null ? "active" : "none");
|
||
} catch (Throwable ignore) {
|
||
cur.addProperty("postEffect", "unknown");
|
||
}
|
||
cur.addProperty("graphicsMode", mc.options.graphicsMode().get().name());
|
||
cur.addProperty("ambientOcclusion", mc.options.ambientOcclusion().get());
|
||
cur.addProperty("mipmapLevels", mc.options.mipmapLevels().get());
|
||
cur.addProperty("gamma", mc.options.gamma().get());
|
||
cur.addProperty("entityShadows", mc.options.entityShadows().get());
|
||
cur.addProperty("clouds", mc.options.cloudStatus().get().name());
|
||
cur.addProperty("renderDistance", mc.options.getEffectiveRenderDistance());
|
||
r.add("current", cur);
|
||
emit(id, r);
|
||
break;
|
||
}
|
||
case "registry": {
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
r.addProperty("blockCount", BuiltInRegistries.BLOCK.size());
|
||
r.addProperty("stateCount", Block.BLOCK_STATE_REGISTRY.size());
|
||
emit(id, r);
|
||
break;
|
||
}
|
||
case "lookup": {
|
||
int sid = req.get("stateId").getAsInt();
|
||
BlockState st = Block.stateById(sid);
|
||
ResourceLocation key = BuiltInRegistries.BLOCK.getKey(st.getBlock());
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
r.addProperty("stateId", sid);
|
||
r.addProperty("block", key.toString());
|
||
r.addProperty("state", st.toString());
|
||
r.addProperty("inRange", sid >= 0 && sid < Block.BLOCK_STATE_REGISTRY.size());
|
||
emit(id, r);
|
||
break;
|
||
}
|
||
case "fog": {
|
||
// 关雾:fogStart/fogEnd 是 levelRenderer 内部状态;
|
||
// 更简单可靠的是加大 renderDistance + 用 BLOCK 光照
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
emit(id, r);
|
||
break;
|
||
}
|
||
case "resolve": {
|
||
// ★ 方块名(+属性) → state id。存档 palette 是名字,网络载荷要 id,
|
||
// 而这个名字→id 的权威映射只有客户端注册表有。
|
||
// 入参: {"cmd":"resolve","blocks":["minecraft:oak_log[axis=y]", ...]}
|
||
JsonArray arr = req.getAsJsonArray("blocks");
|
||
// ★ parseForBlock 要 HolderLookup(不是 Registry)
|
||
var reg = mc.level.registryAccess().lookupOrThrow(Registries.BLOCK);
|
||
JsonArray out = new JsonArray();
|
||
int unknown = 0;
|
||
for (JsonElement el : arr) {
|
||
String spec = el.getAsString();
|
||
int sid;
|
||
try {
|
||
var res = net.minecraft.commands.arguments.blocks.BlockStateParser
|
||
.parseForBlock(reg, spec, false);
|
||
sid = Block.getId(res.blockState());
|
||
} catch (Throwable t) {
|
||
sid = -1; unknown++;
|
||
}
|
||
out.add(sid);
|
||
}
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
r.add("ids", out);
|
||
r.addProperty("unknown", unknown);
|
||
r.addProperty("total", arr.size());
|
||
emit(id, r);
|
||
break;
|
||
}
|
||
case "debug": {
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
r.addProperty("playerPos", mc.player.getX() + "," + mc.player.getY() + "," + mc.player.getZ());
|
||
int pcx = (int)Math.floor(mc.player.getX()) >> 4, pcz = (int)Math.floor(mc.player.getZ()) >> 4;
|
||
r.addProperty("playerChunk", pcx + "," + pcz);
|
||
var cc = (net.minecraft.client.multiplayer.ClientChunkCache) level.getChunkSource();
|
||
r.addProperty("loadedChunks", cc.getLoadedChunksCount());
|
||
r.addProperty("chunksRendered", mc.levelRenderer.countRenderedSections());
|
||
r.addProperty("hasRenderedAllChunks", mc.levelRenderer.hasRenderedAllSections());
|
||
var got = cc.getChunk(pcx, pcz, net.minecraft.world.level.chunk.status.ChunkStatus.FULL, false);
|
||
r.addProperty("chunkAtPlayer", got != null ? got.getClass().getSimpleName() : "null");
|
||
if (got != null) {
|
||
// 扫描玩家所在列,找最高的非空气方块
|
||
int top = Integer.MIN_VALUE;
|
||
for (int y = level.getMaxBuildHeight() - 1; y >= level.getMinBuildHeight(); y--) {
|
||
var st = got.getBlockState(new BlockPos(
|
||
(int)Math.floor(mc.player.getX()) & 15, y, (int)Math.floor(mc.player.getZ()) & 15));
|
||
if (!st.isAir()) {
|
||
top = y;
|
||
r.addProperty("topSolidY", y);
|
||
r.addProperty("topSolidBlock", BuiltInRegistries.BLOCK.getKey(st.getBlock()).toString());
|
||
r.addProperty("topSolidProps", st.toString());
|
||
break;
|
||
}
|
||
}
|
||
if (top == Integer.MIN_VALUE) r.addProperty("topSolidY", "none");
|
||
}
|
||
// viewCenter
|
||
r.addProperty("fogStart", String.valueOf(mc.options.renderDistance().get()));
|
||
emit(id, r);
|
||
break;
|
||
}
|
||
case "viewport": {
|
||
// ★ viewCenter 决定 ClientChunkCache 收哪些区块(超范围会被丢弃)
|
||
int cx = req.get("cx").getAsInt(), cz = req.get("cz").getAsInt();
|
||
if (req.has("radius")) {
|
||
level.getChunkSource().updateViewRadius(req.get("radius").getAsInt());
|
||
}
|
||
level.getChunkSource().updateViewCenter(cx, cz);
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
r.addProperty("viewCenter", cx + "," + cz);
|
||
emit(id, r);
|
||
break;
|
||
}
|
||
case "render": {
|
||
int frames = req.has("frames") ? req.get("frames").getAsInt() : 60;
|
||
String out = req.has("out") ? req.get("out").getAsString() : null;
|
||
if (req.has("width")) width = req.get("width").getAsInt();
|
||
if (req.has("height")) height = req.get("height").getAsInt();
|
||
render(id, frames, out);
|
||
break;
|
||
}
|
||
default:
|
||
emitError(id, "未知命令: " + cmd);
|
||
}
|
||
}
|
||
|
||
static JsonObject applyChunk(int cx, int cz, byte[] data) {
|
||
// ★ 官方路径:ClientChunkCache.replaceWithPacketData
|
||
// 内部:new LevelChunk + chunk.replaceWithPacketData(buf, heightmaps, consumer)
|
||
// 与网络包 handleLevelChunkWithLight 完全同一路径
|
||
FriendlyByteBuf buf = new FriendlyByteBuf(io.netty.buffer.Unpooled.wrappedBuffer(data));
|
||
var cache = level.getChunkSource();
|
||
// 显式匿名类而非 lambda:lambda 参数类型推断在此处会失败(实测)
|
||
net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput beOut =
|
||
new net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput() {
|
||
@Override public void accept(BlockPos bp,
|
||
net.minecraft.world.level.block.entity.BlockEntityType<?> type,
|
||
net.minecraft.nbt.CompoundTag tag) { }
|
||
};
|
||
// ★ 参数是 Consumer<BlockEntityTagOutput>(消费这个接口本身),不是该接口的方法签名。
|
||
// LevelChunk.replaceWithPacketData 内部会调 beOut.accept(...),这里直接空实现。
|
||
cache.replaceWithPacketData(cx, cz, buf, new net.minecraft.nbt.CompoundTag(), out -> {});
|
||
// 无光照数据 ⇒ 显式全亮,否则地形全黑
|
||
var light = cache.getLightEngine();
|
||
for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) {
|
||
DataLayer sky = new 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(LightLayer.SKY, net.minecraft.core.SectionPos.of(cx, sy, cz), sky);
|
||
light.queueSectionData(LightLayer.BLOCK, net.minecraft.core.SectionPos.of(cx, sy, cz), sky);
|
||
}
|
||
light.setLightEnabled(new ChunkPos(cx, cz), true);
|
||
int upd = light.runLightUpdates();
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("lightUpdates", upd);
|
||
return r;
|
||
}
|
||
|
||
static void levelRendererAllChanged() {
|
||
mc.levelRenderer.allChanged();
|
||
}
|
||
|
||
/** tick + 渲染各帧(区块网格重建队列靠 tick 推进) */
|
||
static void tickFrames(int n) {
|
||
MainTarget target = (MainTarget) mc.getMainRenderTarget();
|
||
for (int f = 0; f < n; f++) {
|
||
try { mc.tick(); } catch (Throwable ignore) {}
|
||
RenderSystem.clear(16640, Minecraft.ON_OSX);
|
||
target.bindWrite(true);
|
||
// ★ 世代差异(1.21+):render(DeltaTracker, boolean)
|
||
mc.gameRenderer.render(mc.getTimer(), true);
|
||
target.unbindWrite();
|
||
target.blitToScreen(mc.getWindow().getWidth(), mc.getWindow().getHeight());
|
||
mc.getWindow().updateDisplay();
|
||
}
|
||
}
|
||
|
||
static void render(Integer id, int frames, String out) throws Exception {
|
||
tickFrames(frames);
|
||
|
||
MainTarget target = (MainTarget) mc.getMainRenderTarget();
|
||
int w = mc.getWindow().getWidth(), h = mc.getWindow().getHeight();
|
||
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);
|
||
java.util.Set<Integer> colors = new java.util.HashSet<>();
|
||
for (int y = 0; y < h; y++)
|
||
for (int x = 0; x < w; x++) {
|
||
int i = ((h - 1 - y) * w + x) * 4;
|
||
int rgb = ((buf.get(i) & 0xFF) << 16) | ((buf.get(i + 1) & 0xFF) << 8) | (buf.get(i + 2) & 0xFF);
|
||
img.setRGB(x, y, rgb);
|
||
colors.add(rgb);
|
||
}
|
||
|
||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||
ImageIO.write(img, "png", bos);
|
||
byte[] png = bos.toByteArray();
|
||
if (out != null) ImageIO.write(img, "png", new File(out));
|
||
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
r.addProperty("width", w);
|
||
r.addProperty("height", h);
|
||
r.addProperty("chunksRendered", mc.levelRenderer.countRenderedSections());
|
||
r.addProperty("hasRenderedAllChunks", mc.levelRenderer.hasRenderedAllSections());
|
||
r.addProperty("distinctColors", colors.size());
|
||
JsonArray cam = new JsonArray();
|
||
cam.add(mc.player.getX()); cam.add(mc.player.getY()); cam.add(mc.player.getZ());
|
||
r.add("camera", cam);
|
||
if (out != null) r.addProperty("path", out);
|
||
r.addProperty("image", Base64.getEncoder().encodeToString(png));
|
||
emit(id, r);
|
||
}
|
||
|
||
// ---------------- 启动 ----------------
|
||
|
||
static void boot(String gameDir, String assets, String assetIdx) throws Exception {
|
||
SharedConstants.tryDetectVersion();
|
||
// ★ 世代差异(1.21+):enableDataFixerOptimizations() 已被移除(实测映射表无此方法)
|
||
Bootstrap.bootStrap();
|
||
|
||
File runDir = new File(gameDir);
|
||
runDir.mkdirs();
|
||
// ★ 世代差异(1.21+):User(String, UUID, String, Optional, Optional, Type)
|
||
// 1.20.x 是 (String, String sessionId, String, ...)
|
||
User user = new User("mcfold_cam", java.util.UUID.nameUUIDFromBytes("mcfold_cam".getBytes()),
|
||
"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();
|
||
mc = new Minecraft(cfg);
|
||
RenderSystem.finishInitialization();
|
||
System.err.println("[mcfold] Minecraft constructed");
|
||
|
||
reloadResources(mc);
|
||
System.err.println("[mcfold] resources reloaded");
|
||
|
||
Connection conn = new Connection(PacketFlow.CLIENTBOUND);
|
||
GameProfile profile = new GameProfile(UUID.randomUUID(), "mcfold_cam");
|
||
// 初始注册表:官方静态层(1.21 的 cookie 需要它,随后会被真实注册表替换)
|
||
net.minecraft.core.RegistryAccess.Frozen listenerRegistryAccess =
|
||
net.minecraft.client.multiplayer.ClientRegistryLayer.createRegistryAccess()
|
||
.compositeAccess();
|
||
WorldSessionTelemetryManager telemetry = new WorldSessionTelemetryManager(
|
||
TelemetryEventSender.DISABLED, false, Duration.ZERO, "");
|
||
// ★ 世代差异(1.21+):ClientPacketListener(Minecraft, Connection, CommonListenerCookie)
|
||
// 1.20.x 是 (Minecraft, Screen, Connection, ServerData, GameProfile, TelemetryManager)。
|
||
// ★★ 这是本世代最大的一处变化 —— 参数个数与语义都不同。
|
||
var cookie = new net.minecraft.client.multiplayer.CommonListenerCookie(
|
||
profile,
|
||
telemetry,
|
||
listenerRegistryAccess, // 后面注册表加载后会被替换
|
||
net.minecraft.world.flag.FeatureFlags.DEFAULT_FLAGS,
|
||
null, // serverBrand
|
||
null, // serverData
|
||
null, // postDisconnectScreen
|
||
java.util.Map.of(), // serverCookies
|
||
null, // chatState
|
||
false, // strictErrorHandling
|
||
java.util.Map.of(), // customReportDetails
|
||
net.minecraft.server.ServerLinks.EMPTY);
|
||
ClientPacketListener listener = new ClientPacketListener(mc, conn, cookie);
|
||
|
||
// ★ 世代差异(1.21+):createPackRepository(Path, DirectoryValidator)
|
||
// DirectoryValidator 没有 NONE 常量,构造要 PathMatcher;
|
||
// 传一个「全部拒绝」的 matcher 最安全(我们不加载外来符号链接数据包)
|
||
var packs = net.minecraft.server.packs.repository.ServerPacksSource.createPackRepository(
|
||
new File(runDir, "datapacks").toPath(),
|
||
new net.minecraft.world.level.validation.DirectoryValidator(p -> false));
|
||
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()));
|
||
var all = new java.util.ArrayList<RegistryDataLoader.RegistryData<?>>();
|
||
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});
|
||
var rf = ClientPacketListener.class.getDeclaredField("registryAccess");
|
||
rf.setAccessible(true);
|
||
rf.set(listener, layered);
|
||
|
||
Holder<DimensionType> dimHolder = loaded.registryOrThrow(Registries.DIMENSION_TYPE)
|
||
.getHolderOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, ResourceLocation.parse("minecraft:overworld")));
|
||
var levelData = new ClientLevel.ClientLevelData(Difficulty.NORMAL, false, false);
|
||
level = new ClientLevel(listener, levelData, Level.OVERWORLD, dimHolder,
|
||
Math.max(8, mc.options.renderDistance().get()), 8,
|
||
() -> InactiveProfiler.INSTANCE, mc.levelRenderer, false, 0L);
|
||
// ★ 世代差异(1.21+):setLevel(ClientLevel, ReceivingLevelScreen$Reason)
|
||
// 1.20.x 只有一个参数。这是本文件与 gen_1_20 的差异之一。
|
||
// 枚举值实测:NETHER_PORTAL / END_PORTAL / OTHER(没有 LEVEL_LOAD)
|
||
// 我们不是经传送门进入世界 ⇒ OTHER
|
||
mc.setLevel(level, net.minecraft.client.gui.screens.ReceivingLevelScreen.Reason.OTHER);
|
||
|
||
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);
|
||
|
||
net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager());
|
||
|
||
mc.setOverlay(null);
|
||
mc.setScreen(null);
|
||
mc.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK);
|
||
System.err.println("[mcfold] boot done");
|
||
}
|
||
|
||
/** 同步驱动各 reload listener(不能 join Minecraft 的 reloadResourcePacks:等主循环 → 死锁)*/
|
||
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);
|
||
mc.getResourcePackRepository().reload();
|
||
|
||
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;
|
||
java.util.concurrent.Executor sync = Runnable::run;
|
||
|
||
for (var listener : reloadListeners(rm)) {
|
||
String name = listener.getName();
|
||
try {
|
||
var fut = listener.reload(barrier, rm, prof, prof, sync, sync);
|
||
long deadline = System.currentTimeMillis() + 60_000;
|
||
while (!fut.isDone() && System.currentTimeMillis() < deadline) Thread.sleep(10);
|
||
if (fut.isDone()) fut.join();
|
||
else System.err.println("[mcfold] TIMEOUT " + name);
|
||
} catch (Throwable e) {
|
||
System.err.println("[mcfold] FAILED " + name + ": " + e);
|
||
}
|
||
}
|
||
}
|
||
|
||
@SuppressWarnings("unchecked")
|
||
static java.util.List<net.minecraft.server.packs.resources.PreparableReloadListener> reloadListeners(Object rm) throws Exception {
|
||
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");
|
||
}
|
||
|
||
// ---------------- 工具 ----------------
|
||
|
||
static byte[] decode(String b64) {
|
||
return Base64.getDecoder().decode(b64);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
static synchronized void emit(Integer id, JsonObject obj) {
|
||
if (id != null) obj.addProperty("id", id);
|
||
OUT.println(GSON.toJson(obj));
|
||
OUT.flush();
|
||
}
|
||
|
||
static void emitError(Integer id, String msg) {
|
||
JsonObject o = new JsonObject();
|
||
o.addProperty("ok", false);
|
||
o.addProperty("error", msg);
|
||
emit(id, o);
|
||
}
|
||
|
||
static void event(String name, JsonObject payload) {
|
||
JsonObject o = new JsonObject();
|
||
o.addProperty("event", name);
|
||
if (payload != null) o.add("payload", payload);
|
||
OUT.println(GSON.toJson(o));
|
||
OUT.flush();
|
||
}
|
||
}
|