Files
agentmc/scaffold/McfoldRenderServer.java
JianFeeeee 34ed70ab53 修复视距不生效 + 接入统一测试
视距(用户报「无法调整视距?」)——根因是三处不同步:
  1. options.renderDistance        本地设置(原先只设了这个)
  2. options.setServerRenderDistance 服务端下发上限(getEffectiveRenderDistance = min(两者))
  3. ClientChunkCache.updateViewRadius storage 半径
第三处最关键:官方只在收到服务端半径包时才调它(ClientPacketListener:2316),
我们不连服 ⇒ Storage.inRange 用旧半径判定,超出区块被**静默丢弃**。

另一个前置:加载半径必须跟随视距。视距 12 只喂 3 圈数据,
画面看起来就像没生效(最容易误判的坑)。

实测(同相机同存档):
  视距 4  → 注入 81   渲染 55   色彩 15226
  视距 12 → 注入 595  渲染 447  色彩 18477
画面差异显著:RD=12 能看到延伸到地平线的森林与完整海岸线。
(sample-render-distance-{4,12}.png)

测试:新增 tests/test_anvil.py(13 项,存档/NBT/编码)
      tests/test_render_bridge.py(3 项,桥协议+合成渲染+真实存档渲染)
      run_tests.sh 改为默认跑单元测试、--with-render 跑联测
      合计 61 项全绿(原 48 + 13)
2026-09-25 17:47:58 +08:00

590 lines
28 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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());
}
JsonObject r = new JsonObject();
r.addProperty("ok", true);
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.countRenderedChunks());
r.addProperty("hasRenderedAllChunks", mc.levelRenderer.hasRenderedAllChunks());
var got = cc.getChunk(pcx, pcz, net.minecraft.world.level.chunk.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);
mc.gameRenderer.render(0.0F, (long) f * 16_000_000L, 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.countRenderedChunks());
r.addProperty("hasRenderedAllChunks", mc.levelRenderer.hasRenderedAllChunks());
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();
SharedConstants.enableDataFixerOptimizations();
Bootstrap.bootStrap();
File runDir = new File(gameDir);
runDir.mkdirs();
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();
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");
WorldSessionTelemetryManager telemetry = new WorldSessionTelemetryManager(
TelemetryEventSender.DISABLED, false, Duration.ZERO, "");
ClientPacketListener listener = new ClientPacketListener(mc, null, conn, null, profile, telemetry);
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()));
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, new ResourceLocation("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);
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);
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();
}
}