37b211a7f8
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>
59 lines
3.0 KiB
C#
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<T></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 && !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();
|
|
}
|
|
}
|
|
}
|