Files
Project-M/Assets/_Project/Scripts/Client/Presentation/PlayerAnimationDriveSystem.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

308 lines
19 KiB
C#

using ProjectM.Simulation;
using Rukhanka; // FastAnimatorParameter, AnimatorParametersAspect, ParameterValue,
// AnimatorControllerParameterComponent, AnimatorControllerParameterIndexTableComponent,
// RukhankaAnimationSystemGroup
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode; // GhostOwnerIsLocal, NetworkTime, NetworkTick
using Unity.Transforms; // LocalTransform
using Unity.CharacterController; // KinematicCharacterBody
namespace ProjectM.Client
{
/// <summary>
/// Client-only animation driver. OBSERVES authoritative/replicated state and writes Rukhanka animator
/// blend params; never mutates the sim (presentation-only). Runs once/frame in the client/local world,
/// BEFORE Rukhanka evaluates the controller this frame (same-frame, no 1-tick lag). NOTE: this lands in
/// SimulationSystemGroup (via UpdateBefore the Rukhanka group, which itself has no UpdateInGroup), a
/// deliberate, documented exception to the project's "all juice = PresentationSystemGroup" rule -- the
/// params MUST be set before Rukhanka's same-frame controller eval (see DR-022).
///
/// Drives MoveX/MoveZ/Speed/IsDead from locomotion + IsDead, plus IsAttacking (MC-4) pulsed from the
/// replicated <see cref="MeleeCombo"/> swing window so the AC_PlayerTopDown MeleeSwing state plays per swing.
///
/// Two paths:
/// LOCAL (owner-predicted, GhostOwnerIsLocal ENABLED): realized CC RelativeVelocity (wall-aware).
/// REMOTE (interpolated, GhostOwnerIsLocal DISABLED): KinematicCharacterBody is NOT a [GhostField] and
/// the CC processor is owner-only, so RelativeVelocity stays baked-zero on remotes -> derive
/// planar velocity from replicated LocalTransform.Position deltas. PlayerFacing.Direction is a
/// [GhostField] (valid on remotes); EffectiveCharacterStats.MoveSpeed is derived locally each
/// tick by StatRecomputeSystem (present on remotes). MeleeCombo replicates so teammates' swings
/// animate too. Cache prevPos per Entity, prune each frame.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.LocalSimulation | WorldSystemFilterFlags.ClientSimulation)]
[UpdateBefore(typeof(RukhankaAnimationSystemGroup))]
[RequireMatchingQueriesForUpdate]
public partial class PlayerAnimationDriveSystem : SystemBase
{
// Perfect-hash keys, built once (managed string ctor). Names MUST match AC_PlayerTopDown.controller
// parameter names exactly. Immutable readonly hashes -> domain-reload safe.
static readonly FastAnimatorParameter k_MoveX = new FastAnimatorParameter("MoveX");
static readonly FastAnimatorParameter k_MoveZ = new FastAnimatorParameter("MoveZ");
static readonly FastAnimatorParameter k_Speed = new FastAnimatorParameter("Speed");
static readonly FastAnimatorParameter k_IsDead = new FastAnimatorParameter("IsDead");
static readonly FastAnimatorParameter k_IsAttacking = new FastAnimatorParameter("IsAttacking");
static readonly FastAnimatorParameter k_IsFiring = new FastAnimatorParameter("IsFiring"); // Blender-authored A_Fire_OneHand (B7)
static readonly FastAnimatorParameter k_IsDashing = new FastAnimatorParameter("IsDashing"); // Blender-authored A_Dash_Lean (local player only - DashState is not replicated)
static readonly FastAnimatorParameter k_Bank = new FastAnimatorParameter("Bank");
static readonly FastAnimatorParameter k_StrideScale = new FastAnimatorParameter("StrideScale"); // 07-16b: Locomotion playback = planarSpeed / TrudgeNaturalSpeed (foot-skate fix) // 07-16: additive Posture layer — signed facing turn rate, local player only (subtle; not derived for remotes)
static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter("ComboStep"); // replicated MeleeCombo.Step -> per-step swing clips (Swing1/2/3)
static readonly FastAnimatorParameter k_IsCone = new FastAnimatorParameter("IsCone"); // ability archetype (replicated AbilityRef + blob): Cone -> two-hand slam, else the shot
static readonly FastAnimatorParameter k_InCombat = new FastAnimatorParameter("InCombat"); // 07-18: Menacing01 combat idle -- swing recency (FeelConfig.CombatIdleHoldSec)
// Ticks after a swing-start that IsAttacking stays true (drives the MeleeSwing state). Kept < the swing lock
// (MeleeRecoverTicks ~30) so a CHAINED swing re-pulses the bool false->true and re-triggers the Any State
// transition per hit. ~0.43s @ 60Hz (07-19 heavy retime). Presentation-only.
const uint k_AttackAnimTicks = PlayerAimSystem.CastFacingTicks; // structurally tied (07-16 review): body-yaw cast-turn and the swing anim pulse must agree
// Remote prevPos cache (per ghost Entity). Pruned every frame (a vanished remote = a despawn).
NativeParallelHashMap<Entity, float3> _prevPos;
// 07-16 banking: previous local-player facing + the smoothed bank value (main-thread state; one entity).
float2 _prevLocalFacing;
bool _bankInit;
float _bankSmoothed;
protected override void OnCreate()
{
_prevPos = new NativeParallelHashMap<Entity, float3>(16, Allocator.Persistent);
}
protected override void OnDestroy()
{
if (_prevPos.IsCreated) _prevPos.Dispose();
}
protected override void OnUpdate()
{
float dt = SystemAPI.Time.DeltaTime; // wall-frame delta is correct for presentation
if (dt < 1e-5f) dt = 1e-5f;
// Current authoritative tick for the swing-window check (default = invalid -> IsAttacking stays false).
NetworkTick serverTick = default, interpTick = default;
if (SystemAPI.TryGetSingleton<NetworkTime>(out var nt))
{
serverTick = nt.ServerTick; // predicted: local owner
interpTick = nt.InterpolationTick.IsValid ? nt.InterpolationTick : nt.ServerTick; // interpolated: remotes
}
// Ability blob for the per-class special read (Cone -> slam anim); default(BlobAssetReference) if absent.
var abilityBlob = SystemAPI.TryGetSingleton<AbilityDatabase>(out var adbSingleton) ? adbSingleton.Value : default;
// 07-18 combat idle: swing-recency window (the same wrap-safe SwingActive predicate as the anim pulse, wider). 60Hz sim ticks.
uint combatIdleTicks = (uint)math.max(1f, FeelConfig.CombatIdleHoldSec * 60f);
// 07-16 banking: signed facing turn rate -> Bank (-1..1, + = bank right, leaning INTO the turn).
// Sampled main-thread (a single local player), smoothed so the additive pose eases in and out.
float bankTarget = 0f;
foreach (var facingRO in SystemAPI.Query<RefRO<PlayerFacing>>().WithAll<GhostOwnerIsLocal>())
{
float2 f = facingRO.ValueRO.Direction;
if (_bankInit && math.lengthsq(f) > 1e-6f && math.lengthsq(_prevLocalFacing) > 1e-6f)
{
float2 a2 = math.normalize(_prevLocalFacing);
float2 b2 = math.normalize(f);
float signed = math.atan2(a2.x * b2.y - a2.y * b2.x, math.clamp(math.dot(a2, b2), -1f, 1f));
float degPerSec = math.degrees(signed) / dt;
bankTarget = math.clamp(-degPerSec / math.max(30f, FeelConfig.BankFullTurnDegPerSec), -1f, 1f);
}
_prevLocalFacing = f;
_bankInit = true;
break;
}
_bankSmoothed = math.lerp(_bankSmoothed, bankTarget, 1f - math.exp(-6f * dt));
// --- LOCAL owner (CC velocity) ---
var localJob = new LocalDriveJob
{
moveX = k_MoveX, moveZ = k_MoveZ, speed = k_Speed, isDead = k_IsDead,
isAttacking = k_IsAttacking, isFiring = k_IsFiring, isDashing = k_IsDashing,
comboStep = k_ComboStep, isCone = k_IsCone, abilityDb = abilityBlob,
serverTick = serverTick, attackTicks = k_AttackAnimTicks,
bank = k_Bank, bankValue = _bankSmoothed,
inCombat = k_InCombat, combatIdleTicks = combatIdleTicks,
strideScale = k_StrideScale, trudgeNaturalSpeed = math.max(0.5f, FeelConfig.TrudgeNaturalSpeed),
runNaturalSpeed = math.max(0.5f, FeelConfig.RunNaturalSpeed),
};
Dependency = localJob.ScheduleParallel(Dependency);
// --- REMOTE players (position-delta). Single-threaded write to the shared prevPos cache. ---
// Remote teammates are INTERPOLATED ghosts: their replicated MeleeCombo / SocketCooldown arrive on the
// interpolation timeline, so their swing windows must be timed against InterpolationTick. Using the
// predicted ServerTick here desynced a teammate's swing animation from their damage by ~RTT/2 +
// interp buffer — invisible on loopback (audit finding M1). The LOCAL job above stays on ServerTick:
// the owning player IS predicted.
var seen = new NativeParallelHashSet<Entity>(16, Allocator.TempJob);
var remoteJob = new RemoteDriveJob
{
moveX = k_MoveX, moveZ = k_MoveZ, speed = k_Speed, isDead = k_IsDead,
isAttacking = k_IsAttacking, isFiring = k_IsFiring, isDashing = k_IsDashing,
comboStep = k_ComboStep, isCone = k_IsCone, abilityDb = abilityBlob,
serverTick = interpTick, attackTicks = k_AttackAnimTicks,
dt = dt,
strideScale = k_StrideScale, trudgeNaturalSpeed = math.max(0.5f, FeelConfig.TrudgeNaturalSpeed),
runNaturalSpeed = math.max(0.5f, FeelConfig.RunNaturalSpeed),
inCombat = k_InCombat, combatIdleTicks = combatIdleTicks,
prevPos = _prevPos,
seen = seen,
};
Dependency = remoteJob.Schedule(Dependency); // .Schedule (not parallel): mutates _prevPos
// Prune stale entries (despawned remotes) AFTER the job, on the main thread.
Dependency.Complete();
PruneCache(seen);
seen.Dispose();
}
void PruneCache(NativeParallelHashSet<Entity> seen)
{
using var keys = _prevPos.GetKeyArray(Allocator.Temp);
for (int i = 0; i < keys.Length; i++)
if (!seen.Contains(keys[i])) _prevPos.Remove(keys[i]);
}
// The swing/fire tick-window predicates (SwingActive/FireActive/SocketFireAndCone) moved to
// TickWindowMath (Simulation/Combat) 07-15 — shared verbatim with the predicted facing path
// (PlayerAimSystem); this system passes k_AttackAnimTicks at the call sites.
// LOCAL: GhostOwnerIsLocal ENABLED -> exactly the owned player. WithPresent<Dead> so alive
// (Dead-disabled) players are visited. NOTE: GhostOwnerIsLocal as a WithAll filter respects the
// enable bit; do NOT take it as an `in` parameter (that matches on presence -> drives remotes too).
[BurstCompile]
[WithAll(typeof(GhostOwnerIsLocal))]
[WithPresent(typeof(Dead))]
partial struct LocalDriveJob : IJobEntity
{
public FastAnimatorParameter bank;
public float bankValue;
public FastAnimatorParameter inCombat;
public uint combatIdleTicks;
public FastAnimatorParameter strideScale;
public float trudgeNaturalSpeed;
public float runNaturalSpeed;
public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking, isFiring, isDashing, comboStep, isCone;
public BlobAssetReference<AbilityDatabaseBlob> abilityDb;
public NetworkTick serverTick;
public uint attackTicks;
void Execute(
AnimatorControllerParameterIndexTableComponent indexTable,
DynamicBuffer<AnimatorControllerParameterComponent> parametersArr,
in PlayerFacing facing,
in EffectiveCharacterStats stats,
in KinematicCharacterBody body,
in MeleeCombo melee,
in SocketCooldown socketCd,
[ReadOnly] DynamicBuffer<AbilitySocket> sockets,
[ReadOnly] DynamicBuffer<EffectiveSocketStats> effSockets,
in DashState dashState,
EnabledRefRO<Dead> dead)
{
var a = new AnimatorParametersAspect(parametersArr, indexTable);
float3 p = AnimParamMath.LocomotionParams(body.RelativeVelocity, facing.Direction, stats.MoveSpeed);
Write(ref a, p, dead.ValueRO, moveX, moveZ, speed, isDead);
if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, TickWindowMath.SwingActive(melee, serverTick, attackTicks));
TickWindowMath.SocketFireAndCone(socketCd, sockets, effSockets, abilityDb, serverTick, attackTicks, out bool firingL, out bool coneL);
if (a.HasParameter(isFiring)) a.SetParameterValue(isFiring, firingL);
// Dash lean: the LOCAL player's predicted DashState window [StartTick, IFrameUntilTick+tail).
bool dashActive = false;
if (dashState.StartTick != 0u && dashState.IFrameUntilTick != 0u && serverTick.IsValid)
{
var dStart = new NetworkTick(dashState.StartTick);
var dEnd = new NetworkTick(TickUtil.NonZero(dashState.IFrameUntilTick + 6u));
dashActive = dStart.IsValid && dEnd.IsValid && !dStart.IsNewerThan(serverTick) && dEnd.IsNewerThan(serverTick);
}
if (a.HasParameter(isDashing)) a.SetParameterValue(isDashing, dashActive);
if (a.HasParameter(bank)) a.SetParameterValue(bank, bankValue);
if (a.HasParameter(strideScale))
a.SetParameterValue(strideScale, StrideScaleValue(math.length(body.RelativeVelocity.xz), stats.MoveSpeed, trudgeNaturalSpeed, runNaturalSpeed));
if (a.HasParameter(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step);
if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneL);
if (a.HasParameter(inCombat)) a.SetParameterValue(inCombat, TickWindowMath.SwingActive(melee, serverTick, combatIdleTicks));
}
}
// REMOTE: GhostOwnerIsLocal DISABLED -> interpolated teammates. Velocity from LocalTransform.Position
// delta (KinematicCharacterBody.RelativeVelocity is baked-zero on remotes, non-GhostField, owner-only).
[BurstCompile]
[WithDisabled(typeof(GhostOwnerIsLocal))]
[WithPresent(typeof(Dead))]
partial struct RemoteDriveJob : IJobEntity
{
public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking, isFiring, isDashing, comboStep, isCone;
public BlobAssetReference<AbilityDatabaseBlob> abilityDb;
public NetworkTick serverTick;
public uint attackTicks;
public float dt;
public FastAnimatorParameter strideScale;
public float trudgeNaturalSpeed;
public float runNaturalSpeed;
public NativeParallelHashMap<Entity, float3> prevPos;
public NativeParallelHashSet<Entity> seen;
public FastAnimatorParameter inCombat;
public uint combatIdleTicks;
void Execute(
Entity e,
AnimatorControllerParameterIndexTableComponent indexTable,
DynamicBuffer<AnimatorControllerParameterComponent> parametersArr,
in LocalTransform xform,
in PlayerFacing facing,
in EffectiveCharacterStats stats,
in MeleeCombo melee,
in SocketCooldown socketCd,
[ReadOnly] DynamicBuffer<AbilitySocket> sockets,
[ReadOnly] DynamicBuffer<EffectiveSocketStats> effSockets,
EnabledRefRO<Dead> dead)
{
seen.Add(e);
float3 cur = xform.Position;
float3 vel = float3.zero;
if (prevPos.TryGetValue(e, out var prev))
vel = (cur - prev) / dt;
prevPos[e] = cur;
var a = new AnimatorParametersAspect(parametersArr, indexTable);
float3 p = AnimParamMath.LocomotionParams(vel, facing.Direction, stats.MoveSpeed);
Write(ref a, p, dead.ValueRO, moveX, moveZ, speed, isDead);
if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, TickWindowMath.SwingActive(melee, serverTick, attackTicks));
TickWindowMath.SocketFireAndCone(socketCd, sockets, effSockets, abilityDb, serverTick, attackTicks, out bool firingR, out bool coneR);
if (a.HasParameter(isFiring)) a.SetParameterValue(isFiring, firingR);
if (a.HasParameter(isDashing)) a.SetParameterValue(isDashing, false); // DashState is not replicated to remotes
if (a.HasParameter(strideScale))
a.SetParameterValue(strideScale, StrideScaleValue(math.length(vel.xz), stats.MoveSpeed, trudgeNaturalSpeed, runNaturalSpeed));
if (a.HasParameter(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step);
if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneR);
if (a.HasParameter(inCombat)) a.SetParameterValue(inCombat, TickWindowMath.SwingActive(melee, serverTick, combatIdleTicks));
}
}
// 07-16e: playback correction against the walk→run BLENDED natural — the two-ring gait tree picks
// the clip; this keeps residual cadence honest between/beyond the rings. Pure, Burst-safe.
static float StrideScaleValue(float planarSpeed, float maxSpeed, float walkNatural, float runNatural)
{
float maxSp = math.max(0.5f, maxSpeed);
float walkRing = walkNatural / maxSp;
float t = math.saturate((planarSpeed / maxSp - walkRing) / math.max(0.05f, 1f - walkRing));
float natural = math.lerp(walkNatural, runNatural, t);
return math.clamp(planarSpeed / math.max(0.3f, natural), 0.6f, 2.2f);
}
// ParameterValue has implicit float/bool operators -> SetParameterValue(key, float) / (key, bool) compile.
static void Write(ref AnimatorParametersAspect a, float3 p, bool isDeadVal,
FastAnimatorParameter moveX, FastAnimatorParameter moveZ,
FastAnimatorParameter speed, FastAnimatorParameter isDead)
{
if (a.HasParameter(moveX)) a.SetParameterValue(moveX, p.x);
if (a.HasParameter(moveZ)) a.SetParameterValue(moveZ, p.y);
if (a.HasParameter(speed)) a.SetParameterValue(speed, p.z);
if (a.HasParameter(isDead)) a.SetParameterValue(isDead, isDeadVal);
}
}
}