M1(构建): - build.sh 编译 CP 加入 $OUT/fabric/*.jar,解决 net.fabricmc.* 找不到 - gen_1_21 内联 Sodium 通知改为委托共用 RenderCompat,补 reportRenderCompat - 1.21.1 全量七步构建产物齐备(classes-intermediary.jar / names*.json / srg / inheritance.map) M2(协议): - login_start 按协议数据发 UUID(1.21 必填;1.20 可选) - login_acknowledged / serverbound 包 id 改为查表,不再写死 0x00 - send() 实现压缩帧格式(varint(dataLen)+数据,小包不压) - configuration 双向协商:发 settings、回 select_known_packs、拒 resource_pack - protodef 补 bitflags / entityMetadataLoop / count(ctx引用) / compareTo 路径表达式 - NBT 读取器由占位符改为真实现(anonymousNbt 支撑 registry_data) - 坏包容错:长度前缀是帧层权威,解不开整包丢弃并布告,不断流 实测:login→configuration(11 注册表)→play,位置 (5.5,-60.0,-0.5),map_chunk×9 判据:protodef 6→13 项(含线上真实 entity_metadata 字节回归);全量 77 项通过
1326 lines
70 KiB
Java
1326 lines
70 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.BlockBehaviour;
|
||
import net.minecraft.world.level.block.Blocks;
|
||
import net.minecraft.core.Registry;
|
||
import net.minecraft.world.level.block.state.BlockState;
|
||
import net.minecraft.world.level.chunk.DataLayer;
|
||
import net.minecraft.world.level.chunk.LevelChunk;
|
||
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||
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;
|
||
import mcfold.McfoldNames;
|
||
import mcfold.RenderCompat;
|
||
|
||
/**
|
||
* 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));
|
||
|
||
// ★ 不硬编码实例目录(版本隔离策略):
|
||
// 实例目录 = mods/config/options 的独占边界,必须由调用方按档案指定。
|
||
// 缺省只给一个不含 mods 的临时目录,避免污染任何真实实例。
|
||
String gameDir = arg(args, "--gameDir", System.getProperty("java.io.tmpdir")
|
||
+ "/mcfold-noinstance");
|
||
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 "setblocks": {
|
||
// ★ 世代无关:Python 只做路由,方块写入、调色板组装、入 cache
|
||
// 全部由 Java 用官方 API 完成 —— 版本差异不落回 Python。
|
||
// 载荷:sections[].rle = [[stateId,count],...],按 (y,z,x) 线性序覆盖该 section 4096 格。
|
||
// 优势:协议位打包(历代改过)不再由 Python 复制;入 cache 走官方
|
||
// ClientboundLevelChunkPacketData(LevelChunk) → getReadBuffer(),
|
||
// 签名两代一致,**无需反射**。
|
||
int cx = req.get("cx").getAsInt(), cz = req.get("cz").getAsInt();
|
||
LevelChunk chunk = new LevelChunk(level, new ChunkPos(cx, cz));
|
||
LevelChunkSection[] sections = chunk.getSections();
|
||
int placed = 0;
|
||
long cells = 0;
|
||
for (JsonElement se : req.getAsJsonArray("sections")) {
|
||
JsonObject so = se.getAsJsonObject();
|
||
int si = so.get("index").getAsInt();
|
||
if (si < 0 || si >= sections.length) continue;
|
||
LevelChunkSection sec = sections[si];
|
||
int x = 0, y = 0, z = 0;
|
||
for (JsonElement re : so.getAsJsonArray("rle")) {
|
||
JsonArray pair = re.getAsJsonArray();
|
||
int stateId = pair.get(0).getAsInt();
|
||
int count = pair.get(1).getAsInt();
|
||
BlockState st = stateId == 0 ? null : Block.stateById(stateId);
|
||
for (int i = 0; i < count; i++) {
|
||
if (st != null) { sec.setBlockState(x, y, z, st); placed++; }
|
||
cells++;
|
||
if (++x == 16) { x = 0; if (++z == 16) { z = 0; y++; } }
|
||
}
|
||
}
|
||
}
|
||
// 光照(无光照数据则全亮,否则地形全黑)
|
||
var light = level.getChunkSource().getLightEngine();
|
||
for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) {
|
||
DataLayer sky = new DataLayer();
|
||
for (int lx = 0; lx < 16; lx++)
|
||
for (int ly = 0; ly < 16; ly++)
|
||
for (int lz = 0; lz < 16; lz++) sky.set(lx, ly, lz, 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);
|
||
}
|
||
// ★ 官方序列化 → 官方入口入 cache(两代签名一致)
|
||
var pkt = new net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData(chunk);
|
||
level.getChunkSource().replaceWithPacketData(cx, cz,
|
||
pkt.getReadBuffer(), pkt.getHeightmaps(),
|
||
new java.util.function.Consumer<
|
||
net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput>() {
|
||
@Override public void accept(
|
||
net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput t) { }
|
||
});
|
||
light.setLightEnabled(new ChunkPos(cx, cz), true);
|
||
light.runLightUpdates();
|
||
// ★★★ 补一步官方广播(见 gen_1_20 同处的完整说明):
|
||
// 渲染替换件(Sodium 等)靠「区块加载事件」得知区块存在;
|
||
// 我们绕过网络包直接塞数据 ⇒ 缺这一步它们永远看不见地形
|
||
// (实测:画面只有天空、chunks=0、它的重建队列恒空,
|
||
// 而 blockinfo 能读出方块 ⇒ 数据在,只是没人通知)
|
||
announceChunkLoaded(cx, cz);
|
||
// ★ 必须触发网格重建 —— 否则渲染器不知道该 section 有数据(实测)
|
||
mc.levelRenderer.allChanged();
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
r.addProperty("placed", placed);
|
||
r.addProperty("cells", cells);
|
||
r.addProperty("sections", sections.length);
|
||
r.addProperty("loadedChunks", level.getChunkSource().getLoadedChunksCount());
|
||
emit(id, r);
|
||
break;
|
||
}
|
||
case "mods": {
|
||
// ★ mod 支持全在 Java 侧完成(用户 2026-09-25 定向):
|
||
// Java 自己扫 mods 目录、自己读 jar、自己注册方块、自己加载资源包。
|
||
// Python 只传目录路径。
|
||
String dir = req.has("dir") ? req.get("dir").getAsString() : null;
|
||
if (dir == null) {
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
r.addProperty("mods", 0);
|
||
r.addProperty("note", "未提供 dir");
|
||
emit(id, r);
|
||
break;
|
||
}
|
||
JsonObject res = loadMods(dir);
|
||
emit(id, res);
|
||
break;
|
||
}
|
||
case "modelcheck": {
|
||
// ★ 程序化判据:mod 方块的 baked model 是否就是 missing model
|
||
// (画面里看到紫黑棋盘格是「缺贴图」的视觉表现,这里给出可断言的答案)
|
||
JsonArray specs = req.getAsJsonArray("blocks");
|
||
var shaper = mc.getBlockRenderer().getBlockModelShaper();
|
||
Object missing = null;
|
||
try {
|
||
var mmF = McfoldNames.field(Minecraft.class,
|
||
"net/minecraft/client/Minecraft/modelManager");
|
||
mmF.setAccessible(true);
|
||
Object mm = mmF.get(mc);
|
||
missing = McfoldNames.method(mm.getClass(),
|
||
"net/minecraft/client/resources/model/ModelManager/getMissingModel").invoke(mm);
|
||
} catch (Throwable t) { }
|
||
var lookup = BuiltInRegistries.BLOCK.asLookup();
|
||
JsonArray rows = new JsonArray();
|
||
for (JsonElement el : specs) {
|
||
String spec = el.getAsString();
|
||
JsonObject ro = new JsonObject();
|
||
ro.addProperty("spec", spec);
|
||
try {
|
||
var pr = net.minecraft.commands.arguments.blocks.BlockStateParser
|
||
.parseForBlock(lookup, spec, false);
|
||
var st = pr.blockState();
|
||
Object baked = shaper.getBlockModel(st);
|
||
ro.addProperty("isMissing", baked == missing);
|
||
ro.addProperty("modelClass", baked.getClass().getSimpleName());
|
||
try {
|
||
Object icon = McfoldNames.method(baked.getClass(),
|
||
"net/minecraft/client/resources/model/BakedModel/getParticleIcon",
|
||
com.mojang.serialization.Codec.class).invoke(baked,
|
||
(Object) null);
|
||
} catch (Throwable ignore) { }
|
||
} catch (Throwable t) {
|
||
ro.addProperty("error", String.valueOf(t));
|
||
}
|
||
rows.add(ro);
|
||
}
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
r.add("results", rows);
|
||
emit(id, r);
|
||
break;
|
||
}
|
||
case "atlascheck": {
|
||
// ★ 判据:mod 贴图是否真进了 blocks atlas
|
||
// 正确 API 是 Minecraft.getTextureAtlas(...) → Function<RL,Sprite>
|
||
// (早先用 ModelManager.getAtlasSet 是错的,导致全 NPE 假绿)
|
||
JsonArray arr = req.getAsJsonArray("textures");
|
||
var fn = mc.getTextureAtlas(
|
||
net.minecraft.client.renderer.texture.TextureAtlas.LOCATION_BLOCKS);
|
||
JsonArray rows = new JsonArray();
|
||
for (JsonElement el : arr) {
|
||
String tex = el.getAsString();
|
||
JsonObject ro = new JsonObject();
|
||
ro.addProperty("texture", tex);
|
||
ro.addProperty("isMissing", true);
|
||
ro.addProperty("sprite", "");
|
||
try {
|
||
var sprite = fn.apply(ResourceLocation.parse(tex));
|
||
String reg = sprite.contents().name().toString();
|
||
ro.addProperty("sprite", reg);
|
||
ro.addProperty("isMissing", reg.contains("missingno"));
|
||
} catch (Throwable t) {
|
||
ro.addProperty("sprite", "ERROR: " + t);
|
||
}
|
||
rows.add(ro);
|
||
}
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
r.add("results", rows);
|
||
emit(id, r);
|
||
break;
|
||
}
|
||
case "blockinfo": {
|
||
// ★ 方块信息查询(Java 侧,因为只有它有 level + 注册表 + 模型状态)。
|
||
// Python 只路由,不做任何格式/语义判断。
|
||
// 入参:{"cmd":"blockinfo","x":..,"y":..,"z":..}
|
||
int bx = req.get("x").getAsInt();
|
||
int by = req.get("y").getAsInt();
|
||
int bz = req.get("z").getAsInt();
|
||
BlockPos pos = new BlockPos(bx, by, bz);
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
r.addProperty("x", bx); r.addProperty("y", by); r.addProperty("z", bz);
|
||
|
||
int pcx = bx >> 4, pcz = bz >> 4;
|
||
// ★ 判据必须是「真拿到了 LevelChunk」:
|
||
// ClientLevel.hasChunk() 实测**恒返回 true**(反编译见 306 行),
|
||
// 基类 ChunkSource.hasChunk() 走 getChunk(...,false),
|
||
// 而 ClientChunkCache 覆写后在该参数下返回 emptyChunk(void_air 占位)
|
||
// ⇒ 都不可用于「未加载 vs 空气」的区分。
|
||
// 可靠做法:getChunk(..., requireChunk=**false**) —— 反编译实测
|
||
// return requireChunk ? emptyChunk : null;
|
||
// 即 requireChunk=false 时**未加载才给 null**(传 true 反而给占位)。
|
||
var realChunk = level.getChunkSource()
|
||
// ★ 世代差异:1.21 把 ChunkStatus 移到 world.level.chunk.status
|
||
.getChunk(pcx, pcz,
|
||
net.minecraft.world.level.chunk.status.ChunkStatus.FULL, false);
|
||
boolean loaded = realChunk != null;
|
||
r.addProperty("chunkLoaded", loaded);
|
||
r.addProperty("inBuildHeight", level.isInWorldBounds(pos));
|
||
|
||
if (!loaded) {
|
||
// ★ 未加载 ≠ 空气(设计文档附录 D 强调的语义区分)
|
||
r.addProperty("known", false);
|
||
r.addProperty("reason", "chunk_not_loaded");
|
||
emit(id, r);
|
||
break;
|
||
}
|
||
|
||
BlockState st = level.getBlockState(pos);
|
||
Block blk = st.getBlock();
|
||
ResourceLocation key = BuiltInRegistries.BLOCK.getKey(blk);
|
||
|
||
r.addProperty("known", true);
|
||
r.addProperty("block", key == null ? "unknown" : key.toString());
|
||
r.addProperty("stateId", Block.getId(st));
|
||
r.addProperty("state", st.toString());
|
||
r.addProperty("isAir", st.isAir());
|
||
r.addProperty("isSolid", st.isSolid());
|
||
|
||
// 方块属性(朝向/开关等)—— 渲染与指令都需要
|
||
JsonObject props = new JsonObject();
|
||
try {
|
||
for (var e : st.getValues().entrySet()) {
|
||
props.addProperty(e.getKey().getName(), String.valueOf(e.getValue()));
|
||
}
|
||
} catch (Throwable ignore) { }
|
||
r.add("properties", props);
|
||
|
||
// 光照与硬度(agent 判断「这能不能挖/亮不亮」用)
|
||
try {
|
||
r.addProperty("lightEmission", st.getLightEmission());
|
||
r.addProperty("destroySpeed", st.getDestroySpeed(level, pos));
|
||
r.addProperty("canOcclude", st.canOcclude());
|
||
r.addProperty("hasBlockEntity", st.hasBlockEntity());
|
||
} catch (Throwable ignore) { }
|
||
|
||
// 贴图是否真的在 atlas 里(缺贴图的**程序化判据**,不靠看图)
|
||
// BlockModelShaper.getParticleIcon(BlockState) 是静态的;
|
||
// 它返回的 sprite 名字若含 missingno 就是缺贴图。
|
||
try {
|
||
var shaper = mc.getBlockRenderer().getBlockModelShaper();
|
||
// 实例方法(1.20.1 与 1.21.1 签名一致)
|
||
var icon = shaper.getParticleIcon(st);
|
||
String reg = icon.contents().name().toString();
|
||
r.addProperty("particleTexture", reg);
|
||
r.addProperty("isMissingTexture", reg.contains("missingno"));
|
||
} catch (Throwable t) {
|
||
r.addProperty("particleTexture", "unavailable: " + t.getClass().getSimpleName());
|
||
}
|
||
|
||
// 是否来自 mod(零加载器注册的方块 namespace 不在 minecraft)
|
||
r.addProperty("fromMod", key != null && !key.getNamespace().equals("minecraft"));
|
||
|
||
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();
|
||
}
|
||
// ── HUD(截图场景下应关掉)──
|
||
// ★ 用**官方选项**而非改渲染代码:
|
||
// GameRenderer.render 里 `if (!options.hideGui) gui.render(...)`
|
||
// ⇒ 这就是官方 F1 的同一条路径,改一个 bool 即可。
|
||
// 为什么要关:截图只关心画面,HUD 是噪声,而且它会给
|
||
// distinctColors 掺一大块固定像素(实测污染判据)。
|
||
if (req.has("hideGui")) {
|
||
mc.options.hideGui = req.get("hideGui").getAsBoolean();
|
||
}
|
||
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 = McfoldNames.field(
|
||
net.minecraft.client.renderer.GameRenderer.class,
|
||
"net/minecraft/client/renderer/GameRenderer/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 "capacity": {
|
||
// ★ 世界尺寸由运行时提供 —— Python 不硬编码版本相关常量。
|
||
// 这些方法都在 LevelHeightAccessor 上(Level 实现),两代 API 一致。
|
||
JsonObject r = new JsonObject();
|
||
r.addProperty("ok", true);
|
||
r.addProperty("sectionsCount", level.getSectionsCount());
|
||
r.addProperty("minSection", level.getMinSection());
|
||
r.addProperty("maxSection", level.getMaxSection());
|
||
r.addProperty("minBuildHeight", level.getMinBuildHeight());
|
||
r.addProperty("maxBuildHeight", level.getMaxBuildHeight());
|
||
r.addProperty("height", level.getHeight());
|
||
// 生物群系默认 id(不硬编码 1)
|
||
try {
|
||
var bio = mc.level.registryAccess().registryOrThrow(Registries.BIOME);
|
||
var key = bio.getKey(bio.byId(1));
|
||
r.addProperty("defaultBiome", key != null ? key.toString() : "minecraft:plains");
|
||
} catch (Throwable t) {
|
||
r.addProperty("defaultBiome", "minecraft:plains");
|
||
}
|
||
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)。
|
||
// 用**静态注册表**的 asLookup():它是方块名→id 的权威来源
|
||
// (mod 方块也注册在这里),且两代 API 一致。
|
||
// 不用 mc.level.registryAccess() —— 1.21 上它来自 cookie,
|
||
// 只含 worldgen 那批注册表,查 block 会报 "Registry not found"(实测)。
|
||
var reg = BuiltInRegistries.BLOCK.asLookup();
|
||
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("screenNow", mc.screen != null ? mc.screen.getClass().getSimpleName() : "null");
|
||
r.addProperty("overlayNow", mc.getOverlay() != null ? mc.getOverlay().getClass().getSimpleName() : "null");
|
||
r.addProperty("levelNow", mc.level != null);
|
||
r.addProperty("playerNow", mc.player != null);
|
||
r.addProperty("cameraEntity", mc.getCameraEntity() != null ? "set" : "null");
|
||
r.addProperty("noRender", mc.noRender);
|
||
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()));
|
||
reportRenderCompat(r);
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 广播「区块已就绪」(等价于网络包路径里会发生的那一步)。
|
||
*
|
||
* ★ 加载器共存的关键:渲染替换件(Sodium 等)靠这个事件发现区块,
|
||
* 缺了它们就永远看不见地形(画面只有天空、chunks=0、它的队列恒空,
|
||
* 而 blockinfo 能读出方块 —— 数据在,只是没人通知)。
|
||
*/
|
||
static void announceChunkLoaded(int cx, int cz) {
|
||
// ① 官方广播(对 vanilla 路径无害)
|
||
try {
|
||
level.onChunkLoaded(new ChunkPos(cx, cz));
|
||
} catch (Throwable t) {
|
||
System.err.println("[mcfold] onChunkLoaded(" + cx + "," + cz + ") 失败: " + t);
|
||
}
|
||
// ② 渲染替换件的 tracker —— 走共用桥(完整根因说明见 RenderCompat)
|
||
RenderCompat.notifyChunkLoaded(level, cx, cz);
|
||
}
|
||
|
||
/** 渲染替换件在本次渲染期间的变化([chunkReady 增量, loadQueue 增量])。 */
|
||
static final long[] SODIUM_DELTA = new long[2];
|
||
|
||
/** 诊断:地形渲染替换件(Sodium 等)的真实状态。根因说明见 RenderCompat。 */
|
||
static void reportRenderCompat(JsonObject r) {
|
||
if (!RenderCompat.available()) { r.addProperty("renderCompat", "absent"); return; }
|
||
r.addProperty("renderCompat", "present");
|
||
long[] snap = RenderCompat.trackerSnapshot(level);
|
||
if (snap != null) {
|
||
r.addProperty("rc_chunkStatus", snap[0]);
|
||
r.addProperty("rc_chunkReady", snap[1]);
|
||
r.addProperty("rc_loadQueue", snap[2]);
|
||
}
|
||
Integer vis = RenderCompat.visibleChunks(level);
|
||
if (vis != null) r.addProperty("rc_visibleChunks", vis);
|
||
String dbg = RenderCompat.chunksDebugString(level);
|
||
if (dbg != null) r.addProperty("rc_chunksDebug", dbg);
|
||
int[] q = RenderCompat.rebuildQueue(level);
|
||
if (q != null) {
|
||
JsonObject qo = new JsonObject();
|
||
qo.addProperty("INITIAL_BUILD", q[0]);
|
||
qo.addProperty("REBUILD", q[1]);
|
||
qo.addProperty("IMPORTANT_REBUILD", q[2]);
|
||
r.add("rc_rebuildQueue", qo);
|
||
}
|
||
r.addProperty("rc_deltaChunkReady", SODIUM_DELTA[0]);
|
||
r.addProperty("rc_deltaLoadQueue", SODIUM_DELTA[1]);
|
||
}
|
||
|
||
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 t) {
|
||
if (f == 0) { System.err.println("[mcfold] tick 异常(第1帧): " + t); t.printStackTrace(System.err); }
|
||
}
|
||
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 {
|
||
// ★ 1.21 实测:setLevel 会挂 ReceivingLevelScreen;
|
||
// 若渲染前它有残留,画面就是加载屏(distinctColors 停在 ~199)。
|
||
mc.setScreen(null);
|
||
mc.setOverlay(null);
|
||
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");
|
||
|
||
// ★★ 世代差异(1.21+):注册表经 CommonListenerCookie.receivedRegistries 传入,
|
||
// 并在 ClientPacketListener 构造时赋给 **final** 字段
|
||
// (实测:1.20.x 是可变 LayeredRegistryAccess,可事后反射替换;
|
||
// 1.21 是 final RegistryAccess$Frozen,反射会抛
|
||
// "Can not set final ... field")。
|
||
// ⇒ 本世代的正确顺序:**先加载注册表,再构造 cookie + listener**。
|
||
// 这比 1.20 的反射注入更干净。
|
||
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 base = net.minecraft.client.multiplayer.ClientRegistryLayer
|
||
.createRegistryAccess().compositeAccess();
|
||
RegistryAccess.Frozen loaded = RegistryDataLoader.load(dataRm, base, all);
|
||
System.err.println("[mcfold] registries loaded: dimension_type="
|
||
+ loaded.registryOrThrow(Registries.DIMENSION_TYPE).size());
|
||
|
||
Connection conn = new Connection(PacketFlow.CLIENTBOUND);
|
||
GameProfile profile = new GameProfile(UUID.randomUUID(), "mcfold_cam");
|
||
WorldSessionTelemetryManager telemetry = new WorldSessionTelemetryManager(
|
||
TelemetryEventSender.DISABLED, false, Duration.ZERO, "");
|
||
var cookie = new net.minecraft.client.multiplayer.CommonListenerCookie(
|
||
profile,
|
||
telemetry,
|
||
loaded, // ← 注册表从这里进
|
||
net.minecraft.world.flag.FeatureFlags.DEFAULT_FLAGS,
|
||
null, null, null,
|
||
java.util.Map.of(), null, false, java.util.Map.of(),
|
||
net.minecraft.server.ServerLinks.EMPTY);
|
||
ClientPacketListener listener = new ClientPacketListener(mc, conn, cookie);
|
||
System.err.println("[mcfold] listener ok, registryAccess dims="
|
||
+ listener.registryAccess().registryOrThrow(Registries.DIMENSION_TYPE).size());
|
||
|
||
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());
|
||
|
||
// ★★ 世代差异(1.21+,编译期看不出):
|
||
// GameRenderer.render(DeltaTracker,boolean) 里有门禁
|
||
// boolean ok = minecraft.isGameLoadFinished();
|
||
// if (ok && renderLevel && level != null) renderLevel(...);
|
||
// 而 gameLoadFinished 只在 **reloadResourcePacks 的回调**
|
||
// (onResourceLoadFinished)里被置 true。
|
||
// 我们不跑异步 reload(会死锁)⇒ 该位恒 false ⇒ 世界永不渲染
|
||
// (症状:画面停在 Mojang 加载屏,distinctColors≈199,chunksRendered=0)。
|
||
// 1.20.x 无此门禁,所以 gen_1_20 不受影响。
|
||
net.minecraft.client.renderer.McfoldBridge.markGameLoadFinished(mc);
|
||
|
||
mc.setOverlay(null);
|
||
mc.setScreen(null);
|
||
mc.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK);
|
||
System.err.println("[mcfold] boot done");
|
||
}
|
||
|
||
/**
|
||
* 把 blockstate 里的 model 引用规范化成 jar 里实际存在的路径。
|
||
*
|
||
* 实测两类写法:
|
||
* "ns:block/foo" → models/block/foo.json (createbigcannons 风格,正确)
|
||
* "ns:foo" → models/foo.json(不存在!) (MTS 风格,实际在 models/block/foo.json)
|
||
* 所以先原样查,再试补 "block/"。
|
||
*/
|
||
static String normalizeModelPath(java.util.zip.ZipFile zf, String model) {
|
||
int i = model.indexOf(':');
|
||
String ns = i > 0 ? model.substring(0, i) : "minecraft";
|
||
String path = i > 0 ? model.substring(i + 1) : model;
|
||
if (zf.getEntry("assets/" + ns + "/models/" + path + ".json") != null) return model;
|
||
if (zf.getEntry("assets/" + ns + "/models/block/" + path + ".json") != null) {
|
||
return ns + ":block/" + path;
|
||
}
|
||
return model; // 都不存在 → 保持原样(会显示 missing,但至少不误导)
|
||
}
|
||
|
||
/**
|
||
* 生成一个「blockstates 覆盖包」:把 mod 的变体键统一改写成 ""。
|
||
*
|
||
* ★ 为什么需要(实测根因):
|
||
* 我们的占位方块只有 defaultBlockState(变体键 ""),
|
||
* 而 mod 的 blockstates 常用属性键(如 "normal"、"facing=north")⇒ 匹配不上
|
||
* ⇒ missing model ⇒ 画面上是紫黑棋盘格。
|
||
* 例:createbigcannons:steel_block 的键是 ""(渲染正常,测过),
|
||
* mts:...fancydirt_block 的键是 "normal"(棋盘格)。
|
||
*
|
||
* 做法:读每个 mod jar 的 assets/<ns>/blockstates/*.json,取第一个 variant 的 model,
|
||
* 改写成 {"variants":{"":{"model":"<那个 model>"}}},打进一个 zip。
|
||
* 该 zip 作为**最后选中**的资源包,优先级最高,从而覆盖原 blockstates。
|
||
* 模型/贴图仍由原 jar 提供(不动)。
|
||
*/
|
||
static File buildBlockstateOverlay(java.util.List<File> jars, java.nio.file.Path outDir) throws Exception {
|
||
File overlay = new File(outDir.toFile(), "mcfold-blockstate-overlay.zip");
|
||
java.util.TreeMap<String, byte[]> entries = new java.util.TreeMap<>();
|
||
entries.put("pack.mcmeta",
|
||
("{\"pack\":{\"description\":\"mcfold blockstate overlay\",\"pack_format\":15}}")
|
||
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||
int rewritten = 0;
|
||
for (File jar : jars) {
|
||
try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(jar)) {
|
||
var en = zf.entries();
|
||
while (en.hasMoreElements()) {
|
||
var e = en.nextElement();
|
||
String nm = e.getName();
|
||
if (!nm.startsWith("assets/") || !nm.contains("/blockstates/")
|
||
|| !nm.endsWith(".json")) continue;
|
||
String text;
|
||
try (var in = zf.getInputStream(e)) {
|
||
text = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||
}
|
||
JsonObject root;
|
||
try { root = JsonParser.parseString(text).getAsJsonObject(); }
|
||
catch (Throwable t) { continue; }
|
||
if (!root.has("variants") || !root.get("variants").isJsonObject()) continue;
|
||
|
||
// 取第一个 variant 的第一个 model —— 只求外观,不求语义
|
||
String model = null;
|
||
for (var ve : root.getAsJsonObject("variants").entrySet()) {
|
||
JsonElement v = ve.getValue();
|
||
JsonElement one = v.isJsonArray() ? (v.getAsJsonArray().size() > 0
|
||
? v.getAsJsonArray().get(0) : null) : v;
|
||
if (one != null && one.isJsonObject() && one.getAsJsonObject().has("model")) {
|
||
model = one.getAsJsonObject().get("model").getAsString();
|
||
break;
|
||
}
|
||
}
|
||
if (model == null) continue;
|
||
// ★ 规范化 model 路径(实测根因):
|
||
// MTS 写 "mts:mtsofficialpack.fancydirt_block"(缺 block/),
|
||
// 而文件在 models/block/... ⇒ MC 找不到 ⇒ missing model ⇒ 棋盘格。
|
||
// createbigcannons 写 "createbigcannons:block/steel_block"(有 block/),正常。
|
||
// 策略:先按原样看 jar 里有没有;没有就试补 block/ 前缀。
|
||
model = normalizeModelPath(zf, model);
|
||
String out = "{\"variants\":{\"\":{\"model\":\"" + model + "\"}}}";
|
||
entries.put(nm, out.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||
rewritten++;
|
||
}
|
||
}
|
||
}
|
||
if (rewritten == 0) return null;
|
||
try (var zos = new java.util.zip.ZipOutputStream(
|
||
new java.io.FileOutputStream(overlay))) {
|
||
for (var kv : entries.entrySet()) {
|
||
zos.putNextEntry(new java.util.zip.ZipEntry(kv.getKey()));
|
||
zos.write(kv.getValue());
|
||
zos.closeEntry();
|
||
}
|
||
}
|
||
System.err.println("[mcfold] overlay: 重写 " + rewritten + " 个 blockstates → " + overlay);
|
||
return overlay;
|
||
}
|
||
|
||
/**
|
||
* 加载 mod:扫 jar → 注册占位方块 → 挂资源包(外观)。
|
||
*
|
||
* ★ 全部在 Java 侧完成(用户 2026-09-25 定向):Java 自己读 jar、自己注册、
|
||
* 自己挂资源包。Python 只传目录路径。
|
||
*
|
||
* 原理(见 scaffold/MOD-BLOCKS.md):方块**渲染**只需要
|
||
* (a) 注册表里有这个名字(拿 state id)
|
||
* (b) 资源包里有它的 blockstates/models/textures
|
||
* 与方块行为无关 ⇒ 零 mod 加载器也能正确显示。
|
||
*/
|
||
static JsonObject loadMods(String dir) {
|
||
JsonObject out = new JsonObject();
|
||
File d = new File(dir);
|
||
File[] jars = d.listFiles((f, n) -> n.endsWith(".jar"));
|
||
if (jars == null || jars.length == 0) {
|
||
out.addProperty("ok", true);
|
||
out.addProperty("mods", 0);
|
||
out.addProperty("blocksRegistered", 0);
|
||
return out;
|
||
}
|
||
|
||
// ── 1. 解冻 BLOCK 注册表(frozen=false + 恢复 intrusiveHolders)──
|
||
// 两个字段都必须处理:只清 frozen 会抛 "can't create intrusive holders"
|
||
Object blockReg = BuiltInRegistries.BLOCK;
|
||
try {
|
||
for (Class<?> c = blockReg.getClass(); c != null; c = c.getSuperclass()) {
|
||
try {
|
||
var f = McfoldNames.field(c, "net/minecraft/core/MappedRegistry/frozen");
|
||
f.setAccessible(true);
|
||
f.setBoolean(blockReg, false);
|
||
} catch (NoSuchFieldException ignore) { }
|
||
try {
|
||
var f = McfoldNames.field(c,
|
||
"net/minecraft/core/MappedRegistry/unregisteredIntrusiveHolders");
|
||
f.setAccessible(true);
|
||
if (f.get(blockReg) == null) f.set(blockReg, new java.util.IdentityHashMap<>());
|
||
} catch (NoSuchFieldException ignore) { }
|
||
}
|
||
} catch (Throwable t) {
|
||
System.err.println("[mcfold] 解冻失败: " + t);
|
||
}
|
||
|
||
int mods = 0, blocksAdded = 0, skipped = 0;
|
||
JsonArray detail = new JsonArray();
|
||
for (File jar : jars) {
|
||
JsonObject m = new JsonObject();
|
||
m.addProperty("jar", jar.getName());
|
||
int n = 0, skip = 0;
|
||
try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(jar)) {
|
||
var en = zf.entries();
|
||
java.util.Set<String> seen = new java.util.LinkedHashSet<>();
|
||
while (en.hasMoreElements()) {
|
||
String name = en.nextElement().getName();
|
||
// 方块名来源:assets/<ns>/blockstates/<name>.json(权威,见 P5 实测)
|
||
java.util.regex.Matcher mm = java.util.regex.Pattern
|
||
.compile("^assets/([^/]+)/blockstates/(.+)\\.json$").matcher(name);
|
||
if (!mm.matches()) continue;
|
||
String ns = mm.group(1), blk = mm.group(2);
|
||
if (!seen.add(ns + ":" + blk)) continue;
|
||
// ★ 世代差异(1.21+):ResourceLocation 构造变 private → 静态工厂
|
||
ResourceLocation rl = ResourceLocation.fromNamespaceAndPath(ns, blk);
|
||
try {
|
||
if (BuiltInRegistries.BLOCK.containsKey(rl)) { skip++; continue; }
|
||
// ★ 世代差异(1.21+):Properties.copy(Block) 已移除,只有 of()
|
||
Block b = new Block(BlockBehaviour.Properties.of());
|
||
Registry.register(BuiltInRegistries.BLOCK, rl, b);
|
||
// ★ BlockState 不会自动进 BLOCK_STATE_REGISTRY(实测 state id 会是 0)
|
||
Block.BLOCK_STATE_REGISTRY.add(b.defaultBlockState());
|
||
n++;
|
||
} catch (Throwable t) {
|
||
skip++;
|
||
}
|
||
}
|
||
mods++;
|
||
} catch (Throwable t) {
|
||
m.addProperty("error", String.valueOf(t));
|
||
}
|
||
m.addProperty("blocksRegistered", n);
|
||
m.addProperty("skipped", skip);
|
||
blocksAdded += n;
|
||
skipped += skip;
|
||
detail.add(m);
|
||
}
|
||
|
||
// ── 2. 挂资源包(外观:blockstates/models/textures)──
|
||
// 把 mod jar 作为资源包加入并选中,然后重载资源管理器。
|
||
int packsAdded = 0;
|
||
try {
|
||
var repo = mc.getResourcePackRepository();
|
||
java.nio.file.Path rpDir = mc.getResourcePackDirectory();
|
||
for (File jar : jars) {
|
||
// ★ 必须是 .zip 后缀:FolderRepositorySource 只认
|
||
// isDirectory+pack.mcmeta 或 **.zip**(实测它不看 .jar)
|
||
String base = jar.getName().replaceAll("\\.jar$", "");
|
||
File dst = new File(rpDir.toFile(), "mcfold-" + base + ".zip");
|
||
if (!dst.isFile()) java.nio.file.Files.copy(jar.toPath(), dst.toPath(),
|
||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||
}
|
||
// ★ 生成 blockstates 覆盖包并放进资源包目录
|
||
// (把 mod 的属性变体键改写成 "" —— 占位方块只有 defaultBlockState)
|
||
java.util.List<File> jarList = new java.util.ArrayList<>();
|
||
for (File j : jars) jarList.add(j);
|
||
File overlay = buildBlockstateOverlay(jarList, rpDir);
|
||
if (overlay != null) {
|
||
File od = new File(rpDir.toFile(), "zzz-mcfold-blockstate-overlay.zip");
|
||
java.nio.file.Files.copy(overlay.toPath(), od.toPath(),
|
||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||
}
|
||
repo.reload();
|
||
// 诊断(放进响应,stderr 会被后续日志冲掉)
|
||
out.addProperty("packDir", rpDir.toString());
|
||
JsonArray avail = new JsonArray();
|
||
for (String ai : repo.getAvailableIds()) avail.add(ai);
|
||
out.add("availableIds", avail);
|
||
JsonArray compat = new JsonArray();
|
||
for (var pk2 : repo.getAvailablePacks()) {
|
||
JsonObject po = new JsonObject();
|
||
po.addProperty("id", pk2.getId());
|
||
po.addProperty("compat", String.valueOf(pk2.getCompatibility()));
|
||
compat.add(po);
|
||
}
|
||
out.add("availablePacks", compat);
|
||
var sel = new java.util.LinkedHashSet<>(repo.getSelectedIds());
|
||
// 先加 mod 包(提供 models/textures),再加 overlay(覆盖 blockstates)
|
||
java.util.List<String> overlayIds = new java.util.ArrayList<>();
|
||
for (String packId : repo.getAvailableIds()) {
|
||
if (packId.contains("zzz-mcfold-blockstate-overlay")) { overlayIds.add(packId); continue; }
|
||
if (packId.contains("mcfold-")) { sel.add(packId); packsAdded++; }
|
||
}
|
||
sel.addAll(overlayIds); // 最后 = 最高优先级
|
||
JsonArray selArr = new JsonArray();
|
||
for (String si : sel) selArr.add(si);
|
||
out.add("selectedIds", selArr);
|
||
repo.setSelected(sel);
|
||
|
||
// 用新 pack 列表重建 resource manager(照官方 createReload 内部做法),
|
||
// 再手工驱动 listener(不能 join Minecraft.reloadResourcePacks,会死锁)
|
||
java.util.List<net.minecraft.server.packs.PackResources> packs = new java.util.ArrayList<>();
|
||
for (var pk : repo.getSelectedPacks()) packs.add(pk.open());
|
||
var rmF = McfoldNames.field(Minecraft.class,
|
||
"net/minecraft/client/Minecraft/resourceManager");
|
||
rmF.setAccessible(true);
|
||
var rm = (net.minecraft.server.packs.resources.ReloadableResourceManager) rmF.get(mc);
|
||
var resF = McfoldNames.field(
|
||
net.minecraft.server.packs.resources.ReloadableResourceManager.class,
|
||
"net/minecraft/server/packs/resources/ReloadableResourceManager/resources");
|
||
resF.setAccessible(true);
|
||
resF.set(rm, new net.minecraft.server.packs.resources.MultiPackResourceManager(
|
||
net.minecraft.server.packs.PackType.CLIENT_RESOURCES, packs));
|
||
reloadResources(mc);
|
||
net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager());
|
||
mc.levelRenderer.allChanged();
|
||
|
||
// ★ 自验证:重载后 mod 资源是否真的可读(TOO_OLD 是否阻止加载)
|
||
ResourceManager rm2 = mc.getResourceManager();
|
||
System.err.println("[mcfold] resourceManager=" + rm2.getClass().getSimpleName()
|
||
+ " namespaces=" + rm2.getNamespaces());
|
||
JsonArray check = new JsonArray();
|
||
for (File jar : jars) {
|
||
try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(jar)) {
|
||
var en2 = zf.entries();
|
||
while (en2.hasMoreElements()) {
|
||
String nm = en2.nextElement().getName();
|
||
java.util.regex.Matcher m2 = java.util.regex.Pattern
|
||
.compile("^assets/([^/]+)/(blockstates/.+\\.json)$").matcher(nm);
|
||
if (!m2.matches()) continue;
|
||
// ★ 世代差异(1.21+):构造 private → 静态工厂
|
||
ResourceLocation rid = ResourceLocation.fromNamespaceAndPath(
|
||
m2.group(1), m2.group(2));
|
||
JsonObject co = new JsonObject();
|
||
co.addProperty("id", rid.toString());
|
||
var got2 = rm2.getResource(rid);
|
||
co.addProperty("readable", got2.isPresent());
|
||
if (got2.isPresent()) {
|
||
try (var in2 = got2.get().open()) {
|
||
co.addProperty("bytes", in2.readAllBytes().length);
|
||
}
|
||
}
|
||
check.add(co);
|
||
break; // 每个 jar 只查一个样本
|
||
}
|
||
} catch (Throwable ignore) { }
|
||
}
|
||
out.add("resourceCheck", check);
|
||
} catch (Throwable t) {
|
||
out.addProperty("packError", String.valueOf(t));
|
||
System.err.println("[mcfold] 资源包挂载失败: " + t);
|
||
}
|
||
|
||
out.addProperty("ok", true);
|
||
out.addProperty("mods", mods);
|
||
out.addProperty("blocksRegistered", blocksAdded);
|
||
out.addProperty("skipped", skipped);
|
||
out.addProperty("packsAdded", packsAdded);
|
||
out.add("detail", detail);
|
||
return out;
|
||
}
|
||
|
||
/** 同步驱动各 reload listener(不能 join Minecraft 的 reloadResourcePacks:等主循环 → 死锁)*/
|
||
static void reloadResources(Minecraft mc) throws Exception {
|
||
var rmF = McfoldNames.field(Minecraft.class,
|
||
"net/minecraft/client/Minecraft/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();
|
||
}
|
||
}
|