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>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression pin for the RPC-leak class the 2026-08-06 audit reproduced live: every RPC receiver in this
|
||||
/// project gates itself behind RequireForUpdate on a scene-baked singleton, so in a scene missing that
|
||||
/// singleton the receiver never runs and the request entity is never destroyed. Netcode's own stale-RPC
|
||||
/// warning Consume()s but never destroys, and is compiled out of player builds — so the entities accumulated
|
||||
/// forever, worst in a shipped build.
|
||||
///
|
||||
/// <see cref="StaleRpcReaperSystem"/> is the terminal drain. These tests pin the two halves of its contract:
|
||||
/// an aged UNCONSUMED request is destroyed, and a CONSUMED one is left alone for its owning receiver.
|
||||
/// </summary>
|
||||
public class StaleRpcReaperSystemTests
|
||||
{
|
||||
[Test]
|
||||
public void Unconsumed_Request_Is_Destroyed_Once_It_Has_Aged()
|
||||
{
|
||||
var (world, group) = TestWorld.Make<StaleRpcReaperSystem>("ReaperAged", tick: 100, server: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = em.CreateEntity();
|
||||
// Age 2 == "survived a full frame unhandled" (the reaper's k_MaxAgeFrames).
|
||||
em.AddComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = Entity.Null, Age = 2 });
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.IsFalse(em.Exists(e),
|
||||
"an unconsumed RPC request that outlived its receiving frame must be reaped, not leaked forever.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Fresh_Request_Survives_So_A_Late_Receiver_Still_Sees_It()
|
||||
{
|
||||
var (world, group) = TestWorld.Make<StaleRpcReaperSystem>("ReaperFresh", tick: 100, server: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = Entity.Null, Age = 0 });
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.IsTrue(em.Exists(e),
|
||||
"a request that arrived this frame must NOT be reaped — a receiver ordered later still owns it.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Consumed_Request_Is_Left_To_Its_Receiver()
|
||||
{
|
||||
var (world, group) = TestWorld.Make<StaleRpcReaperSystem>("ReaperConsumed", tick: 100, server: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = em.CreateEntity();
|
||||
var recv = new ReceiveRpcCommandRequest { SourceConnection = Entity.Null, Age = 250 };
|
||||
recv.Consume();
|
||||
em.AddComponentData(e, recv);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.IsTrue(em.Exists(e),
|
||||
"a CONSUMED request belongs to the receiver that consumed it; the reaper must not race it.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user