刻印流程
[!INFO] 源码
network/MakeRecipePanelMessage.java、network/ConfigurePanelMessage.java、RecipeSnapshot.java、network/ServerTasks.java
从 NEI 配方界面点面板按钮,到拿到一个刻好配方的面板物品,完整走一遍客户端 → 服务端 → 客户端的链路。
网络通道
主类用 NetworkRegistry.INSTANCE.newSimpleChannel("neirecipepanels") 建立 SimpleNetworkWrapper,在 CommonProxy.preInit 注册两个消息,都是客户端 → 服务端(Side.SERVER):
| 序号 | 消息 | 载荷 | 作用 |
|---|---|---|---|
| 0 | MakeRecipePanelMessage |
一个 NBTTagCompound(客户端给的快照) |
请求刻印一块面板 |
| 1 | ConfigurePanelMessage |
x、y、z(各一个 int)、name(UTF-8 字符串)、transparent(boolean) | 保存已放置面板的显示设置 |
两个 Handler 收到包后都不直接动世界,而是 ServerTasks.submit(() -> ...) 排队。ServerTasks 在 TickEvent.ServerTickEvent 的 END 阶段把队列清空——即所有校验和世界改动都发生在服务端主线程上,与 netty 线程隔离。
刻印快照里存了什么
这是本 mod 最关键的设计:面板不存配方内容,只存坐标式的引用。
RecipeSnapshot.capture(handler, recipeIndex) 只抓三样:
| NBT 键 | 类型 | 含义 |
|---|---|---|
ver |
int | 快照版本,固定 VERSION = 3 |
recipeId |
String | Recipe.RecipeId.toJsonObject() 的 JSON 文本 |
resultPerm |
int | 产物格当时显示的是第几个循环变体 |
inPerms |
int[] | 每个材料格当时显示的循环变体下标 |
otherPerms |
int[] | 每个副产物格当时显示的循环变体下标 |
permutationIndex(PositionedStack) 用 ps.getPermutationIndex(ps.item) 求下标;注释特别说明 item 是 items[index] 的一份拷贝而非同一引用,所以走的是 NEI 自己的按类型查找。
存了这些就够,因为物品、坐标、概率、NC 标记每次都由 ResolvedRecipe.of 从当下还活着的 NEI handler 重新算出来。所以面板永远显示配方的当前状态,而不是刻印那一刻的冻结副本;变的只有"每个格显示哪个循环变体"这个选择。
RecipeId 本身取不到的情况是有兜底的:recipeId(handler, index) 包在 try/catch (Throwable) 里,失败就存空字符串,后面 parseRecipeId 返回 null,PanelFboManager 标记 resolveFailed 并画出纯色占位面板。
服务端校验(MakeRecipePanelMessage.Handler.grant)
按顺序执行:
| 步 | 检查 | 失败后果 |
|---|---|---|
| 1 | player == null || player.isDead || raw == null |
静默 return |
| 2 | Config.panelMode == DISABLED |
静默 return(无聊天提示) |
| 3 | CREATIVE_ONLY 且非创造 |
neirecipepanels.chat.notAllowed |
| 4 | OP_ONLY 且非 OP |
neirecipepanels.chat.notAllowed |
| 5 | RecipeSnapshot.sanitize(raw, maxIngredients, maxSnapshotBytes) 返回 null |
neirecipepanels.chat.badSnapshot |
| 6 | spend && !consumeBlueprint(player) |
neirecipepanels.chat.needBlueprint |
| 7 | 组装 ItemRecipePanel.withSnapshot(clean) 并发放 |
— |
第 5 步的 sanitize 做三件事,是防恶意客户端提交超大数据的唯一关卡:
gzippedSize(raw)用CompressedStreamTools.compress量出 gzip 后字节数,< 0(写不出)或> maxBytes→ 拒绝readFromNBT包在try/catch (RuntimeException)里,畸形 NBT → 拒绝ingredientPermutations.length或otherPermutations.length超过maxSlots→ 拒绝
通过后返回一份规范化副本:recipeId 字符串被 trim 到 MAX_STRING = 12000 字符,再重新 writeToNBT() 输出。客户端拿到的是服务端的副本而不是自己提交的原始数据。
第 6 步 spend = !creative || Config.consumeInCreative;consumeBlueprint 先试 inventory.consumeInventoryItem(recipeBlueprint),失败再看光标栈(inventory.getItemStack())。
发放
player.inventory.addItemStackToInventory(panel)失败则player.entityDropItem(panel, 0.5F)掉在脚下- 最后
player.sendContainerToPlayer(player.inventoryContainer)做一次全窗口重同步。注释解释:此时配方界面是客户端打开的容器,对玩家背包(热键栏之外)的逐槽更新会被客户端丢弃
配置面板的校验(ConfigurePanelMessage.Handler.apply)
改已放置面板的显示设置走另一条消息,校验更严:
world.getBlock(x,y,z) != ModBlocks.recipePanel→ 放弃!player.canPlayerEdit(x, y, z, 0, null)→ 放弃player.getDistanceSq(x+0.5, y+0.5, z+0.5) > 64D→ 放弃(8 格距离上限,防止远程改面板)- TileEntity 不是
RecipePanelTile→ 放弃 - 通过后写
PanelSettings,settings.isEmpty()时传null(setSettings会把空 NBT 归一为null)
x/y/z 由客户端从 tile.xCoord 等直接取,没有额外校验——但距离与方块类型检查已经挡住了绝大部分滥用。
面板生命周期的另一条链
配方面板方块 被破坏时,breakBlock 不走 getItemDropped(后者返回 null),而是主动生成 EntityItem 并把 TileEntity 里的 snapshot 与 settings 复制进物品 NBT,delayBeforeCanPickup = 10。所以面板可以拆下来带走,合成回 配方蓝图 再刻。