LANTERN P1 step 6: manual-aim windups (SoD readable telegraphs)

AbilityDefBlob/AbilityDefinition gain WindupTicks. AbilityFireSystem's socket
loop resolves each socket at the historical command (serverTick - WindupTicks)
via GetDataAtTick — a pure fn of replicated data on both worlds (Build Spec §4/
DB-6: no local fireAtTick that would diverge under reprediction; fireCount from
that command, spawn uses the current tick's aim/pos; history gap = no-fire).
WindupTicks set on the 4 non-Blink Sparks (Blink is movement, resolved by
BlinkSystem). 497 EditMode green (no regression). Client telegraph VFX + the
reprediction L3 test are operator-eyes / netcode-world follow-ups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 08:35:20 -07:00
parent 98c8da3fd7
commit 8f099c32ba
8 changed files with 27 additions and 7 deletions
@@ -52,6 +52,7 @@ namespace ProjectM.Authoring
CooldownTicks = def.CooldownTicks,
EffectFlags = (byte)(def.Reel ? ProjectileEffectFlag.Reel : (byte)0),
DurationTicks = def.EffectDurationTicks,
WindupTicks = def.WindupTicks,
Name = def.DisplayName,
};
}
@@ -33,6 +33,9 @@ namespace ProjectM.Authoring
public bool Reel = false;
[Min(0), Tooltip("Aoe/zone + decoy lifetime in ticks (0 = system default).")]
public int EffectDurationTicks = 0;
[Header("Manual-aim windup")]
[Min(0), Tooltip("Telegraph delay (ticks, ~60/s) before the ability resolves. 0 = instant. The SoD 'readable windup'.")]
public int WindupTicks = 0;
[Header("Prefab (baked into the prefab buffer, not the blob)")]
public GameObject ProjectilePrefab;
@@ -27,6 +27,7 @@ namespace ProjectM.Simulation
public int CooldownTicks;
public byte EffectFlags; // ProjectileEffectFlag bits stamped at spawn (the Reel bit ⇒ HookPull homes the hit target to the caster). Config, not replicated.
public int DurationTicks; // Aoe/zone + decoy lifetime, ticks (0 = system default). Reel leash is a system const.
public int WindupTicks; // manual-aim telegraph delay (ticks) before the ability resolves; 0 = instant (Build Spec §4/DB-6)
public FixedString64Bytes Name;
}
@@ -131,9 +131,8 @@ namespace ProjectM.Simulation
BoonEffects bfx = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity] : default;
bool pull = (bfx.Flags & BoonFlag.KnockToPull) != 0;
// Replicated command buffer at this tick (per-socket fire count for the SpawnId + scheme for aim assist).
// Replicated command buffer (windup resolve + per-socket fire count for the SpawnId + scheme for aim assist).
var inputBuffer = SystemAPI.GetBuffer<InputBufferData<PlayerInput>>(entity);
bool haveApplied = inputBuffer.GetDataAtTick(serverTick, out var applied);
bool cdDirty = false;
int socketCount = math.min(SocketId.Count, sockets.Length);
@@ -141,7 +140,23 @@ namespace ProjectM.Simulation
{
byte sparkId = sockets[sk].SparkId;
if (sparkId == 0) continue; // empty socket
if (!input.ValueRO.GetSocket(sk).IsSet) continue; // this socket not fired this tick
EffectiveSocketStats es = sk < effSockets.Length ? effSockets[sk] : default;
byte archetype = adb.TryGetAbility(sparkId, out var adef) ? adef.Archetype : (byte)AbilityArchetype.Projectile;
// MANUAL-AIM WINDUP (Build Spec §4/DB-6): resolve when the command WindupTicks ago had this socket
// set, so the resolve tick is a pure fn of replicated data on both worlds (no local fireAtTick that
// diverges under reprediction). fireCount comes from THAT command; the spawn uses the CURRENT tick's
// aim/position. WindupTicks == 0 = instant. A history gap (older than the buffer window) = no-fire.
NetworkTick resolveTick = serverTick;
if (adef.WindupTicks > 0)
{
uint riNow = serverTick.TickIndexForValidTick;
if (riNow <= (uint)adef.WindupTicks) continue; // not enough history yet -> no-fire
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
// Per-socket cooldown gate (0 = ready).
uint nextFireRaw = cd.Get(sk);
@@ -151,9 +166,6 @@ namespace ProjectM.Simulation
if (nextTick.IsValid && nextTick.IsNewerThan(serverTick)) continue;
}
EffectiveSocketStats es = sk < effSockets.Length ? effSockets[sk] : default;
byte archetype = adb.TryGetAbility(sparkId, out var adef) ? adef.Archetype : (byte)AbilityArchetype.Projectile;
// CONE: no projectile ghost. Predict the cooldown on both worlds; apply server-only cleave.
if (archetype == (byte)AbilityArchetype.Cone)
{
@@ -261,7 +273,6 @@ namespace ProjectM.Simulation
if (abilityPrefabs[i].Id == sparkId) { prefab = abilityPrefabs[i].Prefab; break; }
}
if (prefab == Entity.Null) continue;
if (!haveApplied) continue;
uint socketFireCount = applied.InternalInput.GetSocket(sk).Count;