网络协议:两类 CodeChickenLib 包

结构运动通过 CodeChickenLib 的 PacketCustom 同步,通道名就是 ForgeRelocation。只有 2 种包:描述符包(type 1)与完成包(type 2)。源码注释自述为 “Tweaked version of Chickenbones’ compressed end-of-tick tile data stream.”

代码位置:src/main/scala/mrtjp/relocation/handler/network.scala(191 行)。

通道与处理器

class RelocationPH { val channel = RelocationMod }   // channel = ForgeRelocation

object RelocationCPH extends RelocationPH with IClientPacketHandler { ... }
object RelocationSPH extends RelocationPH with IServerPacketHandler { ... }

channel 取的是 RelocationMod 对象本身(不是字符串常量)——RelocationMod.modID 恰好是 "ForgeRelocation",CodeChickenLib 会用它作通道名。

处理器在 postInit 注册(proxies.scala:57-62 服务端 / 67-69 客户端):

// 服务端
PacketCustom.assignHandler(RelocationSPH.channel, RelocationSPH)
FMLCommonHandler.instance.bus.register(RelocationEventHandler)
MinecraftForge.EVENT_BUS.register(RelocationEventHandler)

// 客户端(额外)
PacketCustom.assignHandler(RelocationCPH.channel, RelocationCPH)
FMLCommonHandler.instance.bus.register(RelocationClientEventHandler)
MinecraftForge.EVENT_BUS.register(RelocationClientEventHandler)

⚠️ 服务端的 handlePacket 是空实现(network.scala:80-84,方法体只有 {})——本 mod 不接受任何客户端发来的包。移动只能由服务端发起。

⚠️ 三个事件处理器对象同时注册到 FMLCommonHandler.bus 和 MinecraftForge.EVENT_BUS。RelocationEventHandler 的三个方法(WorldEvent.Unload、ChunkWatchEvent.Watch/UnWatch、TickEvent.ServerTickEvent)属 FML 事件,RelocationClientEventHandler 的后两个(RenderWorldLastEvent、TickEvent.ClientTickEvent)属 Forge 事件;重复注册意味着 FML 事件在两个总线上各触发一次,但处理方法都是幂等的(onChunkWatch 往列表追加、可能重复,onTickEnd 重发已清空的流)。

包类型 1:结构描述符

private def getDescPacket(world: World, chunks: Set[ChunkCoordIntPair]): PacketCustom = {
  val packet = new PacketCustom(channel, 1)
  if (MovementManager2.writeDesc(world, chunks, packet)) packet else null
}

返回 null 表示没有需要发送的结构——调用方 sendDesc 判空后跳过,不产生空包。

def writeDesc(w: World, chunks: Set[ChunkCoordIntPair], out: MCDataOutput) = {
  var send = false
  for (s <- getWorldStructs(w).structs if s.getChunks.exists(chunks.contains)) {
    send = true
    out.writeShort(s.id)
    s.writeDesc(out)
  }
  if (send) out.writeShort(Short.MaxValue)
  send
}

格式:对每个与玩家已加载区块有交集的结构,写 UShort id + 描述符;最后写 Short.MaxValue(32767)作终止符(仅当至少发了一个结构时才写)。这正是 BlockStruct.claimID 把 ID 上限设在 32765 的原因——给终止符留出空间。

结构描述符本体:

def writeDesc(out: MCDataOutput) {
  out.writeFloat(progress.toFloat)
  out.writeFloat(speed.toFloat)
  out.writeByte(rows.length)
  for (r <- rows) {
    out.writeLong(WorldLib.packCoords(r.pos))
    out.writeByte(r.moveDir)
    out.writeShort(r.size)
  }
}
字段 类型 含义
progress writeFloat 当前进度 0.0 ~ 1.0
speed writeFloat 每 tick 增量(客户端插值用)
rows.length writeByte 行数
每行 pos writeLong WorldLib.packCoords 打包的 BlockCoord
每行 moveDir writeByte ForgeDirection 索引 0~5
每行 size writeShort 行长度(占 size + 1 格)

