Description
Redundant per-tick ClientboundSetHealthPacket forwarding triggers GrimAC movement setbacks for controlling clients
Summary
When a client is controlling the proxy on a server running GrimAC, ZenithProxy forwards a ClientboundSetHealthPacket to the controlling client on (nearly) every server tick, even when health/food/saturation are unchanged. GrimAC emits a transaction (ClientboundPing) sandwich for every SetHealth packet it sends, so this roughly doubles the transaction rate and delivers transactions in bursts. That corrupts Grim's movement clock and produces a stream of Simulation (and Timer) setbacks.
For a human player this is mostly invisible (manual movement tolerates the rubber-banding), but for an automated/pathfinding client it is severe: the client crawls at a fraction of normal walking speed or gets stuck in a setback loop, because a pathfinder cannot absorb constant sub-block rewinds.
Environment
- ZenithProxy
1.21.4 branch (observed on current HEAD, MCProtocolLib 1.21.4.62)
- Backend: a 1.21.4 server running GrimAC (a local Folia test server here; 2b2t also runs Grim)
- Controlling client: a headless/automated 1.21.4 client (no ViaVersion involved — native 1.21.4 on both hops)
How it was found
While debugging why an automated client walked normally when connected directly to the backend but crawled when connected through ZenithProxy (same account, same backend, same Grim), I compared the inbound packet streams on both paths:
- Direct to backend: ~0
SetHealth packets while idle; ~16–17 transactions/s (≈ 1 per tick, normal).
- Through ZenithProxy: ~22
SetHealth(20/20/20) per second while completely idle (not moving, not placing) — a constant, unchanging value — and ~40+ transactions/s arriving in bursts.
Enabling ZenithProxy's own packet log (debug.packetLog, filter sethealth) confirmed the packets arrive from the backend on the ClientSession and are forwarded 1:1 to the controlling client:
[ClientSession] Received: ClientboundSetHealthPacket(health=20.0, food=20, saturation=20.0)
[ServerSession] Sending: ClientboundSetHealthPacket(health=20.0, food=20, saturation=20.0)
On the Grim side, PacketPlayerRespawn#onPacketSend (the UPDATE_HEALTH handler) only early-returns for pre-1.9 servers, so on modern servers it calls player.sendTransaction() and queues another transaction after send for every health packet — two transactions per redundant health update. At ~20 redundant packets/s this is what desynchronizes Grim's clock and yields constant Simulation setbacks.
Local patch (for validation)
I patched SetHealthHandler to still update the cache and fire events for every packet, but to suppress forwarding a SetHealth to the controlling client(s) when it is byte-for-byte identical to the last one forwarded. A freshly connected controller still receives its current health through the login cache sync (EntityPlayer#addPackets), so nothing observable changes client-side; only the redundant per-tick stream is dropped.
Built as a shadowJar and deployed to two live instances. Results, same map/route/account, through the proxy:
| Metric |
Before |
After |
SetHealth to client (idle) |
~22/s |
0/s |
Grim Simulation setbacks |
constant |
0 |
Grim Timer setbacks |
constant |
0 |
| Automated client throughput |
crawl / stuck |
normal walking speed |
Proposal
De-duplicate identical consecutive SetHealth packets in the forward-to-client path (only forward when health/food/saturation actually change). I'm happy to open a PR with the patch I've been running.
Open question for the maintainer
Is the current 1:1 forwarding intentional (e.g. some client/anticheat elsewhere relies on the per-tick health stream), or is this an unintended passthrough? If suppressing is undesirable by default, it could be gated behind a config flag. Deferring to your judgment on the right approach here.
Description
Redundant per-tick
ClientboundSetHealthPacketforwarding triggers GrimAC movement setbacks for controlling clientsSummary
When a client is controlling the proxy on a server running GrimAC, ZenithProxy forwards a
ClientboundSetHealthPacketto the controlling client on (nearly) every server tick, even when health/food/saturation are unchanged. GrimAC emits a transaction (ClientboundPing) sandwich for everySetHealthpacket it sends, so this roughly doubles the transaction rate and delivers transactions in bursts. That corrupts Grim's movement clock and produces a stream ofSimulation(andTimer) setbacks.For a human player this is mostly invisible (manual movement tolerates the rubber-banding), but for an automated/pathfinding client it is severe: the client crawls at a fraction of normal walking speed or gets stuck in a setback loop, because a pathfinder cannot absorb constant sub-block rewinds.
Environment
1.21.4branch (observed on currentHEAD, MCProtocolLib1.21.4.62)How it was found
While debugging why an automated client walked normally when connected directly to the backend but crawled when connected through ZenithProxy (same account, same backend, same Grim), I compared the inbound packet streams on both paths:
SetHealthpackets while idle; ~16–17 transactions/s (≈ 1 per tick, normal).SetHealth(20/20/20)per second while completely idle (not moving, not placing) — a constant, unchanging value — and ~40+ transactions/s arriving in bursts.Enabling ZenithProxy's own packet log (
debug.packetLog, filtersethealth) confirmed the packets arrive from the backend on the ClientSession and are forwarded 1:1 to the controlling client:On the Grim side,
PacketPlayerRespawn#onPacketSend(theUPDATE_HEALTHhandler) only early-returns for pre-1.9 servers, so on modern servers it callsplayer.sendTransaction()and queues another transaction after send for every health packet — two transactions per redundant health update. At ~20 redundant packets/s this is what desynchronizes Grim's clock and yields constantSimulationsetbacks.Local patch (for validation)
I patched
SetHealthHandlerto still update the cache and fire events for every packet, but to suppress forwarding aSetHealthto the controlling client(s) when it is byte-for-byte identical to the last one forwarded. A freshly connected controller still receives its current health through the login cache sync (EntityPlayer#addPackets), so nothing observable changes client-side; only the redundant per-tick stream is dropped.Built as a shadowJar and deployed to two live instances. Results, same map/route/account, through the proxy:
SetHealthto client (idle)SimulationsetbacksTimersetbacksProposal
De-duplicate identical consecutive
SetHealthpackets in the forward-to-client path (only forward when health/food/saturation actually change). I'm happy to open a PR with the patch I've been running.Open question for the maintainer
Is the current 1:1 forwarding intentional (e.g. some client/anticheat elsewhere relies on the per-tick health stream), or is this an unintended passthrough? If suppressing is undesirable by default, it could be gated behind a config flag. Deferring to your judgment on the right approach here.