Serverbound 一侧
半包规则 · 服务端校验 · 返回总条目
客户端在收到首个 Clientbound 一侧的东西之后 1 tick 内,有概率可以主动发出这一侧。它同样不是新注册的包类型,字段来自 C08、半个 C07 与 C04/C03 族。
| Packet ID | State | Bound To | Field Name | Field Type | Notes |
|---|---|---|---|---|---|
| 独立 ID 无 由组成包 各自的 ID 承载 |
Play | Server | X | Double | 发包时玩家的绝对坐标;来源 C04/C03 族。服务端据此校验发包位置。 |
| Y | Double | ||||
| Z | Double | ||||
| Yaw | Float | 水平视角,单位为度。发半个时省略,服务端沿用上一个移动包的值。 | |||
| Pitch | Float | 垂直视角,单位为度。发半个时省略,服务端沿用上一个移动包的值。 | |||
| On Ground | Boolean | 发包时是否在地面。每个移动包都带这一位,半个也不例外。 | |||
| Charge Ticks | VarInt | 发包时客户端本地 getTicksUsingItem() 的值,不是包字段。 | |||
| Sequence | VarInt | 必须等于打开窗口的那个 Clientbound 一侧回带过的 Sequence(即那次 S32 的 id)。 |
“半个”是什么意思
“半个”不是把一个包截掉一半发出去——协议里每个包都带长度前缀、按帧解码,少一个字节服务端都会读错。它指的是只带一半参数,另一半和上一个包一样,由服务端沿用。原版移动包本来就是这么设计的:ServerboundMovePlayerPacket 有四个变体,各自有独立的包 ID,变体本身就决定带哪些字段。
| 写法 | 移动部分用的包 | 线上字段 | 字节数 | 没带的部分 |
|---|---|---|---|---|
| 一个 | C06(PosRot) |
X, Y, Z, Yaw, Pitch, On Ground | 8 × 3 + 4 × 2 + 1 = 33 | 无,全部由客户端给出 |
| 半个 | C04(Pos) |
X, Y, Z, On Ground | 8 × 3 + 1 = 25 | Yaw、Pitch 沿用上一个移动包的值 |
Charge Ticks 与 Sequence 不在移动包里:前者是客户端本地计时,后者由 C0F 带回。两种写法里它们都一样。
服务端校验顺序
服务端收到这一侧之后依次检查三项,任何一项不通过就整包丢弃:
- 手持物:主手必须仍然是弓。副手持弓不算,中途切槽会使这一次作废。
- 蓄力:Charge Ticks ≥ 20。低于 20 的值直接拒绝,不做插值也不做容差。
- 配对:Sequence 必须等于窗口打开时那个值。过期或伪造的号一律丢弃。
三项都过了之后,这一轮才算成立——注意服务端并不合并这些包,它们仍按到达顺序逐个执行。这个顺序也解释了为什么大多数失败发生在第 3 项:手持物和蓄力在客户端侧很容易满足,配对号却依赖那一次 S32 / C0F 往返是否落在同一 tick。
为什么需要“半个”
完整布局要把坐标、朝向、状态全部写一遍,共 24 字节的坐标加 8 字节朝向。而在窗口只有 1 tick 的情况下,客户端手里的朝向通常和上一次移动包报上去的完全一样——重复发一遍不增加信息,只增加占用。
半个写法把这部分省掉,换用不带朝向的 C04,服务端沿用上一个包的朝向。代价是客户端必须确信服务端那份状态是新的,而这正是 Sequence 要保证的事:配对号对得上,说明窗口就是刚开的那个,服务端手里那份状态也刚更新过。
与这一侧对应的另一半见 Clientbound 一侧;窗口如何打开见握手序列。