客户端读取:

def readDesc(w: World, in: MCDataInput) {
  var id = in.readUShort()
  while (id != Short.MaxValue) {
    val struct = new BlockStruct
    struct.id = id
    struct.readDesc(in)
    addStructToWorld(w, struct)
    id = in.readUShort()
  }
}

addStructToWorld 内部按 id 去重(WorldStructs.addStruct 遇到相同 id 静默返回),因此重复收到描述符不会叠加出两份结构。

包类型 2:完成 / 进度

def sendCycle(w: World, struct: BlockStruct) {
  RelocationSPH.getStream(w, struct.getChunks, 2).writeShort(struct.id)
  RelocationSPH.forceSendData()
}

客户端处理:

case 2 =>
  val id = in.readUShort()
  getWorldStructs(w).structs.find(_.id == id) match {
    case Some(struct) => clientCycleMove(w, struct)
    case None => throw new RuntimeException(s"DC: Moving structure with id $id was not found client-side.")
  }

找不到结构就抛异常——这是协议失步的硬失败,不是静默忽略。

clientCycleMove:

def clientCycleMove(w: World, struct: BlockStruct) {
  getWorldStructs(w).removeStruct(struct)
  struct.rows.foreach(_.pushEntities(w, 1.0))
  cycleMove(w, s)
}

注意 pushEntities(w, 1.0) 传 1.0 而非当前 progress——客户端在收到完成包时把实体一次性推到终点(prevProg 已在之前 tick 累积到接近 1.0,这里补齐最后一段)。

单包多流:字节数组 + 终止符

type 2 的包可以携带多份字节流(一个玩家同时观看的多个区块组):

private def sendData(players: Seq[EntityPlayerMP]) {
  for (p <- players if chunkWatchers.containsKey(p.getEntityId)) {
    updateMap.get(p.worldObj) match {
      case Some(m) if m.nonEmpty =>
        val chunks = chunkWatchers(p.getEntityId)
        val packet = new PacketCustom(channel, 2).compress()
        var send = false
        for ((uchunks, stream) <- m if uchunks.exists(chunks.contains)) {
          send = true
          packet.writeByteArray(stream.getBytes)
          packet.writeByte(255)   // terminator
        }
        if (send) packet.sendToPlayer(p)
      case _ =>
    }
  }
  updateMap.foreach(_._2.clear())
}
  • .compress() —— type 2 启用 CodeChickenLib 的压缩;type 1 不压缩
  • 每份流是独立的 MCByteStream(MCDataOutputWrapper 包 ByteArrayOutputStream),writeByteArray 写入后跟 writeByte(255) 终止符
  • 客户端按 255 逐流切分:
def handleChunkData(packet: PacketCustom, world: World) {
  var i = packet.readUByte()
  while (i != 255) { MovementManager2.read(world, packet, i); i = packet.readUByte() }
}
  • 末尾 updateMap.foreach(_._2.clear()) 清空所有流——本 tick 攒的数据一次性发完即弃

getStream 惰性创建两级嵌套的流表:

def getStream(world: World, chunks: Set[ChunkCoordIntPair], key: Int) =
  updateMap.getOrElseUpdate(world, {
    if (world.isRemote) throw new IllegalArgumentException("Cannot use RelocationSPH on a client world")
    MMap()
  }).getOrElseUpdate(chunks, { val s = new MCByteStream(new ByteArrayOutputStream); s.writeByte(key); s })

⚠️ 对客户端世界调用会抛 IllegalArgumentException。新建的流第一个字节就是 key(1 或 2),随后才是 UShort id——客户端的读取循环先读这个 key 再调 MovementManager2.read(world, in, key),与上面的分派一致。

MCByteStream 只是给 MCDataOutputWrapper 加了一个 getBytes 出口方法。

发送时机

