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; } } } } } } }