Files
Project-M/Assets/_Project/Scripts/Server/Connection/StaleRpcReaperSystem.cs
T
kronic 37b211a7f8 Netcode: fix interpolated-tick cues + add a terminal RPC reaper (audit M1/M4/M12)
M1 — two systems timed INTERPOLATED ghosts against the PREDICTED tick,
the exact hazard CLAUDE.md documents and the one that is invisible on
loopback:
- EnemyDangerTelegraphSystem timed the red danger cone off nt.ServerTick,
  so over a real connection the dodge tell finished ~RTT/2 + interp
  buffer EARLY. The cue lied.
- PlayerAnimationDriveSystem fed the same predicted tick to RemoteDriveJob
  ([WithDisabled(GhostOwnerIsLocal)] — i.e. interpolated teammates), so a
  teammate's swing animation desynced from their damage.
Both now use the ZoneTelegraphSystem idiom. The LOCAL drive job keeps
ServerTick: the owning player really is predicted.

M12 — the RPC leak I reproduced live during the audit. Every receiver in
this project gates on RequireForUpdate over a scene-baked singleton; in a
scene without it the receiver never runs and the request entity is never
destroyed. Netcode's WarnAboutStaleRpcSystem Consume()s but never
destroys, and is compiled out of player builds — so these accumulated
silently, and worst in a shipped build.
New StaleRpcReaperSystem (server, OrderLast, no RequireForUpdate) destroys
any unconsumed request that outlived its receiving frame. Consumed
requests are left to their owner. Verified live: a planted unconsumed
request is gone within a few frames. Three regression tests pin both
halves of the contract.

Also: HealthApplyDamageSystem and ProjectileDamageSystem now filter
.WithAll<Simulate>(). They are ServerSimulation-only so it was not a bug,
but the audit's "all predicted systems filter Simulate" reassurance was
false until now — the rule is unconditional again.

The five undisposed Allocator.Temp ECBs the audit flagged all lived in
systems the purge deleted; none remain.

298/298 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:17:47 -07:00

59 lines
3.0 KiB
C#

using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Terminal drain for RPC requests no receiver consumed. Destroys any entity still carrying an unconsumed
/// <see cref="ReceiveRpcCommandRequest"/> after it has survived a couple of frames, so a missing receiver
/// degrades to a silent drop instead of a permanent entity leak.
///
/// WHY (2026-08-06 audit, reproduced live): every RPC receiver in this project gates itself behind
/// <c>RequireForUpdate&lt;T&gt;</c> on a scene-baked singleton. In DevSandbox none of those singletons exist,
/// so eleven receivers never ran at all — one keypress on E/Q/G/T created a server request entity that
/// nothing ever visited. Netcode's own <c>WarnAboutStaleRpcSystem</c> logs "has not been consumed or
/// destroyed for 4 (MaxRpcAgeFrames) frames", then calls Consume() — it never destroys the entity, and it is
/// compiled out of player builds (<c>#if UNITY_EDITOR &amp;&amp; !NETCODE_NDEBUG</c>). So the entities
/// accumulated forever, silently, and worst in a shipped build.
///
/// ORDERING: OrderLast in the plain server <see cref="SimulationSystemGroup"/>, so every same-frame receiver
/// (including those inside the nested predicted group) has already had its chance. The Age gate is belt and
/// braces on top of that: Age increments per frame the request survives, so requiring >= 2 means the request
/// lived through a full frame unhandled before we reap it. Consumed requests are skipped — their owning
/// receiver is responsible for destroying them, and Consume() sets Age to ushort.MaxValue anyway.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup), OrderLast = true)]
public partial struct StaleRpcReaperSystem : ISystem
{
/// <summary>Frames an unconsumed request may survive before it is reaped. Must exceed 1 so a receiver
/// that legitimately runs later in the same frame is never robbed of its request.</summary>
const ushort k_MaxAgeFrames = 2;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
var b = new EntityQueryBuilder(Allocator.Temp).WithAll<ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(b));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (recv, entity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
{
if (recv.ValueRO.IsConsumed) continue; // a receiver owns it; not ours to destroy
if (recv.ValueRO.Age < k_MaxAgeFrames) continue;
ecb.DestroyEntity(entity);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
}
}