时机 调用 行为
结构创建 sendStruct → getStream(...,1) + forceSendData() 立即发 type 1
每 tick 末(服务端) RelocationEventHandler.serverTick(ServerTickEvent END)→ RelocationSPH.onTickEnd() → sendData(players) + sendDesc(players) 常规批量
结构完成 sendCycle → getStream(...,2) + forceSendData() 立即发 type 2
区块开始监视 ChunkWatchEvent.Watch → onChunkWatch 入 newWatchers,延迟到 tick 末批量发描述符
区块取消监视 ChunkWatchEvent.UnWatch → onChunkUnWatch 立即从两个表移除
世界卸载 WorldEvent.Unload → onWorldUnload 清 updateMap + 同维度玩家的监视表

forceSendData() / forceSendDesc() 是 onTickEnd 里那两步的公开包装(forceSendDesc 源码中无任何调用点)。

普通进度不打包:进度只在 type 1 描述符里发一次,之后由客户端自己 progress + speed * partial 插值。移动中的方块位置不需要持续网络更新。

区块监视表

private val updateMap      = MMap[World, MMap[Set[ChunkCoordIntPair], MCByteStream]]()
private val chunkWatchers  = new MHashMap[Int, MSet[ChunkCoordIntPair]] with MMultiMap[Int, ChunkCoordIntPair]
private val newWatchers    = MMap[Int, JLinkedList[ChunkCoordIntPair]]()

三个表都用 player.getEntityId 做键(不是 UUID——重登后 ID 变化,onWorldUnload 里靠遍历在线玩家清理)。

sendDesc 的两阶段设计:先给新开始监视区块的玩家补发 type 1,再把它们并入 chunkWatchers,最后 newWatchers.clear():

private def sendDesc(players: Seq[EntityPlayerMP]) {
  for (p <- players if newWatchers.containsKey(p.getEntityId)) {
    val watched = newWatchers(p.getEntityId)
    val pkt = getDescPacket(p.worldObj, watched.toSet)
    if (pkt != null) pkt.sendToPlayer(p)
    for (c <- watched) chunkWatchers.addBinding(p.getEntityId, c)
  }
  newWatchers.clear()
}

newWatchers 用 JLinkedList(允许重复,靠 for (c <- watched) 去重进入 chunkWatchers),chunkWatchers 是 MultiMap(一次多绑)。onChunkUnWatch 用 removeBinding 精确移除单个键值对。

失步处理:直接踢人

客户端处理器用异常消息前缀 DC: 作为协议错误标记:

try {
  packet.getType match {
    case 1 => handleChunkDesc(packet, mc.theWorld)
    case 2 => handleChunkData(packet, mc.theWorld)
  }
} catch {
  case e: RuntimeException if e.getMessage.startsWith("DC: ") =>
    netHandler.handleDisconnect(
      new S40PacketDisconnect(new ChatComponentText(e.getMessage.substring(4))))
}

DC = Data Check。substring(4) 去掉 "DC: " 前缀,把剩余文本作为踢出理由显示给玩家。两处抛出点:

  • MovementManager2.read 的 case 2 —— "DC: Moving structure with id $id was not found client-side."
  • MovementManager2.read 的 case _ —— "DC: Packet with ID $key was not handled. Skipped ${...getByteBuf.array().length} bytes."(附带被跳过的字节数)

未知的 PacketCustom 类型(既非 1 也非 2)落在 case 1 => / case 2 => 都不匹配的位置——handlePacket 的 match 没有 case _,会抛 scala.MatchError(不带 DC: 前缀),因此不会被这个 catch 捕获,而是向上抛给 CodeChickenLib。⚠️ 源码现状。

第二套通道:马达姿态

⚠️ 马达方块的位置/姿态同步不走本通道。 TileMotor 用 MrTJPCore InstancedBlockTile 的 ICustomPacketTile 机制(writeStream(2).writeByte(orientation).sendToChunk()),是方块实体自带的流,与 PacketCustom(channel, 1|2) 无关,且这里的 2 是 TileEntity 描述符的 key、不是本协议的包类型 2。详见 马达方块。

相关条目