刻印流程

[!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 做三件事,是防恶意客户端提交超大数据的唯一关卡:

  1. gzippedSize(raw) 用 CompressedStreamTools.compress 量出 gzip 后字节数,< 0(写不出)或 > maxBytes → 拒绝
  2. readFromNBT 包在 try/catch (RuntimeException) 里,畸形 NBT → 拒绝
  3. 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。所以面板可以拆下来带走,合成回 配方蓝图 再刻。

相关条目