using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
using Unity.NetCode.LowLevel;
namespace ProjectM.Client
{
///
/// Client-only predicted-spawn classifier for projectiles. When a predicted client fires, it
/// locally spawns a predicted projectile ghost; later the server's authoritative spawn arrives in
/// the . This system pairs the incoming server ghost with the matching
/// locally predicted entity so netcode reconciles them instead of double-spawning. The match key is
/// — a deterministic (ownerNetId << 16) | absoluteFireCount
/// value computed identically on client and server, replicated as a [GhostField] so it is
/// present in snapshot history and readable here via .
/// Mirrors the official Netcode HelloNetcode 02_PredictedSpawning GrenadeClassificationSystem,
/// with GrenadeData→ and GrenadeSpawner→.
/// Runs after the built-in (so any owner-predicted
/// default classification has already had a pass) and before the OrderLast
/// DefaultGhostSpawnClassificationSystem (so entries this system does NOT match still fall
/// through to the spawn-tick-window fallback).
///
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(GhostSpawnClassificationSystemGroup))]
[UpdateAfter(typeof(GhostSpawnClassificationSystem))]
[CreateAfter(typeof(GhostCollectionSystem))]
[CreateAfter(typeof(GhostReceiveSystem))]
// NOTE: intentionally NOT [BurstCompile]d. The cross-assembly generic
// SnapshotDataBufferComponentLookup.TryGetComponentDataFromSnapshotHistory<T>() trips a Burst
// internal compiler error (type-hash resolution) on Netcode 1.13.2. Classification only runs when
// ghost spawns are received (a cold path, not the prediction loop), so a managed job is fine here.
public partial struct ProjectileClassificationSystem : ISystem
{
SnapshotDataLookupHelper m_SnapshotDataLookupHelper;
BufferLookup m_PredictedGhostSpawnLookup;
ComponentLookup m_ProjectileLookup;
///
/// Resolved once in : the ghost-collection index of our projectile prefab.
/// -1 until the buffer has been populated and scanned.
///
int m_GhostType;
public void OnCreate(ref SystemState state)
{
// Match the built-in GhostSpawnClassificationSystem / DefaultGhostSpawnClassificationSystem:
// in a single-world host (a world flagged GameClient AND GameServer) there is no real client
// snapshot history to classify against, so the package's classification systems disable
// themselves. We do the same so we never run alone in that scenario after the system we
// UpdateAfter has switched itself off. (For Project M's standard separate ClientWorld +
// ServerWorld over IPC, IsHost() is false and this guard is a no-op.)
if (state.WorldUnmanaged.IsHost())
{
state.Enabled = false;
return;
}
// Build the snapshot lookup helper from the two collection singletons. CreateAfter on
// GhostCollectionSystem + GhostReceiveSystem guarantees both singletons exist by now.
m_SnapshotDataLookupHelper = new SnapshotDataLookupHelper(
ref state,
SystemAPI.GetSingletonEntity(),
SystemAPI.GetSingletonEntity());
m_PredictedGhostSpawnLookup = state.GetBufferLookup(true);
m_ProjectileLookup = state.GetComponentLookup(true);
state.RequireForUpdate();
state.RequireForUpdate();
state.RequireForUpdate();
state.RequireForUpdate();
m_GhostType = -1;
}
public void OnUpdate(ref SystemState state)
{
// Resolve our projectile ghost-type index once by scanning the ghost-collection prefab
// buffer for the spawner's prefab entity. The collection is populated only after the ghost
// prefabs have loaded, so retry each tick until found.
if (m_GhostType == -1)
{
var projectilePrefab = SystemAPI.GetSingleton().Prefab;
var ghostCollection = SystemAPI.GetSingletonEntity();
var prefabs = SystemAPI.GetBuffer(ghostCollection);
for (int i = 0; i < prefabs.Length; ++i)
{
if (prefabs[i].GhostPrefab == projectilePrefab)
{
m_GhostType = i;
break;
}
}
if (m_GhostType == -1)
return;
}
m_SnapshotDataLookupHelper.Update(ref state);
m_PredictedGhostSpawnLookup.Update(ref state);
m_ProjectileLookup.Update(ref state);
// SystemAPI is a system-context-only facade and cannot be used inside the IJobEntity; resolve
// the predicted-spawn-list singleton entity here and pass it into the job (this mirrors the
// built-in DefaultGhostSpawnClassificationJob.spawnListEntity pattern).
var classificationJob = new ProjectileClassificationJob
{
GhostType = m_GhostType,
SnapshotDataLookup = m_SnapshotDataLookupHelper.CreateSnapshotBufferLookup(),
PredictedSpawnListEntity = SystemAPI.GetSingletonEntity(),
PredictedGhostSpawnLookup = m_PredictedGhostSpawnLookup,
ProjectileLookup = m_ProjectileLookup,
};
state.Dependency = classificationJob.Schedule(state.Dependency);
}
///
/// For each newly received server spawn in a , attempts to find a
/// locally predicted projectile with the same read out of
/// snapshot history. On a match it points the queued spawn at the predicted entity (so netcode
/// adopts it instead of instantiating a duplicate), marks the entry classified, and removes that
/// predicted entry from the list. Entries this system does NOT match are left untouched so the
/// OrderLast DefaultGhostSpawnClassificationSystem fallback can still try the spawn-tick
/// window match.
///
[WithAll(typeof(GhostSpawnQueue))]
partial struct ProjectileClassificationJob : IJobEntity
{
public int GhostType;
public SnapshotDataBufferComponentLookup SnapshotDataLookup;
// Resolved in OnUpdate (SystemAPI is unavailable inside a job). A single Entity field, so it
// is NOT marked [ReadOnly].
public Entity PredictedSpawnListEntity;
[ReadOnly] public BufferLookup PredictedGhostSpawnLookup;
[ReadOnly] public ComponentLookup ProjectileLookup;
// 'data' is taken by value (NOT 'in') because TryGetComponentDataFromSnapshotHistory needs a
// mutable 'ref DynamicBuffer'. The built-in GhostSpawnClassification uses
// 'in' only because it never calls that ref overload — do not copy that here.
public void Execute(DynamicBuffer newSpawns, DynamicBuffer data)
{
var predictedSpawnList = PredictedGhostSpawnLookup[PredictedSpawnListEntity];
for (int i = 0; i < newSpawns.Length; ++i)
{
ref var newSpawn = ref newSpawns.ElementAt(i);
// Only classify our own ghost type, and only predicted spawns that have not already
// been matched/claimed (PredictedSpawnEntity == Null && !HasClassifiedPredictedSpawn)
// — leave everything else to the defaults.
if (newSpawn.GhostType != GhostType)
continue;
if (newSpawn.SpawnType != GhostSpawnBuffer.Type.Predicted ||
newSpawn.HasClassifiedPredictedSpawn ||
newSpawn.PredictedSpawnEntity != Entity.Null)
continue;
if (!SnapshotDataLookup.TryGetComponentDataFromSnapshotHistory(
newSpawn.GhostType, data, out Projectile incoming, i))
continue;
for (int j = 0; j < predictedSpawnList.Length; ++j)
{
if (predictedSpawnList[j].ghostType != GhostType)
continue;
var predictedEntity = predictedSpawnList[j].entity;
if (incoming.SpawnId == ProjectileLookup[predictedEntity].SpawnId)
{
// Claim the decision ONLY on a real match, so non-matches still fall through
// to the OrderLast default classifier (matches the official sample).
newSpawn.PredictedSpawnEntity = predictedEntity;
newSpawn.HasClassifiedPredictedSpawn = true;
predictedSpawnList.RemoveAtSwapBack(j);
break;
}
}
}
}
}
}
}