构成

复合包 · 逐项溯源 · 返回总条目

拉弓包最容易被误解的一点,是被当成“一个字段特别多的新包”。它不是。它的字段之所以多,是因为它是几个原版包在同一个 tick 里连续发出、本页把它们的字段列在一起,每一项都能在某个原版包里找到出处——没有一项是新造的字段。

本页把这七项逐条摊开,并在最后给出对着官方映射自查的方法。

序组成包高版本类名方向贡献的内容作用
1S32ClientboundPingPacket服务端 → 客户端Sequence序号往返的起点。这是 Sequence 唯一的真实来源
2C0FServerboundPongPacket客户端 → 服务端Sequence(回带)把 S32 的 id 原样送回。必须异步,否则会被主线程队列挤到下一个 tick
3C08ServerboundUseItemPacket客户端 → 服务端—重发一次使用物品,把服务端侧的 getTicksUsingItem() 归零
4C07ServerboundPlayerActionPacket客户端 → 服务端—释放使用物品(RELEASE_USE_ITEM)。所谓“半个”指的就是它:只有动作字段有效,位置与朝向是占位值
5C04 / C03ServerboundMovePlayerPacket客户端 → 服务端X、Y、Z、Yaw、Pitch、On Ground位移与朝向。复合包里这几项就是这一族包字段的并置
6S08ClientboundPlayerPositionPacket服务端 → 客户端位置基准服务端认为位置对不上时下发;拉弓包要求它延后应用
7本地状态LivingEntity.getTicksUsingItem()—Charge Ticks不是包字段,来自客户端本地计时

逐项说明

1. S32:序号的来源

S32 是 ClientboundPingPacket,原版用途很简单:服务端发一个 int 给客户端,客户端必须用 C0F 把这个值原样送回来。它原本用来测往返延迟,1.17 起还接管了原先由事务确认(C0F 的前身 ServerboundContainerAckPacket)承担的角色。

拉弓包的 Sequence 就是从这个 int 来的。这也解释了为什么这个字段不是随便一个自增值:它必须能对上一个服务端确实发出过的号,否则客户端无从得知该填什么。

2. C0F:为什么必须异步

C0F 是 ServerboundPongPacket。它唯一要做的事就是把 S32 带来的 id 送回去。

关键在发送方式。客户端的正常发包路径要经过主线程的 tick 队列,一个包如果在这一 tick 内排不上,就会延到下一 tick。而拉弓包要求这一来一回落在同一个 tick 里,所以 C0F 必须绕开队列、在收到 S32 的当下直接写进 netty 管道。这是整个序列里唯一不能等主线程的一步。

3. C08:为什么这次不射箭

C08 是 ServerboundUseItemPacket,也就是“我开始使用手上的物品了”。拿弓时它启动服务端的蓄力计时。

在本地计数已经 ≥ 20 之后再补发一次 C08,服务端会重新走一次 startUsingItem(),它自己那份 getTicksUsingItem() 因此归零。紧接着到来的松手在 BowItem.releaseUsing() 里算出的力度低于 0.1,判为“没拉满”,于是不生成箭矢。

这一步是全页少数能与源码逐行对照的环节:BowItem.getPowerForTime(20) 恰好等于 1.0,而低于 0.1 的力度不会射出箭。

4. C07:RELEASE_USE_ITEM

C07 是 ServerboundPlayerActionPacket,PlayerAction 的枚举里有一项 RELEASE_USE_ITEM。松开弓走的就是它。

所谓半个 C07,指的是这个包只带了一半有效参数:RELEASE_USE_ITEM 只有动作字段有意义,坐标固定填 BlockPos.ZERO、朝向固定填 DOWN,服务端不读它们,而是沿用此前的状态(正在使用的物品、玩家当前位置与朝向)。整个包照常带长度前缀、完整发出,“半个”说的是参数,不是字节。这与“半个拉弓包”不是一回事,见Serverbound 一侧。

5. C04 / C03:六个位移字段的出处

C04 是 ServerboundMovePlayerPacket.Pos,C03 是同一族的 StatusOnly。前者带坐标,后者只带状态位(含 onGround)。

复合包里的 X、Y、Z、Yaw、Pitch、On Ground 六项,就是这一族包的字段——不是新写的序列化逻辑,它们仍然分几条包各自发出,只是落在同一个 tick 里。

6. S08:收下,但不立刻用

S08 是 ClientboundPlayerPositionPacket。服务端发现客户端报的位置和自己算的对不上时,用它把玩家拉回来。

拉弓包对它的要求是反过来的:收到但不立即应用,压 15 – 20 tick 再处理。压的时间要长于一次拉弓包的滞空时间(约 14 tick),这样在拉回生效之前,下一次已经发出去了。注意压的是“应用”,不是“接收”——两条并不冲突。

7. Charge Ticks:唯一不是包字段的一项

LivingEntity.getTicksUsingItem() 返回“已使用当前物品的 tick 数”。它是实体上的本地状态,不属于任何一个包。复合包里之所以要有这一项,是因为服务端需要知道触发发生时的蓄力值,而它自己那份已经被第 3 步的重发 C08 清零了。

自查方法

上面七项都可以对照官方映射逐个验证,不需要抓包:

  1. 在映射里搜 ClientboundPingPacket,确认它只有一个 int 字段。
  2. 搜 ServerboundPongPacket,确认它同样只有一个 int,且构造器参数就叫 id。
  3. 搜 ServerboundUseItemPacket 与 ServerboundPlayerActionPacket,确认后者的枚举里有 RELEASE_USE_ITEM。
  4. 搜 BowItem.releaseUsing() 与 BowItem.getPowerForTime(),确认力度低于 0.1 时不射箭。
  5. 搜 ServerboundMovePlayerPacket,确认 Pos / PosRot / StatusOnly 三种变体的字段构成。

五条都能对上之后,剩下要确认的只有顺序:服务端收到的包按到达顺序排队,由主线程逐个处理,不会合并。所以同一 tick 里先到的 C08 一定先执行,C07 处理时看到的是已经被重置过的状态——拉弓包的效果就来自这个先后。三种说法对这一点没有分歧,分歧在别处,见理论模型。