Fix: socket auto-recast at every cooldown reopen (raw InputBufferData IsSet = ever-pressed)

Netcode accumulates InputEvent counts on the wire; only the decoded component is delta-corrected.
AbilityFireSystem's windup resolve gated on raw IsSet, so after the first press every socket
re-fired the instant its cooldown reopened (live repro: recasts at exactly t3933/t4353 with zero
input). Gate is now a count-STEP vs the previous tick's command; missing prior command = no-fire.
Also lands the G6 ConeContactPending schedule machinery + the cone test fixture (wire-true
monotonic counts) + regression Cast_Never_Refires_When_The_Cooldown_Reopens. 430/430; live proof:
one tap -> one cast -> silence across 2+ reopens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 13:28:08 -07:00
parent 040463ad07
commit 5569fcd552
3 changed files with 414 additions and 17 deletions
@@ -51,6 +51,8 @@ namespace ProjectM.Simulation
ComponentLookup<ZoneEffect> m_ZoneLookup;
ComponentLookup<DecoyTag> m_DecoyLookup;
ComponentLookup<LocalTransform> m_LtLookup;
// 07-21 G6: server-only scheduled Cone cleave (the MeleeCleavePending idiom on the socket kit).
ComponentLookup<ConeContactPending> m_ConePendingLookup;
/// <summary>~9 degree gap between adjacent Split-Shot projectiles (tunable).</summary>
const float k_ForkSpreadRad = 0.157f;
@@ -71,6 +73,7 @@ namespace ProjectM.Simulation
m_ZoneLookup = state.GetComponentLookup<ZoneEffect>(isReadOnly: true);
m_DecoyLookup = state.GetComponentLookup<DecoyTag>(isReadOnly: true);
m_LtLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true);
m_ConePendingLookup = state.GetComponentLookup<ConeContactPending>(isReadOnly: false);
}
[BurstCompile]
@@ -89,6 +92,9 @@ namespace ProjectM.Simulation
ref var adb = ref abilityDb.Value.Value;
bool isServer = state.WorldUnmanaged.IsServer();
// 07-21 G6: cone contact knob (0 = legacy immediate). Defaults() fallback matches release servers.
var tcfg = SystemAPI.TryGetSingleton<TuningConfig>(out var tcv) ? tcv : TuningConfig.Defaults();
uint coneContact = (uint)math.max(0f, tcfg.ConeContactTicks);
m_KnockbackLookup.Update(ref state);
m_BossLookup.Update(ref state);
m_BoonEffectsLookup.Update(ref state);
@@ -98,6 +104,7 @@ namespace ProjectM.Simulation
m_ZoneLookup.Update(ref state);
m_DecoyLookup.Update(ref state);
m_LtLookup.Update(ref state);
m_ConePendingLookup.Update(ref state);
// Server-only LIVING-enemy target set (auto-target assist + Cone cleave), collected once.
var candidatePositions = new NativeList<float3>(Allocator.Temp);
@@ -131,6 +138,28 @@ namespace ProjectM.Simulation
BoonEffects bfx = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity] : default;
bool pull = (bfx.Flags & BoonFlag.KnockToPull) != 0;
// 07-21 G6 (review wf_98bf1268): fire a DUE scheduled cone BEFORE the cast loop (the
// MeleeCleavePending idiom — wrap-safe elapsed compare, tick-batch-proof, consumed by zeroing).
// Server-only state; the client copy stays zero (contact cues are presentation-side). The armed
// socket is RE-VALIDATED (SetClass can swap the loadout mid-flight) — consume-drop on mismatch.
bool hasConePending = m_ConePendingLookup.HasComponent(entity);
if (isServer && hasConePending)
{
var pend = m_ConePendingLookup[entity];
if (pend.ResolveTick != 0u && !new NetworkTick(pend.ResolveTick).IsNewerThan(serverTick))
{
if (pend.Socket < sockets.Length && pend.Socket < effSockets.Length
&& adb.TryGetAbility(sockets[pend.Socket].SparkId, out var pendDef)
&& pendDef.Archetype == (byte)AbilityArchetype.Cone)
{
float2 pFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
FireCone(xform.ValueRO.Position, pFace, effSockets[pend.Socket], owner.ValueRO.NetworkId,
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup);
}
m_ConePendingLookup[entity] = default; // consume (drop on mismatch)
}
}
// Replicated command buffer (windup resolve + per-socket fire count for the SpawnId + scheme for aim assist).
var inputBuffer = SystemAPI.GetBuffer<InputBufferData<PlayerInput>>(entity);
@@ -156,7 +185,17 @@ namespace ProjectM.Simulation
resolveTick = new NetworkTick(riNow - (uint)adef.WindupTicks);
}
if (!inputBuffer.GetDataAtTick(resolveTick, out var applied)) continue; // history gap -> no-fire
if (!applied.InternalInput.GetSocket(sk).IsSet) continue; // socket not fired at the resolve tick
// 07-21 AUTO-RECAST FIX (live repro: one press → a recast at EVERY cooldown reopen, forever):
// the netcode copy layer ACCUMULATES InputEvent counts on the wire — a raw buffer entry's
// IsSet means "ever pressed", not "pressed THIS tick" (only the decoded COMPONENT is
// delta-corrected). A press AT resolveTick = a count STEP vs the previous tick's command
// (Netcode's own decode semantics). Missing previous command → no-fire (dropping a
// buffer-edge windup press beats an infinite recast loop).
var prevTick = resolveTick;
prevTick.Decrement();
if (!inputBuffer.GetDataAtTick(prevTick, out var prevCmd)) continue;
if (applied.InternalInput.GetSocket(sk).Count == prevCmd.InternalInput.GetSocket(sk).Count)
continue; // no NEW press at the resolve tick
// Per-socket cooldown gate (0 = ready).
uint nextFireRaw = cd.Get(sk);
@@ -166,28 +205,38 @@ namespace ProjectM.Simulation
if (nextTick.IsValid && nextTick.IsNewerThan(serverTick)) continue;
}
// CONE: no projectile ghost. Predict the cooldown on both worlds; apply server-only cleave.
// CONE (SpecialSlam): no projectile ghost. Predict the cooldown on both worlds; server-only cleave.
// 07-21 G6 (review wf_98bf1268): with the contact knob armed, damage lands at the slam's visual
// contact via ConeContactPending (schedule-and-consume); knob 0 / missing slot = legacy at-fire.
if (archetype == (byte)AbilityArchetype.Cone)
{
if (isServer)
{
float2 cFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); // manual-aim (07-15): cursor wins; facing fallback = resting gamepad stick
float cRange = math.max(0.1f, es.Range);
float cCosHalf = math.cos(math.clamp(es.AutoTargetConeRadians, 0.01f, 3.14159f));
uint cStamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
for (int ci = 0; ci < coneTargets.Length; ci++)
if (coneContact > 0u && hasConePending)
{
if (!MeleeConeMath.InCone(xform.ValueRO.Position, cFace, cRange, cCosHalf, coneTargetPos[ci]))
continue;
ecb.AppendToBuffer(coneTargets[ci], new DamageEvent
// EARLY-FLUSH a still-armed pending (re-validated) so no knob combination can lose a slam.
var armed = m_ConePendingLookup[entity];
if (armed.ResolveTick != 0u
&& armed.Socket < sockets.Length && armed.Socket < effSockets.Length
&& adb.TryGetAbility(sockets[armed.Socket].SparkId, out var flushDef)
&& flushDef.Archetype == (byte)AbilityArchetype.Cone)
{
Amount = es.Damage,
SourceNetworkId = owner.ValueRO.NetworkId,
SourceTick = cStamp,
});
KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, coneTargets[ci],
xform.ValueRO.Position, coneTargetPos[ci], cFace, Tuning.KnockbackSpeed,
TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), pull);
float2 fFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
FireCone(xform.ValueRO.Position, fFace, effSockets[armed.Socket], owner.ValueRO.NetworkId,
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup);
}
m_ConePendingLookup[entity] = new ConeContactPending
{
ResolveTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + coneContact),
Socket = (byte)sk,
};
}
else
{
// Legacy immediate (knob 0, or a plain test world without the baked pending slot).
float2 cFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); // manual-aim (07-15): cursor wins; facing fallback = resting gamepad stick
FireCone(xform.ValueRO.Position, cFace, es, owner.ValueRO.NetworkId,
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup);
}
}
cd.Set(sk, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, es.CooldownTicks)));
@@ -336,5 +385,31 @@ namespace ProjectM.Simulation
coneTargets.Dispose();
coneTargetPos.Dispose();
}
/// <summary>Resolve one Cone-socket cleave from LIVE state — shared by the legacy immediate path, the
/// due-fire and the early-flush (review wf_98bf1268: ONE resolve path, no drift). Server-only callers.</summary>
static void FireCone(float3 casterPos, float2 face, in EffectiveSocketStats es, int ownerNetId,
NetworkTick serverTick, bool pull, in NativeList<Entity> coneTargets,
in NativeList<float3> coneTargetPos, ref EntityCommandBuffer ecb,
ref ComponentLookup<KnockbackState> knockbackLookup, in ComponentLookup<BossState> bossLookup)
{
float cRange = math.max(0.1f, es.Range);
float cCosHalf = math.cos(math.clamp(es.AutoTargetConeRadians, 0.01f, 3.14159f));
uint cStamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
for (int ci = 0; ci < coneTargets.Length; ci++)
{
if (!MeleeConeMath.InCone(casterPos, face, cRange, cCosHalf, coneTargetPos[ci]))
continue;
ecb.AppendToBuffer(coneTargets[ci], new DamageEvent
{
Amount = es.Damage,
SourceNetworkId = ownerNetId,
SourceTick = cStamp,
});
KnockbackUtil.Stamp(ref knockbackLookup, bossLookup, coneTargets[ci],
casterPos, coneTargetPos[ci], face, Tuning.KnockbackSpeed,
TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), pull);
}
}
}
}