diff --git a/Assets/NetworkAssets.asset b/Assets/NetworkAssets.asset index dd0d2c8a..5c362dee 100644 --- a/Assets/NetworkAssets.asset +++ b/Assets/NetworkAssets.asset @@ -102,6 +102,7 @@ MonoBehaviour: - {fileID: 11400000, guid: 3f5b5dff67a942289a9defa416b206f3, type: 2} - {fileID: 11400000, guid: d9e68ba3f8528bc4cbfd099ae712789c, type: 2} - {fileID: 11400000, guid: 03336e7833763664bb617f0c6b1f1e44, type: 2} + - {fileID: 11400000, guid: 93afd3489a7fc45419d11b759e9d4ec8, type: 2} _availableTypeNames: - UnityEngine.ScriptableObject, UnityEngine.CoreModule, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null @@ -429,7 +430,8 @@ MonoBehaviour: - 3f5b5dff67a942289a9defa416b206f3 - d9e68ba3f8528bc4cbfd099ae712789c - 03336e7833763664bb617f0c6b1f1e44 - _bakedIds: 000000000100000002000000030000000400000005000000060000000700000008000000090000000a0000000b0000000c0000000d0000000e0000000f000000100000001100000012000000130000001400000015000000160000001700000018000000190000001a0000001b0000001c0000001d0000001e0000001f000000200000002100000022000000230000002400000025000000260000002700000028000000290000002a0000002b0000002c0000002d0000002e0000002f000000300000003100000032000000330000003400000035000000360000003700000038000000390000003a0000003b0000003c0000003d0000003e0000003f000000400000004100000042000000430000004400000045000000460000004700000048000000490000004a0000004b0000004c0000004d0000004e0000004f0000005000000051000000 + - 93afd3489a7fc45419d11b759e9d4ec8 + _bakedIds: 000000000100000002000000030000000400000005000000060000000700000008000000090000000a0000000b0000000c0000000d0000000e0000000f000000100000001100000012000000130000001400000015000000160000001700000018000000190000001a0000001b0000001c0000001d0000001e0000001f000000200000002100000022000000230000002400000025000000260000002700000028000000290000002a0000002b0000002c0000002d0000002e0000002f000000300000003100000032000000330000003400000035000000360000003700000038000000390000003a0000003b0000003c0000003d0000003e0000003f000000400000004100000042000000430000004400000045000000460000004700000048000000490000004a0000004b0000004c0000004d0000004e0000004f000000500000005100000052000000 _bakedAssets: - {fileID: -944628639613478452, guid: 052faaac586de48259a63d0c4782560b, type: 3} - {fileID: 1120369429361536294, guid: 052faaac586de48259a63d0c4782560b, type: 3} @@ -513,6 +515,7 @@ MonoBehaviour: - {fileID: 11400000, guid: 3f5b5dff67a942289a9defa416b206f3, type: 2} - {fileID: 11400000, guid: d9e68ba3f8528bc4cbfd099ae712789c, type: 2} - {fileID: 11400000, guid: 03336e7833763664bb617f0c6b1f1e44, type: 2} + - {fileID: 11400000, guid: 93afd3489a7fc45419d11b759e9d4ec8, type: 2} _bakedPersistentIds: - 052faaac586de48259a63d0c4782560b - 052faaac586de48259a63d0c4782560b_1120369429361536294 @@ -596,3 +599,4 @@ MonoBehaviour: - 3f5b5dff67a942289a9defa416b206f3 - d9e68ba3f8528bc4cbfd099ae712789c - 03336e7833763664bb617f0c6b1f1e44 + - 93afd3489a7fc45419d11b759e9d4ec8 diff --git a/Assets/PredictionTests/README.md b/Assets/PredictionTests/README.md index d5f6197f..8009a3dd 100644 --- a/Assets/PredictionTests/README.md +++ b/Assets/PredictionTests/README.md @@ -47,13 +47,29 @@ Optional args: `-port`, `-serverHost`, `-connectTimeout`. Exit code is non-zero Policy regression scenarios are included in the normal suite. Pass `-policyRegressionScenariosOnly` to run just the bootstrap and the three focused policy scenarios. +## Interest management scenario + +Pass `-interestScenariosOnly` to run the bootstrap plus `InterestManagementScenario`. This focused +scenario requires a pure server and exactly two clients. It creates one player-owned +`PredictedTransform` anchor per client, two distant deterministic roots, and reciprocal owned +keyed-input movers. It verifies culling, both sides of the LOD hysteresis band, dormant +simulation/view behavior, hierarchy retention, deterministic and input-driven absolute-state +convergence on reentry, and a per-recipient packed-frame byte reduction while culled. + +``` +PurrDictionTests -batchmode -nographics -role server -count 2 -interestScenariosOnly -results server.json -logFile server.log +PurrDictionTests -batchmode -nographics -role client -count 2 -interestScenariosOnly -results client-1.json -logFile client-1.log +PurrDictionTests -batchmode -nographics -role client -count 2 -interestScenariosOnly -results client-2.json -logFile client-2.log +``` + ## Server load benchmark Pass `-serverLoadBenchmark` to run only the bootstrap plus `ServerLoadBenchmarkScenario`: a `BenchDriver` spawns `-benchObjects` (default 200) input-driven `BenchMover` identities, then the server samples the `WriteFrameOnServer` sub-markers (`WriteInputHistory`, `WriteStateDeltas`, `WriteFullFrame`, `WriteEventHandles`, `SendFrame`) plus client ack lag for `-benchSeconds` -(default 20) and reports them in the scenario result message. Use +(default 20), records frame payload bytes per recipient through `TickBandwidthProfiler`, and +reports the normalized `bytesPerClientTick` in the scenario result message. Use `Tools/PurrDiction/Analysis/Run Server Load Latency Sweep` (or `-executeMethod PurrNet.Prediction.Benchmarks.Editor.ServerLoadBenchmarkRunner.RunFromCommandLine`) to build the player and sweep several simulated latencies (`-slbLatencies "0,50,100,200"`, diff --git a/Assets/PredictionTests/Scripts/PredictionBootstrap.cs b/Assets/PredictionTests/Scripts/PredictionBootstrap.cs index 1169cea9..0b407ec1 100644 --- a/Assets/PredictionTests/Scripts/PredictionBootstrap.cs +++ b/Assets/PredictionTests/Scripts/PredictionBootstrap.cs @@ -71,6 +71,17 @@ private void Awake() return; } + if (CommandLineUtils.HasFlag("-interestScenariosOnly")) + { + _scenarios = new Scenario[] + { + this, + gameObject.AddComponent() + }; + _results = new ScenarioDetails?[_scenarios.Length]; + return; + } + if (CommandLineUtils.HasFlag("-includeHistoryStressScenario")) gameObject.AddComponent(); diff --git a/Assets/PredictionTests/Scripts/Scenarios/InterestManagementScenario.cs b/Assets/PredictionTests/Scripts/Scenarios/InterestManagementScenario.cs new file mode 100644 index 00000000..fb5931f0 --- /dev/null +++ b/Assets/PredictionTests/Scripts/Scenarios/InterestManagementScenario.cs @@ -0,0 +1,1901 @@ +using System; +using System.Collections.Generic; +using Cysharp.Threading.Tasks; +using PurrNet; +using PurrNet.Prediction; +using PurrNet.Prediction.Profiler; +using UnityEngine; + +public sealed class InterestManagementScenario : Scenario +{ + private const float Timeout = 90f; + private const float RootOffset = 100f; + private const float HoldTier0Distance = 35f; + private const float Tier1Distance = 45f; + private const float HoldTier1Distance = 35f; + private const float Tier2Distance = 75f; + private const float HoldTier2Distance = 65f; + private const float CullDistance = 105f; + private const float HoldCulledDistance = 95f; + private const float ReentryTier2Distance = 85f; + private const float ApproachDistance = 25f; + private const int InitialBarrier = 21000; + private const int HoldTier0Barrier = 21001; + private const int Tier1Barrier = 21002; + private const int HoldTier1Barrier = 21003; + private const int Tier2Barrier = 21004; + private const int HoldTier2Barrier = 21005; + private const int OwnerBarrier = 21006; + private const int OwnerReleaseBarrier = 21007; + private const int CulledBarrier = 21008; + private const int HoldCulledBarrier = 21009; + private const int ReentryTier2Barrier = 21010; + private const int ApproachBarrier = 21011; + private const int DigestChannel = 21012; + private const int FinalInputBarrier = 21013; + private const int FrameSampleCount = 30; + private const int RateSampleTicks = 32; + + private PredictionLODProfile _predictionProfile; + private NetworkLODProfile _networkProfile; + private GameObject _anchorPrefab; + private GameObject _probePrefab; + private GameObject _rateProbePrefab; + private GameObject _replayTierProbePrefab; + private GameObject _inputProbePrefab; + private int _anchorPrefabId; + private int _probePrefabId; + private int _rateProbePrefabId; + private int _replayTierProbePrefabId; + private int _inputProbePrefabId; + + private readonly List _players = new(2); + private InterestAnchorMarker _serverAnchorA; + private InterestAnchorMarker _serverAnchorB; + private InterestStateProbe _serverProbeA; + private InterestStateProbe _serverProbeB; + private InterestRateProbe _serverRateProbeA; + private InterestRateProbe _serverRateProbeB; + private InterestReplayTierProbe _serverReplayTierProbeA; + private InterestReplayTierProbe _serverReplayTierProbeB; + private InterestInputProbe _serverInputProbeA; + private InterestInputProbe _serverInputProbeB; + private InterestAnchorMarker _localAnchor; + private InterestStateProbe _localNearProbe; + private InterestRateProbe _localNearRateProbe; + private InterestReplayTierProbe _localNearReplayTierProbe; + private InterestInputProbe _localObservedInputProbe; + private PredictedObjectID _localNearProbeRoot; + private PredictedObjectID _localFarProbeRoot; + private PredictedObjectID _localNearRateProbeRoot; + private PredictedObjectID _localFarRateProbeRoot; + private PredictedObjectID _localNearReplayTierProbeRoot; + private PredictedObjectID _localFarReplayTierProbeRoot; + private PredictedObjectID _localObservedInputProbeRoot; + private Dictionary _visibleFrameBytes; + private Dictionary _culledFrameBytes; + private RateWriteSample _tier0Writes; + private RateWriteSample _tier1Writes; + private RateWriteSample _tier2Writes; + + public override void Setup(ScenarioContext ctx, NetworkManager manager) + { + _networkProfile = ScriptableObject.CreateInstance(); + _networkProfile.Configure(new[] + { + new NetworkLODTier + { + maxDistance = 30f, + hysteresis = 10f, + sendIntervalTicks = 1 + }, + new NetworkLODTier + { + maxDistance = 60f, + hysteresis = 10f, + sendIntervalTicks = 2 + }, + new NetworkLODTier + { + maxDistance = 90f, + hysteresis = 10f, + sendIntervalTicks = 4 + } + }, true); + + _predictionProfile = ScriptableObject.CreateInstance(); + _predictionProfile.Configure( + _networkProfile, + new[] + { + new PredictionLODTier { suggestedPolicy = PredictionPolicyOverride.KeepConfigured }, + new PredictionLODTier { suggestedPolicy = PredictionPolicyOverride.SoftCorrection }, + new PredictionLODTier { suggestedPolicy = PredictionPolicyOverride.ServerRelay } + }, + PredictionPolicyOverride.ServerRelay); + ctx.predictionManager.predictionLODProfile = _predictionProfile; + + InterestAnchorMarker.ResetInstances(); + InterestStateProbe.ResetInstances(); + InterestRateProbe.ResetInstances(); + InterestReplayTierProbe.ResetInstances(); + InterestInputProbe.ResetInstances(); + InterestInputReportGate.Reset(); + InterestRateReportGate.Reset(); + + _anchorPrefab = PredictionTestUtils.CreatePrefab("InterestAnchor"); + _anchorPrefab.AddComponent(); + _anchorPrefabId = ctx.predictionManager.predictedPrefabs.prefabs.Count; + PredictionTestUtils.RegisterPrefab(ctx, _anchorPrefab); + + _probePrefab = PredictionTestUtils.CreatePrefab("InterestProbe"); + _probePrefab.AddComponent(); + var probeBody = _probePrefab.AddComponent(); + probeBody.useGravity = false; + _probePrefab.AddComponent(); + _probePrefabId = ctx.predictionManager.predictedPrefabs.prefabs.Count; + PredictionTestUtils.RegisterPrefab(ctx, _probePrefab); + + _rateProbePrefab = PredictionTestUtils.CreatePrefab("InterestRateProbe"); + _rateProbePrefab.GetComponent().configuredPredictionPolicy = + PredictionPolicy.FullPrediction; + _rateProbePrefabId = ctx.predictionManager.predictedPrefabs.prefabs.Count; + PredictionTestUtils.RegisterPrefab(ctx, _rateProbePrefab); + + _replayTierProbePrefab = new GameObject("InterestReplayTierProbe"); + _replayTierProbePrefab.SetActive(false); + var replayBody = _replayTierProbePrefab.AddComponent(); + replayBody.useGravity = false; + _replayTierProbePrefab.AddComponent(); + var replayTierProbe = _replayTierProbePrefab.AddComponent(); + replayTierProbe.configuredPredictionPolicy = PredictionPolicy.FullPrediction; + UnityEngine.Object.DontDestroyOnLoad(_replayTierProbePrefab); + _replayTierProbePrefabId = ctx.predictionManager.predictedPrefabs.prefabs.Count; + PredictionTestUtils.RegisterPrefab(ctx, _replayTierProbePrefab); + + _inputProbePrefab = PredictionTestUtils.CreatePrefab("InterestInputProbe"); + _inputProbePrefabId = ctx.predictionManager.predictedPrefabs.prefabs.Count; + PredictionTestUtils.RegisterPrefab(ctx, _inputProbePrefab); + } + + public override async UniTask RunScenario(ScenarioContext ctx) + { + if (ctx.role == NetworkRole.Host || ctx.expectedConnections != 2) + return ScenarioResult.Fail("interest scenario requires one pure server and exactly two client processes"); + + if (!ctx.predictionManager.interest.enabled) + return ScenarioResult.Fail("prediction interest did not initialize with the runtime profile"); + + return await RunSplit(ctx, RunClient, RunServer); + } + + private async UniTask RunServer(ScenarioContext ctx) + { + var pm = ctx.predictionManager; + string stage = "players"; + + try + { + await UniTaskUtils.WaitWithTimeout( + () => CopyPlayers(pm), + Timeout, + ctx.cancellationToken); + + stage = "spawn"; + if (!CreateServerRoots(pm)) + return ScenarioResult.Fail("failed to create interest anchors or probes"); + + stage = "initial tiers"; + await UniTaskUtils.WaitWithTimeout( + () => HasNeighborhoodTiers(pm, 0), + Timeout, + ctx.cancellationToken); + await WaitServerTicks(pm, 24, ctx); + _tier0Writes = await SampleRateWrites(ctx, pm, 1); + if (_tier0Writes.failure != null) + return ScenarioResult.Fail(_tier0Writes.failure); + _visibleFrameBytes = await SampleFrameBytes(ctx); + await ScenarioBarrier.Wait(ctx, InitialBarrier, Timeout); + + stage = "tier 0 hysteresis"; + SetAnchorDistance(HoldTier0Distance); + await WaitServerTicks(pm, 16, ctx); + if (!HasNeighborhoodTiers(pm, 0)) + return ScenarioResult.Fail($"tier 0 hysteresis failed: {DescribeServerTiers(pm)}"); + await ScenarioBarrier.Wait(ctx, HoldTier0Barrier, Timeout); + + stage = "tier 1"; + SetAnchorDistance(Tier1Distance); + await UniTaskUtils.WaitWithTimeout( + () => HasNeighborhoodTiers(pm, 1), + Timeout, + ctx.cancellationToken); + await WaitServerTicks(pm, 24, ctx); + _tier1Writes = await SampleRateWrites(ctx, pm, 2); + if (_tier1Writes.failure != null) + return ScenarioResult.Fail(_tier1Writes.failure); + _serverRateProbeA.Freeze(1); + _serverRateProbeB.Freeze(1); + var convergenceResult = await InterestRateReportGate.Compare( + ctx, + 1, + _serverRateProbeA, + _serverRateProbeB, + Timeout); + if (!convergenceResult.success) + return convergenceResult; + _serverRateProbeA.Resume(); + _serverRateProbeB.Resume(); + await ScenarioBarrier.Wait(ctx, Tier1Barrier, Timeout); + + stage = "tier 1 hysteresis"; + SetAnchorDistance(HoldTier1Distance); + await WaitServerTicks(pm, 16, ctx); + if (!HasNeighborhoodTiers(pm, 1)) + return ScenarioResult.Fail($"tier 1 hysteresis failed: {DescribeServerTiers(pm)}"); + await ScenarioBarrier.Wait(ctx, HoldTier1Barrier, Timeout); + + stage = "tier 2"; + SetAnchorDistance(Tier2Distance); + await UniTaskUtils.WaitWithTimeout( + () => HasNeighborhoodTiers(pm, 2), + Timeout, + ctx.cancellationToken); + await WaitServerTicks(pm, 24, ctx); + _tier2Writes = await SampleRateWrites(ctx, pm, 4); + if (_tier2Writes.failure != null) + return ScenarioResult.Fail(_tier2Writes.failure); + var rateFailure = ValidateRateScaling(); + if (rateFailure != null) + return ScenarioResult.Fail(rateFailure); + _serverRateProbeA.Freeze(2); + _serverRateProbeB.Freeze(2); + convergenceResult = await InterestRateReportGate.Compare( + ctx, + 2, + _serverRateProbeA, + _serverRateProbeB, + Timeout); + if (!convergenceResult.success) + return convergenceResult; + _serverRateProbeA.Resume(); + _serverRateProbeB.Resume(); + await ScenarioBarrier.Wait(ctx, Tier2Barrier, Timeout); + + stage = "tier 2 hysteresis"; + SetAnchorDistance(HoldTier2Distance); + await WaitServerTicks(pm, 16, ctx); + if (!HasNeighborhoodTiers(pm, 2)) + return ScenarioResult.Fail($"tier 2 hysteresis failed: {DescribeServerTiers(pm)}"); + await ScenarioBarrier.Wait(ctx, HoldTier2Barrier, Timeout); + + stage = "owner exemption"; + SetAnchorDistance(Tier2Distance); + await WaitServerTicks(pm, 4, ctx); + pm.SetOwnership(_serverRateProbeA.rootObjectId, _players[0]); + pm.SetOwnership(_serverRateProbeB.rootObjectId, _players[1]); + await UniTaskUtils.WaitWithTimeout( + () => HasOwnedRateExemptionTiers(pm), + Timeout, + ctx.cancellationToken); + await WaitServerTicks(pm, 12, ctx); + await ScenarioBarrier.Wait(ctx, OwnerBarrier, Timeout); + + stage = "owner release"; + pm.SetOwnership(_serverRateProbeA.rootObjectId, null); + pm.SetOwnership(_serverRateProbeB.rootObjectId, null); + await UniTaskUtils.WaitWithTimeout( + () => HasNeighborhoodTiers(pm, 2), + Timeout, + ctx.cancellationToken); + await WaitServerTicks(pm, 12, ctx); + await ScenarioBarrier.Wait(ctx, OwnerReleaseBarrier, Timeout); + + stage = "cull"; + SetAnchorDistance(CullDistance); + await UniTaskUtils.WaitWithTimeout( + () => HasCulledNeighborhoodTiers(pm), + Timeout, + ctx.cancellationToken); + await WaitServerTicks(pm, 12, ctx); + _culledFrameBytes = await SampleFrameBytes(ctx); + var bandwidthFailure = ValidateFrameByteReduction(); + if (bandwidthFailure != null) + return ScenarioResult.Fail(bandwidthFailure); + await WaitServerTicks(pm, 40, ctx); + await ScenarioBarrier.Wait(ctx, CulledBarrier, Timeout); + + stage = "culled hysteresis"; + SetAnchorDistance(HoldCulledDistance); + await WaitServerTicks(pm, 16, ctx); + if (!HasCulledNeighborhoodTiers(pm)) + return ScenarioResult.Fail($"culled hysteresis failed: {DescribeServerTiers(pm)}"); + await ScenarioBarrier.Wait(ctx, HoldCulledBarrier, Timeout); + + stage = "tier 2 reentry"; + SetAnchorDistance(ReentryTier2Distance); + await UniTaskUtils.WaitWithTimeout( + () => HasNeighborhoodTiers(pm, 2), + Timeout, + ctx.cancellationToken); + await ScenarioBarrier.Wait(ctx, ReentryTier2Barrier, Timeout); + + stage = "approach"; + SetAnchorDistance(ApproachDistance); + await UniTaskUtils.WaitWithTimeout( + () => HasNeighborhoodTiers(pm, 0), + Timeout, + ctx.cancellationToken); + await ScenarioBarrier.Wait(ctx, ApproachBarrier, Timeout); + + BroadcastStopInputGeneration(); + stage = "input finalization"; + await UniTaskUtils.WaitWithTimeout( + () => _serverInputProbeA.currentState.finalized && _serverInputProbeB.currentState.finalized, + Timeout, + ctx.cancellationToken); + await ScenarioBarrier.Wait(ctx, FinalInputBarrier, Timeout); + + var inputResult = await InterestInputReportGate.Compare( + ctx, _serverInputProbeA, _serverInputProbeB, Timeout); + if (!inputResult.success) + return inputResult; + + if (!_serverProbeA.stateIsValid || !_serverProbeB.stateIsValid || + !_serverRateProbeA.stateIsValid || !_serverRateProbeB.stateIsValid) + return ScenarioResult.Fail("server probe state failed its checksum"); + + var digestResult = await DigestExchange.Compare( + ctx, + DigestChannel, + BuildDigest(pm, _serverProbeA, _serverRateProbeA), + Timeout); + return digestResult.success + ? ScenarioResult.Ok($"{digestResult.message};{FrameByteSummary()}") + : digestResult; + } + catch (TimeoutException) + { + return ScenarioResult.Fail($"server timed out during {stage}: {DescribeServerTiers(pm)}"); + } + } + + [ObserversRpc(runLocally: true)] + private static void BroadcastStopInputGeneration() + { + InterestInputProbe.StopInputGeneration(); + } + + private async UniTask RunClient(ScenarioContext ctx) + { + var pm = ctx.predictionManager; + string stage = "layout"; + + try + { + await UniTaskUtils.WaitWithTimeout( + () => ResolveLocalLayout(pm), + Timeout, + ctx.cancellationToken); + + stage = "initial tiers"; + await UniTaskUtils.WaitWithTimeout( + () => _localNearProbe.isRelevant && + _localNearRateProbe.isRelevant && + _localNearReplayTierProbe.isRelevant && + _localObservedInputProbe.isRelevant && + IsLocallyCulled(pm, _localFarProbeRoot) && + IsLocallyCulled(pm, _localFarRateProbeRoot) && + IsLocallyCulled(pm, _localFarReplayTierProbeRoot), + Timeout, + ctx.cancellationToken); + + if (!HasRetainedHierarchy(pm)) + return ScenarioResult.Fail("culled root was removed from the replicated hierarchy"); + if (pm.hierarchy.TryGetGameObject(_localFarProbeRoot, out _) || + pm.hierarchy.TryGetGameObject(_localFarRateProbeRoot, out _) || + pm.hierarchy.TryGetGameObject(_localFarReplayTierProbeRoot, out _)) + return ScenarioResult.Fail("initially culled roots were materialized locally"); + if (!_localObservedInputProbe.gameObject.activeInHierarchy) + return ScenarioResult.Fail("input-driven root was locally despawned or deactivated"); + if (!LocalOwnerIsExempt()) + return ScenarioResult.Fail("locally owned anchor did not retain its configured policy"); + if (_localNearProbe.GetComponent().isKinematic) + return ScenarioResult.Fail("visible raw rigidbody started frozen"); + + await UniTaskUtils.WaitWithTimeout( + () => _localNearProbe.localSimulationCalls >= 10 && + _localNearProbe.localViewUpdates >= 3 && + _localNearRateProbe.localSimulationCalls >= 10 && + _localNearRateProbe.localViewUpdates >= 3 && + _localNearReplayTierProbe.transitionAttempted, + Timeout, + ctx.cancellationToken); + if (!_localNearReplayTierProbe.transitionDuringReplay || + !_localNearReplayTierProbe.tierPolicyApplied || + !_localNearReplayTierProbe.replayPhysicsApplied || + !_localNearReplayTierProbe.tierRestored || + !_localNearReplayTierProbe.configuredPolicyRestored || + !_localNearReplayTierProbe.configuredPhysicsRestored) + { + return ScenarioResult.Fail( + $"mid-replay tier flip failed: {_localNearReplayTierProbe.TransitionDigest()}"); + } + var rateFailure = await WaitForRateConvergence(pm, _localNearRateProbe, 1, ctx); + if (rateFailure != null) + return ScenarioResult.Fail(rateFailure); + if (!HasLocalPolicies(PredictionPolicy.FullPrediction, PredictionPolicy.FullPrediction)) + return ScenarioResult.Fail($"tier 0 did not preserve configured policies: {DescribeClient(pm)}"); + uint initialInputs = _localObservedInputProbe.currentState.appliedInputs; + await ScenarioBarrier.Wait(ctx, InitialBarrier, Timeout); + + stage = "tier 0 hysteresis"; + await WaitClientTicks(pm, 24, ctx); + if (!HasLocalNeighborhoodTier(pm, 0) || + !HasLocalPolicies(PredictionPolicy.FullPrediction, PredictionPolicy.FullPrediction) || + _localObservedInputProbe.currentState.appliedInputs <= initialInputs) + return ScenarioResult.Fail($"tier 0 hysteresis or input continuity failed: {DescribeClient(pm)}"); + uint tier0Inputs = _localObservedInputProbe.currentState.appliedInputs; + await ScenarioBarrier.Wait(ctx, HoldTier0Barrier, Timeout); + + stage = "tier 1"; + await UniTaskUtils.WaitWithTimeout( + () => HasLocalNeighborhoodTier(pm, 1), + Timeout, + ctx.cancellationToken); + rateFailure = await WaitForRateConvergence(pm, _localNearRateProbe, 2, ctx); + if (rateFailure != null) + return ScenarioResult.Fail(rateFailure); + await UniTaskUtils.WaitWithTimeout( + () => _localObservedInputProbe.currentState.appliedInputs >= tier0Inputs + 10, + Timeout, + ctx.cancellationToken); + if (!HasLocalPolicies(PredictionPolicy.FullPrediction, PredictionPolicy.SoftCorrection)) + return ScenarioResult.Fail($"unsupported tier 1 SoftCorrection was not normalized: {DescribeClient(pm)}"); + if (!LocalOwnerIsExempt()) + return ScenarioResult.Fail("tier 1 overrode the locally owned anchor"); + await UniTaskUtils.WaitWithTimeout( + () => _localNearRateProbe.currentState.checkpoint == 1 && + !_localNearRateProbe.currentState.active, + Timeout, + ctx.cancellationToken); + InterestRateReportGate.Report(1, _localNearRateProbe); + uint tier1Inputs = _localObservedInputProbe.currentState.appliedInputs; + await ScenarioBarrier.Wait(ctx, Tier1Barrier, Timeout); + + stage = "tier 1 hysteresis"; + await WaitClientTicks(pm, 24, ctx); + if (!HasLocalNeighborhoodTier(pm, 1) || + _localObservedInputProbe.currentState.appliedInputs <= tier1Inputs) + return ScenarioResult.Fail($"tier 1 hysteresis or input continuity failed: {DescribeClient(pm)}"); + await ScenarioBarrier.Wait(ctx, HoldTier1Barrier, Timeout); + + stage = "tier 2"; + await UniTaskUtils.WaitWithTimeout( + () => HasLocalNeighborhoodTier(pm, 2), + Timeout, + ctx.cancellationToken); + rateFailure = await WaitForRateConvergence(pm, _localNearRateProbe, 4, ctx); + if (rateFailure != null) + return ScenarioResult.Fail(rateFailure); + await UniTaskUtils.WaitWithTimeout( + () => _localObservedInputProbe.currentState.appliedInputs >= tier1Inputs + 10, + Timeout, + ctx.cancellationToken); + if (!HasLocalPolicies(PredictionPolicy.ServerRelay, PredictionPolicy.ServerRelay)) + return ScenarioResult.Fail($"tier 2 did not apply relay policy: {DescribeClient(pm)}"); + if (!_localNearReplayTierProbe.rb.isKinematic) + return ScenarioResult.Fail("tier 2 relay policy did not apply its rigidbody side effect"); + if (!LocalOwnerIsExempt()) + return ScenarioResult.Fail("tier 2 overrode the locally owned anchor"); + await UniTaskUtils.WaitWithTimeout( + () => _localNearRateProbe.currentState.checkpoint == 2 && + !_localNearRateProbe.currentState.active, + Timeout, + ctx.cancellationToken); + InterestRateReportGate.Report(2, _localNearRateProbe); + uint tier2Inputs = _localObservedInputProbe.currentState.appliedInputs; + await ScenarioBarrier.Wait(ctx, Tier2Barrier, Timeout); + + stage = "tier 2 hysteresis"; + await WaitClientTicks(pm, 24, ctx); + if (!HasLocalNeighborhoodTier(pm, 2) || + !HasLocalPolicies(PredictionPolicy.ServerRelay, PredictionPolicy.ServerRelay) || + _localObservedInputProbe.currentState.appliedInputs <= tier2Inputs) + return ScenarioResult.Fail($"tier 2 hysteresis or input continuity failed: {DescribeClient(pm)}"); + await ScenarioBarrier.Wait(ctx, HoldTier2Barrier, Timeout); + + stage = "owner exemption"; + await UniTaskUtils.WaitWithTimeout( + () => _localNearRateProbe.owner == pm.localPlayer && + GetLocalTier(pm, _localNearRateProbe) == 0 && + _localNearRateProbe.predictionPolicy == PredictionPolicy.FullPrediction, + Timeout, + ctx.cancellationToken); + if (!_localNearRateProbe.isRelevant || + _localNearRateProbe.configuredPredictionPolicy != PredictionPolicy.FullPrediction) + return ScenarioResult.Fail($"owned rate root did not preserve configured policy: {DescribeClient(pm)}"); + await ScenarioBarrier.Wait(ctx, OwnerBarrier, Timeout); + + stage = "owner release"; + await UniTaskUtils.WaitWithTimeout( + () => !_localNearRateProbe.owner.HasValue && + GetLocalTier(pm, _localNearRateProbe) == 2 && + _localNearRateProbe.predictionPolicy == PredictionPolicy.ServerRelay, + Timeout, + ctx.cancellationToken); + rateFailure = await WaitForRateConvergence(pm, _localNearRateProbe, 4, ctx); + if (rateFailure != null) + return ScenarioResult.Fail(rateFailure); + await ScenarioBarrier.Wait(ctx, OwnerReleaseBarrier, Timeout); + + stage = "cull"; + await UniTaskUtils.WaitWithTimeout( + () => IsLocallyCulled(pm, _localNearProbeRoot) && + IsLocallyCulled(pm, _localNearRateProbeRoot) && + IsLocallyCulled(pm, _localObservedInputProbeRoot), + Timeout, + ctx.cancellationToken); + await UniTask.NextFrame(ctx.cancellationToken); + await UniTask.NextFrame(ctx.cancellationToken); + + uint frozenSimulations = _localNearProbe.currentState.simulations; + int frozenCalls = _localNearProbe.localSimulationCalls; + int frozenViews = _localNearProbe.localViewUpdates; + var frozenRateState = _localNearRateProbe.currentState; + int frozenRateCalls = _localNearRateProbe.localSimulationCalls; + int frozenRateViews = _localNearRateProbe.localViewUpdates; + var frozenInputState = _localObservedInputProbe.currentState; + int frozenInputCalls = _localObservedInputProbe.localSimulationCalls; + ulong dormantStart = pm.localTick; + await UniTaskUtils.WaitWithTimeout( + () => pm.localTick >= dormantStart + 30, + Timeout, + ctx.cancellationToken); + + if (_localNearProbe.currentState.simulations != frozenSimulations || + _localNearProbe.localSimulationCalls != frozenCalls || + _localNearProbe.localViewUpdates != frozenViews) + return ScenarioResult.Fail($"culled probe kept simulating or updating its view: {DescribeClient(pm)}"); + if (!_localNearRateProbe.StateEquals(frozenRateState) || + _localNearRateProbe.localSimulationCalls != frozenRateCalls || + _localNearRateProbe.localViewUpdates != frozenRateViews) + return ScenarioResult.Fail($"culled rate probe kept simulating or updating its view: {DescribeClient(pm)}"); + if (!_localObservedInputProbe.StateEquals(frozenInputState) || + _localObservedInputProbe.localSimulationCalls != frozenInputCalls) + return ScenarioResult.Fail($"culled input probe kept simulating: {DescribeClient(pm)}"); + if (!_localNearProbe.GetComponent().isKinematic) + return ScenarioResult.Fail("culled raw rigidbody remained dynamic"); + if (!HasRetainedHierarchy(pm) || + pm.hierarchy.TryGetGameObject(_localNearProbeRoot, out _) || + pm.hierarchy.TryGetGameObject(_localNearRateProbeRoot, out _) || + pm.hierarchy.TryGetGameObject(_localObservedInputProbeRoot, out _)) + return ScenarioResult.Fail("culled roots did not retain records without live instances"); + if (_localNearRateProbe.predictionPolicy != PredictionPolicy.ServerRelay || + _localNearRateProbe.configuredPredictionPolicy != PredictionPolicy.FullPrediction) + return ScenarioResult.Fail("culled dormancy did not remain stronger than the tier policy override"); + if (!_localNearReplayTierProbe.rb.isKinematic) + return ScenarioResult.Fail("culled replay-tier rigidbody remained dynamic"); + if (!LocalOwnerIsExempt()) + return ScenarioResult.Fail("culling overrode the locally owned anchor"); + + await ScenarioBarrier.Wait(ctx, CulledBarrier, Timeout); + + stage = "culled hysteresis"; + await WaitClientTicks(pm, 24, ctx); + if (!IsLocallyCulled(pm, _localNearProbeRoot) || + !IsLocallyCulled(pm, _localNearRateProbeRoot) || + !IsLocallyCulled(pm, _localObservedInputProbeRoot)) + return ScenarioResult.Fail($"culled hysteresis failed: {DescribeClient(pm)}"); + if (!HasRetainedHierarchy(pm)) + return ScenarioResult.Fail("culled hierarchy records did not survive pool expiry"); + await ScenarioBarrier.Wait(ctx, HoldCulledBarrier, Timeout); + + stage = "tier 2 absolute reentry"; + await UniTaskUtils.WaitWithTimeout( + () => RefreshLocalNearInstances(pm) && + _localNearProbe.isRelevant && + _localNearRateProbe.isRelevant && + _localNearProbe.currentState.simulations >= frozenSimulations + 20 && + _localNearProbe.stateIsValid, + Timeout, + ctx.cancellationToken); + await UniTaskUtils.WaitWithTimeout( + () => _localObservedInputProbe.isRelevant && + _localObservedInputProbe.currentState.appliedInputs >= frozenInputState.appliedInputs + 10, + Timeout, + ctx.cancellationToken); + await UniTaskUtils.WaitWithTimeout( + () => _localNearProbe.localSimulationCalls > 0 && + _localNearProbe.localViewUpdates > 0 && + _localNearRateProbe.localSimulationCalls > 0 && + _localNearRateProbe.localViewUpdates > 0, + Timeout, + ctx.cancellationToken); + rateFailure = await WaitForRateConvergence(pm, _localNearRateProbe, 4, ctx); + if (rateFailure != null) + return ScenarioResult.Fail(rateFailure); + + if (!IsLocallyCulled(pm, _localFarProbeRoot) || !IsLocallyCulled(pm, _localFarRateProbeRoot)) + return ScenarioResult.Fail("reentry made the other client's far root relevant"); + if (!HasRetainedHierarchy(pm)) + return ScenarioResult.Fail("hierarchy diverged across cull and reentry"); + if (_localNearProbe.GetComponent().isKinematic) + return ScenarioResult.Fail("reentered raw rigidbody remained frozen"); + if (!HasLocalNeighborhoodTier(pm, 2) || + !HasLocalPolicies(PredictionPolicy.ServerRelay, PredictionPolicy.ServerRelay)) + return ScenarioResult.Fail($"tier 2 reentry did not restore relay policy: {DescribeClient(pm)}"); + + await ScenarioBarrier.Wait(ctx, ReentryTier2Barrier, Timeout); + + stage = "approach"; + await UniTaskUtils.WaitWithTimeout( + () => HasLocalNeighborhoodTier(pm, 0) && + HasLocalPolicies(PredictionPolicy.FullPrediction, PredictionPolicy.FullPrediction), + Timeout, + ctx.cancellationToken); + rateFailure = await WaitForRateConvergence(pm, _localNearRateProbe, 1, ctx); + if (rateFailure != null) + return ScenarioResult.Fail(rateFailure); + if (_localNearRateProbe.configuredPredictionPolicy != PredictionPolicy.FullPrediction) + return ScenarioResult.Fail("approach did not restore the exact configured policy"); + if (_localNearReplayTierProbe.rb.isKinematic) + return ScenarioResult.Fail("approach restored policy but not authoritative rigidbody mode"); + await ScenarioBarrier.Wait(ctx, ApproachBarrier, Timeout); + + stage = "input finalization"; + await UniTaskUtils.WaitWithTimeout( + () => _localObservedInputProbe.currentState.finalized, + Timeout, + ctx.cancellationToken); + if (_localObservedInputProbe.currentState.travel.rawValue == 0 || + _localObservedInputProbe.currentState.appliedInputs <= frozenInputState.appliedInputs) + return ScenarioResult.Fail("input-driven probe did not move after absolute reentry"); + + InterestInputReportGate.Report(_localObservedInputProbe); + await ScenarioBarrier.Wait(ctx, FinalInputBarrier, Timeout); + return await DigestExchange.Compare( + ctx, + DigestChannel, + BuildDigest(pm, _localNearProbe, _localNearRateProbe), + Timeout); + } + catch (TimeoutException) + { + return ScenarioResult.Fail($"client timed out during {stage}: {DescribeClient(pm)}"); + } + } + + private bool CopyPlayers(PredictionManager pm) + { + var source = pm.players.players; + if (source.Count != 2) + return false; + + _players.Clear(); + _players.Add(source[0]); + _players.Add(source[1]); + _players.Sort((a, b) => a.id.value.CompareTo(b.id.value)); + return true; + } + + private bool CreateServerRoots(PredictionManager pm) + { + var anchorA = pm.hierarchy.Create(_anchorPrefab, new Vector3(-RootOffset, 0f, 0f), Quaternion.identity, _players[0]); + var anchorB = pm.hierarchy.Create(_anchorPrefab, new Vector3(RootOffset, 0f, 0f), Quaternion.identity, _players[1]); + var probeA = pm.hierarchy.Create(_probePrefab, new Vector3(-RootOffset, 0f, 0f), Quaternion.identity); + var probeB = pm.hierarchy.Create(_probePrefab, new Vector3(RootOffset, 0f, 0f), Quaternion.identity); + var rateProbeA = pm.hierarchy.Create(_rateProbePrefab, new Vector3(-RootOffset, 0f, 0f), Quaternion.identity); + var rateProbeB = pm.hierarchy.Create(_rateProbePrefab, new Vector3(RootOffset, 0f, 0f), Quaternion.identity); + var replayTierProbeA = pm.hierarchy.Create( + _replayTierProbePrefab, + new Vector3(-RootOffset, 2f, 0f), + Quaternion.identity); + var replayTierProbeB = pm.hierarchy.Create( + _replayTierProbePrefab, + new Vector3(RootOffset, 2f, 0f), + Quaternion.identity); + var inputProbeA = pm.hierarchy.Create(_inputProbePrefab, new Vector3(RootOffset, 0f, 0f), Quaternion.identity, _players[0]); + var inputProbeB = pm.hierarchy.Create(_inputProbePrefab, new Vector3(-RootOffset, 0f, 0f), Quaternion.identity, _players[1]); + + if (!anchorA.HasValue || !anchorB.HasValue || !probeA.HasValue || !probeB.HasValue || + !rateProbeA.HasValue || !rateProbeB.HasValue || + !replayTierProbeA.HasValue || !replayTierProbeB.HasValue || + !inputProbeA.HasValue || !inputProbeB.HasValue) + return false; + + _serverAnchorA = pm.hierarchy.GetComponent(anchorA); + _serverAnchorB = pm.hierarchy.GetComponent(anchorB); + _serverProbeA = pm.hierarchy.GetComponent(probeA); + _serverProbeB = pm.hierarchy.GetComponent(probeB); + _serverRateProbeA = pm.hierarchy.GetComponent(rateProbeA); + _serverRateProbeB = pm.hierarchy.GetComponent(rateProbeB); + _serverReplayTierProbeA = pm.hierarchy.GetComponent(replayTierProbeA); + _serverReplayTierProbeB = pm.hierarchy.GetComponent(replayTierProbeB); + _serverInputProbeA = pm.hierarchy.GetComponent(inputProbeA); + _serverInputProbeB = pm.hierarchy.GetComponent(inputProbeB); + return _serverAnchorA && _serverAnchorB && _serverProbeA && _serverProbeB && + _serverRateProbeA && _serverRateProbeB && + _serverReplayTierProbeA && _serverReplayTierProbeB && + _serverInputProbeA && _serverInputProbeB; + } + + private bool ResolveLocalLayout(PredictionManager pm) + { + if (!pm.localPlayer.HasValue || !pm.hierarchy || InterestAnchorMarker.instances.Count == 0 || + PredictionTestUtils.CountInstances(pm, _anchorPrefabId) != 2 || + PredictionTestUtils.CountInstances(pm, _probePrefabId) != 2 || + PredictionTestUtils.CountInstances(pm, _rateProbePrefabId) != 2 || + PredictionTestUtils.CountInstances(pm, _replayTierProbePrefabId) != 2 || + PredictionTestUtils.CountInstances(pm, _inputProbePrefabId) != 2) + return false; + + _localAnchor = null; + for (var i = 0; i < InterestAnchorMarker.instances.Count; i++) + { + var anchor = InterestAnchorMarker.instances[i]; + if (anchor && anchor.predictedTransform.owner == pm.localPlayer) + { + _localAnchor = anchor; + break; + } + } + + if (!_localAnchor || Mathf.Abs(_localAnchor.position.x) < RootOffset * 0.5f) + return false; + + if (!TryResolveRootPair(pm, _probePrefabId, out _localNearProbeRoot, out _localFarProbeRoot) || + !TryResolveRootPair(pm, _rateProbePrefabId, out _localNearRateProbeRoot, out _localFarRateProbeRoot) || + !TryResolveRootPair(pm, _replayTierProbePrefabId, out _localNearReplayTierProbeRoot, + out _localFarReplayTierProbeRoot) || + !TryResolveRootPair(pm, _inputProbePrefabId, out _localObservedInputProbeRoot, out _)) + return false; + + if (!RefreshLocalNearInstances(pm)) + return false; + + return _localObservedInputProbe.owner != pm.localPlayer; + } + + private bool TryResolveRootPair(PredictionManager pm, int prefabId, + out PredictedObjectID nearRoot, out PredictedObjectID farRoot) + { + nearRoot = default; + farRoot = default; + PredictedObjectID firstRoot = default; + PredictedObjectID secondRoot = default; + Vector3 firstPosition = default; + Vector3 secondPosition = default; + int found = 0; + var records = pm.hierarchy.currentState.spawnedPrefabs; + + for (var i = 0; i < records.Count; i++) + { + var record = records[i]; + if (!record.isRootRecord || record.prefabId.value != prefabId) + continue; + + if (found == 0) + { + firstRoot = record.rootId; + firstPosition = record.spawnPosition; + } + else if (found == 1) + { + secondRoot = record.rootId; + secondPosition = record.spawnPosition; + } + else + { + return false; + } + + found++; + } + + if (found != 2) + return false; + + float firstDistance = Mathf.Abs(firstPosition.x - _localAnchor.position.x); + float secondDistance = Mathf.Abs(secondPosition.x - _localAnchor.position.x); + nearRoot = firstDistance < secondDistance ? firstRoot : secondRoot; + farRoot = nearRoot.Equals(firstRoot) ? secondRoot : firstRoot; + return true; + } + + private bool RefreshLocalNearInstances(PredictionManager pm) + { + _localNearProbe = pm.hierarchy.GetComponent(_localNearProbeRoot); + _localNearRateProbe = pm.hierarchy.GetComponent(_localNearRateProbeRoot); + _localNearReplayTierProbe = pm.hierarchy.GetComponent(_localNearReplayTierProbeRoot); + _localObservedInputProbe = pm.hierarchy.GetComponent(_localObservedInputProbeRoot); + return _localNearProbe && _localNearRateProbe && _localNearReplayTierProbe && _localObservedInputProbe; + } + + private void SetAnchorDistance(float distance) + { + _serverAnchorA.SetServerPosition(new Vector3(-RootOffset - distance, 0f, 0f)); + _serverAnchorB.SetServerPosition(new Vector3(RootOffset + distance, 0f, 0f)); + } + + private bool HasNeighborhoodTiers(PredictionManager pm, byte tier) + { + return HasTier(pm, _players[0], _serverProbeA, tier) && + HasTier(pm, _players[0], _serverProbeB, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[1], _serverProbeA, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[1], _serverProbeB, tier) && + HasTier(pm, _players[0], _serverRateProbeA, tier) && + HasTier(pm, _players[0], _serverRateProbeB, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[1], _serverRateProbeA, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[1], _serverRateProbeB, tier) && + HasTier(pm, _players[0], _serverReplayTierProbeA, tier) && + HasTier(pm, _players[0], _serverReplayTierProbeB, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[1], _serverReplayTierProbeA, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[1], _serverReplayTierProbeB, tier) && + HasTier(pm, _players[0], _serverInputProbeA, 0) && + HasTier(pm, _players[0], _serverInputProbeB, tier) && + HasTier(pm, _players[1], _serverInputProbeA, tier) && + HasTier(pm, _players[1], _serverInputProbeB, 0); + } + + private bool HasOwnedRateExemptionTiers(PredictionManager pm) + { + return HasTier(pm, _players[0], _serverRateProbeA, 0) && + HasTier(pm, _players[0], _serverRateProbeB, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[1], _serverRateProbeA, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[1], _serverRateProbeB, 0) && + HasTier(pm, _players[0], _serverProbeA, 2) && + HasTier(pm, _players[1], _serverProbeB, 2); + } + + private bool HasCulledNeighborhoodTiers(PredictionManager pm) + { + return HasTier(pm, _players[0], _serverProbeA, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[0], _serverProbeB, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[1], _serverProbeA, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[1], _serverProbeB, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[0], _serverRateProbeA, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[0], _serverRateProbeB, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[1], _serverRateProbeA, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[1], _serverRateProbeB, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[0], _serverReplayTierProbeA, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[0], _serverReplayTierProbeB, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[1], _serverReplayTierProbeA, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[1], _serverReplayTierProbeB, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[0], _serverInputProbeA, 0) && + HasTier(pm, _players[0], _serverInputProbeB, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[1], _serverInputProbeA, NetworkLODProfile.CulledTier) && + HasTier(pm, _players[1], _serverInputProbeB, 0); + } + + private static bool HasTier(PredictionManager pm, PlayerID player, PredictedIdentity probe, byte expected) + { + return probe && pm.interest.TryGetTier(player, probe.rootObjectId, out var tier) && tier == expected; + } + + private static bool IsLocallyCulled(PredictionManager pm, PredictedObjectID root) + { + return pm.interest.TryGetLocalRelevance(root, out var tier) && + tier == NetworkLODProfile.CulledTier && + !pm.hierarchy.TryGetGameObject(root, out _); + } + + private bool HasLocalNeighborhoodTier(PredictionManager pm, byte tier) + { + return _localNearProbe && _localNearProbe.isRelevant && GetLocalTier(pm, _localNearProbe) == tier && + _localNearRateProbe && _localNearRateProbe.isRelevant && GetLocalTier(pm, _localNearRateProbe) == tier && + _localNearReplayTierProbe && _localNearReplayTierProbe.isRelevant && + GetLocalTier(pm, _localNearReplayTierProbe) == tier && + _localObservedInputProbe && _localObservedInputProbe.isRelevant && + GetLocalTier(pm, _localObservedInputProbe) == tier && + IsLocallyCulled(pm, _localFarProbeRoot) && + IsLocallyCulled(pm, _localFarRateProbeRoot) && + IsLocallyCulled(pm, _localFarReplayTierProbeRoot); + } + + private bool HasLocalPolicies(PredictionPolicy unsupportedPolicy, PredictionPolicy replayPolicy) + { + return _localNearProbe.predictionPolicy == unsupportedPolicy && + _localNearRateProbe.predictionPolicy == unsupportedPolicy && + _localObservedInputProbe.predictionPolicy == unsupportedPolicy && + _localNearReplayTierProbe.predictionPolicy == replayPolicy; + } + + private bool LocalOwnerIsExempt() + { + return _localAnchor && _localAnchor.isRelevant && + _localAnchor.predictionPolicy == PredictionPolicy.FullPrediction && + _localAnchor.predictedTransform.isRelevant && + _localAnchor.predictedTransform.predictionPolicy == PredictionPolicy.FullPrediction; + } + + private bool HasRetainedHierarchy(PredictionManager pm) + { + return PredictionTestUtils.CountInstances(pm, _anchorPrefabId) == 2 && + PredictionTestUtils.CountInstances(pm, _probePrefabId) == 2 && + PredictionTestUtils.CountInstances(pm, _rateProbePrefabId) == 2 && + PredictionTestUtils.CountInstances(pm, _replayTierProbePrefabId) == 2 && + PredictionTestUtils.CountInstances(pm, _inputProbePrefabId) == 2 && + HasRetainedRoot(pm, _localNearProbeRoot) && + HasRetainedRoot(pm, _localFarProbeRoot) && + HasRetainedRoot(pm, _localNearRateProbeRoot) && + HasRetainedRoot(pm, _localFarRateProbeRoot) && + HasRetainedRoot(pm, _localNearReplayTierProbeRoot) && + HasRetainedRoot(pm, _localFarReplayTierProbeRoot) && + HasRetainedRoot(pm, _localObservedInputProbeRoot); + } + + private static bool HasRetainedRoot(PredictionManager pm, PredictedObjectID root) + { + return pm.hierarchy.TryGetRootId(root, out var retainedRoot) && retainedRoot.Equals(root); + } + + private static async UniTask WaitServerTicks(PredictionManager pm, ulong ticks, ScenarioContext ctx) + { + ulong start = pm.time.tick; + await UniTaskUtils.WaitWithTimeout( + () => pm.time.tick >= start + ticks, + Timeout, + ctx.cancellationToken); + } + + private static async UniTask WaitClientTicks(PredictionManager pm, ulong ticks, ScenarioContext ctx) + { + ulong start = pm.localTick; + await UniTaskUtils.WaitWithTimeout( + () => pm.localTick >= start + ticks, + Timeout, + ctx.cancellationToken); + } + + private static async UniTask WaitForRateConvergence( + PredictionManager pm, + InterestRateProbe probe, + int interval, + ScenarioContext ctx) + { + ulong start = probe.lastVerifiedTick ?? 0; + await UniTaskUtils.WaitWithTimeout( + () => probe.lastVerifiedTick.HasValue && + probe.lastVerifiedTick.Value >= start + (ulong)(interval * 3) && + ((uint)probe.lastVerifiedTick.Value + probe.rootObjectId.instanceId.value) % interval == 0 && + probe.stateIsValid && + probe.verifiedStateIsValid, + Timeout, + ctx.cancellationToken); + + if (GetLocalTier(pm, probe) == NetworkLODProfile.CulledTier) + return "rate probe became culled while waiting for convergence"; + + return null; + } + + private async UniTask SampleRateWrites( + ScenarioContext ctx, + PredictionManager pm, + int interval) + { + using var sampler = new RateWriteSampler( + pm, + _serverRateProbeA, + _serverRateProbeB, + RateSampleTicks); + await UniTaskUtils.WaitWithTimeout( + () => sampler.complete, + Timeout, + ctx.cancellationToken); + return sampler.Validate(interval); + } + + private async UniTask> SampleFrameBytes(ScenarioContext ctx) + { + using var sampler = new RecipientFrameSampler(_players); + await UniTaskUtils.WaitWithTimeout( + () => sampler.HasSamples(FrameSampleCount), + Timeout, + ctx.cancellationToken); + return sampler.GetAverageBytes(); + } + + private string ValidateFrameByteReduction() + { + for (var i = 0; i < _players.Count; i++) + { + var player = _players[i]; + if (!_visibleFrameBytes.TryGetValue(player, out var visible) || + !_culledFrameBytes.TryGetValue(player, out var culled)) + return $"missing frame-byte samples for player {player.id.value}"; + if (culled >= visible * 0.9) + return $"culling did not reduce player {player.id.value} frame bytes: visible={visible:F2} culled={culled:F2}"; + } + + return null; + } + + private string ValidateRateScaling() + { + int expectedTier0 = RateSampleTicks * 2; + int expectedTier1 = RateSampleTicks; + int expectedTier2 = RateSampleTicks / 2; + if (_tier0Writes.totalWrites != expectedTier0 || + _tier1Writes.totalWrites != expectedTier1 || + _tier2Writes.totalWrites != expectedTier2) + { + return $"rate scaling mismatch: tier0={_tier0Writes.totalWrites}/{expectedTier0} " + + $"tier1={_tier1Writes.totalWrites}/{expectedTier1} " + + $"tier2={_tier2Writes.totalWrites}/{expectedTier2}"; + } + + return null; + } + + private string FrameByteSummary() + { + if (_players.Count != 2 || _visibleFrameBytes == null || _culledFrameBytes == null) + return "bytes=unavailable"; + + var p0 = _players[0]; + var p1 = _players[1]; + return $"bytes=p0:{_visibleFrameBytes[p0]:F2}->{_culledFrameBytes[p0]:F2}," + + $"p1:{_visibleFrameBytes[p1]:F2}->{_culledFrameBytes[p1]:F2};" + + $"writes={_tier0Writes.totalWrites}->{_tier1Writes.totalWrites}->{_tier2Writes.totalWrites}"; + } + + private string DescribeServerTiers(PredictionManager pm) + { + if (_players.Count != 2 || !_serverProbeA || !_serverProbeB) + return $"players={_players.Count} probes={(_serverProbeA ? 1 : 0) + (_serverProbeB ? 1 : 0)}"; + + return $"p0=({GetTier(pm, _players[0], _serverProbeA)},{GetTier(pm, _players[0], _serverProbeB)};" + + $"{GetTier(pm, _players[0], _serverRateProbeA)},{GetTier(pm, _players[0], _serverRateProbeB)};" + + $"{GetTier(pm, _players[0], _serverInputProbeA)},{GetTier(pm, _players[0], _serverInputProbeB)}) " + + $"p1=({GetTier(pm, _players[1], _serverProbeA)},{GetTier(pm, _players[1], _serverProbeB)};" + + $"{GetTier(pm, _players[1], _serverRateProbeA)},{GetTier(pm, _players[1], _serverRateProbeB)};" + + $"{GetTier(pm, _players[1], _serverInputProbeA)},{GetTier(pm, _players[1], _serverInputProbeB)})"; + } + + private string DescribeClient(PredictionManager pm) + { + if (!_localNearProbe || !_localNearRateProbe || !_localObservedInputProbe) + return $"anchors={InterestAnchorMarker.instances.Count} probes={InterestStateProbe.instances.Count} " + + $"rateProbes={InterestRateProbe.instances.Count} inputProbes={InterestInputProbe.instances.Count} " + + $"nearTier={GetLocalTier(pm, _localNearProbeRoot)} farTier={GetLocalTier(pm, _localFarProbeRoot)} " + + $"nearLive={pm.hierarchy.TryGetGameObject(_localNearProbeRoot, out _)} " + + $"farLive={pm.hierarchy.TryGetGameObject(_localFarProbeRoot, out _)}"; + + return $"nearRelevant={_localNearProbe.isRelevant} farLive={pm.hierarchy.TryGetGameObject(_localFarProbeRoot, out _)} " + + $"nearTier={GetLocalTier(pm, _localNearProbeRoot)} farTier={GetLocalTier(pm, _localFarProbeRoot)} " + + $"state={_localNearProbe.currentState.simulations} calls={_localNearProbe.localSimulationCalls} " + + $"views={_localNearProbe.localViewUpdates} rateTier={GetLocalTier(pm, _localNearRateProbe)} " + + $"ratePolicy={_localNearRateProbe.predictionPolicy} rateState={_localNearRateProbe.StateDigest()} " + + $"replayRelevant={_localNearReplayTierProbe.isRelevant} " + + $"replayTier={GetLocalTier(pm, _localNearReplayTierProbe)} " + + $"replayPolicy={_localNearReplayTierProbe.predictionPolicy} " + + $"inputRelevant={_localObservedInputProbe.isRelevant} " + + $"inputTier={GetLocalTier(pm, _localObservedInputProbe)} " + + $"inputState={_localObservedInputProbe.StateDigest()}"; + } + + private static int GetTier(PredictionManager pm, PlayerID player, PredictedIdentity probe) + { + return pm.interest.TryGetTier(player, probe.rootObjectId, out var tier) ? tier : -1; + } + + private static int GetLocalTier(PredictionManager pm, PredictedIdentity probe) + { + return probe ? GetLocalTier(pm, probe.rootObjectId) : -1; + } + + private static int GetLocalTier(PredictionManager pm, PredictedObjectID root) + { + return pm.interest.TryGetLocalRelevance(root, out var tier) ? tier : 0; + } + + private string BuildDigest(PredictionManager pm, InterestStateProbe probe, InterestRateProbe rateProbe) + { + return $"anchors={PredictionTestUtils.CountInstances(pm, _anchorPrefabId)};" + + $"probes={PredictionTestUtils.CountInstances(pm, _probePrefabId)};" + + $"rateProbes={PredictionTestUtils.CountInstances(pm, _rateProbePrefabId)};" + + $"replayProbes={PredictionTestUtils.CountInstances(pm, _replayTierProbePrefabId)};" + + $"inputProbes={PredictionTestUtils.CountInstances(pm, _inputProbePrefabId)};" + + $"first={probe.currentState.firstTick};valid={probe.stateIsValid};" + + $"rateFirst={rateProbe.currentState.firstTick};rateValid={rateProbe.stateIsValid};" + + $"rateCheckpoint={rateProbe.currentState.checkpoint};" + + $"rateCheckpointLast={rateProbe.currentState.checkpointLastTick};" + + $"rateCheckpointSequence={rateProbe.currentState.checkpointSequence}"; + } + + private readonly struct RateWriteSample + { + public readonly int totalWrites; + public readonly string failure; + + public RateWriteSample(int totalWrites, string failure) + { + this.totalWrites = totalWrites; + this.failure = failure; + } + } + + private sealed class RateWriteSampler : IDisposable + { + private readonly PredictionManager _manager; + private readonly InterestRateProbe _first; + private readonly InterestRateProbe _second; + private readonly int _sampleTicks; + private readonly List _ticks; + private readonly Dictionary<(uint root, ulong tick), int> _writes = new(); + + public RateWriteSampler( + PredictionManager manager, + InterestRateProbe first, + InterestRateProbe second, + int sampleTicks) + { + _manager = manager; + _first = first; + _second = second; + _sampleTicks = sampleTicks; + _ticks = new List(sampleTicks); + TickBandwidthProfiler.onTickEnded += OnTickEnded; + } + + public bool complete => _ticks.Count >= _sampleTicks; + + public void Dispose() + { + TickBandwidthProfiler.onTickEnded -= OnTickEnded; + } + + public RateWriteSample Validate(int interval) + { + int total = 0; + for (var i = 0; i < _ticks.Count; i++) + { + ulong tick = _ticks[i]; + var failure = ValidateProbe(_first, tick, interval, ref total); + if (failure != null) + return new RateWriteSample(total, failure); + failure = ValidateProbe(_second, tick, interval, ref total); + if (failure != null) + return new RateWriteSample(total, failure); + } + + return new RateWriteSample(total, null); + } + + private string ValidateProbe( + InterestRateProbe probe, + ulong tick, + int interval, + ref int total) + { + uint root = probe.rootObjectId.instanceId.value; + int actual = _writes.GetValueOrDefault((root, tick)); + int expected = ((uint)tick + root) % interval == 0 ? 1 : 0; + total += actual; + return actual == expected + ? null + : $"tier interval {interval} root {root} tick {tick} wrote {actual}, expected {expected}"; + } + + private void OnTickEnded() + { + if (_ticks.Count >= _sampleTicks) + return; + + ulong tick = _manager.localTick == 0 ? 0 : _manager.localTick - 1; + _ticks.Add(tick); + + var writes = TickBandwidthProfiler.wroteStates; + for (var i = 0; i < writes.Count; i++) + { + var reference = writes[i].reference; + uint root; + if (ReferenceEquals(reference, _first)) + root = _first.rootObjectId.instanceId.value; + else if (ReferenceEquals(reference, _second)) + root = _second.rootObjectId.instanceId.value; + else + continue; + + var key = (root, tick); + _writes[key] = _writes.GetValueOrDefault(key) + 1; + } + } + } + + private sealed class RecipientFrameSampler : IDisposable + { + private readonly List _players; + private readonly Dictionary _bits = new(); + private readonly Dictionary _frames = new(); + + public RecipientFrameSampler(List players) + { + _players = players; + TickBandwidthProfiler.onTickEnded += OnTickEnded; + } + + public void Dispose() + { + TickBandwidthProfiler.onTickEnded -= OnTickEnded; + } + + public bool HasSamples(int count) + { + for (var i = 0; i < _players.Count; i++) + { + if (_frames.GetValueOrDefault(_players[i]) < count) + return false; + } + return true; + } + + public Dictionary GetAverageBytes() + { + var result = new Dictionary(_players.Count); + for (var i = 0; i < _players.Count; i++) + { + var player = _players[i]; + result[player] = _bits[player] / 8.0 / _frames[player]; + } + return result; + } + + private void OnTickEnded() + { + var frames = TickBandwidthProfiler.wroteFrames; + for (var i = 0; i < frames.Count; i++) + { + var frame = frames[i]; + if (!_players.Contains(frame.player)) + continue; + _bits[frame.player] = _bits.GetValueOrDefault(frame.player) + frame.bitCount; + _frames[frame.player] = _frames.GetValueOrDefault(frame.player) + 1; + } + } + } +} + +public sealed class InterestAnchorMarker : PredictedIdentity +{ + public struct AnchorState : IPredictedData + { + public void Dispose() { } + } + + public static readonly List instances = new(); + + public PredictedTransform predictedTransform { get; private set; } + + public Vector3 position => predictedTransform.currentState.unityPosition; + + public static void ResetInstances() + { + instances.Clear(); + } + + private void Awake() + { + predictedTransform = GetComponent(); + } + + protected override void LateAwake() + { + instances.Add(this); + } + + protected override void Destroyed() + { + instances.Remove(this); + } + + public void SetServerPosition(Vector3 position) + { + transform.position = position; + ref var state = ref predictedTransform.currentState; + state.unityPosition = position; + } +} + +public sealed class InterestStateProbe : DeterministicIdentity +{ + private const ulong InitialSequence = 14695981039346656037UL; + + public struct ProbeState : IPredictedData + { + public ulong firstTick; + public ulong lastTick; + public ulong sequence; + public uint simulations; + + public void Dispose() { } + } + + public static readonly List instances = new(); + + private PredictedTransform _predictedTransform; + + public int localSimulationCalls { get; private set; } + public int localViewUpdates { get; private set; } + public Vector3 position => _predictedTransform.currentState.unityPosition; + public bool stateIsValid => Validate(currentState); + + public static void ResetInstances() + { + instances.Clear(); + } + + private void Awake() + { + _predictedTransform = GetComponent(); + } + + protected override void LateAwake() + { + instances.Add(this); + } + + protected override void Destroyed() + { + instances.Remove(this); + } + + protected override ProbeState GetInitialState() + { + return new ProbeState + { + sequence = InitialSequence + }; + } + + protected override void Simulate(ref ProbeState state, sfloat delta) + { + ulong tick = predictionManager.time.tick; + if (state.simulations == 0) + state.firstTick = tick; + state.lastTick = tick; + state.sequence = Advance(state.sequence, tick); + state.simulations += 1; + localSimulationCalls += 1; + } + + protected override void UpdateView(ProbeState viewState, ProbeState? verified) + { + localViewUpdates += 1; + } + + private static bool Validate(ProbeState state) + { + if (state.simulations == 0 || state.lastTick != state.firstTick + state.simulations - 1) + return false; + + ulong sequence = InitialSequence; + for (uint i = 0; i < state.simulations; i++) + sequence = Advance(sequence, state.firstTick + i); + return sequence == state.sequence; + } + + private static ulong Advance(ulong sequence, ulong tick) + { + return unchecked((sequence ^ tick) * 1099511628211UL + 11400714819323198485UL); + } +} + +public sealed class InterestRateProbe : PredictedIdentity +{ + private const ulong InitialSequence = 1099511628211UL; + + public struct ProbeState : IPredictedData + { + public ulong firstTick; + public ulong lastTick; + public ulong sequence; + public ulong checkpointLastTick; + public ulong checkpointSequence; + public uint simulations; + public uint checkpoint; + public bool active; + + public void Dispose() { } + } + + public static readonly List instances = new(); + + public int localSimulationCalls { get; private set; } + public int localViewUpdates { get; private set; } + public Vector3 position => transform.position; + public bool stateIsValid => Validate(currentState); + + public bool verifiedStateIsValid + { + get + { + var state = verifiedState; + return state.HasValue && Validate(state.Value); + } + } + + public static void ResetInstances() + { + instances.Clear(); + } + + protected override void LateAwake() + { + instances.Add(this); + } + + protected override void Destroyed() + { + instances.Remove(this); + } + + protected override ProbeState GetInitialState() + { + return new ProbeState + { + sequence = InitialSequence, + active = true + }; + } + + protected override void Simulate(ref ProbeState state, float delta) + { + if (!state.active) + return; + + ulong tick = state.simulations == 0 + ? predictionManager.time.tick + : state.lastTick + 1; + if (state.simulations == 0) + state.firstTick = tick; + state.lastTick = tick; + state.sequence = Advance(state.sequence, tick); + state.simulations += 1; + localSimulationCalls += 1; + } + + protected override void UpdateView(ProbeState viewState, ProbeState? verified) + { + localViewUpdates += 1; + } + + public bool StateEquals(ProbeState other) + { + ref var state = ref currentState; + return state.firstTick == other.firstTick && + state.lastTick == other.lastTick && + state.sequence == other.sequence && + state.checkpointLastTick == other.checkpointLastTick && + state.checkpointSequence == other.checkpointSequence && + state.simulations == other.simulations && + state.checkpoint == other.checkpoint && + state.active == other.active; + } + + public void Freeze(uint checkpoint) + { + ref var state = ref currentState; + state.active = false; + state.checkpoint = checkpoint; + state.checkpointLastTick = state.lastTick; + state.checkpointSequence = state.sequence; + } + + public void Resume() + { + currentState.active = true; + } + + public string StateDigest() + { + ref var state = ref currentState; + return $"first={state.firstTick};last={state.lastTick};simulations={state.simulations};" + + $"sequence={state.sequence};checkpoint={state.checkpoint};" + + $"checkpointLast={state.checkpointLastTick};checkpointSequence={state.checkpointSequence};" + + $"active={state.active};verified={lastVerifiedTick}"; + } + + public string ExactStateDigest() + { + ref var state = ref currentState; + return $"first={state.firstTick};last={state.lastTick};simulations={state.simulations};" + + $"sequence={state.sequence};checkpoint={state.checkpoint};" + + $"checkpointLast={state.checkpointLastTick};checkpointSequence={state.checkpointSequence};" + + $"active={state.active}"; + } + + private static bool Validate(ProbeState state) + { + if (state.simulations == 0 || state.lastTick != state.firstTick + state.simulations - 1) + return false; + + ulong sequence = InitialSequence; + for (uint i = 0; i < state.simulations; i++) + sequence = Advance(sequence, state.firstTick + i); + return sequence == state.sequence; + } + + private static ulong Advance(ulong sequence, ulong tick) + { + return unchecked((sequence ^ tick) * 14695981039346656037UL + 11400714819323198485UL); + } +} + +public sealed class InterestReplayTierProbe : PredictedRigidbody +{ + public static readonly List instances = new(); + private static readonly HashSet _transitionedRoots = new(); + + public bool transitionAttempted { get; private set; } + public bool transitionDuringReplay { get; private set; } + public bool tierPolicyApplied { get; private set; } + public bool replayPhysicsApplied { get; private set; } + public bool tierRestored { get; private set; } + public bool configuredPolicyRestored { get; private set; } + public bool configuredPhysicsRestored { get; private set; } + + public static void ResetInstances() + { + instances.Clear(); + _transitionedRoots.Clear(); + } + + protected override void LateAwake() + { + base.LateAwake(); + instances.Add(this); + } + + protected override void Destroyed() + { + instances.Remove(this); + base.Destroyed(); + } + + protected override void Simulate(ref UnityRigidbodyState state, float delta) + { + base.Simulate(ref state, delta); + + if (transitionAttempted || _transitionedRoots.Contains(rootObjectId) || !predictionManager || + predictionManager.cachedIsServer || + !predictionManager.isReplaying || !isRelevant) + return; + + _transitionedRoots.Add(rootObjectId); + transitionAttempted = true; + transitionDuringReplay = predictionManager.isReplaying; + bool wasKinematic = rb.isKinematic; + var previousConstraints = rb.constraints; + ulong serverTick = lastVerifiedTick ?? predictionManager.localTick; + predictionManager.interest.ReceiveLocalTier(rootObjectId, 1, serverTick); + tierPolicyApplied = predictionPolicy == PredictionPolicy.SoftCorrection; + replayPhysicsApplied = rb && rb.constraints == RigidbodyConstraints.FreezeAll; + predictionManager.interest.ReceiveLocalTier(rootObjectId, 0, serverTick); + tierRestored = !predictionManager.interest.TryGetLocalRelevance(rootObjectId, out _); + configuredPolicyRestored = predictionPolicy == PredictionPolicy.FullPrediction; + configuredPhysicsRestored = rb && rb.isKinematic == wasKinematic && + rb.constraints == previousConstraints; + } + + public string TransitionDigest() + { + return $"attempted={transitionAttempted};duringReplay={transitionDuringReplay};" + + $"tierPolicy={tierPolicyApplied};replayPhysics={replayPhysicsApplied};" + + $"tierRestored={tierRestored};restoredPolicy={configuredPolicyRestored};" + + $"restoredPhysics={configuredPhysicsRestored};" + + $"policy={predictionPolicy};kinematic={(rb ? rb.isKinematic : false)}"; + } +} + +public sealed class InterestInputProbe : DeterministicIdentity +{ + private const ulong InitialChecksum = 7809847782465536322UL; + private static bool _generateActiveInputs = true; + + public struct ProbeInput : IPredictedData + { + public bool valid; + public bool active; + public int direction; + public uint sequence; + public ulong tokenA; + public ulong tokenB; + public ulong tokenC; + public ulong tokenD; + + public void Dispose() { } + } + + public struct ProbeState : IPredictedData + { + public sfloat x; + public sfloat z; + public sfloat travel; + public ulong checksum; + public uint appliedInputs; + public uint lastSequence; + public uint finalSequence; + public bool finalized; + + public void Dispose() { } + } + + public static readonly List instances = new(); + + private uint _nextSequence; + + public int localSimulationCalls { get; private set; } + public Vector3 position => transform.position; + + public static void ResetInstances() + { + instances.Clear(); + _generateActiveInputs = true; + } + + public static void StopInputGeneration() + { + _generateActiveInputs = false; + } + + protected override void LateAwake() + { + instances.Add(this); + } + + protected override void Destroyed() + { + instances.Remove(this); + } + + protected override ProbeState GetInitialState() + { + return new ProbeState + { + x = sfloat.FromFloat(transform.position.x), + z = sfloat.FromFloat(transform.position.z), + checksum = InitialChecksum + }; + } + + protected override void GetFinalInput(ref ProbeInput input) + { + _nextSequence += 1; + if (_nextSequence == 0) + _nextSequence = 1; + + input.valid = true; + input.active = _generateActiveInputs; + input.direction = (_nextSequence & 1) == 0 ? 1 : -1; + input.sequence = _nextSequence; + input.tokenA = Mix(_nextSequence, 0x9E3779B97F4A7C15UL); + input.tokenB = Mix(_nextSequence, 0xD1B54A32D192ED03UL); + input.tokenC = Mix(_nextSequence, 0x94D049BB133111EBUL); + input.tokenD = Mix(_nextSequence, 0xBF58476D1CE4E5B9UL); + } + + protected override void ModifyExtrapolatedInput(ref ProbeInput input) + { + input.valid = false; + } + + protected override void Simulate(ProbeInput input, ref ProbeState state, sfloat delta) + { + localSimulationCalls += 1; + + if (!input.valid || state.finalized) + return; + + if (!input.active) + { + state.finalized = true; + state.finalSequence = input.sequence; + return; + } + + state.checksum = Advance(state.checksum, input); + state.appliedInputs += 1; + state.lastSequence = input.sequence; + + var step = sfloat.FromFloat(input.direction * 0.025f); + state.z += step; + state.travel += sfloat.FromFloat(Mathf.Abs(input.direction) * 0.025f); + SetUnityState(state); + } + + protected override void SetUnityState(ProbeState state) + { + transform.position = new Vector3(state.x.ToFloat(), 0f, state.z.ToFloat()); + } + + public bool StateEquals(ProbeState other) + { + ref var state = ref currentState; + return state.x.rawValue == other.x.rawValue && + state.z.rawValue == other.z.rawValue && + state.travel.rawValue == other.travel.rawValue && + state.checksum == other.checksum && + state.appliedInputs == other.appliedInputs && + state.lastSequence == other.lastSequence && + state.finalSequence == other.finalSequence && + state.finalized == other.finalized; + } + + public string StateDigest() + { + ref var state = ref currentState; + return $"applied={state.appliedInputs};last={state.lastSequence};final={state.finalSequence};" + + $"checksum={state.checksum};x={state.x.rawValue};z={state.z.rawValue};travel={state.travel.rawValue}"; + } + + private static ulong Advance(ulong checksum, ProbeInput input) + { + checksum = unchecked((checksum ^ input.tokenA) * 1099511628211UL + input.sequence); + checksum = unchecked((checksum ^ input.tokenB) * 1099511628211UL + (uint)input.direction); + checksum = unchecked((checksum ^ input.tokenC) * 1099511628211UL); + return unchecked((checksum ^ input.tokenD) * 1099511628211UL); + } + + private static ulong Mix(uint sequence, ulong salt) + { + ulong value = sequence + salt; + value = unchecked((value ^ (value >> 30)) * 0xBF58476D1CE4E5B9UL); + value = unchecked((value ^ (value >> 27)) * 0x94D049BB133111EBUL); + return value ^ (value >> 31); + } +} + +public static class InterestRateReportGate +{ + private readonly struct RateReport + { + public readonly uint root; + public readonly string digest; + + public RateReport(uint root, string digest) + { + this.root = root; + this.digest = digest; + } + } + + private static readonly Dictionary> _reports = new(); + + public static void Reset() + { + _reports.Clear(); + } + + public static void Report(uint checkpoint, InterestRateProbe probe) + { + Submit(checkpoint, probe.rootObjectId.instanceId.value, probe.ExactStateDigest()); + } + + public static async UniTask Compare( + ScenarioContext ctx, + uint checkpoint, + InterestRateProbe first, + InterestRateProbe second, + float timeout) + { + await UniTaskUtils.WaitWithTimeout( + () => _reports.TryGetValue(checkpoint, out var reports) && + reports.Count >= ctx.externalClientCount, + timeout, + ctx.cancellationToken); + + var failures = new List(); + var received = _reports[checkpoint]; + foreach (var pair in received) + { + InterestRateProbe expected = null; + if (first.rootObjectId.instanceId.value == pair.Value.root) + expected = first; + else if (second.rootObjectId.instanceId.value == pair.Value.root) + expected = second; + + if (!expected) + { + failures.Add( + $"player {pair.Key.id.value} reported unknown rate root {pair.Value.root} at checkpoint {checkpoint}"); + continue; + } + + var expectedDigest = expected.ExactStateDigest(); + if (pair.Value.digest != expectedDigest) + { + failures.Add( + $"player {pair.Key.id.value} rate root {pair.Value.root} checkpoint {checkpoint} diverged: " + + $"'{pair.Value.digest}' != '{expectedDigest}'"); + } + } + + _reports.Remove(checkpoint); + return failures.Count == 0 + ? ScenarioResult.Ok() + : ScenarioResult.Fail(string.Join(" | ", failures)); + } + + [ServerRpc(requireOwnership: false)] + private static void Submit(uint checkpoint, uint root, string digest, RPCInfo info = default) + { + if (!_reports.TryGetValue(checkpoint, out var reports)) + { + reports = new Dictionary(); + _reports[checkpoint] = reports; + } + + reports[info.sender] = new RateReport(root, digest); + } +} + +public static class InterestInputReportGate +{ + private readonly struct InputReport + { + public readonly uint root; + public readonly string digest; + + public InputReport(uint root, string digest) + { + this.root = root; + this.digest = digest; + } + } + + private static readonly Dictionary _reports = new(); + + public static void Reset() + { + _reports.Clear(); + } + + public static void Report(InterestInputProbe probe) + { + Submit(probe.rootObjectId.instanceId.value, probe.StateDigest()); + } + + public static async UniTask Compare(ScenarioContext ctx, InterestInputProbe first, + InterestInputProbe second, float timeout) + { + await UniTaskUtils.WaitWithTimeout( + () => _reports.Count >= ctx.externalClientCount, + timeout, + ctx.cancellationToken); + + var failures = new List(); + foreach (var pair in _reports) + { + InterestInputProbe expected = null; + if (first.rootObjectId.instanceId.value == pair.Value.root) + expected = first; + else if (second.rootObjectId.instanceId.value == pair.Value.root) + expected = second; + + if (!expected) + { + failures.Add($"player {pair.Key.id.value} reported unknown input root {pair.Value.root}"); + continue; + } + + var expectedDigest = expected.StateDigest(); + if (pair.Value.digest != expectedDigest) + failures.Add($"player {pair.Key.id.value} input root {pair.Value.root} diverged: " + + $"'{pair.Value.digest}' != '{expectedDigest}'"); + } + + _reports.Clear(); + return failures.Count == 0 + ? ScenarioResult.Ok() + : ScenarioResult.Fail(string.Join(" | ", failures)); + } + + [ServerRpc(requireOwnership: false)] + private static void Submit(uint root, string digest, RPCInfo info = default) + { + _reports[info.sender] = new InputReport(root, digest); + } +} diff --git a/Assets/PredictionTests/Scripts/Scenarios/InterestManagementScenario.cs.meta b/Assets/PredictionTests/Scripts/Scenarios/InterestManagementScenario.cs.meta new file mode 100644 index 00000000..d827bdaf --- /dev/null +++ b/Assets/PredictionTests/Scripts/Scenarios/InterestManagementScenario.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 63a41ff4e6b94918bb952fd804682355 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/PredictionTests/Scripts/Scenarios/ServerLoadBenchmarkScenario.cs b/Assets/PredictionTests/Scripts/Scenarios/ServerLoadBenchmarkScenario.cs index 6886990a..0688fff3 100644 --- a/Assets/PredictionTests/Scripts/Scenarios/ServerLoadBenchmarkScenario.cs +++ b/Assets/PredictionTests/Scripts/Scenarios/ServerLoadBenchmarkScenario.cs @@ -4,6 +4,7 @@ using Cysharp.Threading.Tasks; using PurrNet; using PurrNet.Prediction; +using PurrNet.Prediction.Profiler; using UnityEngine; public class ServerLoadBenchmarkScenario : Scenario @@ -119,6 +120,7 @@ await UniTaskUtils.WaitWithTimeout( await UniTask.WaitForSeconds(_settleSeconds, cancellationToken: ctx.cancellationToken); var sampler = ScenarioPerformanceSampler.StartDefault(); + using var bandwidth = new BandwidthSampler(); var startTick = pm.localTick; double lagSum = 0; ulong lagMax = 0; @@ -141,7 +143,7 @@ await UniTaskUtils.WaitWithTimeout( var elapsedTicks = pm.localTick - startTick; var perf = sampler.Stop(pm); - return ScenarioResult.Ok(BuildReport(perf, elapsedTicks, lagSum, lagMax, lagSamples)); + return ScenarioResult.Ok(BuildReport(perf, bandwidth, elapsedTicks, lagSum, lagMax, lagSamples)); } finally { @@ -151,6 +153,7 @@ await UniTaskUtils.WaitWithTimeout( private string BuildReport( ScenarioPerformanceDetails perf, + BandwidthSampler bandwidth, ulong elapsedTicks, double lagSum, ulong lagMax, @@ -164,6 +167,12 @@ private string BuildReport( sb.Append(" ticks=").Append(elapsedTicks); sb.Append(" ackLagAvg=").Append((lagSamples > 0 ? lagSum / lagSamples : 0).ToString("0.##", CultureInfo.InvariantCulture)); sb.Append(" ackLagMax=").Append(lagMax); + sb.Append(" bandwidthTicks=").Append(bandwidth.tickCount); + sb.Append(" bandwidthClients=").Append(bandwidth.clientCount); + sb.Append(" bandwidthFrames=").Append(bandwidth.frameCount); + sb.Append(" bandwidthBytes=").Append(bandwidth.byteCount); + sb.Append(" bytesPerClientTick=").Append( + bandwidth.bytesPerClientTick.ToString("0.##", CultureInfo.InvariantCulture)); if (perf.markers != null) { @@ -188,4 +197,45 @@ private string BuildReport( return sb.ToString(); } + + private sealed class BandwidthSampler : IDisposable + { + private readonly System.Collections.Generic.HashSet _players = new(); + private long _bitCount; + + public int tickCount { get; private set; } + public int frameCount { get; private set; } + public int clientCount => _players.Count; + public long byteCount => _bitCount / 8; + public double bytesPerClientTick => tickCount > 0 && clientCount > 0 + ? _bitCount / 8.0 / tickCount / clientCount + : 0; + + public BandwidthSampler() + { + TickBandwidthProfiler.onTickEnded += OnTickEnded; + } + + public void Dispose() + { + TickBandwidthProfiler.onTickEnded -= OnTickEnded; + } + + private void OnTickEnded() + { + var frames = TickBandwidthProfiler.wroteFrames; + if (frames.Count == 0) + return; + + tickCount++; + + for (var i = 0; i < frames.Count; i++) + { + var frame = frames[i]; + _players.Add(frame.player); + _bitCount += frame.bitCount; + frameCount++; + } + } + } } diff --git a/Assets/PurrDiction/Documentation/PurrDocs.url b/Assets/PurrDiction/Documentation/PurrDocs.url index a44fd010..7022c83d 100644 --- a/Assets/PurrDiction/Documentation/PurrDocs.url +++ b/Assets/PurrDiction/Documentation/PurrDocs.url @@ -2,4 +2,4 @@ Prop3=19,11 [InternetShortcut] IDList= -URL=https://purrnet.gitbook.io/docs/tools/client-side-prediction +URL=https://purrnet.dev/docs/client-side-prediction diff --git a/Assets/PurrDiction/Editor/PredictedPrefabsEditor.cs b/Assets/PurrDiction/Editor/PredictedPrefabsEditor.cs index a25e992f..2582e270 100644 --- a/Assets/PurrDiction/Editor/PredictedPrefabsEditor.cs +++ b/Assets/PurrDiction/Editor/PredictedPrefabsEditor.cs @@ -38,37 +38,50 @@ private void SetupReorderableList() _reorderableList.drawHeaderCallback = (Rect rect) => { float fullWidth = rect.width - REORDERABLE_LIST_BUTTON_WIDTH; - CalculateWidths(fullWidth, out float prefabWidth, out float poolWidth, out float warmupWidth); + CalculateWidths(fullWidth, out float prefabWidth, out float minimumTierWidth, + out float poolWidth, out float warmupWidth); EditorGUI.LabelField(new Rect(rect.x, rect.y, prefabWidth, rect.height), "Prefab"); - EditorGUI.LabelField( - new Rect(rect.x + prefabWidth + SPACING, rect.y, poolWidth + warmupWidth, rect.height), "Pool"); + float x = rect.x + prefabWidth + SPACING; + EditorGUI.LabelField(new Rect(x, rect.y, minimumTierWidth, rect.height), + new GUIContent("Min Tier", "Distance-based tier floor. 0 allows full detail; 255 starts culled.")); + x += minimumTierWidth + SPACING; + EditorGUI.LabelField(new Rect(x, rect.y, poolWidth, rect.height), "Pool"); + x += poolWidth + SPACING; + EditorGUI.LabelField(new Rect(x, rect.y, warmupWidth, rect.height), "Warmup"); }; _reorderableList.drawElementCallback = (Rect rect, int index, bool isActive, bool isFocused) => { SerializedProperty element = _prefabs.GetArrayElementAtIndex(index); SerializedProperty prefabProp = element.FindPropertyRelative("prefab"); + SerializedProperty minimumTierProp = element.FindPropertyRelative("minimumInterestTier"); SerializedProperty poolProp = element.FindPropertyRelative("pooled"); SerializedProperty warmupCountProp = element.FindPropertyRelative("warmupCount"); float fullWidth = rect.width - REORDERABLE_LIST_BUTTON_WIDTH; - CalculateWidths(fullWidth, out float prefabWidth, out float poolWidth, out float warmupWidth); + CalculateWidths(fullWidth, out float prefabWidth, out float minimumTierWidth, + out float poolWidth, out float warmupWidth); EditorGUI.BeginDisabledGroup(_target.autoGenerate); EditorGUI.PropertyField(new Rect(rect.x, rect.y, prefabWidth, rect.height), prefabProp, GUIContent.none); EditorGUI.EndDisabledGroup(); + float x = rect.x + prefabWidth + SPACING; + EditorGUI.PropertyField(new Rect(x, rect.y, minimumTierWidth, rect.height), + minimumTierProp, GUIContent.none); + x += minimumTierWidth + SPACING; + poolProp.boolValue = - EditorGUI.Toggle(new Rect(rect.x + prefabWidth + SPACING, rect.y, poolWidth, rect.height), - poolProp.boolValue); + EditorGUI.Toggle(new Rect(x, rect.y, poolWidth, rect.height), poolProp.boolValue); + + x += poolWidth + SPACING; if (poolProp.boolValue) { - EditorGUI.PropertyField( - new Rect(rect.x + prefabWidth + poolWidth + (SPACING * 2), rect.y, warmupWidth, rect.height), - warmupCountProp, GUIContent.none); + EditorGUI.PropertyField(new Rect(x, rect.y, warmupWidth, rect.height), warmupCountProp, + GUIContent.none); } }; @@ -83,6 +96,7 @@ private void SetupReorderableList() element.FindPropertyRelative("prefab").objectReferenceValue = null; element.FindPropertyRelative("pooled").boolValue = _target.poolByDefault; element.FindPropertyRelative("warmupCount").intValue = 5; + element.FindPropertyRelative("minimumInterestTier").intValue = 0; element.FindPropertyRelative("guid").stringValue = string.Empty; serializedObject.ApplyModifiedProperties(); }); @@ -101,6 +115,7 @@ private void SetupReorderableList() element.FindPropertyRelative("prefab").objectReferenceValue = obj; element.FindPropertyRelative("pooled").boolValue = _target.poolByDefault; element.FindPropertyRelative("warmupCount").intValue = 5; + element.FindPropertyRelative("minimumInterestTier").intValue = 0; element.FindPropertyRelative("guid").stringValue = AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(obj)); } @@ -116,11 +131,13 @@ private void SetupReorderableList() }; } - private void CalculateWidths(float fullWidth, out float prefabWidth, out float poolWidth, out float warmupWidth) + private void CalculateWidths(float fullWidth, out float prefabWidth, out float minimumTierWidth, + out float poolWidth, out float warmupWidth) { - poolWidth = 20f; + minimumTierWidth = 55f; + poolWidth = 30f; warmupWidth = 60f; - prefabWidth = fullWidth - poolWidth - warmupWidth - (SPACING * 2); + prefabWidth = fullWidth - minimumTierWidth - poolWidth - warmupWidth - (SPACING * 3); } public override void OnInspectorGUI() diff --git a/Assets/PurrDiction/Editor/PredictionLODProfileEditor.cs b/Assets/PurrDiction/Editor/PredictionLODProfileEditor.cs new file mode 100644 index 00000000..eeee8827 --- /dev/null +++ b/Assets/PurrDiction/Editor/PredictionLODProfileEditor.cs @@ -0,0 +1,51 @@ +using PurrNet.Editor; +using UnityEditor; +using UnityEngine; + +namespace PurrNet.Prediction.Editor +{ + [CustomEditor(typeof(PredictionLODProfile))] + [CanEditMultipleObjects] + internal sealed class PredictionLODProfileEditor : UnityEditor.Editor + { + private SerializedProperty _networkProfile; + private SerializedProperty _tiers; + private SerializedProperty _culledPolicy; + private bool _showInlineNetworkProfile; + + private void OnEnable() + { + _networkProfile = serializedObject.FindProperty("_networkProfile"); + _tiers = serializedObject.FindProperty("_tiers"); + _culledPolicy = serializedObject.FindProperty("_culledPolicy"); + } + + public override void OnInspectorGUI() + { + serializedObject.UpdateIfRequiredOrScript(); + EditorGUILayout.PropertyField(_networkProfile); + + if (!_networkProfile.hasMultipleDifferentValues && + _networkProfile.objectReferenceValue is NetworkLODProfile networkProfile) + { + NetworkLODProfileEditorGUI.DrawSummary(networkProfile); + _showInlineNetworkProfile = EditorGUILayout.Foldout( + _showInlineNetworkProfile, + "Edit Network Profile Inline", + true); + + if (_showInlineNetworkProfile) + { + EditorGUI.indentLevel++; + NetworkLODProfileEditorGUI.DrawInlineEditor(networkProfile); + EditorGUI.indentLevel--; + } + } + + EditorGUILayout.Space(5f); + EditorGUILayout.PropertyField(_tiers, true); + EditorGUILayout.PropertyField(_culledPolicy); + serializedObject.ApplyModifiedProperties(); + } + } +} diff --git a/Assets/PurrDiction/Editor/PredictionLODProfileEditor.cs.meta b/Assets/PurrDiction/Editor/PredictionLODProfileEditor.cs.meta new file mode 100644 index 00000000..bc55fc5c --- /dev/null +++ b/Assets/PurrDiction/Editor/PredictionLODProfileEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 36dea97e84e76f24e91e7e604ca170fd \ No newline at end of file diff --git a/Assets/PurrDiction/Editor/Profiler/PurrdictionProfiler.cs b/Assets/PurrDiction/Editor/Profiler/PurrdictionProfiler.cs index 9ab0d931..83e356ed 100644 --- a/Assets/PurrDiction/Editor/Profiler/PurrdictionProfiler.cs +++ b/Assets/PurrDiction/Editor/Profiler/PurrdictionProfiler.cs @@ -45,6 +45,7 @@ public static void ShowWindow() private static readonly Color ColorReadStates = new Color(0.25f, 0.55f, 0.90f, 1f); private static readonly Color ColorWroteInputs = new Color(0.95f, 0.60f, 0.20f, 1f); private static readonly Color ColorReadInputs = new Color(0.80f, 0.30f, 0.80f, 1f); + private static GUIStyle _tierBadgeStyle; private readonly Dictionary _foldoutStates = new Dictionary(); private void OnEnable() @@ -325,6 +326,7 @@ private bool _showSectionHeaderAndList(string label, Color color, DisposableList private void DrawPackingList(DisposableList list) { const float BitsColWidth = 70f; + const float TierColWidth = 54f; const float ReferenceColWidth = 240f; const float PingColWidth = 44f; float rowHeight = EditorGUIUtility.singleLineHeight + 4f; @@ -355,11 +357,13 @@ private void DrawPackingList(DisposableList list) EditorGUI.DrawRect(headerRect, new Color(0.18f, 0.18f, 0.18f, 1f)); EditorGUI.DrawRect(new Rect(headerRect.x, headerRect.yMax - 1f, headerRect.width, 1f), new Color(0f, 0f, 0f, 0.35f)); - float parentColWidth = Mathf.Max(80f, headerRect.width - BitsColWidth - ReferenceColWidth - PingColWidth); + float parentColWidth = Mathf.Max(80f, headerRect.width - BitsColWidth - TierColWidth - ReferenceColWidth - PingColWidth); float x = headerRect.x; GUI.Label(new Rect(x + 6f, headerRect.y + 2f, BitsColWidth - 8f, rowHeight - 4f), "Bits", EditorStyles.boldLabel); x += BitsColWidth; GUI.Label(new Rect(x + 6f, headerRect.y + 2f, parentColWidth - 8f, rowHeight - 4f), "Parent", EditorStyles.boldLabel); + x += parentColWidth; + GUI.Label(new Rect(x + 6f, headerRect.y + 2f, TierColWidth - 8f, rowHeight - 4f), new GUIContent("Tier", "Current local interest tier. Server samples have no single receiver tier."), EditorStyles.boldLabel); x = headerRect.x + headerRect.width - (ReferenceColWidth + PingColWidth); GUI.Label(new Rect(x + 6f, headerRect.y + 2f, ReferenceColWidth - 8f, rowHeight - 4f), "Reference", EditorStyles.boldLabel); @@ -372,16 +376,19 @@ private void DrawPackingList(DisposableList list) if ((visibleRowIndex & 1) == 0) EditorGUI.DrawRect(rowRect, new Color(1f, 1f, 1f, 0.035f)); - float rowParentWidth = Mathf.Max(80f, rowRect.width - BitsColWidth - ReferenceColWidth - PingColWidth); + float rowParentWidth = Mathf.Max(80f, rowRect.width - BitsColWidth - TierColWidth - ReferenceColWidth - PingColWidth); float xBits = rowRect.x; float xParent = xBits + BitsColWidth; + float xTier = xParent + rowParentWidth; float xRef = rowRect.x + rowRect.width - (ReferenceColWidth + PingColWidth); EditorGUI.DrawRect(new Rect(xParent, rowRect.y, 1f, rowHeight), new Color(0f, 0f, 0f, 0.2f)); + EditorGUI.DrawRect(new Rect(xTier, rowRect.y, 1f, rowHeight), new Color(0f, 0f, 0f, 0.2f)); EditorGUI.DrawRect(new Rect(xRef, rowRect.y, 1f, rowHeight), new Color(0f, 0f, 0f, 0.2f)); GUI.Label(new Rect(xBits + 6f, rowRect.y + 2f, BitsColWidth - 8f, rowHeight - 4f), info.bitCount.ToString()); GUI.Label(new Rect(xParent + 6f, rowRect.y + 2f, rowParentWidth - 8f, rowHeight - 4f), info.parent != null ? info.parent.Name : ""); + DrawTierBadge(new Rect(xTier + 5f, rowRect.y + 3f, TierColWidth - 10f, rowHeight - 6f), info); EditorGUI.BeginDisabledGroup(true); var obj = info.reference; @@ -440,11 +447,13 @@ private void DrawPackingList(DisposableList list) var headerRect = EditorGUILayout.GetControlRect(false, rowHeight); EditorGUI.DrawRect(headerRect, new Color(0.18f, 0.18f, 0.18f, 1f)); EditorGUI.DrawRect(new Rect(headerRect.x, headerRect.yMax - 1f, headerRect.width, 1f), new Color(0f, 0f, 0f, 0.35f)); - float parentColWidth = Mathf.Max(80f, headerRect.width - BitsColWidth - ReferenceColWidth - PingColWidth); + float parentColWidth = Mathf.Max(80f, headerRect.width - BitsColWidth - TierColWidth - ReferenceColWidth - PingColWidth); float x = headerRect.x; GUI.Label(new Rect(x + 6f, headerRect.y + 2f, BitsColWidth - 8f, rowHeight - 4f), "Bits", EditorStyles.boldLabel); x += BitsColWidth; GUI.Label(new Rect(x + 6f, headerRect.y + 2f, parentColWidth - 8f, rowHeight - 4f), "Parent", EditorStyles.boldLabel); + x += parentColWidth; + GUI.Label(new Rect(x + 6f, headerRect.y + 2f, TierColWidth - 8f, rowHeight - 4f), new GUIContent("Tier", "Current local interest tier. Server samples have no single receiver tier."), EditorStyles.boldLabel); x = headerRect.x + headerRect.width - (ReferenceColWidth + PingColWidth); GUI.Label(new Rect(x + 6f, headerRect.y + 2f, ReferenceColWidth - 8f, rowHeight - 4f), "Reference", EditorStyles.boldLabel); @@ -459,16 +468,19 @@ private void DrawPackingList(DisposableList list) if ((visibleRowIndex & 1) == 0) EditorGUI.DrawRect(rowRect, new Color(1f, 1f, 1f, 0.035f)); - float rowParentWidth = Mathf.Max(80f, rowRect.width - BitsColWidth - ReferenceColWidth - PingColWidth); + float rowParentWidth = Mathf.Max(80f, rowRect.width - BitsColWidth - TierColWidth - ReferenceColWidth - PingColWidth); float xBits = rowRect.x; float xParent = xBits + BitsColWidth; + float xTier = xParent + rowParentWidth; float xRef = rowRect.x + rowRect.width - (ReferenceColWidth + PingColWidth); EditorGUI.DrawRect(new Rect(xParent, rowRect.y, 1f, rowHeight), new Color(0f, 0f, 0f, 0.2f)); + EditorGUI.DrawRect(new Rect(xTier, rowRect.y, 1f, rowHeight), new Color(0f, 0f, 0f, 0.2f)); EditorGUI.DrawRect(new Rect(xRef, rowRect.y, 1f, rowHeight), new Color(0f, 0f, 0f, 0.2f)); GUI.Label(new Rect(xBits + 6f, rowRect.y + 2f, BitsColWidth - 8f, rowHeight - 4f), info.bitCount.ToString()); GUI.Label(new Rect(xParent + 6f, rowRect.y + 2f, rowParentWidth - 8f, rowHeight - 4f), info.parent != null ? info.parent.Name : ""); + DrawTierBadge(new Rect(xTier + 5f, rowRect.y + 3f, TierColWidth - 10f, rowHeight - 6f), info); EditorGUI.BeginDisabledGroup(true); var obj = info.reference; @@ -486,6 +498,51 @@ private void DrawPackingList(DisposableList list) } } + private static void DrawTierBadge(Rect rect, PackingInfo info) + { + _tierBadgeStyle ??= new GUIStyle(EditorStyles.miniLabel) + { + alignment = TextAnchor.MiddleCenter, + fontStyle = FontStyle.Bold + }; + + if (!TryGetLocalTier(info, out var tier)) + { + GUI.Label(rect, "—", _tierBadgeStyle); + return; + } + + if (tier == NetworkLODProfile.CulledTier) + { + EditorGUI.DrawRect(rect, new Color(0.35f, 0.35f, 0.35f, 0.65f)); + GUI.Label(rect, "Cull", _tierBadgeStyle); + return; + } + + float blend = Mathf.Clamp01(tier / 3f); + EditorGUI.DrawRect(rect, Color.Lerp( + new Color(0.20f, 0.62f, 0.32f, 0.65f), + new Color(0.82f, 0.48f, 0.16f, 0.65f), + blend)); + GUI.Label(rect, $"T{tier}", _tierBadgeStyle); + } + + private static bool TryGetLocalTier(PackingInfo info, out byte tier) + { + tier = 0; + if (info.reference is not PredictedIdentity identity || !identity) + return false; + + var manager = identity.predictionManager; + var interest = manager ? manager.interest : null; + if (interest == null || !interest.enabled || manager.cachedIsServer) + return false; + + if (!interest.TryGetLocalRelevance(identity.rootObjectId, out tier)) + tier = 0; + return true; + } + private struct GroupRow { public string key; diff --git a/Assets/PurrDiction/Examples/SimpleLODProfile.asset b/Assets/PurrDiction/Examples/SimpleLODProfile.asset new file mode 100644 index 00000000..5c96c73d --- /dev/null +++ b/Assets/PurrDiction/Examples/SimpleLODProfile.asset @@ -0,0 +1,20 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 57406ac0d08043fa950ec4a911873a8d, type: 3} + m_Name: SimpleLODProfile + m_EditorClassIdentifier: PurrNet.Prediction::PurrNet.Prediction.PredictionLODProfile + _networkProfile: {fileID: 11400000, guid: 02cee7bdf1cd1144a8d285db2fb7e191, type: 2} + _tiers: + - suggestedPolicy: 0 + - suggestedPolicy: 3 + - suggestedPolicy: 2 + _culledPolicy: 2 diff --git a/Assets/PurrDiction/Examples/SimpleLODProfile.asset.meta b/Assets/PurrDiction/Examples/SimpleLODProfile.asset.meta new file mode 100644 index 00000000..1fc0a13e --- /dev/null +++ b/Assets/PurrDiction/Examples/SimpleLODProfile.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 93afd3489a7fc45419d11b759e9d4ec8 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/PurrDiction/Runtime/AssemblyInfo.cs b/Assets/PurrDiction/Runtime/AssemblyInfo.cs index 01580d21..e1ad0d16 100644 --- a/Assets/PurrDiction/Runtime/AssemblyInfo.cs +++ b/Assets/PurrDiction/Runtime/AssemblyInfo.cs @@ -3,3 +3,4 @@ [assembly: InternalsVisibleTo("PurrDiction.Editor")] [assembly: InternalsVisibleTo("PurrNet.Prediction.Benchmarks.Editor")] [assembly: InternalsVisibleTo("PurrNet.Prediction.EditorTests")] +[assembly: InternalsVisibleTo("PurrDiction.PlayModeTests")] diff --git a/Assets/PurrDiction/Runtime/Core/DeterministicIdentity.cs b/Assets/PurrDiction/Runtime/Core/DeterministicIdentity.cs index 4c3d7a3f..5f75e3f7 100644 --- a/Assets/PurrDiction/Runtime/Core/DeterministicIdentity.cs +++ b/Assets/PurrDiction/Runtime/Core/DeterministicIdentity.cs @@ -92,6 +92,7 @@ internal override void ClearFuture(ulong stateTick) private InterpolatedWithDispose> _interpolatedState; private History> _stateHistory; + private int _defaultInterpolationBufferSize; protected TickManager tickModule { get; private set; } @@ -188,15 +189,21 @@ internal override void Setup(NetworkManager manager, PredictionManager world, Pr GetLatestUnityState(); var interpolationBuffer = (int)Mathf.Max(world.tickRate / (float)10, 2); + _defaultInterpolationBufferSize = interpolationBuffer; if (_interpolatedState == null) { _interpolatedState = new InterpolatedWithDispose>( - FULLInterpolate, 1f / world.tickRate, fullPredictedState.DeepCopy(), interpolationBuffer); + FULLInterpolate, + 1f / world.tickRate, + fullPredictedState.DeepCopy(), + Math.Max(interpolationBuffer, interestSendIntervalTicks)); } else _interpolatedState.Teleport(fullPredictedState.DeepCopy()); + SyncInterestInterpolationWindow(interestSendIntervalTicks, false); + _viewState?.Dispose(); _viewState = null; @@ -277,6 +284,12 @@ public override void UpdateRollbackInterpolationState(float delta, bool accumula _viewState = copy; } + internal override void SyncInterestInterpolationWindow(int sendIntervalTicks, bool sparse) + { + if (_interpolatedState != null) + _interpolatedState.maxBufferSize = Math.Max(_defaultInterpolationBufferSize, sendIntervalTicks); + } + protected virtual void ModifyRollbackViewState(ref STATE state, float delta, bool accumulateError) { } protected virtual STATE GetInitialState() => default; diff --git a/Assets/PurrDiction/Runtime/Core/PlayerPacker.cs b/Assets/PurrDiction/Runtime/Core/PlayerPacker.cs index 25446138..e1457e13 100644 --- a/Assets/PurrDiction/Runtime/Core/PlayerPacker.cs +++ b/Assets/PurrDiction/Runtime/Core/PlayerPacker.cs @@ -1,16 +1,338 @@ -using PurrNet.Packing; +using System.Collections.Generic; +using PurrNet.Packing; namespace PurrNet.Prediction { + internal sealed class IdentityEstablishmentState + { + readonly Dictionary _sentTicks = new (); + + public void Reset() + { + _sentTicks.Clear(); + } + + public void MarkSent(PredictedComponentID id, ulong sentTick) + { + _sentTicks[id] = sentTick; + } + + public bool Prepare(PredictedComponentID id, ulong ackedTick, ulong sentTick) + { + if (!_sentTicks.TryGetValue(id, out var identitySentTick)) + { + _sentTicks.Add(id, sentTick); + return true; + } + + if (identitySentTick == 0) + return false; + + if (ackedTick >= identitySentTick) + { + _sentTicks[id] = 0; + return false; + } + + return true; + } + + public bool RequiresAbsolute(PredictedComponentID id) + { + return !_sentTicks.TryGetValue(id, out var sentTick) || sentTick != 0; + } + + public bool Invalidate(PredictedComponentID id) + { + if (!_sentTicks.TryGetValue(id, out var sentTick) || sentTick != 0) + return false; + + _sentTicks.Remove(id); + return true; + } + + public void Remove(PredictedComponentID id) + { + _sentTicks.Remove(id); + } + + public void Clear() + { + _sentTicks.Clear(); + } + } + + internal sealed class RootSendBaselineState + { + private sealed class RootHistory + { + public readonly List sentTicks = new (16); + public int firstPending; + public ulong acknowledgedTick; + } + + readonly Dictionary _roots = new (); + + public ulong GetBaseline(PredictedObjectID root, ulong ackedServerTick) + { + if (!_roots.TryGetValue(root, out var history)) + return ackedServerTick; + + while (history.firstPending < history.sentTicks.Count && + history.sentTicks[history.firstPending] <= ackedServerTick) + { + history.acknowledgedTick = history.sentTicks[history.firstPending++]; + } + + if (history.firstPending == history.sentTicks.Count) + { + history.sentTicks.Clear(); + history.firstPending = 0; + } + else if (history.firstPending >= 32 && history.firstPending * 2 >= history.sentTicks.Count) + { + history.sentTicks.RemoveRange(0, history.firstPending); + history.firstPending = 0; + } + + return history.acknowledgedTick == 0 ? ackedServerTick : history.acknowledgedTick; + } + + public void MarkSent(PredictedObjectID root, ulong sentTick) + { + if (!_roots.TryGetValue(root, out var history)) + { + history = new RootHistory(); + _roots.Add(root, history); + } + + int count = history.sentTicks.Count; + if (count == 0 || history.sentTicks[count - 1] != sentTick) + history.sentTicks.Add(sentTick); + } + + public void Reset(PredictedObjectID root) + { + _roots.Remove(root); + } + + public void PruneBefore(ulong oldestTick) + { + foreach (var history in _roots.Values) + { + while (history.firstPending < history.sentTicks.Count && + history.sentTicks[history.firstPending] < oldestTick) + { + history.firstPending++; + } + + if (history.firstPending == 0) + continue; + + history.sentTicks.RemoveRange(0, history.firstPending); + history.firstPending = 0; + } + } + + public void Clear() + { + _roots.Clear(); + } + } + + internal readonly struct InterestTierChange + { + public readonly PredictedObjectID root; + public readonly byte tier; + + public InterestTierChange(PredictedObjectID root, byte tier) + { + this.root = root; + this.tier = tier; + } + } + + internal sealed class InterestControlState + { + readonly Dictionary _deliveredTiers = new (); + readonly Dictionary _absoluteUntilConfirmed = new (); + readonly HashSet _baselineResets = new (); + readonly List _pending = new (8); + readonly List _prepared = new (8); + + public int count => _prepared.Count; + + public bool hasUnconfirmedReentries => _absoluteUntilConfirmed.Count > 0; + + public InterestTierChange this[int index] => _prepared[index]; + + public void Queue(PredictedObjectID root, byte tier) + { + byte deliveredTier = GetTierAfterPrepared(root); + + for (var i = 0; i < _pending.Count; i++) + { + if (!_pending[i].root.Equals(root)) + continue; + + if (tier == deliveredTier) + _pending.RemoveAt(i); + else + _pending[i] = new InterestTierChange(root, tier); + return; + } + + if (tier != deliveredTier) + _pending.Add(new InterestTierChange(root, tier)); + } + + public void PrepareForFrame(ulong sentTick) + { + _prepared.Clear(); + _prepared.AddRange(_pending); + _pending.Clear(); + + for (var i = 0; i < _prepared.Count; i++) + { + var change = _prepared[i]; + byte deliveredTier = _deliveredTiers.GetValueOrDefault(change.root, (byte)0); + + if (deliveredTier == NetworkLODProfile.CulledTier && + change.tier != NetworkLODProfile.CulledTier && + !_absoluteUntilConfirmed.ContainsKey(change.root)) + { + _absoluteUntilConfirmed.Add(change.root, sentTick); + _baselineResets.Add(change.root); + } + else if (change.tier == NetworkLODProfile.CulledTier) + { + _absoluteUntilConfirmed.Remove(change.root); + _baselineResets.Remove(change.root); + } + } + } + + public bool RequiresAbsolute(PredictedObjectID root) + { + return _absoluteUntilConfirmed.ContainsKey(root); + } + + public bool ConfirmReentry(PredictedObjectID root, ulong serverTick) + { + if (!_absoluteUntilConfirmed.TryGetValue(root, out var sentTick) || serverTick < sentTick) + return false; + + _absoluteUntilConfirmed.Remove(root); + _baselineResets.Remove(root); + return true; + } + + public bool ConsumeBaselineReset(PredictedObjectID root) + { + return _baselineResets.Remove(root); + } + + public void MarkDelivered() + { + for (var i = 0; i < _prepared.Count; i++) + { + var change = _prepared[i]; + if (change.tier == 0) + _deliveredTiers.Remove(change.root); + else + _deliveredTiers[change.root] = change.tier; + } + + _prepared.Clear(); + } + + public void Remove(PredictedObjectID root) + { + _deliveredTiers.Remove(root); + _absoluteUntilConfirmed.Remove(root); + _baselineResets.Remove(root); + + for (var i = _pending.Count - 1; i >= 0; i--) + { + if (_pending[i].root.Equals(root)) + _pending.RemoveAt(i); + } + + for (var i = _prepared.Count - 1; i >= 0; i--) + { + if (_prepared[i].root.Equals(root)) + _prepared.RemoveAt(i); + } + } + + private byte GetTierAfterPrepared(PredictedObjectID root) + { + for (var i = 0; i < _prepared.Count; i++) + { + if (_prepared[i].root.Equals(root)) + return _prepared[i].tier; + } + + return _deliveredTiers.GetValueOrDefault(root, (byte)0); + } + + public void Clear() + { + _deliveredTiers.Clear(); + _absoluteUntilConfirmed.Clear(); + _baselineResets.Clear(); + _pending.Clear(); + _prepared.Clear(); + } + } + + internal struct ReliableFrameDeliveryState + { + private ulong _sentTick; + + public bool ShouldSuppress(ulong acknowledgedTick) + { + if (_sentTick == 0) + return false; + + if (acknowledgedTick < _sentTick) + return true; + + _sentTick = 0; + return false; + } + + public void MarkSent(ulong sentTick) + { + _sentTick = sentTick; + } + + public void Clear() + { + _sentTick = 0; + } + } + internal struct PlayerPacker { public PlayerID player; public BitPacker packer; public bool fullFrame; + public ulong preparedFrameTick; + public int maxUnreliableFrameBytes; + public ReliableFrameDeliveryState reliableFrame; + public IdentityEstablishmentState identityEstablishment; + public RootSendBaselineState sendBaselines; + public InterestControlState interestControls; public void Dispose() { packer?.Dispose(); + preparedFrameTick = 0; + reliableFrame.Clear(); + identityEstablishment?.Clear(); + sendBaselines?.Clear(); + interestControls?.Clear(); } } } diff --git a/Assets/PurrDiction/Runtime/Core/PredictedIdentity.Lifecycle.cs b/Assets/PurrDiction/Runtime/Core/PredictedIdentity.Lifecycle.cs index 4c262def..82858a50 100644 --- a/Assets/PurrDiction/Runtime/Core/PredictedIdentity.Lifecycle.cs +++ b/Assets/PurrDiction/Runtime/Core/PredictedIdentity.Lifecycle.cs @@ -50,18 +50,27 @@ internal void RunGetLatestUnityState() internal void RunUpdateView(float deltaTime) { + if (IsLocallyDormant()) + return; + UpdateView(deltaTime); UpdateModuleView(deltaTime); } internal void RunLateUpdateView(float deltaTime) { + if (IsLocallyDormant()) + return; + LateUpdateView(deltaTime); LateUpdateModuleView(deltaTime); } - internal void RunRollback(ulong tick) + internal void RunRollback(ulong tick, bool allowDormant = false) { + if (!allowDormant && IsLocallyDormant()) + return; + RollbackDynamicModules(tick); RollbackModules(tick); Rollback(tick); @@ -77,6 +86,9 @@ internal void RunSaveState(ulong tick) internal void RunSaveStateUnchecked(ulong tick) { + if (IsLocallyDormant()) + return; + PredictionHistoryTelemetry.RecordSave(isEventHandler); SaveModulesState(tick); SaveStateInHistory(tick); @@ -85,6 +97,9 @@ internal void RunSaveStateUnchecked(ulong tick) internal void RunUpdateRollbackInterpolation(float delta, bool accumulateError) { + if (IsLocallyDormant()) + return; + bool shouldAccumulateError = accumulateError && AccumulatesRollbackInterpolationError(); UpdateModulesInterpolation(delta, shouldAccumulateError); UpdateRollbackInterpolationState(delta, shouldAccumulateError); @@ -119,6 +134,20 @@ internal void RunWriteFirstState(ulong tick, BitPacker packer) WriteFirstState(tick, packer); } + internal void RunWriteAbsoluteState(ulong tick, BitPacker packer) + { + WriteFirstDynamicModuleSnapshot(tick, packer); + WriteFirstStateModules(tick, packer); + WriteAbsoluteState(tick, packer); + } + + internal void RunReadAbsoluteState(ulong tick, BitPacker packer, ulong serverTick) + { + ReadFirstDynamicModuleSnapshot(tick, packer, serverTick); + ReadFirstStateModules(tick, packer, serverTick); + ReadAbsoluteState(tick, packer, serverTick); + } + internal void RunReadFirstState(ulong tick, BitPacker packer, ulong serverTick) { ReadFirstDynamicModuleSnapshot(tick, packer, serverTick); diff --git a/Assets/PurrDiction/Runtime/Core/PredictedIdentity.cs b/Assets/PurrDiction/Runtime/Core/PredictedIdentity.cs index 6c33dc03..7bf8ddab 100644 --- a/Assets/PurrDiction/Runtime/Core/PredictedIdentity.cs +++ b/Assets/PurrDiction/Runtime/Core/PredictedIdentity.cs @@ -56,13 +56,24 @@ public PredictedObjectID rootObjectId { get { + if (_hasCachedRootObjectId) + return _cachedRootObjectId; + var manager = predictionManager; if (manager && manager.hierarchy && manager.hierarchy.TryGetRootId(id.objectId, out var rootId)) + { + _cachedRootObjectId = rootId; + _hasCachedRootObjectId = true; return rootId; + } + return id.objectId; } } + private PredictedObjectID _cachedRootObjectId; + private bool _hasCachedRootObjectId; + internal bool isFreshSpawn = true; internal bool preservesStateOnSetup { get; private set; } private bool _simulateSoftCorrectionDuringReplay; @@ -98,6 +109,9 @@ public PredictedObjectID rootObjectId private PredictionPolicy _lastRegisteredPredictionPolicy; private bool _hasLastRegisteredPredictionPolicy; private bool _hasPendingSetupPolicyChange; + private PredictionPolicy _resolvedConfiguredPolicy; + private PredictionPolicy? _interestPolicyOverride; + private PredictionPolicy _pendingSetupOldConfiguredPolicy; private PredictionPolicy _pendingSetupOldPolicy; private PredictionPolicy _pendingSetupNewPolicy; private bool _isResolvingSetupPredictionPolicy; @@ -201,7 +215,19 @@ private bool TryGetPredictionPolicyScopeForSetup(out PredictionPolicyScope scope } internal PredictionPolicy ResolvePredictionPolicyForSetup() - => ResolveSetupPredictionPolicy(); + => ResolveAppliedPredictionPolicy(ResolveSetupPredictionPolicy()); + + internal void PrepareInterestPolicyForRegistration(bool preserve) + { + if (preserve) + return; + + _interestSendIntervalTicks = 1; + if (!_interestPolicyOverride.HasValue) + return; + + _interestPolicyOverride = null; + } internal PredictionPolicy previousRegisteredPredictionPolicy => _hasLastRegisteredPredictionPolicy @@ -274,11 +300,36 @@ public void SetPredictionPolicy(PredictionPolicy policy) CancelPendingPredictionPolicySetup(); + _resolvedConfiguredPolicy = policy; + ApplyResolvedPredictionPolicy(); + } + + internal void SetInterestPolicyOverride(PredictionPolicy? policy) + { + if (_interestPolicyOverride == policy) + return; + + CancelPendingPredictionPolicySetup(); + _interestPolicyOverride = policy; + ApplyResolvedPredictionPolicy(); + } + + private PredictionPolicy ResolveAppliedPredictionPolicy(PredictionPolicy configuredPolicy) + { + var selected = _interestPolicyOverride ?? configuredPolicy; + return NormalizePredictionPolicy(selected, false); + } + + private void ApplyResolvedPredictionPolicy() + { + var policy = ResolveAppliedPredictionPolicy(_resolvedConfiguredPolicy); + if (predictionPolicy == policy) return; var oldPolicy = predictionPolicy; predictionPolicy = policy; + SyncInterestInterpolationWindow(); OnPredictionPolicyChanged(oldPolicy, policy); if (predictionManager) predictionManager.HandlePredictionPolicyChanged(this, oldPolicy, policy); @@ -289,12 +340,11 @@ private void PreparePredictionPolicyForSetup(PredictionPolicy policy) CancelPendingPredictionPolicySetup(); policy = NormalizePredictionPolicy(policy, true); - if (predictionPolicy == policy) - return; - + _pendingSetupOldConfiguredPolicy = _resolvedConfiguredPolicy; _pendingSetupOldPolicy = predictionPolicy; - _pendingSetupNewPolicy = policy; - predictionPolicy = policy; + _resolvedConfiguredPolicy = policy; + _pendingSetupNewPolicy = ResolveAppliedPredictionPolicy(policy); + predictionPolicy = _pendingSetupNewPolicy; _hasPendingSetupPolicyChange = true; } @@ -306,12 +356,17 @@ internal void CompletePredictionPolicySetup() var oldPolicy = _pendingSetupOldPolicy; var newPolicy = _pendingSetupNewPolicy; _hasPendingSetupPolicyChange = false; + _pendingSetupOldConfiguredPolicy = default; _pendingSetupOldPolicy = default; _pendingSetupNewPolicy = default; - OnPredictionPolicyChanged(oldPolicy, newPolicy); - if (predictionManager) - predictionManager.HandlePredictionPolicyChanged(this, oldPolicy, newPolicy); + if (oldPolicy != newPolicy) + { + SyncInterestInterpolationWindow(); + OnPredictionPolicyChanged(oldPolicy, newPolicy); + if (predictionManager) + predictionManager.HandlePredictionPolicyChanged(this, oldPolicy, newPolicy); + } } internal void CancelPendingPredictionPolicySetup() @@ -319,8 +374,10 @@ internal void CancelPendingPredictionPolicySetup() if (!_hasPendingSetupPolicyChange) return; + _resolvedConfiguredPolicy = _pendingSetupOldConfiguredPolicy; predictionPolicy = _pendingSetupOldPolicy; _hasPendingSetupPolicyChange = false; + _pendingSetupOldConfiguredPolicy = default; _pendingSetupOldPolicy = default; _pendingSetupNewPolicy = default; } @@ -397,6 +454,9 @@ internal bool SkipsCurrentSimulationPhase() if (manager.cachedIsServer) return false; + if (IsLocallyDormant()) + return true; + if (UsesFullPredictionTimeline()) return false; @@ -412,6 +472,52 @@ internal virtual void OnReplayEnd() { } internal virtual void SyncEffectivePolicySideEffects() { } + internal virtual void SyncLocalRelevanceSideEffects(bool relevant) { } + + private int _interestSendIntervalTicks = 1; + + internal int interestSendIntervalTicks => _interestSendIntervalTicks; + + internal bool UsesSparseInterestInterpolation() + { + return !isDeterministic && _interestSendIntervalTicks > 1 && UsesServerRelayTimeline(); + } + + internal void SetInterestSendIntervalTicks(int sendIntervalTicks) + { + sendIntervalTicks = Math.Max(1, sendIntervalTicks); + if (_interestSendIntervalTicks == sendIntervalTicks) + return; + + _interestSendIntervalTicks = sendIntervalTicks; + SyncInterestInterpolationWindow(); + } + + private void SyncInterestInterpolationWindow() + { + bool sparse = UsesSparseInterestInterpolation(); + SyncInterestInterpolationWindow(_interestSendIntervalTicks, sparse); + + for (var i = 0; i < _modules.Count; i++) + _modules[i].SetInterestInterpolationWindowInternal(_interestSendIntervalTicks, sparse); + } + + internal virtual void SyncInterestInterpolationWindow(int sendIntervalTicks, bool sparse) { } + + internal void UpdateLocalRelevance(bool relevant) + { + _locallyDormant = !relevant; + SyncLocalRelevanceSideEffects(relevant); + } + + private bool _locallyDormant; + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + internal bool IsLocallyDormant() + { + return _locallyDormant; + } + [UsedByIL] public bool IsSimulating() { @@ -430,6 +536,10 @@ public virtual void ResetState() preservesStateOnSetup = false; _simulateSoftCorrectionDuringReplay = false; _skipReplaySpawnInitialization = false; + _locallyDormant = false; + _interestSendIntervalTicks = 1; + _interestPolicyOverride = null; + _hasCachedRootObjectId = false; owner = null; id = default; ResetModulesForPool(); @@ -453,11 +563,21 @@ internal void SetSkipReplaySpawnInitialization(bool skip) internal void SetOwner(PlayerID? player, bool syncPolicySideEffects = true) { + bool changed = owner != player; owner = player; OnOwnerAssigned(player); if (syncPolicySideEffects) - SyncEffectivePolicySideEffects(); + { + if (changed && predictionManager && predictionManager.interest != null) + { + predictionManager.HandleLocalOwnershipChanged(this); + SyncInterestInterpolationWindow(); + SyncEffectivePolicySideEffects(); + } + else + SyncEffectivePolicySideEffects(); + } } protected virtual void OnOwnerAssigned(PlayerID? player) { } @@ -562,6 +682,7 @@ internal virtual void Setup(NetworkManager manager, PredictionManager world, Pre { isServer = manager.isServer; this.id = id; + _hasCachedRootObjectId = false; _destroyedFired = false; predictionManager = world; sceneId = world.sceneId; @@ -569,7 +690,7 @@ internal virtual void Setup(NetworkManager manager, PredictionManager world, Pre _metadataVerified = null; _moduleSetVerified = null; SetOwner(owner, false); - PreparePredictionPolicyForSetup(ResolvePredictionPolicyForSetup()); + PreparePredictionPolicyForSetup(ResolveSetupPredictionPolicy()); if (!isFreshSpawn) { @@ -629,6 +750,8 @@ private bool IsLastLiveIdentityOnGameObject() public bool isOwner => IsOwner(); + public bool isRelevant => !IsLocallyDormant(); + public bool isController { get @@ -710,6 +833,16 @@ internal virtual void LateUpdateView(float deltaTime) { } internal abstract void WriteFirstState(ulong tick, BitPacker packer); + internal virtual void WriteAbsoluteState(ulong tick, BitPacker packer) + { + WriteFirstState(tick, packer); + } + + internal virtual void ReadAbsoluteState(ulong tick, BitPacker packer, ulong serverTick) + { + ReadFirstState(tick, packer, serverTick); + } + internal abstract bool WriteCurrentState(PlayerID receiver, BitPacker packer, ulong baselineTick); internal abstract void ReadFirstState(ulong tick, BitPacker packer, ulong serverTick); diff --git a/Assets/PurrDiction/Runtime/Core/PredictedIdentityStatefull.cs b/Assets/PurrDiction/Runtime/Core/PredictedIdentityStatefull.cs index 5e559e43..1cd7987d 100644 --- a/Assets/PurrDiction/Runtime/Core/PredictedIdentityStatefull.cs +++ b/Assets/PurrDiction/Runtime/Core/PredictedIdentityStatefull.cs @@ -21,6 +21,9 @@ public override string ToString() private InterpolatedWithDispose> _interpolatedState; private History> _stateHistory; private History> _verifiedHistory; + private float _baseInterpolationTickDelta; + private int _defaultInterpolationBufferSize; + private ulong? _lastSparseInterpolationTick; protected TickManager tickModule { get; private set; } private bool _firstViewUpdate = true; @@ -29,6 +32,7 @@ public override string ToString() public override void ResetInterpolation() { _interpolatedState?.Teleport(fullPredictedState.DeepCopy()); + _lastSparseInterpolationTick = null; } public override void ResetState() @@ -56,6 +60,7 @@ private void DisposeStateStorage() _viewState = null; _interpolatedState?.Teleport(default); + _lastSparseInterpolationTick = null; _stateHistory?.Clear(); _verifiedHistory = null; @@ -127,11 +132,16 @@ internal override void Setup(NetworkManager manager, PredictionManager world, Pr GetLatestUnityState(); var interpolationBuffer = (int)Mathf.Max(world.tickRate / (float)10, 2); + _baseInterpolationTickDelta = 1f / world.tickRate; + _defaultInterpolationBufferSize = interpolationBuffer; if (_interpolatedState == null) { _interpolatedState = new InterpolatedWithDispose>( - FULLInterpolate, 1f / world.tickRate, fullPredictedState.DeepCopy(), interpolationBuffer); + FULLInterpolate, + _baseInterpolationTickDelta, + fullPredictedState.DeepCopy(), + Math.Max(interpolationBuffer, interestSendIntervalTicks)); OnViewInterpolationReset(); } else if (!preserveInterpolation) @@ -140,6 +150,8 @@ internal override void Setup(NetworkManager manager, PredictionManager world, Pr OnViewInterpolationReset(); } + SyncInterestInterpolationWindow(interestSendIntervalTicks, UsesSparseInterestInterpolation()); + _viewState?.Dispose(); _viewState = null; @@ -219,6 +231,18 @@ private void WriteOwnedStateIfChanged(ulong tick, ref FULL_STATE state) public override void UpdateRollbackInterpolationState(float delta, bool accumulateError) { + if (UsesSparseInterestInterpolation()) + { + if (!lastVerifiedTick.HasValue || _lastSparseInterpolationTick == lastVerifiedTick.Value) + return; + + _lastSparseInterpolationTick = lastVerifiedTick.Value; + } + else + { + _lastSparseInterpolationTick = null; + } + var copy = fullPredictedState.DeepCopy(); ModifyRollbackViewState(ref copy.state, delta, accumulateError); @@ -226,6 +250,18 @@ public override void UpdateRollbackInterpolationState(float delta, bool accumula _viewState = copy; } + internal override void SyncInterestInterpolationWindow(int sendIntervalTicks, bool sparse) + { + if (_interpolatedState == null) + return; + + _interpolatedState.maxBufferSize = Math.Max(_defaultInterpolationBufferSize, sendIntervalTicks); + _interpolatedState.tickDelta = sparse + ? _baseInterpolationTickDelta * sendIntervalTicks + : _baseInterpolationTickDelta; + _lastSparseInterpolationTick = null; + } + protected virtual void ModifyRollbackViewState(ref STATE state, float delta, bool accumulateError) { } /// @@ -317,6 +353,29 @@ internal override void ReadFirstState(ulong tick, BitPacker packer, ulong server WriteOwnedStateIfChanged(tick, ref newState); } + internal override void WriteAbsoluteState(ulong tick, BitPacker packer) + { + RefreshVerifiedFromLive(tick); + + int pos = packer.positionInBits; + FULL_STATE baseline = default; + DeltaPacker.Write(packer, baseline.prediction, fullPredictedState.prediction); + WriteDeltaState(packer, in baseline.state, in fullPredictedState.state); + TickBandwidthProfiler.OnWroteState(myType, packer.positionInBits - pos, this); + } + + internal override void ReadAbsoluteState(ulong tick, BitPacker packer, ulong serverTick) + { + int pos = packer.positionInBits; + FULL_STATE baseline = default; + FULL_STATE newState = default; + DeltaPacker.Read(packer, baseline.prediction, ref newState.prediction); + ReadDeltaState(packer, in baseline.state, ref newState.state); + StoreVerified(serverTick, ref newState); + WriteOwnedStateIfChanged(tick, ref newState); + TickBandwidthProfiler.OnReadState(myType, packer.positionInBits - pos, this); + } + internal override bool WriteCurrentState(PlayerID target, BitPacker packer, ulong baselineTick) { RefreshVerifiedFromLive(predictionManager.localTick); diff --git a/Assets/PurrDiction/Runtime/Core/PredictedModule.cs b/Assets/PurrDiction/Runtime/Core/PredictedModule.cs index e15e5204..6a7374c8 100644 --- a/Assets/PurrDiction/Runtime/Core/PredictedModule.cs +++ b/Assets/PurrDiction/Runtime/Core/PredictedModule.cs @@ -155,6 +155,8 @@ protected virtual void UpdateView(float delta) { } protected virtual void LateUpdateView(float delta) { } internal void UpdateInterpolationInternal(float delta, bool accumulateError) => UpdateInterpolation(delta, accumulateError); + internal virtual void SetInterestInterpolationWindowInternal(int sendIntervalTicks, bool sparse) { } + /// /// Updates the interpolation state used for smooth visual rollback. /// diff --git a/Assets/PurrDiction/Runtime/Core/PredictedModuleState.cs b/Assets/PurrDiction/Runtime/Core/PredictedModuleState.cs index d8d21353..ae3c0bd9 100644 --- a/Assets/PurrDiction/Runtime/Core/PredictedModuleState.cs +++ b/Assets/PurrDiction/Runtime/Core/PredictedModuleState.cs @@ -25,6 +25,9 @@ public abstract class PredictedModule : PredictedModule where TState : s private InterpolatedWithDispose> _interpolatedState; private MODULE_STATE? _viewState; + private float _baseInterpolationTickDelta; + private int _defaultInterpolationBufferSize; + private ulong? _lastSparseInterpolationTick; public TState viewState; @@ -73,15 +76,21 @@ internal override void OnCoreInitialize() { var tickRate = predictionManager.tickRate; var bufferSize = (int)Math.Max(tickRate / 10f, 2); + _baseInterpolationTickDelta = 1f / tickRate; + _defaultInterpolationBufferSize = bufferSize; _history = new History>(tickRate * 10); _interpolatedState = new InterpolatedWithDispose>( FULLInterpolate, - 1f / tickRate, + _baseInterpolationTickDelta, fullPredictedState.DeepCopy(), - bufferSize + Math.Max(bufferSize, identity.interestSendIntervalTicks) ); + + SetInterestInterpolationWindowInternal( + identity.interestSendIntervalTicks, + identity.UsesSparseInterestInterpolation()); } private History> _verifiedHistory; @@ -145,6 +154,7 @@ protected override void Setup(PredictedIdentity parent, PredictionManager world) _history?.Clear(); _viewState?.Dispose(); _viewState = null; + _lastSparseInterpolationTick = null; _interpolatedState?.Teleport(fullPredictedState.DeepCopy()); } @@ -191,10 +201,24 @@ private MODULE_STATE FULLInterpolate(MODULE_STATE from, MODULE_S protected override void ResetInterpolation() { _interpolatedState?.Teleport(fullPredictedState.DeepCopy()); + _lastSparseInterpolationTick = null; } protected override void UpdateInterpolation(float delta, bool accumulateError) { + if (identity.UsesSparseInterestInterpolation()) + { + if (!identity.lastVerifiedTick.HasValue || + _lastSparseInterpolationTick == identity.lastVerifiedTick.Value) + return; + + _lastSparseInterpolationTick = identity.lastVerifiedTick.Value; + } + else + { + _lastSparseInterpolationTick = null; + } + var copy = fullPredictedState.DeepCopy(); ModifyRollbackViewState(ref copy.state, delta, accumulateError); @@ -202,6 +226,18 @@ protected override void UpdateInterpolation(float delta, bool accumulateError) _viewState = copy; } + internal override void SetInterestInterpolationWindowInternal(int sendIntervalTicks, bool sparse) + { + if (_interpolatedState == null) + return; + + _interpolatedState.maxBufferSize = Math.Max(_defaultInterpolationBufferSize, sendIntervalTicks); + _interpolatedState.tickDelta = sparse + ? _baseInterpolationTickDelta * sendIntervalTicks + : _baseInterpolationTickDelta; + _lastSparseInterpolationTick = null; + } + protected virtual void ModifyRollbackViewState(ref TState state, float delta, bool accumulateError) { } protected override void Simulate(ulong tick, float delta) @@ -380,6 +416,7 @@ private void DisposeStateStorage() _viewState?.Dispose(); _viewState = null; _interpolatedState?.Teleport(default); + _lastSparseInterpolationTick = null; _verifiedHistory = null; _verifiedHistoryIndex = -1; fullPredictedState.Dispose(); diff --git a/Assets/PurrDiction/Runtime/Core/PredictionInterestModule.cs b/Assets/PurrDiction/Runtime/Core/PredictionInterestModule.cs new file mode 100644 index 00000000..db41aca9 --- /dev/null +++ b/Assets/PurrDiction/Runtime/Core/PredictionInterestModule.cs @@ -0,0 +1,789 @@ +using System; +using System.Collections.Generic; +using PurrNet.Modules; +using UnityEngine; + +namespace PurrNet.Prediction +{ + /// + /// Overrides interest resolution for one player and predicted root. + /// + public enum PredictionInterestPin : byte + { + Default, + Relevant, + Culled + } + + /// + /// Customizes the distance-resolved interest tier for each player and predicted root. + /// + public interface IPredictionInterestProvider + { + /// + /// Returns the tier to pass to pin and ownership resolution. + /// + byte ResolveTier(PredictionManager manager, PlayerID player, PredictedObjectID root, byte computedTier); + } + + /// + /// Provides per-player prediction interest queries, overrides, scheduling, and relevance events. + /// + public sealed class PredictionInterestModule : IDisposable + { + internal sealed class RootTarget : ILODTarget + { + readonly PredictionInterestModule _module; + readonly Dictionary _computedTiers = new (); + readonly Dictionary _effectiveTiers = new (); + readonly HashSet _knownPlayers = new (); + + public readonly PredictedObjectID root; + public readonly byte minimumInterestTier; + public int seenVersion; + public PlayerID? observedOwner; + + internal RootTarget(PredictionInterestModule module, PredictedObjectID root, byte minimumInterestTier) + { + _module = module; + this.root = root; + this.minimumInterestTier = minimumInterestTier; + } + + public Vector3 position + { + get + { + if (_module._manager.hierarchy && + _module._manager.hierarchy.TryGetGameObject(root, out var go) && go) + return go.transform.position; + return default; + } + } + + public uint staggerSeed => root.instanceId.value; + + public void ApplyTier(PlayerID player, byte tier) + { + _knownPlayers.Add(player); + _computedTiers[player] = tier; + ApplyEffectiveTier(player); + } + + public byte GetTier(PlayerID player) + { + if (_effectiveTiers.TryGetValue(player, out var tier)) + return tier; + return _module.ResolveTier(this, player, _computedTiers.GetValueOrDefault(player, (byte)0)); + } + + public void AddPlayer(PlayerID player) + { + _knownPlayers.Add(player); + ApplyEffectiveTier(player); + } + + public void RefreshEffectiveTiers() + { + foreach (var player in _knownPlayers) + ApplyEffectiveTier(player); + } + + public void RemovePlayer(PlayerID player) + { + _knownPlayers.Remove(player); + _computedTiers.Remove(player); + _effectiveTiers.Remove(player); + } + + public void NotifyRemoved() + { + foreach (var pair in _effectiveTiers) + _module.AdjustTierCounts(pair.Key, pair.Value, 0); + } + + private void ApplyEffectiveTier(PlayerID player) + { + byte computed = _computedTiers.GetValueOrDefault(player, (byte)0); + byte next = _module.ResolveTier(this, player, computed); + byte previous = _effectiveTiers.GetValueOrDefault(player, (byte)0); + + _effectiveTiers[player] = next; + + if (previous != next) + _module.HandleServerTierChanged(player, root, previous, next); + } + } + + private sealed class AnchorRegistration + { + public PlayerID player; + public int seenVersion; + } + + private struct PlayerTierCounts + { + public int serializationAffecting; + public int culled; + } + + private readonly struct PendingLocalTier + { + public readonly byte tier; + public readonly ulong serverTick; + + public PendingLocalTier(byte tier, ulong serverTick) + { + this.tier = tier; + this.serverTick = serverTick; + } + } + + private readonly struct QueuedLocalTier + { + public readonly PredictedObjectID root; + public readonly byte tier; + public readonly ulong serverTick; + + public QueuedLocalTier(PredictedObjectID root, byte tier, ulong serverTick) + { + this.root = root; + this.tier = tier; + this.serverTick = serverTick; + } + } + + readonly PredictionManager _manager; + readonly PredictionLODProfile _profile; + readonly NetworkLODFactory _factory; + readonly NetworkLODModule _lodModule; + readonly bool _asServer; + readonly Dictionary _roots = new (); + readonly HashSet _players = new (); + readonly Dictionary<(PlayerID, PredictedObjectID), PredictionInterestPin> _pins = new (); + readonly Dictionary _anchors = new (); + readonly List _rootScratch = new (); + readonly List _anchorScratch = new (); + readonly Dictionary _localTiers = new (); + readonly Dictionary _pendingLocalTiers = new (); + readonly List _queuedLocalTiers = new (); + readonly Dictionary _tierCounts = new (); + readonly List<(PlayerID, PredictedObjectID)> _pinScratch = new (); + int _refreshVersion; + + internal PredictionInterestModule(PredictionManager manager, PredictionLODProfile profile, + NetworkLODFactory factory, NetworkLODModule lodModule, bool asServer) + { + _manager = manager; + _profile = profile; + _factory = factory; + _lodModule = lodModule; + _asServer = asServer; + } + + /// + /// Raised on a client after a root's local tier changes. The arguments are the root, + /// previous tier, and current tier. Re-entry is reported after absolute state is applied. + /// + public event Action OnLocalRelevanceChanged; + + /// + /// Raised on the server after a root's effective tier changes for a player. + /// The arguments are the player, root, previous tier, and current tier. This includes + /// changes between two visible tiers as well as transitions across the culled boundary. + /// + public event Action OnServerTierChanged; + + /// + /// Raised on the server when a root leaves the culled tier for a player. + /// The arguments are the player and root. Visible-tier changes do not raise this event. + /// + public event Action OnServerBecameRelevant; + + /// + /// Raised on the server when a root enters the culled tier for a player. + /// The arguments are the player and root. Visible-tier changes do not raise this event. + /// + public event Action OnServerBecameIrrelevant; + + /// + /// Gets or sets the server-side tier provider applied after the prefab tier floor. + /// + public IPredictionInterestProvider provider { get; set; } + + /// + /// Gets or sets the server-side send scheduler. A null value uses the interval scheduler. + /// + public ILODScheduler scheduler { get; set; } + + /// + /// Gets the prediction LOD profile used by this module. + /// + public PredictionLODProfile profile => _profile; + + /// + /// Gets whether this module has an active network LOD profile. + /// + public bool enabled => _profile && _profile.networkProfile; + + /// + /// Sets or clears the interest override for one player and root. + /// + /// True when the stored pin changed. + public bool SetPin(PlayerID player, PredictedObjectID root, PredictionInterestPin pin) + { + var key = (player, root); + bool changed; + + if (pin == PredictionInterestPin.Default) + changed = _pins.Remove(key); + else if (_pins.TryGetValue(key, out var previous)) + { + changed = previous != pin; + _pins[key] = pin; + } + else + { + _pins.Add(key, pin); + changed = true; + } + + if (changed && _roots.TryGetValue(root, out var target)) + { + target.AddPlayer(player); + target.RefreshEffectiveTiers(); + } + + return changed; + } + + /// + /// Gets the server's effective tier for one player and root. + /// + public bool TryGetTier(PlayerID player, PredictedObjectID root, out byte tier) + { + if (root.instanceId.value == 1) + { + tier = 0; + return true; + } + + if (_roots.TryGetValue(root, out var target)) + { + tier = target.GetTier(player); + return true; + } + + tier = 0; + return false; + } + + /// + /// Gets a client's received nonzero tier for a root. Tier zero is implicit when false. + /// + public bool TryGetLocalRelevance(PredictedObjectID root, out byte tier) + { + if (_localTiers.TryGetValue(root, out tier)) + return true; + tier = 0; + return false; + } + + internal bool IsRelevant(PlayerID player, PredictedObjectID root) + { + return !enabled || !TryGetTier(player, root, out var tier) || tier != NetworkLODProfile.CulledTier; + } + + internal bool ShouldSerializeState(PlayerID player, PredictedObjectID root, ulong tick, bool force) + { + if (!enabled || !_roots.TryGetValue(root, out var target)) + return true; + + byte tier = target.GetTier(player); + if (tier == NetworkLODProfile.CulledTier) + return false; + if (force) + return true; + + var activeScheduler = scheduler ?? LODIntervalScheduler.instance; + return activeScheduler.ShouldSendThisTick( + target, + _profile.networkProfile, + player, + tier, + unchecked((uint)tick)); + } + + internal bool IsLocallyRelevant(PredictedObjectID root) + { + return !_localTiers.TryGetValue(root, out var tier) || tier != NetworkLODProfile.CulledTier; + } + + internal int GetLocalSendIntervalTicks(PredictedObjectID root) + { + if (!enabled) + return 1; + + byte tier = _localTiers.GetValueOrDefault(root, (byte)0); + return _profile.networkProfile.GetSendIntervalTicks(tier); + } + + internal PredictionPolicy? GetLocalSuggestedPolicy(PredictedObjectID root) + { + if (!enabled) + return null; + + byte tier = _localTiers.GetValueOrDefault(root, (byte)0); + return _profile.TryGetSuggestedPolicy(tier, out var policy) ? policy : null; + } + + internal bool CanApplyLocalAbsolute(PredictedObjectID root, ulong serverTick) + { + if (IsLocallyRelevant(root)) + return true; + return _pendingLocalTiers.TryGetValue(root, out var pending) && pending.serverTick <= serverTick; + } + + internal bool CanApplyLocalInput(PredictedObjectID root, ulong inputTick) + { + if (IsLocallyRelevant(root)) + return true; + return _pendingLocalTiers.TryGetValue(root, out var pending) && pending.serverTick <= inputTick; + } + + internal bool CanMaterializeLocalRoot(PredictedObjectID root, ulong serverTick) + { + return !IsLocallyRelevant(root) && + _pendingLocalTiers.TryGetValue(root, out var pending) && + pending.serverTick <= serverTick; + } + + internal bool HasPendingLocalAbsolute(PredictedObjectID root, ulong serverTick) + { + return _pendingLocalTiers.TryGetValue(root, out var pending) && pending.serverTick <= serverTick; + } + + internal bool IsIntentionallyUnmaterialized(PredictedObjectID root) + { + return !IsLocallyRelevant(root); + } + + internal bool HasCulledRoots(PlayerID player) + { + return enabled && _tierCounts.TryGetValue(player, out var counts) && counts.culled > 0; + } + + internal bool HasSerializationAffectingRoots(PlayerID player) + { + return enabled && _tierCounts.TryGetValue(player, out var counts) && + counts.serializationAffecting > 0; + } + + private void AdjustTierCounts(PlayerID player, byte previous, byte next) + { + var counts = _tierCounts.GetValueOrDefault(player); + if (previous != 0) + counts.serializationAffecting--; + if (next != 0) + counts.serializationAffecting++; + if (previous == NetworkLODProfile.CulledTier) + counts.culled--; + if (next == NetworkLODProfile.CulledTier) + counts.culled++; + + if (counts.serializationAffecting > 0 || counts.culled > 0) + _tierCounts[player] = counts; + else + _tierCounts.Remove(player); + } + + internal void RefreshServerTargets(List systems, int systemCount) + { + if (!_asServer || !enabled || _lodModule == null) + return; + + _refreshVersion++; + + for (var i = 0; i < systemCount; i++) + { + var system = systems[i]; + var root = system.rootObjectId; + + if (root.instanceId.value != 1) + { + if (!_roots.TryGetValue(root, out var target)) + { + target = new RootTarget(this, root, ResolveRootMinimumInterestTier(root)); + _roots.Add(root, target); + _lodModule.Register(target, _profile.networkProfile); + + foreach (var player in _players) + target.AddPlayer(player); + } + + if (target.seenVersion != _refreshVersion) + { + target.seenVersion = _refreshVersion; + target.observedOwner = null; + } + + if (system.owner.HasValue) + target.observedOwner = system.owner; + } + + if (system is PredictedTransform predictedTransform && system.owner.HasValue && + _players.Contains(system.owner.Value)) + RefreshAnchor(predictedTransform, system.owner.Value); + } + + _rootScratch.Clear(); + foreach (var pair in _roots) + { + if (pair.Value.seenVersion != _refreshVersion) + { + _rootScratch.Add(pair.Key); + continue; + } + + pair.Value.RefreshEffectiveTiers(); + } + + for (var i = 0; i < _rootScratch.Count; i++) + { + var root = _rootScratch[i]; + var target = _roots[root]; + target.NotifyRemoved(); + _lodModule.Unregister(target); + _roots.Remove(root); + _manager.RemoveInterestRoot(root); + } + + if (_rootScratch.Count > 0 && _pins.Count > 0) + { + _pinScratch.Clear(); + foreach (var pair in _pins) + { + if (_rootScratch.Contains(pair.Key.Item2)) + _pinScratch.Add(pair.Key); + } + + for (var i = 0; i < _pinScratch.Count; i++) + _pins.Remove(_pinScratch[i]); + } + + _anchorScratch.Clear(); + foreach (var pair in _anchors) + { + if (pair.Value.seenVersion != _refreshVersion) + _anchorScratch.Add(pair.Key); + } + + for (var i = 0; i < _anchorScratch.Count; i++) + { + var anchor = _anchorScratch[i]; + var registration = _anchors[anchor]; + if (anchor) + _factory.UnregisterAnchor(registration.player, anchor.transform); + _anchors.Remove(anchor); + } + } + + internal void InitializePlayer(PlayerID player) + { + _players.Add(player); + + foreach (var target in _roots.Values) + { + target.AddPlayer(player); + byte tier = target.GetTier(player); + if (tier != 0) + _manager.QueueInterestTierChange(player, target.root, tier); + } + } + + internal void RemovePlayer(PlayerID player) + { + _players.Remove(player); + _tierCounts.Remove(player); + + foreach (var target in _roots.Values) + target.RemovePlayer(player); + + _anchorScratch.Clear(); + foreach (var pair in _anchors) + { + if (pair.Value.player == player) + _anchorScratch.Add(pair.Key); + } + + for (var i = 0; i < _anchorScratch.Count; i++) + { + var anchor = _anchorScratch[i]; + if (anchor) + _factory.UnregisterAnchor(player, anchor.transform); + _anchors.Remove(anchor); + } + + _rootScratch.Clear(); + foreach (var pair in _pins) + { + if (pair.Key.Item1 == player) + _rootScratch.Add(pair.Key.Item2); + } + + for (var i = 0; i < _rootScratch.Count; i++) + _pins.Remove((player, _rootScratch[i])); + } + + internal void QueueLocalTier(PredictedObjectID root, byte tier, ulong serverTick) + { + int index = _queuedLocalTiers.Count; + while (index > 0 && _queuedLocalTiers[index - 1].serverTick > serverTick) + index--; + _queuedLocalTiers.Insert(index, new QueuedLocalTier(root, tier, serverTick)); + } + + internal void ApplyQueuedLocalTiers(ulong serverTick) + { + int count = 0; + + while (count < _queuedLocalTiers.Count && _queuedLocalTiers[count].serverTick <= serverTick) + { + var queued = _queuedLocalTiers[count]; + ReceiveLocalTier(queued.root, queued.tier, queued.serverTick); + count++; + } + + if (count > 0) + _queuedLocalTiers.RemoveRange(0, count); + } + + internal void ReceiveLocalTier(PredictedObjectID root, byte tier, ulong serverTick) + { + byte current = _localTiers.GetValueOrDefault(root, (byte)0); + + if (current == NetworkLODProfile.CulledTier && tier != NetworkLODProfile.CulledTier) + { + _pendingLocalTiers[root] = new PendingLocalTier(tier, serverTick); + return; + } + + _pendingLocalTiers.Remove(root); + ApplyLocalTier(root, tier); + } + + internal bool ConfirmLocalAbsolute(PredictedObjectID root, ulong serverTick) + { + if (!_pendingLocalTiers.TryGetValue(root, out var pending) || pending.serverTick > serverTick) + return false; + + _pendingLocalTiers.Remove(root); + ApplyLocalTier(root, pending.tier); + return true; + } + + internal void PruneStaleLocalTiers() + { + if (_localTiers.Count == 0 && _pendingLocalTiers.Count == 0) + return; + + var hierarchy = _manager.hierarchy; + if (!hierarchy) + return; + + uint spawnedThrough = hierarchy.currentState.nextInstanceId; + + _rootScratch.Clear(); + foreach (var pair in _localTiers) + { + if (pair.Key.instanceId.value < spawnedThrough && !hierarchy.HasRootRecords(pair.Key)) + _rootScratch.Add(pair.Key); + } + + for (var i = 0; i < _rootScratch.Count; i++) + _localTiers.Remove(_rootScratch[i]); + + _rootScratch.Clear(); + foreach (var pair in _pendingLocalTiers) + { + if (pair.Key.instanceId.value < spawnedThrough && !hierarchy.HasRootRecords(pair.Key)) + _rootScratch.Add(pair.Key); + } + + for (var i = 0; i < _rootScratch.Count; i++) + _pendingLocalTiers.Remove(_rootScratch[i]); + } + + private void RefreshAnchor(PredictedTransform anchor, PlayerID player) + { + if (_anchors.TryGetValue(anchor, out var registration)) + { + if (registration.player != player) + { + _factory.UnregisterAnchor(registration.player, anchor.transform); + _factory.RegisterAnchor(player, anchor.transform); + registration.player = player; + } + + registration.seenVersion = _refreshVersion; + return; + } + + _factory.RegisterAnchor(player, anchor.transform); + _anchors.Add(anchor, new AnchorRegistration + { + player = player, + seenVersion = _refreshVersion + }); + } + + internal byte ResolveTier(RootTarget target, PlayerID player, byte computedTier) + { + byte tier = computedTier >= target.minimumInterestTier + ? computedTier + : target.minimumInterestTier; + tier = provider?.ResolveTier(_manager, player, target.root, tier) ?? tier; + + if (_pins.TryGetValue((player, target.root), out var pin)) + { + if (pin == PredictionInterestPin.Relevant) + tier = 0; + else if (pin == PredictionInterestPin.Culled) + tier = NetworkLODProfile.CulledTier; + } + + if (target.observedOwner == player) + tier = 0; + + return tier; + } + + private byte ResolveRootMinimumInterestTier(PredictedObjectID root) + { + var prefabs = _manager.predictedPrefabs; + var hierarchy = _manager.hierarchy; + if (!prefabs || !hierarchy) + return 0; + + var records = hierarchy.currentState.spawnedPrefabs; + for (var i = 0; i < records.Count; i++) + { + var record = records[i]; + if (!record.isRootRecord || !record.instanceId.Equals(root)) + continue; + + int prefabId = record.prefabId; + if (prefabId < 0 || prefabId >= prefabs.prefabs.Count) + return 0; + + return ClampMinimumInterestTier(prefabs.prefabs[prefabId].minimumInterestTier); + } + + return 0; + } + + internal byte ClampMinimumInterestTier(byte tier) + { + if (tier == NetworkLODProfile.CulledTier) + return tier; + + int tierCount = _profile && _profile.networkProfile + ? _profile.networkProfile.tierCount + : 0; + if (tierCount <= 0) + return 0; + + return (byte)Mathf.Min(tier, tierCount - 1); + } + + private void HandleServerTierChanged(PlayerID player, PredictedObjectID root, byte previous, byte next) + { + AdjustTierCounts(player, previous, next); + + _manager.QueueInterestTierChange(player, root, next); + OnServerTierChanged?.Invoke(player, root, previous, next); + + bool wasRelevant = previous != NetworkLODProfile.CulledTier; + bool isRelevant = next != NetworkLODProfile.CulledTier; + if (wasRelevant == isRelevant) + return; + + if (isRelevant) + OnServerBecameRelevant?.Invoke(player, root); + else + OnServerBecameIrrelevant?.Invoke(player, root); + } + + private void ApplyLocalTier(PredictedObjectID root, byte tier) + { + byte previous = _localTiers.GetValueOrDefault(root, (byte)0); + if (previous == tier) + { + if (tier == NetworkLODProfile.CulledTier) + _manager.DematerializeLocalInterestRoot(root); + return; + } + + if (tier == 0) + _localTiers.Remove(root); + else + _localTiers[root] = tier; + + _manager.HandleLocalInterestChanged(root); + if (tier == NetworkLODProfile.CulledTier) + _manager.DematerializeLocalInterestRoot(root); + OnLocalRelevanceChanged?.Invoke(root, previous, tier); + } + + /// + /// Releases interest registrations and restores locally culled roots. + /// + public void Dispose() + { + Dispose(true); + } + + internal void Dispose(bool restoreLocalRoots) + { + _rootScratch.Clear(); + foreach (var pair in _localTiers) + _rootScratch.Add(pair.Key); + + for (var i = 0; i < _rootScratch.Count; i++) + { + var root = _rootScratch[i]; + _localTiers.Remove(root); + _pendingLocalTiers.Remove(root); + if (restoreLocalRoots) + { + _manager.MaterializeLocalInterestRoot(root); + _manager.HandleLocalInterestChanged(root); + } + } + + if (_lodModule != null) + { + foreach (var target in _roots.Values) + _lodModule.Unregister(target); + } + + if (_factory != null) + { + foreach (var pair in _anchors) + { + if (pair.Key) + _factory.UnregisterAnchor(pair.Value.player, pair.Key.transform); + } + } + + _roots.Clear(); + _players.Clear(); + _pins.Clear(); + _anchors.Clear(); + _localTiers.Clear(); + _pendingLocalTiers.Clear(); + _queuedLocalTiers.Clear(); + _tierCounts.Clear(); + } + } +} diff --git a/Assets/PurrDiction/Runtime/Core/PredictionInterestModule.cs.meta b/Assets/PurrDiction/Runtime/Core/PredictionInterestModule.cs.meta new file mode 100644 index 00000000..86c272ff --- /dev/null +++ b/Assets/PurrDiction/Runtime/Core/PredictionInterestModule.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9d9739ca3cc746449b6508e530bde52b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/PurrDiction/Runtime/Core/PredictionLODProfile.cs b/Assets/PurrDiction/Runtime/Core/PredictionLODProfile.cs new file mode 100644 index 00000000..930bc79b --- /dev/null +++ b/Assets/PurrDiction/Runtime/Core/PredictionLODProfile.cs @@ -0,0 +1,113 @@ +using System; +using UnityEngine; + +namespace PurrNet.Prediction +{ + /// + /// Selects the prediction policy applied while a root occupies an interest tier. + /// + public enum PredictionPolicyOverride : byte + { + KeepConfigured, + FullPrediction, + ServerRelay, + SoftCorrection, + PredictedIfOwned + } + + /// + /// Configures prediction behavior for one visible network LOD tier. + /// + [Serializable] + public struct PredictionLODTier + { + /// + /// The policy override applied while a root occupies this tier. + /// + public PredictionPolicyOverride suggestedPolicy; + } + + /// + /// Maps network LOD tiers to client prediction policies. + /// + [CreateAssetMenu(menuName = "PurrDiction/Prediction LOD Profile", fileName = "New Prediction LOD Profile")] + public sealed class PredictionLODProfile : ScriptableObject + { + [SerializeField] private NetworkLODProfile _networkProfile; + [SerializeField] private PredictionLODTier[] _tiers = Array.Empty(); + [SerializeField] private PredictionPolicyOverride _culledPolicy = PredictionPolicyOverride.ServerRelay; + + /// + /// Gets or sets the network LOD profile that resolves distance tiers and send intervals. + /// + public NetworkLODProfile networkProfile + { + get => _networkProfile; + set => _networkProfile = value; + } + + /// + /// Assigns a network profile while leaving prediction tiers at their configured defaults. + /// + public void Configure(NetworkLODProfile networkProfile) + { + _networkProfile = networkProfile; + } + + /// + /// Gets the number of configured visible prediction tiers. + /// + public int tierCount => _tiers?.Length ?? 0; + + /// + /// Configures the network profile, visible tier policies, and culled policy. + /// + public void Configure( + NetworkLODProfile networkProfile, + PredictionLODTier[] tiers, + PredictionPolicyOverride culledPolicy = PredictionPolicyOverride.ServerRelay) + { + _networkProfile = networkProfile; + _tiers = tiers; + _culledPolicy = culledPolicy; + } + + /// + /// Gets the configured policy override for a tier. + /// + public PredictionPolicyOverride GetSuggestedPolicy(byte tier) + { + if (tier == NetworkLODProfile.CulledTier) + return _culledPolicy; + if (_tiers == null || _tiers.Length == 0) + return PredictionPolicyOverride.KeepConfigured; + return _tiers[Mathf.Min(tier, _tiers.Length - 1)].suggestedPolicy; + } + + /// + /// Resolves a tier to a concrete policy when it does not keep the configured policy. + /// + public bool TryGetSuggestedPolicy(byte tier, out PredictionPolicy policy) + { + switch (GetSuggestedPolicy(tier)) + { + case PredictionPolicyOverride.FullPrediction: + policy = PredictionPolicy.FullPrediction; + return true; + case PredictionPolicyOverride.ServerRelay: + policy = PredictionPolicy.ServerRelay; + return true; + case PredictionPolicyOverride.SoftCorrection: + policy = PredictionPolicy.SoftCorrection; + return true; + case PredictionPolicyOverride.PredictedIfOwned: + policy = PredictionPolicy.PredictedIfOwned; + return true; + default: + policy = default; + return false; + } + } + + } +} diff --git a/Assets/PurrDiction/Runtime/Core/PredictionLODProfile.cs.meta b/Assets/PurrDiction/Runtime/Core/PredictionLODProfile.cs.meta new file mode 100644 index 00000000..e132addf --- /dev/null +++ b/Assets/PurrDiction/Runtime/Core/PredictionLODProfile.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 57406ac0d08043fa950ec4a911873a8d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: b9f80b0cd820e0b4fad6af3f671fe150, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/PurrDiction/Runtime/Core/PredictionManager.cs b/Assets/PurrDiction/Runtime/Core/PredictionManager.cs index ee6c49cb..380e7a3b 100644 --- a/Assets/PurrDiction/Runtime/Core/PredictionManager.cs +++ b/Assets/PurrDiction/Runtime/Core/PredictionManager.cs @@ -51,6 +51,7 @@ public class PredictionManager : NetworkIdentity BuiltInSystems.Players | BuiltInSystems.Random; [SerializeField] private PredictedPrefabs _predictedPrefabs; + [SerializeField] private PredictionLODProfile _predictionLODProfile; [SerializeField] private InputQueueSettings _inputQueueSettings = new() { extrapolateForMissing = true @@ -71,6 +72,19 @@ public PredictedPrefabs predictedPrefabs public bool validateDeterministicData => _validateDeterministicData; + public PredictionLODProfile predictionLODProfile + { + get => _predictionLODProfile; + set + { + _predictionLODProfile = value; + if (isSpawned) + InitializeInterest(); + } + } + + public PredictionInterestModule interest { get; private set; } + static readonly ProfilerMarker SimulateMarker = new("PredictionManager.Simulate"); static readonly ProfilerMarker SimulateInputsMarker = new("PredictionManager.PrepareSimulationInputs"); static readonly ProfilerMarker LateSimulateMarker = new("PredictionManager.LateSimulate"); @@ -311,6 +325,7 @@ protected override void OnSpawned() _tickManager = networkManager.tickModule; _tickManager.onPreTick += OnPreTick; _tickManager.onPostTick += OnPostTick; + InitializeInterest(); } protected override void OnDespawned() @@ -322,6 +337,9 @@ protected override void OnDespawned() _tickManager = null; } + interest?.Dispose(false); + interest = null; + CleanupAllSystems(); } @@ -329,9 +347,13 @@ protected override void OnDestroy() { base.OnDestroy(); + interest?.Dispose(false); + interest = null; + foreach (var packer in _clientFrames) packer.Dispose(); _clientFrames.Clear(); + DisposeDeferredLocalInputs(); if (_tickManager != null) { @@ -346,6 +368,40 @@ protected override void OnDestroy() } } + private void InitializeInterest() + { + interest?.Dispose(); + interest = null; + + if (!isServer) + ClearAllLocalInterestSideEffects(); + + if (!_predictionLODProfile) + return; + + NetworkLODFactory factory = null; + NetworkLODModule lodModule = null; + + if (isServer && networkManager.TryGetModule(true, out factory)) + factory.TryGetModule(sceneId, out lodModule); + + interest = new PredictionInterestModule( + this, + _predictionLODProfile, + factory, + lodModule, + isServer); + + if (!isServer) + SyncAllLocalInterestSideEffects(); + + if (isServer) + { + for (var i = 0; i < _clientFrames.Count; i++) + interest.InitializePlayer(_clientFrames[i].player); + } + } + private void CleanupAllSystems() { if (hierarchy) @@ -370,6 +426,10 @@ private void CleanupAllSystems() queue.Clear(); _clientTicks.Clear(); _clientFrames.Clear(); + _pendingStateRepairs.Clear(); + _stateRepairScratch.Clear(); + DisposeDeferredLocalInputs(); + ResetLocalAbsoluteTracking(); localTick = 1; localTickInContext = 1; _verifiedServerTick = 0; @@ -411,6 +471,7 @@ public void RegisterInstance(GameObject go, PredictedObjectID objectID, PlayerID { var componentId = new PredictedComponentID(objectID, i); bool preserveState = !reset && !component.isFreshSpawn && component.id.Equals(componentId); + component.PrepareInterestPolicyForRegistration(preserveState); var incomingPolicy = component.ResolvePredictionPolicyForSetup(); bool preserveSoftState = preserveState && component.previousRegisteredPredictionPolicy == PredictionPolicy.SoftCorrection && @@ -491,6 +552,7 @@ private void RegisterInstance(PredictedIdentity system, PredictedObjectID object var pid = new PredictedComponentID(objectId, componentId); _instanceMap[pid] = system; + _pendingStateRepairs.Remove(pid); system.SetSoftCorrectionReplaySimulation(false); system.SetSkipReplaySpawnInitialization(false); system.SetPreserveStateOnSetup(preserveState); @@ -522,6 +584,14 @@ private void RegisterInstance(PredictedIdentity system, PredictedObjectID object ++_systemsCount; InvalidateInputBlockCache(); + if (!isServer) + { + if (_ownedRootsCacheValid && _ownedRootsCachePlayer.HasValue && + system.owner == _ownedRootsCachePlayer) + _ownedRootsCache.Add(system.rootObjectId); + SyncLocalInterestSideEffects(system); + } + if (isReplaying && system.UsesSoftCorrectionTimeline() && !preserveState) { system.OnReplayEnd(); @@ -535,8 +605,19 @@ private void RegisterInstance(PredictedIdentity system, PredictedObjectID object public void UnregisterInstance(PredictedIdentity predictedIdentity) { + InvalidateLocallyOwnedRoots(); RemoveSpeculativeRelayLock(predictedIdentity); - _instanceMap.Remove(predictedIdentity.id); + var pid = predictedIdentity.id; + if (_instanceMap.TryGetValue(pid, out var registeredIdentity) && + ReferenceEquals(registeredIdentity, predictedIdentity)) + { + _instanceMap.Remove(pid); + _pendingStateRepairs.Remove(pid); + + for (var i = 0; i < _clientFrames.Count; i++) + _clientFrames[i].identityEstablishment?.Remove(pid); + } + if (_systems.Remove(predictedIdentity)) { --_systemsCount; @@ -549,6 +630,7 @@ protected override void OnObserverRemoved(PlayerID player) { _clientTicks.Remove(player); _pendingFullSync.Remove(player); + interest?.RemovePlayer(player); var frames = _clientFrames.Count; for (var i = 0; i < frames; i++) @@ -586,6 +668,8 @@ private void FlushPendingFullSyncs() for (var p = 0; p < _pendingFullSync.Count; p++) { var player = _pendingFullSync[p]; + int unreliableMtu = networkManager.GetMTU(player, Channel.Unreliable, true); + int maxUnreliableFrameBytes = GetMaxUnreliableFrameBytes(unreliableMtu); _clientTicks[player] = new InputQueue(); @@ -597,7 +681,17 @@ private void FlushPendingFullSyncs() continue; clientFrame.fullFrame = true; + clientFrame.preparedFrameTick = 0; + clientFrame.maxUnreliableFrameBytes = maxUnreliableFrameBytes; + clientFrame.reliableFrame.Clear(); + clientFrame.identityEstablishment ??= new IdentityEstablishmentState(); + clientFrame.identityEstablishment.Clear(); + clientFrame.sendBaselines ??= new RootSendBaselineState(); + clientFrame.sendBaselines.Clear(); + clientFrame.interestControls ??= new InterestControlState(); + clientFrame.interestControls.Clear(); _clientFrames[i] = clientFrame; + interest?.InitializePlayer(player); found = true; break; } @@ -609,8 +703,14 @@ private void FlushPendingFullSyncs() { player = player, packer = BitPackerPool.Get(), - fullFrame = true + fullFrame = true, + maxUnreliableFrameBytes = maxUnreliableFrameBytes, + identityEstablishment = new IdentityEstablishmentState(), + sendBaselines = new RootSendBaselineState(), + interestControls = new InterestControlState() }); + + interest?.InitializePlayer(player); } _pendingFullSync.Clear(); @@ -619,43 +719,123 @@ private void FlushPendingFullSyncs() private void ReadFullFrame(BitPacker frame, ulong stateTick, ulong inputTick, ulong serverTick) { frame.ResetPositionAndMode(true); + _pendingStateRepairs.Clear(); + _stateRepairScratch.Clear(); + ResetLocalAbsoluteTracking(); + ResetDeferredLocalInputs(); tickRate = Packer.Read(frame); tickDelta = Packer.Read(frame); _sessionSeed = Packer.Read(frame); - int count = Packer.Read(frame); + ReadFirstStateEntries(frame, stateTick, serverTick, false); - for (var i = 0; i < count; ++i) + int inputCount = Packer.Read(frame); + if (inputCount >= 0) { - var system = _systems[i]; - if (system.isEventHandler) - continue; - system.RunClearFuture(stateTick); - system.RunReadFirstState(stateTick, frame, serverTick); - system.RunRollback(stateTick); - system.RunResetInterpolation(); - system.lastVerifiedTick = stateTick; + for (var i = 0; i < inputCount; ++i) + _systems[i].ReadFirstInput(inputTick, frame); + } + else + { + ReadFilteredFirstInputEntries(frame, inputTick, -inputCount - 1); } - for (var i = 0; i < count; ++i) - _systems[i].ReadFirstInput(inputTick, frame); + ReadFirstStateEntries(frame, stateTick, serverTick, true); - for (var i = 0; i < count; ++i) + ReplayDeferredLocalInputs(); + ConfirmLocalAbsolutes(stateTick, serverTick); + SyncAllLocalInterestSideEffects(); + SyncTransforms(); + } + + private void WriteFilteredFirstInputEntries(PlayerID receiver, BitPacker frame, BitPacker scratch) + { + FillInputSerializeFlags(receiver); + int count = 0; + + for (var i = 0; i < _systemsCount; i++) + { + if (_inputSerializeFlags[i] && _systems[i].hasInput) + count++; + } + + Packer.Write(frame, -(count + 1)); + + for (var i = 0; i < _systemsCount; i++) { var system = _systems[i]; - if (!system.isEventHandler) + if (!system.hasInput || !_inputSerializeFlags[i]) continue; - system.RunClearFuture(stateTick); - system.RunReadFirstState(stateTick, frame, serverTick); - system.RunRollback(stateTick); - system.lastVerifiedTick = stateTick; + + scratch.ResetPositionAndMode(false); + system.WriteFirstInput(localTick, scratch); + Packer.Write(frame, system.id); + Packer.Write(frame, (uint)scratch.positionInBits); + frame.WriteBitsWithoutConsumingIt(scratch, scratch.positionInBits); } + } - SyncTransforms(); + private void ReadFilteredFirstInputEntries(BitPacker frame, ulong inputTick, int count) + { + for (var i = 0; i < count; i++) + { + PredictedComponentID pid = default; + Packer.Read(frame, ref pid); + PackedUInt payloadBits = default; + Packer.Read(frame, ref payloadBits); + int payloadEnd = frame.positionInBits + (int)payloadBits.value; + + if (_instanceMap.TryGetValue(pid, out var system) && CanApplyLocalInput(system, inputTick)) + system.ReadFirstInput(inputTick, frame); + else if (!_instanceMap.ContainsKey(pid)) + TryDeferLocalInput(pid, inputTick, frame, (int)payloadBits.value); + + CompleteStateEntry(frame, payloadEnd); + } + } + + private const int PredictionFrameRpcArgumentMaxSize = 30; + private const int PredictionFrameCompressionMaxOverhead = 1; + private const int PredictionFrameBatchEntryLengthMaxSize = 6; + + internal static int GetMaxUnreliableFrameBytes(int mtu) + { + int maxFragmentedBytes = FragmentationLayer.GetMaxMessageSize( + mtu, + BroadcastModule.MAX_HEADER_SIZE); + int envelopeBytes = + PredictionFrameRpcArgumentMaxSize + + PredictionFrameCompressionMaxOverhead + + BroadcastModule.MAX_HEADER_SIZE + + RPCBatch.MAX_HEADER_SIZE + + PredictionFrameBatchEntryLengthMaxSize; + return Math.Max(0, maxFragmentedBytes - envelopeBytes); + } + + internal static bool RequiresReliableRecovery( + bool fullFrame, + int frameBytes, + int maxUnreliableFrameBytes) + { + return fullFrame || frameBytes > maxUnreliableFrameBytes; } readonly List _clientFrames = new (16); + readonly Dictionary _pendingStateRepairs = new (); + readonly List _stateRepairScratch = new (); + readonly List _deferredLocalInputs = new (); + readonly HashSet _localAbsoluteConfirmations = new (); + readonly HashSet _localAbsoluteFailures = new (); + BitPacker _deferredLocalInputPayloads; + + private struct DeferredLocalInput + { + public PredictedComponentID id; + public ulong tick; + public int payloadOrigin; + public int payloadBits; + } public bool cachedIsServer { get; private set; } @@ -664,6 +844,16 @@ private void OnPreTick() cachedIsServer = isServer; localTickInContext = localTick; + if (cachedIsServer) + interest?.RefreshServerTargets(_systems, _systemsCount); + + if (!cachedIsServer) + { + PruneStateEntryRepairs(); + if (interest != null && localTick % (ulong)Math.Max(1, tickRate) == 0) + interest.PruneStaleLocalTiers(); + } + if (!cachedIsServer && _pauseAdvanceTicks > 0) { _pauseAdvanceTicks--; @@ -690,7 +880,8 @@ private void OnPreTick() bool controller = system.IsOwner(myPlayer, cachedIsServer); if (controller) ownedIdentities.Add(system); - system.PrepareInput(cachedIsServer, controller, localTick, _inputQueueSettings.extrapolateForMissing); + if (cachedIsServer || IsLocallyRelevant(system)) + system.PrepareInput(cachedIsServer, controller, localTick, _inputQueueSettings.extrapolateForMissing); } if (!cachedIsServer) @@ -874,8 +1065,8 @@ private void FinalizeInputOnClient(DisposableList ownedIdenti } int blockBits = block.positionInBits; - Packer.Write(payload, (PackedUInt)(uint)blockBits); - Packer.Write(payload, (PackedUInt)writtenCount); + Packer.Write(payload, (uint)blockBits); + Packer.Write(payload, writtenCount); payload.WriteBitsWithoutConsumingIt(block, blockBits); tickCount += 1; } @@ -907,17 +1098,16 @@ private void WriteInitialFrameToOthers() { var fCount = _clientFrames.Count; - PackedInt packedSysCount = _systemsCount; + PackedInt packedInputSystemCount = _systemsCount; PackedInt packedTickRate = tickRate; ulong maxAckLag = 0; + using var stateScratch = BitPackerPool.Get(); + using var inputScratch = BitPackerPool.Get(); for (var j = 0; j < fCount; j++) { var clientFrame = _clientFrames[j]; - clientFrame.packer.ResetPositionAndMode(false); - - var frame = clientFrame.packer; var player = clientFrame.player; ulong baselineTick = 0; @@ -928,54 +1118,511 @@ private void WriteInitialFrameToOthers() if (ackLag > maxAckLag) maxAckLag = ackLag; - if (!clientFrame.fullFrame && baselineTick > 0 && - localTick > baselineTick && localTick - baselineTick > (ulong)(tickRate * 8)) + if (clientFrame.reliableFrame.ShouldSuppress(baselineTick)) { - clientFrame.fullFrame = true; + clientFrame.preparedFrameTick = 0; _clientFrames[j] = clientFrame; + continue; } + clientFrame.preparedFrameTick = localTick; + + if (!clientFrame.fullFrame && baselineTick > 0 && + localTick > baselineTick && localTick - baselineTick > (ulong)(tickRate * 8)) + clientFrame.fullFrame = true; + + _clientFrames[j] = clientFrame; + clientFrame.packer.ResetPositionAndMode(false); + + var frame = clientFrame.packer; var fullFrame = clientFrame.fullFrame; + var identityEstablishment = clientFrame.identityEstablishment; + var sendBaselines = clientFrame.sendBaselines; + var interestControls = clientFrame.interestControls; + interestControls?.PrepareForFrame(localTick); + if (localTick > (ulong)(tickRate * 10) && localTick % (ulong)Math.Max(1, tickRate) == 0) + sendBaselines.PruneBefore(localTick - (ulong)(tickRate * 10)); + + bool filtered = RequiresFilteredStateEntries(player, interestControls); + if (fullFrame || filtered) + FillSerializeFlags(player, interestControls, fullFrame); if (fullFrame) { using var _ = WriteFullFrameMarker.Auto(); + identityEstablishment.Reset(); + sendBaselines.Clear(); + Packer.Write(frame, packedTickRate); Packer.Write(frame, tickDelta); Packer.Write(frame, _sessionSeed); - Packer.Write(frame, packedSysCount); - - for (var i = 0; i < _systemsCount; i++) + WriteFirstStateEntries( + frame, + stateScratch, + identityEstablishment, + sendBaselines, + interestControls, + false); + + if (filtered) + WriteFilteredFirstInputEntries(player, frame, inputScratch); + else { - var sys = _systems[i]; - if (!sys.isEventHandler) - sys.RunWriteFirstState(localTick, frame); + Packer.Write(frame, packedInputSystemCount); + for (var i = 0; i < _systemsCount; i++) + _systems[i].WriteFirstInput(localTick, frame); } - - for (var i = 0; i < _systemsCount; i++) - _systems[i].WriteFirstInput(localTick, frame); } else { - Packer.Write(frame, packedSysCount); + bool forceHierarchyAbsolute = filtered && PrepareIdentityEstablishment( + identityEstablishment, + baselineTick); using (WriteInputHistoryMarker.Auto()) - WriteInputHistory(frame, baselineTick); + WriteInputHistory(player, frame, baselineTick); using (WriteStateDeltasMarker.Auto()) { - for (var i = 0; i < _systemsCount; i++) + Packer.Write(frame, filtered); + + if (filtered) + { + WriteCurrentStateEntries( + player, + frame, + stateScratch, + identityEstablishment, + sendBaselines, + interestControls, + baselineTick, + forceHierarchyAbsolute, + false); + } + else { - var sys = _systems[i]; - if (!sys.isEventHandler) - sys.RunWriteCurrentState(player, frame, baselineTick); + WritePositionalStateEntries(player, frame, sendBaselines, baselineTick, false); } } } } - lastMaxAckLagTicks = maxAckLag; + lastMaxAckLagTicks = maxAckLag; + } + + private bool RequiresFilteredStateEntries(PlayerID player, InterestControlState interestControls) + { + if (interest == null) + return false; + return interest.HasSerializationAffectingRoots(player) || + interestControls != null && interestControls.hasUnconfirmedReentries; + } + + private void WritePositionalStateEntries( + PlayerID receiver, + BitPacker frame, + RootSendBaselineState sendBaselines, + ulong ackedTick, + bool eventHandlers) + { + PackedInt count = _systemsCount; + Packer.Write(frame, count); + + for (var i = 0; i < _systemsCount; i++) + { + var system = _systems[i]; + if (system.isEventHandler != eventHandlers) + continue; + + var root = system.rootObjectId; + system.RunWriteCurrentState(receiver, frame, sendBaselines.GetBaseline(root, ackedTick)); + sendBaselines.MarkSent(root, localTick); + } + } + + private void ReadPositionalStateEntries( + BitPacker frame, + ulong stateTick, + ulong baselineTick, + ulong serverTick, + bool eventHandlers) + { + PackedInt packedCount = default; + Packer.Read(frame, ref packedCount); + int count = packedCount; + + for (var i = 0; i < count; i++) + { + if (i >= _systemsCount) + break; + + var system = _systems[i]; + if (system.isEventHandler != eventHandlers) + continue; + + if (!eventHandlers && _validateDeterministicData && system.isDeterministic) + system.RunRollback(stateTick); + bool softCorrected = system.UsesSoftCorrectionTimeline(); + if (!softCorrected) + system.RunClearFuture(stateTick); + system.RunReadState(stateTick, frame, baselineTick, serverTick); + if (!softCorrected) + system.RunRollback(stateTick); + system.lastVerifiedTick = stateTick; + } + } + + private bool PrepareIdentityEstablishment( + IdentityEstablishmentState identityEstablishment, + ulong ackedTick) + { + bool requiresHierarchyBarrier = false; + + for (var i = 0; i < _systemsCount; i++) + { + if (!_stateSerializeFlags[i]) + continue; + + requiresHierarchyBarrier |= identityEstablishment.Prepare(_systems[i].id, ackedTick, localTick); + } + + return requiresHierarchyBarrier; + } + + private uint CountStateEntries(bool eventHandlers) + { + uint count = 0; + + for (var i = 0; i < _systemsCount; i++) + { + if (_stateSerializeFlags[i] && _systems[i].isEventHandler == eventHandlers) + count++; + } + + return count; + } + + private void WriteFirstStateEntries( + BitPacker frame, + BitPacker scratch, + IdentityEstablishmentState identityEstablishment, + RootSendBaselineState sendBaselines, + InterestControlState interestControls, + bool eventHandlers) + { + Packer.Write(frame, CountStateEntries(eventHandlers)); + + for (var i = 0; i < _systemsCount; i++) + { + var system = _systems[i]; + if (system.isEventHandler != eventHandlers || !_stateSerializeFlags[i]) + continue; + + var root = system.rootObjectId; + scratch.ResetPositionAndMode(false); + if (interestControls != null && interestControls.ConsumeBaselineReset(root)) + sendBaselines.Reset(root); + system.RunWriteFirstState(localTick, scratch); + WriteStateEntry(frame, scratch, system.id, true); + identityEstablishment.MarkSent(system.id, localTick); + sendBaselines.MarkSent(root, localTick); + } + } + + private void WriteCurrentStateEntries( + PlayerID receiver, + BitPacker frame, + BitPacker scratch, + IdentityEstablishmentState identityEstablishment, + RootSendBaselineState sendBaselines, + InterestControlState interestControls, + ulong baselineTick, + bool forceHierarchyAbsolute, + bool eventHandlers) + { + Packer.Write(frame, CountStateEntries(eventHandlers)); + + for (var i = 0; i < _systemsCount; i++) + { + var system = _systems[i]; + if (system.isEventHandler != eventHandlers || !_stateSerializeFlags[i]) + continue; + + var root = system.rootObjectId; + scratch.ResetPositionAndMode(false); + bool reentering = interestControls != null && + interestControls.RequiresAbsolute(root); + bool absolute = identityEstablishment.RequiresAbsolute(system.id) || + forceHierarchyAbsolute && system == hierarchy || + reentering; + + if (absolute) + { + if (reentering && interestControls.ConsumeBaselineReset(root)) + sendBaselines.Reset(root); + system.RunWriteAbsoluteState(localTick, scratch); + } + else + { + ulong identityBaseline = sendBaselines.GetBaseline(root, baselineTick); + system.RunWriteCurrentState(receiver, scratch, identityBaseline); + } + + WriteStateEntry(frame, scratch, system.id, absolute); + sendBaselines.MarkSent(root, localTick); + } + } + + private static void WriteStateEntry( + BitPacker frame, + BitPacker payload, + PredictedComponentID id, + bool absolute) + { + Packer.Write(frame, id); + Packer.Write(frame, absolute); + Packer.Write(frame, (uint)payload.positionInBits); + frame.WriteBitsWithoutConsumingIt(payload, payload.positionInBits); + } + + private void ReadFirstStateEntries( + BitPacker frame, + ulong stateTick, + ulong serverTick, + bool eventHandlers) + { + PackedUInt entryCount = default; + Packer.Read(frame, ref entryCount); + + for (uint i = 0; i < entryCount.value; i++) + { + if (!TryReadStateEntry( + frame, + eventHandlers, + serverTick, + out var system, + out _, + out var payloadEnd)) + continue; + + if (interest != null && !interest.CanApplyLocalAbsolute(system.rootObjectId, serverTick)) + { + CompleteStateEntry(frame, payloadEnd); + continue; + } + + ReadAbsoluteStateEntry(system, frame, stateTick, serverTick, eventHandlers, true); + CompleteStateEntry(frame, payloadEnd); + } + } + + private void ReadCurrentStateEntries( + BitPacker frame, + ulong stateTick, + ulong baselineTick, + ulong serverTick, + bool eventHandlers) + { + PackedUInt entryCount = default; + Packer.Read(frame, ref entryCount); + + for (uint i = 0; i < entryCount.value; i++) + { + if (!TryReadStateEntry( + frame, + eventHandlers, + serverTick, + out var system, + out var absolute, + out var payloadEnd)) + continue; + + if (absolute) + { + if (interest != null && !interest.CanApplyLocalAbsolute(system.rootObjectId, serverTick)) + { + CompleteStateEntry(frame, payloadEnd); + continue; + } + + ReadAbsoluteStateEntry(system, frame, stateTick, serverTick, eventHandlers, false); + CompleteStateEntry(frame, payloadEnd); + continue; + } + + if (system.IsLocallyDormant()) + { + CompleteStateEntry(frame, payloadEnd); + continue; + } + + if (!eventHandlers && _validateDeterministicData && system.isDeterministic) + system.RunRollback(stateTick); + bool softCorrected = system.UsesSoftCorrectionTimeline(); + if (!softCorrected) + system.RunClearFuture(stateTick); + system.RunReadState(stateTick, frame, baselineTick, serverTick); + if (!softCorrected) + system.RunRollback(stateTick); + system.lastVerifiedTick = stateTick; + CompleteStateEntry(frame, payloadEnd); + } + } + + private void ReadAbsoluteStateEntry( + PredictedIdentity system, + BitPacker frame, + ulong stateTick, + ulong serverTick, + bool eventHandlers, + bool firstState) + { + system.RunClearFuture(stateTick); + if (firstState) + system.RunReadFirstState(stateTick, frame, serverTick); + else + system.RunReadAbsoluteState(stateTick, frame, serverTick); + system.RunRollback(stateTick, true); + if (!eventHandlers) + system.RunResetInterpolation(); + system.lastVerifiedTick = stateTick; + if (interest != null && interest.HasPendingLocalAbsolute(system.rootObjectId, serverTick)) + _localAbsoluteConfirmations.Add(system.rootObjectId); + } + + private bool TryReadStateEntry( + BitPacker frame, + bool eventHandlers, + ulong serverTick, + out PredictedIdentity system, + out bool absolute, + out int payloadEnd) + { + PredictedComponentID pid = default; + Packer.Read(frame, ref pid); + absolute = Packer.Read(frame); + PackedUInt payloadBits = default; + Packer.Read(frame, ref payloadBits); + payloadEnd = frame.positionInBits + (int)payloadBits.value; + + PredictedObjectID retainedRoot = default; + bool hasRetainedRoot = hierarchy && hierarchy.TryGetRootId(pid.objectId, out retainedRoot); + if (!_instanceMap.ContainsKey(pid) && absolute && hasRetainedRoot && interest != null && + interest.CanMaterializeLocalRoot(retainedRoot, serverTick)) + { + if (!hierarchy.MaterializeLocalRoot(retainedRoot) || !_instanceMap.ContainsKey(pid)) + { + _localAbsoluteFailures.Add(retainedRoot); + TryRequestStateEntryRepair(pid); + } + } + + if (_instanceMap.TryGetValue(pid, out system)) + { + _pendingStateRepairs.Remove(pid); + + if (system.isEventHandler == eventHandlers) + return true; + + frame.SkipBits((int)payloadBits.value); + system = null; + return false; + } + + bool intentionallyUnmaterialized = hasRetainedRoot && interest != null && + interest.IsIntentionallyUnmaterialized(retainedRoot); + + if (absolute || intentionallyUnmaterialized) + _pendingStateRepairs.Remove(pid); + else + TryRequestStateEntryRepair(pid); + + frame.SkipBits((int)payloadBits.value); + return false; + } + + private void ResetLocalAbsoluteTracking() + { + _localAbsoluteConfirmations.Clear(); + _localAbsoluteFailures.Clear(); + } + + private void ConfirmLocalAbsolutes(ulong stateTick, ulong serverTick) + { + if (interest == null || _localAbsoluteConfirmations.Count == 0) + return; + + foreach (var root in _localAbsoluteConfirmations) + { + if (_localAbsoluteFailures.Contains(root)) + continue; + + bool found = false; + bool complete = true; + + for (var i = 0; i < _systemsCount; i++) + { + var system = _systems[i]; + if (!system.rootObjectId.Equals(root)) + continue; + + found = true; + if (system.lastVerifiedTick != stateTick) + { + complete = false; + TryRequestStateEntryRepair(system.id); + } + } + + if (found && complete && interest.ConfirmLocalAbsolute(root, serverTick)) + ConfirmInterestReentry(root, serverTick); + } + } + + private void TryRequestStateEntryRepair(PredictedComponentID id) + { + ulong retryTicks = (ulong)Math.Max(1, tickRate); + + if (_pendingStateRepairs.TryGetValue(id, out var requestTick) && + localTick >= requestTick && localTick - requestTick < retryTicks) + return; + + _pendingStateRepairs[id] = localTick; + RequestStateEntryRepair(id); + } + + private void PruneStateEntryRepairs() + { + if (_pendingStateRepairs.Count == 0) + return; + + ulong interval = (ulong)Math.Max(1, tickRate); + if (localTick % interval != 0) + return; + + ulong lifetime = interval * 10; + _stateRepairScratch.Clear(); + + foreach (var repair in _pendingStateRepairs) + { + if (localTick < repair.Value || localTick - repair.Value >= lifetime) + _stateRepairScratch.Add(repair.Key); + } + + for (var i = 0; i < _stateRepairScratch.Count; i++) + _pendingStateRepairs.Remove(_stateRepairScratch[i]); + + _stateRepairScratch.Clear(); + } + + private static void CompleteStateEntry(BitPacker frame, int payloadEnd) + { + int remainingBits = payloadEnd - frame.positionInBits; + if (remainingBits < 0) + throw new InvalidOperationException("State entry payload exceeded its declared bit length."); + if (remainingBits > 0) + frame.SkipBits(remainingBits); } /// @@ -984,11 +1631,19 @@ private void WriteInitialFrameToOthers() /// public ulong lastMaxAckLagTicks { get; private set; } + private struct InputBlockEntry + { + public int systemIndex; + public int bitOrigin; + public int bitCount; + } + private struct CachedInputBlock { public ulong tick; public uint version; public BitPacker packer; + public List entries; } private CachedInputBlock[] _inputBlockCache; @@ -1013,7 +1668,7 @@ private void DisposeInputBlockCache() _inputBlockCache = null; } - private BitPacker GetInputBlockForTick(ulong tick) + private BitPacker GetInputBlockForTick(ulong tick, out List entries) { _inputBlockCache ??= new CachedInputBlock[(int)MaxInputWindow + 1]; @@ -1021,14 +1676,20 @@ private BitPacker GetInputBlockForTick(ulong tick) ref var slot = ref _inputBlockCache[index]; if (slot.packer != null && slot.tick == tick && slot.version == _inputBlockVersion) + { + entries = slot.entries; return slot.packer; + } slot.packer ??= BitPackerPool.Get(); + slot.entries ??= new List(16); slot.tick = tick; slot.version = _inputBlockVersion; var block = slot.packer; block.ResetPositionAndMode(false); + entries = slot.entries; + entries.Clear(); uint entryCount = 0; for (var i = 0; i < _systemsCount; i++) @@ -1038,7 +1699,7 @@ private BitPacker GetInputBlockForTick(ulong tick) entryCount++; } - Packer.Write(block, (PackedUInt)entryCount); + Packer.Write(block, entryCount); if (entryCount == 0) return block; @@ -1051,18 +1712,25 @@ private BitPacker GetInputBlockForTick(ulong tick) if (!sys.hasInput || !sys.HasInputAt(tick)) continue; + int entryOrigin = block.positionInBits; Packer.Write(block, sys.id); entryScratch.ResetPositionAndMode(false); sys.WriteFirstInput(tick, entryScratch); int bits = entryScratch.positionInBits; - Packer.Write(block, (PackedUInt)(uint)bits); + Packer.Write(block, (uint)bits); block.WriteBitsWithoutConsumingIt(entryScratch, bits); + entries.Add(new InputBlockEntry + { + systemIndex = i, + bitOrigin = entryOrigin, + bitCount = block.positionInBits - entryOrigin + }); } return block; } - private void WriteInputHistory(BitPacker frame, ulong baselineTick) + private void WriteInputHistory(PlayerID receiver, BitPacker frame, ulong baselineTick) { ulong from = baselineTick; if (localTick > MaxInputWindow && from < localTick - MaxInputWindow) @@ -1070,12 +1738,40 @@ private void WriteInputHistory(BitPacker frame, ulong baselineTick) if (from > localTick) from = localTick; - Packer.Write(frame, (PackedUInt)(uint)(localTick - from)); + Packer.Write(frame, (uint)(localTick - from)); + + bool filtered = interest != null && interest.HasCulledRoots(receiver); + if (filtered) + FillInputSerializeFlags(receiver); for (ulong t = from + 1; t <= localTick; t++) { - var block = GetInputBlockForTick(t); - frame.WriteBitsWithoutConsumingIt(block, block.positionInBits); + var block = GetInputBlockForTick(t, out var entries); + + if (filtered) + WriteFilteredInputBlock(frame, block, entries); + else + frame.WriteBitsWithoutConsumingIt(block, block.positionInBits); + } + } + + private void WriteFilteredInputBlock(BitPacker frame, BitPacker block, List entries) + { + uint entryCount = 0; + + for (var i = 0; i < entries.Count; i++) + { + if (_inputSerializeFlags[entries[i].systemIndex]) + entryCount++; + } + + Packer.Write(frame, entryCount); + + for (var i = 0; i < entries.Count; i++) + { + var entry = entries[i]; + if (_inputSerializeFlags[entry.systemIndex]) + frame.WriteBitsWithoutConsumingIt(block, entry.bitOrigin, entry.bitCount); } } @@ -1100,14 +1796,75 @@ private void ReadInputHistory(BitPacker frame, ulong serverTick) PackedUInt bits = default; Packer.Read(frame, ref bits); - if (_instanceMap.TryGetValue(pid, out var system)) + if (_instanceMap.TryGetValue(pid, out var system) && CanApplyLocalInput(system, t)) system.ReadFirstInput(t, frame); else + { + if (!_instanceMap.ContainsKey(pid)) + TryDeferLocalInput(pid, t, frame, (int)bits.value); frame.SkipBits((int)bits.value); + } } } } + private void TryDeferLocalInput(PredictedComponentID id, ulong tick, BitPacker frame, int payloadBits) + { + if (interest == null || !hierarchy || !hierarchy.TryGetRootId(id.objectId, out var root) || + !interest.CanMaterializeLocalRoot(root, tick)) + return; + + _deferredLocalInputPayloads ??= BitPackerPool.Get(); + int payloadOrigin = _deferredLocalInputPayloads.positionInBits; + _deferredLocalInputPayloads.WriteBitsWithoutConsumingIt(frame, frame.positionInBits, payloadBits); + _deferredLocalInputs.Add(new DeferredLocalInput + { + id = id, + tick = tick, + payloadOrigin = payloadOrigin, + payloadBits = payloadBits + }); + } + + private void ReplayDeferredLocalInputs() + { + if (_deferredLocalInputs.Count == 0 || _deferredLocalInputPayloads == null) + return; + + _deferredLocalInputPayloads.ResetMode(true); + + for (var i = 0; i < _deferredLocalInputs.Count; i++) + { + var entry = _deferredLocalInputs[i]; + if (!_instanceMap.TryGetValue(entry.id, out var system) || !CanApplyLocalInput(system, entry.tick)) + continue; + + _deferredLocalInputPayloads.SetBitPosition(entry.payloadOrigin); + system.ReadFirstInput(entry.tick, _deferredLocalInputPayloads); + _deferredLocalInputPayloads.SetBitPosition(entry.payloadOrigin + entry.payloadBits); + } + } + + private void ResetDeferredLocalInputs() + { + _deferredLocalInputs.Clear(); + _deferredLocalInputPayloads?.ResetPositionAndMode(false); + } + + private void DisposeDeferredLocalInputs() + { + _deferredLocalInputs.Clear(); + _deferredLocalInputPayloads?.Dispose(); + _deferredLocalInputPayloads = null; + } + + private bool CanApplyLocalInput(PredictedIdentity system, ulong inputTick) + { + if (IsLocallyRelevant(system)) + return true; + return interest != null && interest.CanApplyLocalInput(system.rootObjectId, inputTick); + } + private void RollbackAllToVerified(ulong tick) { for (var i = 0; i < _systemsCount; i++) @@ -1124,27 +1881,62 @@ private void WriteEventHandles() using var _ = WriteEventHandlesMarker.Auto(); var fCount = _clientFrames.Count; + using var stateScratch = BitPackerPool.Get(); - for (var i = 0; i < _systemsCount; i++) + for (var j = 0; j < fCount; j++) { - if (!_systems[i].isEventHandler) + var clientFrame = _clientFrames[j]; + if (clientFrame.preparedFrameTick != localTick) continue; - var system = _systems[i]; + ulong baselineTick = 0; + if (_clientTicks.TryGetValue(clientFrame.player, out var ackQueue)) + baselineTick = ackQueue.ackedServerTick; - for (var j = 0; j < fCount; j++) + if (clientFrame.fullFrame) { - var frame = _clientFrames[j]; - var packer = frame.packer; - if (frame.fullFrame) - system.RunWriteFirstState(localTick, packer); - else - { - ulong baselineTick = 0; - if (_clientTicks.TryGetValue(frame.player, out var ackQueue)) - baselineTick = ackQueue.ackedServerTick; - system.RunWriteCurrentState(frame.player, packer, baselineTick); - } + FillSerializeFlags( + clientFrame.player, + clientFrame.interestControls, + true); + WriteFirstStateEntries( + clientFrame.packer, + stateScratch, + clientFrame.identityEstablishment, + clientFrame.sendBaselines, + clientFrame.interestControls, + true); + continue; + } + + bool filtered = RequiresFilteredStateEntries(clientFrame.player, clientFrame.interestControls); + Packer.Write(clientFrame.packer, filtered); + + if (filtered) + { + FillSerializeFlags( + clientFrame.player, + clientFrame.interestControls, + false); + WriteCurrentStateEntries( + clientFrame.player, + clientFrame.packer, + stateScratch, + clientFrame.identityEstablishment, + clientFrame.sendBaselines, + clientFrame.interestControls, + baselineTick, + false, + true); + } + else + { + WritePositionalStateEntries( + clientFrame.player, + clientFrame.packer, + clientFrame.sendBaselines, + baselineTick, + true); } } } @@ -1154,14 +1946,31 @@ private void SendFrameToOthers() using var _ = SendFrameMarker.Auto(); var fCount = _clientFrames.Count; + using var controlPacker = BitPackerPool.Get(); for (var j = 0; j < fCount; j++) { var clientFrame = _clientFrames[j]; + if (clientFrame.preparedFrameTick != localTick) + continue; + var player = clientFrame.player; var packer = clientFrame.packer; var deltaLen = packer.ToByteData().length; var fullFrame = clientFrame.fullFrame; + var interestControls = clientFrame.interestControls; + bool hasInterestControls = interestControls != null && interestControls.count > 0; + bool hasUnconfirmedReentries = interestControls != null && interestControls.hasUnconfirmedReentries; + bool requiresReliableRecovery = RequiresReliableRecovery( + fullFrame, + deltaLen, + clientFrame.maxUnreliableFrameBytes); + bool sendReliable = + hasInterestControls || + hasUnconfirmedReentries || + requiresReliableRecovery; + + TickBandwidthProfiler.OnWroteFrame(player, deltaLen * 8); ulong inputAck = 0; ulong baselineTick = 0; @@ -1186,14 +1995,47 @@ private void SendFrameToOthers() } } - if (fullFrame) + if (hasInterestControls) + { + controlPacker.ResetPositionAndMode(false); + WriteInterestControls(controlPacker, interestControls); + int controlLength = controlPacker.ToByteData().length; + SendFrameToRemoteWithInterest( + player, + localTick, + baselineTick, + inputAck, + fullFrame, + hasInputMargin, + inputMargin, + new BitPackerWithLength(controlLength, controlPacker), + new BitPackerWithLength(deltaLen, packer)); + interestControls.MarkDelivered(); + } + else if (sendReliable) SendFrameToRemoteReliable(player, localTick, baselineTick, inputAck, fullFrame, hasInputMargin, inputMargin, new BitPackerWithLength(deltaLen, packer)); else SendFrameToRemote(player, localTick, baselineTick, inputAck, fullFrame, hasInputMargin, inputMargin, new BitPackerWithLength(deltaLen, packer)); + + if (requiresReliableRecovery) + clientFrame.reliableFrame.MarkSent(localTick); + + clientFrame.preparedFrameTick = 0; if (fullFrame) - { clientFrame.fullFrame = false; - _clientFrames[j] = clientFrame; - } + + _clientFrames[j] = clientFrame; + } + } + + private static void WriteInterestControls(BitPacker packer, InterestControlState controls) + { + Packer.Write(packer, (uint)controls.count); + + for (var i = 0; i < controls.count; i++) + { + var change = controls[i]; + Packer.Write(packer, change.root); + Packer.Write(packer, change.tier); } } @@ -1359,6 +2201,33 @@ private void SendFrameToRemoteReliable([UsedImplicitly] PlayerID player, ulong s HandleFrameFromServer(serverTick, baselineTick, inputAck, fullFrame, hasInputMargin, inputMargin, delta); } + [TargetRpc(compressionLevel: CompressionLevel.Best)] + private void SendFrameToRemoteWithInterest([UsedImplicitly] PlayerID player, ulong serverTick, + ulong baselineTick, ulong inputAck, bool fullFrame, bool hasInputMargin, PackedInt inputMargin, + BitPackerWithLength controls, BitPackerWithLength delta) + { + HandleInterestControls(serverTick, controls); + HandleFrameFromServer(serverTick, baselineTick, inputAck, fullFrame, hasInputMargin, inputMargin, delta); + } + + private void HandleInterestControls(ulong serverTick, BitPackerWithLength controls) + { + controls.packer.SkipBytes(controls.originalLength); + controls.packer.ResetPositionAndMode(true); + PackedUInt count = default; + Packer.Read(controls.packer, ref count); + + for (uint i = 0; i < count.value; i++) + { + PredictedObjectID root = default; + Packer.Read(controls.packer, ref root); + byte tier = Packer.Read(controls.packer); + interest?.QueueLocalTier(root, tier, serverTick); + } + + controls.packer.Dispose(); + } + private void HandleFrameFromServer(ulong serverTick, ulong baselineTick, ulong inputAck, bool fullFrame, bool hasInputMargin, PackedInt inputMargin, BitPackerWithLength delta) { delta.packer.SkipBytes(delta.originalLength); @@ -1395,13 +2264,11 @@ private void HandleFrameFromServer(ulong serverTick, ulong baselineTick, ulong i }); } - private void RollbackToFrame(BitPacker frame, ulong stateTick, ulong inputTick, ulong baselineTick, ulong serverTick) + private void RollbackToFrame(BitPacker frame, ulong stateTick, ulong baselineTick, ulong serverTick) { frame.ResetPositionAndMode(true); - - PackedInt _count = default; - Packer.Read(frame, ref _count); - int count = _count; + ResetLocalAbsoluteTracking(); + ResetDeferredLocalInputs(); ReadInputHistory(frame, serverTick); @@ -1415,37 +2282,45 @@ private void RollbackToFrame(BitPacker frame, ulong stateTick, ulong inputTick, SaveEnteringState(serverTick); } - for (var i = 0; i < count; ++i) + if (Packer.Read(frame)) + ReadCurrentStateEntries(frame, stateTick, baselineTick, serverTick, false); + else + ReadPositionalStateEntries(frame, stateTick, baselineTick, serverTick, false); + + if (Packer.Read(frame)) + ReadCurrentStateEntries(frame, stateTick, baselineTick, serverTick, true); + else + ReadPositionalStateEntries(frame, stateTick, baselineTick, serverTick, true); + + ReplayDeferredLocalInputs(); + ConfirmLocalAbsolutes(stateTick, serverTick); + SyncAllLocalInterestSideEffects(); + SyncTransforms(); + } + + private void SyncAllLocalInterestSideEffects() + { + if (isServer || interest == null) + return; + + var ownedRoots = GetLocallyOwnedRoots(); + + for (var i = 0; i < _systemsCount; i++) { var system = _systems[i]; - if (system.isEventHandler) - continue; - if (_validateDeterministicData && system.isDeterministic) - system.RunRollback(stateTick); - bool softCorrected = system.UsesSoftCorrectionTimeline(); - if (!softCorrected) - system.RunClearFuture(stateTick); - system.RunReadState(stateTick, frame, baselineTick, serverTick); - if (!softCorrected) - system.RunRollback(stateTick); - system.lastVerifiedTick = stateTick; + SyncLocalInterestSideEffects(system, ownedRoots.Contains(system.rootObjectId)); } + } - for (var i = 0; i < count; ++i) + private void ClearAllLocalInterestSideEffects() + { + for (var i = 0; i < _systemsCount; i++) { var system = _systems[i]; - if (!system.isEventHandler) - continue; - bool softCorrected = system.UsesSoftCorrectionTimeline(); - if (!softCorrected) - system.RunClearFuture(stateTick); - system.RunReadState(stateTick, frame, baselineTick, serverTick); - if (!softCorrected) - system.RunRollback(stateTick); - system.lastVerifiedTick = stateTick; + system.UpdateLocalRelevance(true); + system.SetInterestPolicyOverride(null); + system.SetInterestSendIntervalTicks(1); } - - SyncTransforms(); } private void SyncTransforms() @@ -1555,6 +2430,7 @@ private void OnPostTick() while (_deltas.Count > 0) { using var frame = _deltas.Dequeue(); + interest?.ApplyQueuedLocalTiers(frame.serverTick); if (frame.serverTick > _latestFrameServerTick) { @@ -1576,13 +2452,13 @@ private void OnPostTick() } else { - RollbackToFrame(frame.packer, frame.serverTick, frame.serverTick, frame.baselineTick, frame.serverTick); + RollbackToFrame(frame.packer, frame.serverTick, frame.baselineTick, frame.serverTick); if (_validateDeterministicData) { for (var i = 0; i < _systemsCount; i++) { - if (_systems[i].isDeterministic) + if (_systems[i].isDeterministic && IsLocallyRelevant(_systems[i])) _systems[i].ValidateDeterministicState(frame.serverTick); } } @@ -1661,7 +2537,9 @@ private void LockSpeculativeRelayStates(ulong tick) for (var i = 0; i < _systemsCount; i++) { var system = _systems[i]; - if (!system.UsesServerRelayTimeline() || !system.SkipsCurrentSimulationPhase()) + if (system.IsLocallyDormant() || + !system.UsesServerRelayTimeline() || + !system.SkipsCurrentSimulationPhase()) continue; if (HasSpeculativeRelayLock(system)) continue; @@ -1933,6 +2811,48 @@ public void Clear() readonly Dictionary _clientTicks = new (); + [ServerRpc(requireOwnership: false)] + private void RequestStateEntryRepair(PredictedComponentID id, RPCInfo info = default) + { + if (!_instanceMap.TryGetValue(id, out var requestedIdentity)) + return; + + for (var i = 0; i < _clientFrames.Count; i++) + { + var clientFrame = _clientFrames[i]; + if (clientFrame.player != info.sender) + continue; + + var identityEstablishment = clientFrame.identityEstablishment; + if (!identityEstablishment.Invalidate(id)) + return; + + var rootObjectId = requestedIdentity.rootObjectId; + for (var systemIndex = 0; systemIndex < _systemsCount; systemIndex++) + { + var system = _systems[systemIndex]; + if (!system.id.Equals(id) && system.rootObjectId.Equals(rootObjectId)) + identityEstablishment.Invalidate(system.id); + } + + return; + } + } + + [ServerRpc(requireOwnership: false)] + private void ConfirmInterestReentry(PredictedObjectID root, ulong serverTick, RPCInfo info = default) + { + for (var i = 0; i < _clientFrames.Count; i++) + { + var clientFrame = _clientFrames[i]; + if (clientFrame.player != info.sender) + continue; + + clientFrame.interestControls?.ConfirmReentry(root, serverTick); + return; + } + } + [ServerRpc(requireOwnership: false, channel: Channel.Unreliable, mtuExceeded: MTUBehaviour.Fragment)] private void SendInputToServerFragmented(ulong firstTick, uint tickCount, ulong frameAck, BitPacker payload, RPCInfo info = default) { @@ -2183,6 +3103,192 @@ internal GameObject InternalCreate(GameObject prefab, Vector3 position, Quaterni } } + internal bool IsLocallyRelevant(PredictedIdentity system) + { + if (cachedIsServer || !system || system.rootObjectId.instanceId.value == 1) + return true; + if (isSpawned && system.owner == localPlayer) + return true; + return interest == null || interest.IsLocallyRelevant(system.rootObjectId); + } + + private readonly HashSet _ownedRootsCache = new (); + private bool _ownedRootsCacheValid; + private PlayerID? _ownedRootsCachePlayer; + + private HashSet GetLocallyOwnedRoots() + { + var player = isSpawned ? localPlayer : null; + if (_ownedRootsCacheValid && Nullable.Equals(_ownedRootsCachePlayer, player)) + return _ownedRootsCache; + + _ownedRootsCache.Clear(); + _ownedRootsCachePlayer = player; + _ownedRootsCacheValid = true; + + if (player.HasValue) + { + for (var i = 0; i < _systemsCount; i++) + { + var system = _systems[i]; + if (system.owner == player) + _ownedRootsCache.Add(system.rootObjectId); + } + } + + return _ownedRootsCache; + } + + private void InvalidateLocallyOwnedRoots() + { + _ownedRootsCacheValid = false; + } + + internal void SyncLocalInterestSideEffects(PredictedIdentity system) + { + if (!system) + return; + + SyncLocalInterestSideEffects(system, IsRootLocallyOwned(system.rootObjectId)); + } + + private void SyncLocalInterestSideEffects(PredictedIdentity system, bool rootOwned) + { + var root = system.rootObjectId; + bool exempt = isServer || root.instanceId.value == 1 || rootOwned; + bool relevant = exempt || interest == null || interest.IsLocallyRelevant(root); + system.UpdateLocalRelevance(relevant); + PredictionPolicy? policyOverride = null; + if (!exempt && interest != null) + policyOverride = interest.GetLocalSuggestedPolicy(root); + system.SetInterestPolicyOverride(policyOverride); + system.SetInterestSendIntervalTicks(exempt || interest == null + ? 1 + : interest.GetLocalSendIntervalTicks(root)); + } + + private bool IsRootLocallyOwned(PredictedObjectID root) + { + return GetLocallyOwnedRoots().Contains(root); + } + + private bool ShouldSerializeIdentity( + PlayerID player, + PredictedIdentity system, + InterestControlState interestControls, + bool forceAllRelevant) + { + if (interest == null || system.owner == player) + return true; + + var root = system.rootObjectId; + if (root.instanceId.value == 1) + return true; + + bool force = forceAllRelevant || + interestControls != null && interestControls.RequiresAbsolute(root); + return interest.ShouldSerializeState(player, root, localTick, force); + } + + private bool[] _stateSerializeFlags = Array.Empty(); + private bool[] _inputSerializeFlags = Array.Empty(); + + private void FillSerializeFlags( + PlayerID receiver, + InterestControlState interestControls, + bool forceAllRelevant) + { + if (_stateSerializeFlags.Length < _systemsCount) + _stateSerializeFlags = new bool[Mathf.NextPowerOfTwo(_systemsCount)]; + + for (var i = 0; i < _systemsCount; i++) + _stateSerializeFlags[i] = ShouldSerializeIdentity( + receiver, + _systems[i], + interestControls, + forceAllRelevant); + } + + private void FillInputSerializeFlags(PlayerID receiver) + { + if (_inputSerializeFlags.Length < _systemsCount) + _inputSerializeFlags = new bool[Mathf.NextPowerOfTwo(_systemsCount)]; + + for (var i = 0; i < _systemsCount; i++) + { + var system = _systems[i]; + _inputSerializeFlags[i] = interest == null || system.owner == receiver || + system.rootObjectId.instanceId.value == 1 || + interest.IsRelevant(receiver, system.rootObjectId); + } + } + + internal void QueueInterestTierChange(PlayerID player, PredictedObjectID root, byte tier) + { + for (var i = 0; i < _clientFrames.Count; i++) + { + var frame = _clientFrames[i]; + if (frame.player != player) + continue; + + frame.interestControls ??= new InterestControlState(); + frame.interestControls.Queue(root, tier); + _clientFrames[i] = frame; + return; + } + } + + internal void RemoveInterestRoot(PredictedObjectID root) + { + for (var i = 0; i < _clientFrames.Count; i++) + { + var frame = _clientFrames[i]; + frame.sendBaselines?.Reset(root); + frame.interestControls?.Remove(root); + } + } + + internal void HandleLocalInterestChanged(PredictedObjectID root) + { + bool rootOwned = IsRootLocallyOwned(root); + for (var i = 0; i < _systemsCount; i++) + { + var system = _systems[i]; + if (system.rootObjectId.Equals(root)) + SyncLocalInterestSideEffects(system, rootOwned); + } + } + + internal bool ShouldMaterializeLocalRoot(PredictedObjectID root) + { + if (isServer || cachedIsServer || root.instanceId.value == 1 || interest == null) + return true; + if (IsRootLocallyOwned(root)) + return true; + return interest.IsLocallyRelevant(root); + } + + internal void DematerializeLocalInterestRoot(PredictedObjectID root) + { + if (!isServer && hierarchy && !ShouldMaterializeLocalRoot(root)) + hierarchy.DematerializeLocalRoot(root); + } + + internal bool MaterializeLocalInterestRoot(PredictedObjectID root) + { + return hierarchy && hierarchy.MaterializeLocalRoot(root); + } + + internal void HandleLocalOwnershipChanged(PredictedIdentity system) + { + if (!system) + return; + + InvalidateLocallyOwnedRoots(); + HandleLocalInterestChanged(system.rootObjectId); + DematerializeLocalInterestRoot(system.rootObjectId); + } + internal void InternalDelete(PackedInt prefabId, GameObject instance) { int pid = prefabId; diff --git a/Assets/PurrDiction/Runtime/Hierarchy/PredictedHierarchy.cs b/Assets/PurrDiction/Runtime/Hierarchy/PredictedHierarchy.cs index bc2d4561..e41d9b54 100644 --- a/Assets/PurrDiction/Runtime/Hierarchy/PredictedHierarchy.cs +++ b/Assets/PurrDiction/Runtime/Hierarchy/PredictedHierarchy.cs @@ -33,6 +33,8 @@ public class PredictedHierarchy : PredictedIdentity readonly HashSet _removedRecordScratch = new (); readonly Dictionary _cascadeParentScratch = new (); readonly Dictionary _cascadeReachScratch = new (); + readonly List _localInterestRecordsScratch = new (); + readonly HashSet _localInterestMembersScratch = new (); private uint _nextInstanceId = 2; @@ -93,6 +95,16 @@ protected override void SetUnityState(PredictedHierarchyState state) if (_removalScratch.Count > 0) RemovePieceSet(_removalScratch, _removalSetScratch, true, false, false); + _spawnedPrefabs.Clear(); + _recordsById.Clear(); + + for (var i = 0; i < target.Count; i++) + { + var record = target[i]; + _spawnedPrefabs.Add(record); + _recordsById[record.instanceId] = record; + } + _additionGroups.Clear(); _additionGroupOrder.Clear(); @@ -104,6 +116,8 @@ protected override void SetUnityState(PredictedHierarchyState state) continue; var rootId = record.rootId; + if (!predictionManager.ShouldMaterializeLocalRoot(rootId)) + continue; if (!_additionGroups.TryGetValue(rootId, out var list)) { @@ -148,16 +162,6 @@ protected override void SetUnityState(PredictedHierarchyState state) ListPool.Destroy(records); } - _spawnedPrefabs.Clear(); - _recordsById.Clear(); - - for (var i = 0; i < target.Count; i++) - { - var record = target[i]; - _spawnedPrefabs.Add(record); - _recordsById[record.instanceId] = record; - } - _nextInstanceId = state.nextInstanceId; _isRollingBack = false; @@ -360,7 +364,7 @@ private GameObject CreateWholeInstance(int prefabId, PiecePrototype proto, List< { if (predictionManager.TryGetIdentity(rootRecord.parent.Value, out var parentIdentity) && parentIdentity) parentTrs = parentIdentity.transform; - else + else if (!IsUnavailableDueToLocalInterest(rootRecord.parent.Value)) PurrLogger.LogError($"Failed to resolve spawn parent {rootRecord.parent.Value} for prefab {prefabId}; spawning unparented."); } @@ -1026,6 +1030,113 @@ public bool TryGetRootId(PredictedObjectID pieceId, out PredictedObjectID rootId return false; } + internal bool HasRootRecords(PredictedObjectID rootId) + { + for (var i = 0; i < _spawnedPrefabs.Count; i++) + { + if (_spawnedPrefabs[i].rootId.Equals(rootId)) + return true; + } + + return false; + } + + internal bool MaterializeLocalRoot(PredictedObjectID rootId) + { + _localInterestRecordsScratch.Clear(); + + for (var i = 0; i < _spawnedPrefabs.Count; i++) + { + var record = _spawnedPrefabs[i]; + if (record.rootId.Equals(rootId)) + _localInterestRecordsScratch.Add(record); + } + + if (_localInterestRecordsScratch.Count == 0) + return false; + + _localInterestRecordsScratch.Sort((a, b) => a.pieceIndex.value.CompareTo(b.pieceIndex.value)); + + bool liveAny = false; + bool missingAny = false; + + for (var i = 0; i < _localInterestRecordsScratch.Count; i++) + { + bool live = _instanceMap.TryGetValue(_localInterestRecordsScratch[i].instanceId, out var go) && go; + liveAny |= live; + missingAny |= !live; + } + + if (!missingAny) + return true; + + var prefabId = _localInterestRecordsScratch[0].prefabId; + var proto = GetPrototype(prefabId); + if (proto == null) + return false; + + if (!liveAny) + CreateWholeInstance(prefabId, proto, _localInterestRecordsScratch); + else + { + for (var i = 0; i < _localInterestRecordsScratch.Count; i++) + { + var record = _localInterestRecordsScratch[i]; + if (!_instanceMap.TryGetValue(record.instanceId, out var go) || !go) + ResurrectPiece(record, proto); + } + } + + for (var i = 0; i < _localInterestRecordsScratch.Count; i++) + { + if (!_instanceMap.TryGetValue(_localInterestRecordsScratch[i].instanceId, out var go) || !go) + return false; + } + + return true; + } + + internal void DematerializeLocalRoot(PredictedObjectID rootId) + { + _localInterestRecordsScratch.Clear(); + _localInterestMembersScratch.Clear(); + + for (var i = 0; i < _spawnedPrefabs.Count; i++) + { + var record = _spawnedPrefabs[i]; + if (!record.rootId.Equals(rootId)) + continue; + + _localInterestRecordsScratch.Add(record); + _localInterestMembersScratch.Add(record.instanceId); + } + + if (_localInterestRecordsScratch.Count == 0) + return; + + bool wasRollingBack = _isRollingBack; + bool wasSuppressingParentWarnings = _suppressParentWarnings; + _isRollingBack = true; + _suppressParentWarnings = true; + + try + { + RemovePieceSet(_localInterestRecordsScratch, _localInterestMembersScratch, true, false, false); + } + finally + { + _suppressParentWarnings = wasSuppressingParentWarnings; + _isRollingBack = wasRollingBack; + } + } + + internal bool IsUnavailableDueToLocalInterest(PredictedComponentID target) + { + return TryGetRootId(target.objectId, out var rootId) && + !predictionManager.ShouldMaterializeLocalRoot(rootId) && + (!_instanceMap.TryGetValue(target.objectId, out var go) || !go); + } + /// /// True when both pieces belong to the same spawn instance. Replaces /// id.objectId equality checks from before pieces had their own ids. @@ -1063,9 +1174,12 @@ private void DeleteNow(PredictedObjectID id) if (!_instanceMap.TryGetValue(id, out var instance) || !instance) { - PurrLogger.LogError($"Deleting {id} which has a record but no live instance; removing the stale record."); - _instanceMap.Remove(id); - RemoveRecord(id); + _removalScratch.Clear(); + _removalSetScratch.Clear(); + CollectCascade(record); + RemovePieceSet(_removalScratch, _removalSetScratch, true, true, true); + if (record.isRootRecord) + PromoteOrphans(record); return; } @@ -1371,8 +1485,13 @@ public void Delete(PredictedIdentity pid) public void Delete(PredictedObjectID? id) { + if (!id.HasValue) + return; + if (id.TryGetGameObject(predictionManager, out var go)) Delete(go); + else if (_recordsById.ContainsKey(id.Value)) + currentState.toDelete.Add(id.Value); } public void Cleanup() diff --git a/Assets/PurrDiction/Runtime/Hierarchy/PredictedParent.cs b/Assets/PurrDiction/Runtime/Hierarchy/PredictedParent.cs index 09a6e0d6..0e2d7e3e 100644 --- a/Assets/PurrDiction/Runtime/Hierarchy/PredictedParent.cs +++ b/Assets/PurrDiction/Runtime/Hierarchy/PredictedParent.cs @@ -114,7 +114,9 @@ protected override void SetUnityState(ParentState state) return; } - if (!_hasPendingParent || !PredictedHierarchy.SameAttach(_pendingParent, state.parent)) + if ((!_hasPendingParent || !PredictedHierarchy.SameAttach(_pendingParent, state.parent)) && + (!state.parent.HasValue || !manager || !manager.hierarchy || + !manager.hierarchy.IsUnavailableDueToLocalInterest(state.parent.Value))) PurrLogger.LogWarning($"'{name}' could not reattach to {(state.parent.HasValue ? state.parent.Value.ToString() : "root")}; keeping the target pending until it resolves.", this); _pendingParent = state.parent; diff --git a/Assets/PurrDiction/Runtime/PhysicsEvents/Predicted2DPhysics.cs b/Assets/PurrDiction/Runtime/PhysicsEvents/Predicted2DPhysics.cs index cba87d2a..3712294e 100644 --- a/Assets/PurrDiction/Runtime/PhysicsEvents/Predicted2DPhysics.cs +++ b/Assets/PurrDiction/Runtime/PhysicsEvents/Predicted2DPhysics.cs @@ -118,7 +118,8 @@ public override void PostSimulate() private static void TriggerEvent(PredictionManager predictionManager, Physics2DEvent ev) { - if (ev.me.TryGetIdentity(predictionManager, out var me)) + if (ev.me.TryGetIdentity(predictionManager, out var me) && + !me.IsLocallyDormant()) { var otherGo = ev.other.GetGameObject(predictionManager); if (ev.isTrigger) @@ -158,6 +159,9 @@ private static void TriggerEvent(PredictionManager predictionManager, Physics2DE public void RegisterEvent(PhysicsEventType type, PredictedRigidbody2D caller, Collision2D other) { + if (caller.IsLocallyDormant()) + return; + if (PredictionManager.TryGetClosestPredictedID(other.gameObject, out var otherId)) { var state = currentState; @@ -182,6 +186,9 @@ public void RegisterEvent(PhysicsEventType type, PredictedRigidbody2D caller, Co public void RegisterEvent(PhysicsEventType type, PredictedRigidbody2D caller, Collider2D other) { + if (caller.IsLocallyDormant()) + return; + if (PredictionManager.TryGetClosestPredictedID(other.gameObject, out var otherId)) { var state = currentState; diff --git a/Assets/PurrDiction/Runtime/PhysicsEvents/Predicted3DPhysics.cs b/Assets/PurrDiction/Runtime/PhysicsEvents/Predicted3DPhysics.cs index 546f48dd..1624a485 100644 --- a/Assets/PurrDiction/Runtime/PhysicsEvents/Predicted3DPhysics.cs +++ b/Assets/PurrDiction/Runtime/PhysicsEvents/Predicted3DPhysics.cs @@ -44,7 +44,8 @@ public override void PostSimulate() private static void TriggerEvent(PredictionManager predictionManager, PhysicsEvent ev) { - if (ev.me.TryGetIdentity(predictionManager, out var me)) + if (ev.me.TryGetIdentity(predictionManager, out PredictedIdentity identity) && + !identity.IsLocallyDormant() && identity is IPredictedPhysicsCallbacks me) { var otherGo = ev.other.GetGameObject(predictionManager); if (ev.isTrigger) @@ -84,6 +85,9 @@ private static void TriggerEvent(PredictionManager predictionManager, PhysicsEve public void RegisterEvent(PhysicsEventType type, PredictedIdentity caller, Collision other) { + if (caller.IsLocallyDormant()) + return; + if (PredictionManager.TryGetClosestPredictedID(other.gameObject, out var otherId)) { var state = currentState; @@ -113,6 +117,9 @@ public void RegisterEvent(PhysicsEventType type, PredictedIdentity caller, Colli public void RegisterEvent(PhysicsEventType type, PredictedIdentity caller, Collider other) { + if (caller.IsLocallyDormant()) + return; + if (PredictionManager.TryGetClosestPredictedID(other.gameObject, out var otherId)) { var state = currentState; @@ -146,6 +153,9 @@ public void RegisterEvent(PhysicsEventType type, PredictedIdentity caller, Colli public void RegisterEvent(PhysicsEventType type, PredictedIdentity caller, GameObject other, bool isTrigger, Vector3 contactPoint = default, Vector3 contactNormal = default, Vector3 relativeVelocity = default) { + if (caller.IsLocallyDormant()) + return; + if (!PredictionManager.TryGetClosestPredictedID(other, out var otherId)) return; diff --git a/Assets/PurrDiction/Runtime/Prefabs/PredictedPrefab.cs b/Assets/PurrDiction/Runtime/Prefabs/PredictedPrefab.cs index b93d3186..a1e82703 100644 --- a/Assets/PurrDiction/Runtime/Prefabs/PredictedPrefab.cs +++ b/Assets/PurrDiction/Runtime/Prefabs/PredictedPrefab.cs @@ -18,5 +18,13 @@ public struct PredictedPrefab public GameObject prefab; public bool pooled; public int warmupCount; + + /// + /// Lowest numeric interest tier allowed by distance resolution for this prefab. + /// Higher values mean lower detail: resolved tiers below this floor are raised to it, + /// while 255 always starts culled. Providers, pins, and ownership may still override it. + /// + [Tooltip("Distance-based interest tier floor. 0 allows full detail; higher values force lower detail; 255 starts culled. Providers, pins, and ownership may override it.")] + public byte minimumInterestTier; } } diff --git a/Assets/PurrDiction/Runtime/Prefabs/PredictedPrefabs.cs b/Assets/PurrDiction/Runtime/Prefabs/PredictedPrefabs.cs index f3526d91..66cddd57 100644 --- a/Assets/PurrDiction/Runtime/Prefabs/PredictedPrefabs.cs +++ b/Assets/PurrDiction/Runtime/Prefabs/PredictedPrefabs.cs @@ -65,7 +65,8 @@ private void OnValidate() { prefab = _prefabs[i], pooled = poolByDefault, - warmupCount = 5 + warmupCount = 5, + minimumInterestTier = 0 }); } _prefabs.Clear(); @@ -82,7 +83,8 @@ private void OnValidate() { prefab = legacy.prefab, pooled = legacy.pooling.usePooling, - warmupCount = legacy.pooling.initialSize + warmupCount = legacy.pooling.initialSize, + minimumInterestTier = 0 }); } _newPrefabs.Clear(); @@ -178,7 +180,8 @@ public void Generate() prefab = (GameObject)scan.asset, pooled = poolByDefault, warmupCount = 5, - guid = scan.guid + guid = scan.guid, + minimumInterestTier = 0 }, e => e.prefab); diff --git a/Assets/PurrDiction/Runtime/Profiling/PackingInfo.cs b/Assets/PurrDiction/Runtime/Profiling/PackingInfo.cs index d2f9533d..788b87d2 100644 --- a/Assets/PurrDiction/Runtime/Profiling/PackingInfo.cs +++ b/Assets/PurrDiction/Runtime/Profiling/PackingInfo.cs @@ -8,4 +8,16 @@ public struct PackingInfo public int bitCount; public UnityEngine.Object reference; } + + public readonly struct FramePackingInfo + { + public readonly PlayerID player; + public readonly int bitCount; + + public FramePackingInfo(PlayerID player, int bitCount) + { + this.player = player; + this.bitCount = bitCount; + } + } } diff --git a/Assets/PurrDiction/Runtime/Profiling/TickBandwidthProfiler.cs b/Assets/PurrDiction/Runtime/Profiling/TickBandwidthProfiler.cs index 2ac1e8c2..911b1bb4 100644 --- a/Assets/PurrDiction/Runtime/Profiling/TickBandwidthProfiler.cs +++ b/Assets/PurrDiction/Runtime/Profiling/TickBandwidthProfiler.cs @@ -9,11 +9,13 @@ public static class TickBandwidthProfiler static readonly List _readStates = new List(256); static readonly List _wroteInputs = new List(256); static readonly List _readInputs = new List(256); + static readonly List _wroteFrames = new List(16); public static IReadOnlyList wroteStates => _wroteStates; public static IReadOnlyList readStates => _readStates; public static IReadOnlyList wroteInputs => _wroteInputs; public static IReadOnlyList readInputs => _readInputs; + public static IReadOnlyList wroteFrames => _wroteFrames; public static event Action onTickEnded; @@ -37,6 +39,11 @@ public static void OnReadInput(Type parent, int bitCount, UnityEngine.Object ref _readInputs.Add(new PackingInfo { parent = parent, bitCount = bitCount, reference = reference }); } + public static void OnWroteFrame(PlayerID player, int bitCount) + { + _wroteFrames.Add(new FramePackingInfo(player, bitCount)); + } + public static void MarkEndOfTick() { onTickEnded?.Invoke(); @@ -45,6 +52,7 @@ public static void MarkEndOfTick() _readStates.Clear(); _wroteInputs.Clear(); _readInputs.Clear(); + _wroteFrames.Clear(); } } } diff --git a/Assets/PurrDiction/Runtime/Transform/PredictedTransform.cs b/Assets/PurrDiction/Runtime/Transform/PredictedTransform.cs index 8b78d6b9..6693732c 100644 --- a/Assets/PurrDiction/Runtime/Transform/PredictedTransform.cs +++ b/Assets/PurrDiction/Runtime/Transform/PredictedTransform.cs @@ -36,9 +36,18 @@ public class PredictedTransform : PredictedIdentity #if UNITY_PHYSICS_3D private Rigidbody _unityRigidbody; private CharacterController _unityCtrler; + private bool _rawRigidbodyDormant; + private bool _rawRigidbodyKinematic; + private CollisionDetectionMode _rawRigidbodyCollisionMode; + private Vector3 _rawRigidbodyLinearVelocity; + private Vector3 _rawRigidbodyAngularVelocity; #endif #if UNITY_PHYSICS_2D private Rigidbody2D _unity2dRigidbody; + private bool _rawRigidbody2dDormant; + private RigidbodyType2D _rawRigidbody2dBodyType; + private Vector2 _rawRigidbody2dLinearVelocity; + private float _rawRigidbody2dAngularVelocity; #endif private bool _hasController; @@ -56,6 +65,7 @@ public class PredictedTransform : PredictedIdentity public override void ResetState() { base.ResetState(); + RestoreRawPhysicsDormancy(); ClearSoftCorrection(); _viewParent = null; @@ -393,6 +403,133 @@ protected override void OnPredictionPolicyChanged(PredictionPolicy oldPolicy, Pr ClearSoftCorrection(); } + internal override void SyncLocalRelevanceSideEffects(bool relevant) + { + if (!relevant) + ApplyRawPhysicsDormancy(); + else + RestoreRawPhysicsDormancy(); + } + + private void ApplyRawPhysicsDormancy() + { + if (isServer) + return; + +#if UNITY_PHYSICS_3D + if (_hasRigidbody && !_rawRigidbodyDormant && + !_unityRigidbody.TryGetComponent(out _)) + { + _rawRigidbodyKinematic = _unityRigidbody.isKinematic; + _rawRigidbodyCollisionMode = _unityRigidbody.collisionDetectionMode; + _rawRigidbodyLinearVelocity = GetRawLinearVelocity(_unityRigidbody); + _rawRigidbodyAngularVelocity = _unityRigidbody.angularVelocity; + + if (!_rawRigidbodyKinematic) + { + SetRawLinearVelocity(_unityRigidbody, default); + _unityRigidbody.angularVelocity = default; + } + + if (_unityRigidbody.collisionDetectionMode is CollisionDetectionMode.Continuous or + CollisionDetectionMode.ContinuousDynamic) + _unityRigidbody.collisionDetectionMode = CollisionDetectionMode.ContinuousSpeculative; + _unityRigidbody.isKinematic = true; + _rawRigidbodyDormant = true; + } +#endif +#if UNITY_PHYSICS_2D + if (_hasRigidbody2d && !_rawRigidbody2dDormant && + !_unity2dRigidbody.TryGetComponent(out _)) + { + _rawRigidbody2dBodyType = _unity2dRigidbody.bodyType; + _rawRigidbody2dLinearVelocity = GetRawLinearVelocity(_unity2dRigidbody); + _rawRigidbody2dAngularVelocity = _unity2dRigidbody.angularVelocity; + _unity2dRigidbody.bodyType = RigidbodyType2D.Kinematic; + SetRawLinearVelocity(_unity2dRigidbody, default); + _unity2dRigidbody.angularVelocity = 0f; + _rawRigidbody2dDormant = true; + } +#endif + } + + private void RestoreRawPhysicsDormancy() + { +#if UNITY_PHYSICS_3D + if (_rawRigidbodyDormant) + { + if (_unityRigidbody) + { + _unityRigidbody.isKinematic = _rawRigidbodyKinematic; + _unityRigidbody.collisionDetectionMode = _rawRigidbodyCollisionMode; + if (!_rawRigidbodyKinematic) + { + SetRawLinearVelocity(_unityRigidbody, _rawRigidbodyLinearVelocity); + _unityRigidbody.angularVelocity = _rawRigidbodyAngularVelocity; + } + } + + _rawRigidbodyDormant = false; + } +#endif +#if UNITY_PHYSICS_2D + if (_rawRigidbody2dDormant) + { + if (_unity2dRigidbody) + { + _unity2dRigidbody.bodyType = _rawRigidbody2dBodyType; + if (_rawRigidbody2dBodyType != RigidbodyType2D.Static) + { + SetRawLinearVelocity(_unity2dRigidbody, _rawRigidbody2dLinearVelocity); + _unity2dRigidbody.angularVelocity = _rawRigidbody2dAngularVelocity; + } + } + + _rawRigidbody2dDormant = false; + } +#endif + } + +#if UNITY_PHYSICS_2D + private static Vector2 GetRawLinearVelocity(Rigidbody2D body) + { +#if UNITY_6000 + return body.linearVelocity; +#else + return body.velocity; +#endif + } + + private static void SetRawLinearVelocity(Rigidbody2D body, Vector2 velocity) + { +#if UNITY_6000 + body.linearVelocity = velocity; +#else + body.velocity = velocity; +#endif + } +#endif + +#if UNITY_PHYSICS_3D + private static Vector3 GetRawLinearVelocity(Rigidbody body) + { +#if UNITY_6000 + return body.linearVelocity; +#else + return body.velocity; +#endif + } + + private static void SetRawLinearVelocity(Rigidbody body, Vector3 velocity) + { +#if UNITY_6000 + body.linearVelocity = velocity; +#else + body.velocity = velocity; +#endif + } +#endif + private void ClearSoftCorrection() { _softPositionError = default; @@ -510,6 +647,7 @@ protected override void OnAddedToPool() protected override void OnDestroy() { + RestoreRawPhysicsDormancy(); base.OnDestroy(); if (_hasView && _unparentGraphics && _graphics) Destroy(_graphics.gameObject); @@ -656,7 +794,7 @@ internal void GetViewWorldPose(float deltaTime, out Vector3 position, out Quater { if (predictionManager != null) { - UpdateView(deltaTime); + RunUpdateView(deltaTime); if (_viewWorldPass == predictionManager.viewPassId) { diff --git a/Assets/PurrDiction/Runtime/UnityPhysics/LocalPhysics.cs b/Assets/PurrDiction/Runtime/UnityPhysics/LocalPhysics.cs index 71cd7836..530e44a4 100644 --- a/Assets/PurrDiction/Runtime/UnityPhysics/LocalPhysics.cs +++ b/Assets/PurrDiction/Runtime/UnityPhysics/LocalPhysics.cs @@ -8,11 +8,21 @@ public class LocalPhysics : MonoBehaviour private PredictionManager _manager; private Rigidbody[] _rigidbodies; private UnityRigidbodyState[] _state; + private bool[] _relevance; + private PredictedIdentity[] _identities; private void Awake() { _rigidbodies = GetComponentsInChildren(); _state = new UnityRigidbodyState[_rigidbodies.Length]; + _relevance = new bool[_rigidbodies.Length]; + _identities = new PredictedIdentity[_rigidbodies.Length]; + + for (var i = 0; i < _rigidbodies.Length; i++) + { + if (!_rigidbodies[i].TryGetComponent(out _identities[i])) + _identities[i] = _rigidbodies[i].GetComponentInParent(); + } } private void Start() @@ -48,6 +58,7 @@ private void OnStartingToRollback() { for (int i = 0; i < _rigidbodies.Length; i++) { + _relevance[i] = IsLocallyRelevant(i); _state[i] = new UnityRigidbodyState(_rigidbodies[i]); _rigidbodies[i].isKinematic = true; } @@ -59,6 +70,8 @@ private void OnRollbackFinished() { var state = _state[i]; var rb = _rigidbodies[i]; + if (_relevance[i] != IsLocallyRelevant(i)) + continue; rb.isKinematic = state.isKinematic; if (state.isKinematic) continue; @@ -74,6 +87,12 @@ private void OnRollbackFinished() else rb.WakeUp(); } } + + private bool IsLocallyRelevant(int index) + { + var identity = _identities[index]; + return !identity || _manager.IsLocallyRelevant(identity); + } #endif } } diff --git a/Assets/PurrDiction/Runtime/UnityPhysics/LocalPhysics2D.cs b/Assets/PurrDiction/Runtime/UnityPhysics/LocalPhysics2D.cs index 0ebae1ae..f6d975a6 100644 --- a/Assets/PurrDiction/Runtime/UnityPhysics/LocalPhysics2D.cs +++ b/Assets/PurrDiction/Runtime/UnityPhysics/LocalPhysics2D.cs @@ -8,11 +8,21 @@ public class LocalPhysics2D : MonoBehaviour private PredictionManager _manager; private Rigidbody2D[] _rigidbodies; private UnityRigidbody2DState[] _state; + private bool[] _relevance; + private PredictedIdentity[] _identities; private void Awake() { _rigidbodies = GetComponentsInChildren(); _state = new UnityRigidbody2DState[_rigidbodies.Length]; + _relevance = new bool[_rigidbodies.Length]; + _identities = new PredictedIdentity[_rigidbodies.Length]; + + for (var i = 0; i < _rigidbodies.Length; i++) + { + if (!_rigidbodies[i].TryGetComponent(out _identities[i])) + _identities[i] = _rigidbodies[i].GetComponentInParent(); + } } private void Start() @@ -40,6 +50,7 @@ private void OnStartingToRollback() { var rb = _rigidbodies[i]; + _relevance[i] = IsLocallyRelevant(i); _state[i] = new UnityRigidbody2DState(rb); rb.bodyType = RigidbodyType2D.Kinematic; @@ -60,6 +71,8 @@ private void OnRollbackFinished() { var state = _state[i]; var rb = _rigidbodies[i]; + if (_relevance[i] != IsLocallyRelevant(i)) + continue; rb.bodyType = (RigidbodyType2D) state.bodyType; //Rigidbody2D can have velocity even when Kinematic. We only skip when bodyType is Static if (rb.bodyType == RigidbodyType2D.Static) @@ -76,6 +89,12 @@ private void OnRollbackFinished() else rb.WakeUp(); } } + + private bool IsLocallyRelevant(int index) + { + var identity = _identities[index]; + return !identity || _manager.IsLocallyRelevant(identity); + } #endif } -} \ No newline at end of file +} diff --git a/Assets/PurrDiction/Runtime/UnityPhysics/PredictedRigidbody.cs b/Assets/PurrDiction/Runtime/UnityPhysics/PredictedRigidbody.cs index ea4c98e2..e7e0337f 100644 --- a/Assets/PurrDiction/Runtime/UnityPhysics/PredictedRigidbody.cs +++ b/Assets/PurrDiction/Runtime/UnityPhysics/PredictedRigidbody.cs @@ -122,6 +122,7 @@ public bool useGravity private bool _defaultKinematic; private CollisionDetectionMode _defaultCollisionMode; private PredictionPolicy _appliedKinematicPolicy = PredictionPolicy.FullPrediction; + private bool _appliedLocallyDormant; private bool _replayFrozen; private bool _frozenKinematic; @@ -186,17 +187,20 @@ private void ApplyEffectiveKinematic() return; var effective = EffectivePolicy(); - if (effective == _appliedKinematicPolicy) + bool dormant = IsLocallyDormant(); + if (effective == _appliedKinematicPolicy && dormant == _appliedLocallyDormant) return; var previous = _appliedKinematicPolicy; + bool wasDormant = _appliedLocallyDormant; _appliedKinematicPolicy = effective; + _appliedLocallyDormant = dormant; if (_replayFrozen) return; - if (effective == PredictionPolicy.ServerRelay) + if (dormant || effective == PredictionPolicy.ServerRelay) ForceRelayKinematic(); - else if (previous == PredictionPolicy.ServerRelay) + else if (wasDormant || previous == PredictionPolicy.ServerRelay) RestoreAuthoritativePhysicsState(); } @@ -206,6 +210,11 @@ internal override void SyncEffectivePolicySideEffects() ApplyEffectiveKinematic(); } + internal override void SyncLocalRelevanceSideEffects(bool relevant) + { + ApplyEffectiveKinematic(); + } + private void ForceRelayKinematic() { if (!_rigidbody.isKinematic) @@ -311,7 +320,7 @@ internal override void OnReplayEnd() _constraintsFrozen = false; } - if (!isServer && UsesServerRelayTimeline()) + if (!isServer && (IsLocallyDormant() || UsesServerRelayTimeline())) { ForceRelayKinematic(); return; @@ -573,6 +582,7 @@ public override void ResetState() base.ResetState(); ClearSoftVelocityCorrection(); _replayFrozen = false; + _appliedLocallyDormant = false; if (_constraintsFrozen) { _rigidbody.constraints = _frozenConstraints; diff --git a/Assets/PurrDiction/Runtime/UnityPhysics/PredictedRigidbody2D.cs b/Assets/PurrDiction/Runtime/UnityPhysics/PredictedRigidbody2D.cs index b940f2e6..faf88014 100644 --- a/Assets/PurrDiction/Runtime/UnityPhysics/PredictedRigidbody2D.cs +++ b/Assets/PurrDiction/Runtime/UnityPhysics/PredictedRigidbody2D.cs @@ -36,6 +36,7 @@ private void Reset() private RigidbodyType2D _defaultBodyType; private PredictionPolicy _appliedKinematicPolicy = PredictionPolicy.FullPrediction; + private bool _appliedLocallyDormant; private bool _replayFrozen; private RigidbodyType2D _frozenBodyType; private bool _constraintsFrozen; @@ -113,17 +114,20 @@ private void ApplyEffectiveKinematic() return; var effective = EffectivePolicy(); - if (effective == _appliedKinematicPolicy) + bool dormant = IsLocallyDormant(); + if (effective == _appliedKinematicPolicy && dormant == _appliedLocallyDormant) return; var previous = _appliedKinematicPolicy; + bool wasDormant = _appliedLocallyDormant; _appliedKinematicPolicy = effective; + _appliedLocallyDormant = dormant; if (_replayFrozen) return; - if (effective == PredictionPolicy.ServerRelay) + if (dormant || effective == PredictionPolicy.ServerRelay) ForceRelayKinematic(); - else if (previous == PredictionPolicy.ServerRelay) + else if (wasDormant || previous == PredictionPolicy.ServerRelay) RestoreAuthoritativePhysicsState(); } @@ -133,6 +137,11 @@ internal override void SyncEffectivePolicySideEffects() ApplyEffectiveKinematic(); } + internal override void SyncLocalRelevanceSideEffects(bool relevant) + { + ApplyEffectiveKinematic(); + } + private void ForceRelayKinematic() { _rigidbody.bodyType = RigidbodyType2D.Kinematic; @@ -229,7 +238,7 @@ internal override void OnReplayEnd() _constraintsFrozen = false; } - if (!isServer && UsesServerRelayTimeline()) + if (!isServer && (IsLocallyDormant() || UsesServerRelayTimeline())) { ForceRelayKinematic(); return; @@ -318,6 +327,7 @@ public override void ResetState() base.ResetState(); ClearSoftVelocityCorrection(); _replayFrozen = false; + _appliedLocallyDormant = false; if (_rigidbody) { if (_constraintsFrozen) diff --git a/Assets/PurrDictionTests/EditorTests/InterestManagementPhase3Tests.cs b/Assets/PurrDictionTests/EditorTests/InterestManagementPhase3Tests.cs new file mode 100644 index 00000000..d4476749 --- /dev/null +++ b/Assets/PurrDictionTests/EditorTests/InterestManagementPhase3Tests.cs @@ -0,0 +1,171 @@ +using NUnit.Framework; +using PurrNet; +using UnityEngine; + +namespace PurrNet.Prediction.Tests.Editor +{ + public sealed class InterestManagementPhase3Tests + { + [Test] + public void PredictionLodProfileMapsVisibleAndCulledTierPolicy() + { + var networkProfile = ScriptableObject.CreateInstance(); + var predictionProfile = ScriptableObject.CreateInstance(); + try + { + predictionProfile.Configure( + networkProfile, + new[] + { + new PredictionLODTier { suggestedPolicy = PredictionPolicyOverride.KeepConfigured }, + new PredictionLODTier { suggestedPolicy = PredictionPolicyOverride.ServerRelay }, + new PredictionLODTier { suggestedPolicy = PredictionPolicyOverride.SoftCorrection } + }, + PredictionPolicyOverride.PredictedIfOwned); + + Assert.That(predictionProfile.networkProfile, Is.SameAs(networkProfile)); + Assert.That(predictionProfile.tierCount, Is.EqualTo(3)); + Assert.That(predictionProfile.GetSuggestedPolicy(0), + Is.EqualTo(PredictionPolicyOverride.KeepConfigured)); + Assert.That(predictionProfile.TryGetSuggestedPolicy(0, out _), Is.False); + Assert.That(predictionProfile.TryGetSuggestedPolicy(1, out var tierOnePolicy), Is.True); + Assert.That(tierOnePolicy, Is.EqualTo(PredictionPolicy.ServerRelay)); + Assert.That(predictionProfile.TryGetSuggestedPolicy(9, out var clampedPolicy), Is.True); + Assert.That(clampedPolicy, Is.EqualTo(PredictionPolicy.SoftCorrection)); + Assert.That(predictionProfile.TryGetSuggestedPolicy( + NetworkLODProfile.CulledTier, out var culledPolicy), Is.True); + Assert.That(culledPolicy, Is.EqualTo(PredictionPolicy.PredictedIfOwned)); + } + finally + { + Object.DestroyImmediate(predictionProfile); + Object.DestroyImmediate(networkProfile); + } + } + + [Test] + public void PredictionLodProfileDefaultsToConfiguredPolicy() + { + var profile = ScriptableObject.CreateInstance(); + try + { + Assert.That(profile.GetSuggestedPolicy(0), + Is.EqualTo(PredictionPolicyOverride.KeepConfigured)); + Assert.That(profile.TryGetSuggestedPolicy(0, out _), Is.False); + Assert.That(profile.GetSuggestedPolicy(NetworkLODProfile.CulledTier), + Is.EqualTo(PredictionPolicyOverride.ServerRelay)); + } + finally + { + Object.DestroyImmediate(profile); + } + } + + [Test] + public void InterestPolicyOverrideRestoresLatestConfiguredPolicy() + { + var managerObject = new GameObject("PredictionManager"); + var identityObject = new GameObject(nameof(InterestPolicyOverrideRestoresLatestConfiguredPolicy)); + try + { + var manager = managerObject.AddComponent(); + var identity = identityObject.AddComponent(); + identity.AttachForTest(manager); + identity.SetPredictionPolicyOverride(PredictionPolicy.SoftCorrection); + + identity.SetInterestPolicyOverride(PredictionPolicy.ServerRelay); + + Assert.That(identity.predictionPolicy, Is.EqualTo(PredictionPolicy.ServerRelay)); + + identity.SetPredictionPolicyOverride(PredictionPolicy.FullPrediction); + + Assert.That(identity.predictionPolicy, Is.EqualTo(PredictionPolicy.ServerRelay)); + + identity.SetInterestPolicyOverride(null); + + Assert.That(identity.predictionPolicy, Is.EqualTo(PredictionPolicy.FullPrediction)); + Assert.That(identity.configuredPredictionPolicy, + Is.EqualTo(PredictionPolicy.FullPrediction)); + identity.DetachForTest(); + } + finally + { + Object.DestroyImmediate(identityObject); + Object.DestroyImmediate(managerObject); + } + } + + [Test] + public void InterestPolicyOverrideNormalizesWithoutReplacingConfiguredPolicy() + { + var identityObject = new GameObject( + nameof(InterestPolicyOverrideNormalizesWithoutReplacingConfiguredPolicy)); + try + { + var identity = identityObject.AddComponent(); + identity.SetPredictionPolicy(PredictionPolicy.ServerRelay); + + identity.SetInterestPolicyOverride(PredictionPolicy.SoftCorrection); + + Assert.That(identity.predictionPolicy, Is.EqualTo(PredictionPolicy.FullPrediction)); + + identity.SetInterestPolicyOverride(null); + + Assert.That(identity.predictionPolicy, Is.EqualTo(PredictionPolicy.ServerRelay)); + } + finally + { + Object.DestroyImmediate(identityObject); + } + } + + [Test] + public void RootWithoutAcknowledgedSendsFallsBackToGlobalAcknowledgedTick() + { + var baselines = new RootSendBaselineState(); + var unseenRoot = new PredictedObjectID(11); + var pendingRoot = new PredictedObjectID(12); + + Assert.That(baselines.GetBaseline(unseenRoot, 25), Is.EqualTo(25)); + + baselines.MarkSent(pendingRoot, 30); + + Assert.That(baselines.GetBaseline(pendingRoot, 25), Is.EqualTo(25)); + } + + [Test] + public void SkippedSendTicksKeepTheLastAcknowledgedSentTickAsBaseline() + { + var baselines = new RootSendBaselineState(); + var root = new PredictedObjectID(13); + + baselines.MarkSent(root, 8); + Assert.That(baselines.GetBaseline(root, 10), Is.EqualTo(8)); + Assert.That(baselines.GetBaseline(root, 13), Is.EqualTo(8)); + + baselines.MarkSent(root, 14); + Assert.That(baselines.GetBaseline(root, 16), Is.EqualTo(14)); + Assert.That(baselines.GetBaseline(root, 21), Is.EqualTo(14)); + } + } + + public sealed class Phase3PolicyProbe : PredictedIdentity + { + public override bool supportsSoftCorrection => true; + + public void AttachForTest(PredictionManager manager) + { + predictionManager = manager; + RefreshResolvedPredictionPolicy(); + } + + public void DetachForTest() + { + predictionManager = null; + } + } + + public sealed class Phase3UnsupportedPolicyProbe : PredictedIdentity + { + } +} diff --git a/Assets/PurrDictionTests/EditorTests/InterestManagementPhase3Tests.cs.meta b/Assets/PurrDictionTests/EditorTests/InterestManagementPhase3Tests.cs.meta new file mode 100644 index 00000000..d813bd2c --- /dev/null +++ b/Assets/PurrDictionTests/EditorTests/InterestManagementPhase3Tests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 67d8056b4f8e4d1597d6126d29bd3ad5 diff --git a/Assets/PurrDictionTests/EditorTests/InterestManagementPhase4Tests.cs b/Assets/PurrDictionTests/EditorTests/InterestManagementPhase4Tests.cs new file mode 100644 index 00000000..0ed86fd0 --- /dev/null +++ b/Assets/PurrDictionTests/EditorTests/InterestManagementPhase4Tests.cs @@ -0,0 +1,210 @@ +using NUnit.Framework; +using PurrNet.Packing; +using UnityEngine; + +namespace PurrNet.Prediction.Tests.Editor +{ + public sealed class InterestManagementPhase4Tests + { + [Test] + public void ReentryAbsolutePersistsUntilClientConfirmation() + { + var controls = new InterestControlState(); + var root = new PredictedObjectID(12); + + controls.Queue(root, NetworkLODProfile.CulledTier); + controls.PrepareForFrame(10); + controls.MarkDelivered(); + controls.Queue(root, 0); + controls.PrepareForFrame(20); + + Assert.That(controls.hasUnconfirmedReentries, Is.True); + Assert.That(controls.RequiresAbsolute(root), Is.True); + Assert.That(controls.ConfirmReentry(root, 19), Is.False); + Assert.That(controls.RequiresAbsolute(root), Is.True); + Assert.That(controls.ConfirmReentry(root, 20), Is.True); + Assert.That(controls.hasUnconfirmedReentries, Is.False); + Assert.That(controls.RequiresAbsolute(root), Is.False); + } + + [Test] + public void PrefabTierFloorDefaultsToZeroAndClampsToProfile() + { + var managerObject = new GameObject(nameof(PrefabTierFloorDefaultsToZeroAndClampsToProfile)); + var networkProfile = CreateNetworkProfile(); + var predictionProfile = CreatePredictionProfile(networkProfile); + var module = new PredictionInterestModule( + managerObject.AddComponent(), + predictionProfile, + null, + null, + true); + + try + { + Assert.That(default(PredictedPrefab).minimumInterestTier, Is.Zero); + Assert.That(module.ClampMinimumInterestTier(0), Is.Zero); + Assert.That(module.ClampMinimumInterestTier(1), Is.EqualTo(1)); + Assert.That(module.ClampMinimumInterestTier(42), Is.EqualTo(2)); + Assert.That(module.ClampMinimumInterestTier(NetworkLODProfile.CulledTier), + Is.EqualTo(NetworkLODProfile.CulledTier)); + } + finally + { + module.Dispose(); + Object.DestroyImmediate(predictionProfile); + Object.DestroyImmediate(networkProfile); + Object.DestroyImmediate(managerObject); + } + } + + [Test] + public void PrefabTierFloorPrecedesProviderPinsAndOwnership() + { + var managerObject = new GameObject(nameof(PrefabTierFloorPrecedesProviderPinsAndOwnership)); + var networkProfile = CreateNetworkProfile(); + var predictionProfile = CreatePredictionProfile(networkProfile); + var manager = managerObject.AddComponent(); + var module = new PredictionInterestModule(manager, predictionProfile, null, null, true); + var root = new PredictedObjectID(17); + var player = new PlayerID(new PackedULong(23), false); + var target = new PredictionInterestModule.RootTarget(module, root, 2); + var provider = new RecordingTierProvider { result = 1 }; + module.provider = provider; + + try + { + Assert.That(module.ResolveTier(target, player, 0), Is.EqualTo(1)); + Assert.That(provider.receivedTier, Is.EqualTo(2)); + + module.SetPin(player, root, PredictionInterestPin.Culled); + Assert.That(module.ResolveTier(target, player, 0), + Is.EqualTo(NetworkLODProfile.CulledTier)); + + target.observedOwner = player; + Assert.That(module.ResolveTier(target, player, 0), Is.Zero); + + target.observedOwner = null; + module.SetPin(player, root, PredictionInterestPin.Relevant); + Assert.That(module.ResolveTier(target, player, 0), Is.Zero); + } + finally + { + module.Dispose(); + Object.DestroyImmediate(predictionProfile); + Object.DestroyImmediate(networkProfile); + Object.DestroyImmediate(managerObject); + } + } + + [Test] + public void ServerEventsSeparateTierChangesFromCulledBoundaryCrossings() + { + var managerObject = new GameObject(nameof(ServerEventsSeparateTierChangesFromCulledBoundaryCrossings)); + var networkProfile = CreateNetworkProfile(); + var predictionProfile = CreatePredictionProfile(networkProfile); + var module = new PredictionInterestModule( + managerObject.AddComponent(), + predictionProfile, + null, + null, + true); + var root = new PredictedObjectID(31); + var player = new PlayerID(new PackedULong(47), false); + var target = new PredictionInterestModule.RootTarget(module, root, 0); + int tierChanges = 0; + int becameRelevant = 0; + int becameIrrelevant = 0; + byte previousTier = 0; + byte currentTier = 0; + + module.OnServerTierChanged += (changedPlayer, changedRoot, previous, current) => + { + Assert.That(changedPlayer, Is.EqualTo(player)); + Assert.That(changedRoot, Is.EqualTo(root)); + tierChanges++; + previousTier = previous; + currentTier = current; + }; + module.OnServerBecameRelevant += (changedPlayer, changedRoot) => + { + Assert.That(changedPlayer, Is.EqualTo(player)); + Assert.That(changedRoot, Is.EqualTo(root)); + becameRelevant++; + }; + module.OnServerBecameIrrelevant += (changedPlayer, changedRoot) => + { + Assert.That(changedPlayer, Is.EqualTo(player)); + Assert.That(changedRoot, Is.EqualTo(root)); + becameIrrelevant++; + }; + + try + { + target.ApplyTier(player, 1); + Assert.That(tierChanges, Is.EqualTo(1)); + Assert.That(becameRelevant, Is.Zero); + Assert.That(becameIrrelevant, Is.Zero); + Assert.That(previousTier, Is.Zero); + Assert.That(currentTier, Is.EqualTo(1)); + + target.ApplyTier(player, NetworkLODProfile.CulledTier); + Assert.That(tierChanges, Is.EqualTo(2)); + Assert.That(becameRelevant, Is.Zero); + Assert.That(becameIrrelevant, Is.EqualTo(1)); + Assert.That(previousTier, Is.EqualTo(1)); + Assert.That(currentTier, Is.EqualTo(NetworkLODProfile.CulledTier)); + + target.ApplyTier(player, 2); + target.ApplyTier(player, 2); + Assert.That(tierChanges, Is.EqualTo(3)); + Assert.That(becameRelevant, Is.EqualTo(1)); + Assert.That(becameIrrelevant, Is.EqualTo(1)); + Assert.That(previousTier, Is.EqualTo(NetworkLODProfile.CulledTier)); + Assert.That(currentTier, Is.EqualTo(2)); + } + finally + { + module.Dispose(); + Object.DestroyImmediate(predictionProfile); + Object.DestroyImmediate(networkProfile); + Object.DestroyImmediate(managerObject); + } + } + + private static NetworkLODProfile CreateNetworkProfile() + { + var profile = ScriptableObject.CreateInstance(); + profile.Configure(new[] + { + new NetworkLODTier { maxDistance = 10f, sendIntervalTicks = 1 }, + new NetworkLODTier { maxDistance = 20f, sendIntervalTicks = 2 }, + new NetworkLODTier { maxDistance = 30f, sendIntervalTicks = 4 } + }, true); + return profile; + } + + private static PredictionLODProfile CreatePredictionProfile(NetworkLODProfile networkProfile) + { + var profile = ScriptableObject.CreateInstance(); + profile.Configure(networkProfile); + return profile; + } + + private sealed class RecordingTierProvider : IPredictionInterestProvider + { + public byte result; + public byte receivedTier; + + public byte ResolveTier( + PredictionManager manager, + PlayerID player, + PredictedObjectID root, + byte computedTier) + { + receivedTier = computedTier; + return result; + } + } + } +} diff --git a/Assets/PurrDictionTests/EditorTests/InterestManagementPhase4Tests.cs.meta b/Assets/PurrDictionTests/EditorTests/InterestManagementPhase4Tests.cs.meta new file mode 100644 index 00000000..49157570 --- /dev/null +++ b/Assets/PurrDictionTests/EditorTests/InterestManagementPhase4Tests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3c6e2d9a746a4c7fb14969498b835021 diff --git a/Assets/PurrDictionTests/EditorTests/LocalGameObjectCullingTests.cs b/Assets/PurrDictionTests/EditorTests/LocalGameObjectCullingTests.cs new file mode 100644 index 00000000..1c5441cd --- /dev/null +++ b/Assets/PurrDictionTests/EditorTests/LocalGameObjectCullingTests.cs @@ -0,0 +1,443 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using NUnit.Framework; +using PurrNet.Modules; +using PurrNet.Packing; +using PurrNet.Pooling; +using PurrNet.Utils; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace PurrNet.Prediction.Tests.Editor +{ + public sealed class LocalGameObjectCullingTests + { + private const BindingFlags InstanceFields = BindingFlags.Instance | BindingFlags.NonPublic; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + NetworkManager.CallAllRegisters(); + Hasher.PrepareType(); + Hasher.PrepareType(); + Hasher.PrepareType(); + Hasher.PrepareType(); + Hasher.PrepareType(); + } + + [Test] + public void QueuedCullWaitsForItsFrameThenRetainsRecordsAndPoolsPieces() + { + using var context = ClientHierarchyContext.Create( + nameof(QueuedCullWaitsForItsFrameThenRetainsRecordsAndPoolsPieces)); + + context.interest.QueueLocalTier( + context.rootId, + NetworkLODProfile.CulledTier, + 30); + context.interest.ApplyQueuedLocalTiers(29); + + Assert.That(context.hierarchy.TryGetGameObject(context.rootId, out var liveRoot), Is.True); + Assert.That(liveRoot, Is.SameAs(context.root)); + + context.interest.ApplyQueuedLocalTiers(30); + + Assert.That(context.root.activeSelf, Is.False); + Assert.That(context.hierarchy.HasRootRecords(context.rootId), Is.True); + + for (var i = 0; i < context.pieceIds.Count; i++) + { + var pieceId = context.pieceIds[i]; + Assert.That(context.hierarchy.TryGetRootId(pieceId, out var retainedRoot), Is.True); + Assert.That(retainedRoot, Is.EqualTo(context.rootId)); + Assert.That(context.hierarchy.TryGetGameObject(pieceId, out _), Is.False); + Assert.That(context.pool.Contains(pieceId), Is.True); + } + } + + [Test] + public void HierarchyReconcileDoesNotRematerializeACulledRoot() + { + using var context = ClientHierarchyContext.Create( + nameof(HierarchyReconcileDoesNotRematerializeACulledRoot)); + var snapshot = context.hierarchy.currentState.Duplicate(); + + try + { + context.interest.ReceiveLocalTier( + context.rootId, + NetworkLODProfile.CulledTier, + 10); + + InvokeSetUnityState(context.hierarchy, snapshot); + + Assert.That(context.hierarchy.HasRootRecords(context.rootId), Is.True); + Assert.That(context.hierarchy.TryGetGameObject(context.rootId, out _), Is.False); + Assert.That(context.pool.Contains(context.rootId), Is.True); + } + finally + { + snapshot.Dispose(); + } + } + + [Test] + public void ReentryMaterializesDormantAndConfirmsOnlyAtTheAbsoluteTick() + { + using var context = ClientHierarchyContext.Create( + nameof(ReentryMaterializesDormantAndConfirmsOnlyAtTheAbsoluteTick)); + + context.interest.ReceiveLocalTier( + context.rootId, + NetworkLODProfile.CulledTier, + 10); + context.interest.ReceiveLocalTier(context.rootId, 1, 20); + + Assert.That(context.interest.CanMaterializeLocalRoot(context.rootId, 19), Is.False); + Assert.That(context.interest.CanMaterializeLocalRoot(context.rootId, 20), Is.True); + Assert.That(context.hierarchy.TryGetGameObject(context.rootId, out _), Is.False); + + Assert.That(context.hierarchy.MaterializeLocalRoot(context.rootId), Is.True); + Assert.That(context.hierarchy.TryGetGameObject(context.rootId, out var materializedRoot), Is.True); + Assert.That(materializedRoot, Is.SameAs(context.root)); + Assert.That(context.manager.TryGetIdentity( + new PredictedComponentID(context.rootId, 0), out var identity), Is.True); + Assert.That(identity.IsLocallyDormant(), Is.True); + + context.interest.ConfirmLocalAbsolute(context.rootId, 19); + + Assert.That(context.interest.TryGetLocalRelevance(context.rootId, out var pendingTier), Is.True); + Assert.That(pendingTier, Is.EqualTo(NetworkLODProfile.CulledTier)); + Assert.That(identity.IsLocallyDormant(), Is.True); + + context.interest.ConfirmLocalAbsolute(context.rootId, 20); + + Assert.That(context.interest.TryGetLocalRelevance(context.rootId, out var confirmedTier), Is.True); + Assert.That(confirmedTier, Is.EqualTo(1)); + Assert.That(identity.IsLocallyDormant(), Is.False); + Assert.That(identity.predictionPolicy, Is.EqualTo(PredictionPolicy.ServerRelay)); + Assert.That(identity.interestSendIntervalTicks, Is.EqualTo(2)); + } + + [Test] + public void RecullingAnUnconfirmedMaterializedRootPoolsItAgain() + { + using var context = ClientHierarchyContext.Create( + nameof(RecullingAnUnconfirmedMaterializedRootPoolsItAgain)); + + context.interest.ReceiveLocalTier( + context.rootId, + NetworkLODProfile.CulledTier, + 10); + context.interest.ReceiveLocalTier(context.rootId, 1, 20); + Assert.That(context.hierarchy.MaterializeLocalRoot(context.rootId), Is.True); + Assert.That(context.hierarchy.TryGetGameObject(context.rootId, out _), Is.True); + + context.interest.ReceiveLocalTier( + context.rootId, + NetworkLODProfile.CulledTier, + 21); + + Assert.That(context.hierarchy.HasRootRecords(context.rootId), Is.True); + Assert.That(context.hierarchy.TryGetGameObject(context.rootId, out _), Is.False); + Assert.That(context.pool.Contains(context.rootId), Is.True); + Assert.That(context.interest.CanMaterializeLocalRoot(context.rootId, 22), Is.False); + } + + [Test] + public void ShutdownDisposeDoesNotRematerializeCulledRoots() + { + using var context = ClientHierarchyContext.Create( + nameof(ShutdownDisposeDoesNotRematerializeCulledRoots)); + + context.interest.ReceiveLocalTier( + context.rootId, + NetworkLODProfile.CulledTier, + 10); + + context.interest.Dispose(false); + + Assert.That(context.hierarchy.HasRootRecords(context.rootId), Is.True); + Assert.That(context.hierarchy.TryGetGameObject(context.rootId, out _), Is.False); + Assert.That(context.pool.Contains(context.rootId), Is.True); + } + + [Test] + public void PruningDeletedRootClearsCulledAndPendingLocalTiers() + { + using var context = ClientHierarchyContext.Create( + nameof(PruningDeletedRootClearsCulledAndPendingLocalTiers)); + + context.interest.ReceiveLocalTier( + context.rootId, + NetworkLODProfile.CulledTier, + 10); + context.interest.ReceiveLocalTier(context.rootId, 1, 20); + + var emptyState = new PredictedHierarchyState( + DisposableList.Create(), + DisposableList.Create(), + 100); + + try + { + InvokeSetUnityState(context.hierarchy, emptyState); + context.hierarchy.currentState.spawnedPrefabs.Clear(); + context.hierarchy.currentState.nextInstanceId = emptyState.nextInstanceId; + + context.interest.PruneStaleLocalTiers(); + + Assert.That(context.hierarchy.HasRootRecords(context.rootId), Is.False); + Assert.That(context.interest.TryGetLocalRelevance(context.rootId, out _), Is.False); + Assert.That(context.interest.CanMaterializeLocalRoot(context.rootId, 20), Is.False); + } + finally + { + emptyState.Dispose(); + } + } + + [Test] + public void RuntimePrefabPoolExpiresOnlyAfterTwoSecondsOfTicks() + { + var managerObject = new GameObject(nameof(RuntimePrefabPoolExpiresOnlyAfterTwoSecondsOfTicks)); + var pooledObject = new GameObject("PooledRuntimePrefab"); + var manager = managerObject.AddComponent(); + var pool = new PredictedPiecePool(); + var id = new PredictedObjectID(40); + + try + { + SetField(typeof(PredictionManager), manager, "k__BackingField", 20); + pool.PutTree( + 3, + id, + Vector3.zero, + pooledObject, + new List { new PooledPiece(id, 0, pooledObject) }, + 10, + true); + + SetField(typeof(PredictionManager), manager, "k__BackingField", 50UL); + pool.ClearOld(manager); + + Assert.That(pool.Contains(id), Is.True); + Assert.That(pooledObject, Is.Not.Null); + + SetField(typeof(PredictionManager), manager, "k__BackingField", 51UL); + pool.ClearOld(manager); + + Assert.That(pool.Contains(id), Is.False); + Assert.That(pooledObject == null, Is.True); + } + finally + { + if (pooledObject) + Object.DestroyImmediate(pooledObject); + Object.DestroyImmediate(managerObject); + } + } + + [Test] + public void PositionalDecodeStopsWhenTheIncomingSystemCountExceedsTheLocalCount() + { + var managerObject = new GameObject( + nameof(PositionalDecodeStopsWhenTheIncomingSystemCountExceedsTheLocalCount)); + + try + { + var manager = managerObject.AddComponent(); + using var packer = BitPackerPool.Get(); + Packer.Write(packer, (PackedInt)4); + packer.ResetPositionAndMode(true); + + var method = typeof(PredictionManager).GetMethod( + "ReadPositionalStateEntries", + InstanceFields); + Assert.That(method, Is.Not.Null); + Assert.That( + () => method.Invoke(manager, new object[] { packer, 10UL, 9UL, 10UL, false }), + Throws.Nothing); + } + finally + { + Object.DestroyImmediate(managerObject); + } + } + + private static void InvokeSetUnityState( + PredictedHierarchy hierarchy, + PredictedHierarchyState state) + { + var method = typeof(PredictedHierarchy).GetMethod("SetUnityState", InstanceFields); + Assert.That(method, Is.Not.Null); + method.Invoke(hierarchy, new object[] { state }); + } + + private static void SetField(Type declaringType, object target, string fieldName, object value) + { + var field = declaringType.GetField(fieldName, InstanceFields); + Assert.That(field, Is.Not.Null, $"Missing field {declaringType.FullName}.{fieldName}"); + field.SetValue(target, value); + } + + private sealed class ClientHierarchyContext : IDisposable + { + public readonly GameObject networkObject; + public readonly GameObject managerObject; + public readonly GameObject root; + public readonly PredictionManager manager; + public readonly PredictedHierarchy hierarchy; + public readonly PredictionInterestModule interest; + public readonly PredictedObjectID rootId; + public readonly List pieceIds; + public readonly PredictedPiecePool pool; + + private readonly NetworkLODProfile _networkProfile; + private readonly PredictionLODProfile _predictionProfile; + + private ClientHierarchyContext( + GameObject networkObject, + GameObject managerObject, + GameObject root, + PredictionManager manager, + PredictedHierarchy hierarchy, + PredictionInterestModule interest, + PredictedObjectID rootId, + List pieceIds, + PredictedPiecePool pool, + NetworkLODProfile networkProfile, + PredictionLODProfile predictionProfile) + { + this.networkObject = networkObject; + this.managerObject = managerObject; + this.root = root; + this.manager = manager; + this.hierarchy = hierarchy; + this.interest = interest; + this.rootId = rootId; + this.pieceIds = pieceIds; + this.pool = pool; + _networkProfile = networkProfile; + _predictionProfile = predictionProfile; + } + + public static ClientHierarchyContext Create(string name) + { + var networkObject = new GameObject($"{name}.NetworkManager"); + var managerObject = new GameObject($"{name}.PredictionManager"); + var root = BuildCompoundRig(name); + var networkManager = networkObject.AddComponent(); + var manager = CreateSpawnedPredictionManager(managerObject, networkManager); + var hierarchy = manager.RegisterSystem(); + SetField(typeof(PredictionManager), manager, "k__BackingField", hierarchy); + + hierarchy.ReserveSceneObject(root, -1); + hierarchy.RegisterReservedSceneObjects(); + hierarchy.RunGetLatestUnityState(); + + Assert.That(hierarchy.TryGetId(root, out var rootId), Is.True); + + var pieceIds = new List(); + var records = hierarchy.currentState.spawnedPrefabs; + for (var i = 0; i < records.Count; i++) + { + if (records[i].rootId.Equals(rootId)) + pieceIds.Add(records[i].instanceId); + } + + var networkProfile = ScriptableObject.CreateInstance(); + networkProfile.Configure(new[] + { + new NetworkLODTier { maxDistance = 10f, sendIntervalTicks = 1 }, + new NetworkLODTier { maxDistance = 20f, sendIntervalTicks = 2 } + }, true); + var predictionProfile = ScriptableObject.CreateInstance(); + predictionProfile.Configure(networkProfile, new[] + { + new PredictionLODTier { suggestedPolicy = PredictionPolicyOverride.KeepConfigured }, + new PredictionLODTier { suggestedPolicy = PredictionPolicyOverride.ServerRelay } + }); + var interest = new PredictionInterestModule( + manager, + predictionProfile, + null, + null, + false); + SetField(typeof(PredictionManager), manager, "k__BackingField", interest); + + var poolField = typeof(PredictedHierarchy).GetField("_pool", InstanceFields); + Assert.That(poolField, Is.Not.Null); + var pool = (PredictedPiecePool)poolField.GetValue(hierarchy); + + return new ClientHierarchyContext( + networkObject, + managerObject, + root, + manager, + hierarchy, + interest, + rootId, + pieceIds, + pool, + networkProfile, + predictionProfile); + } + + public void Dispose() + { + interest.Dispose(); + SetField(typeof(PredictionManager), manager, "k__BackingField", null); + + if (root) + Object.DestroyImmediate(root); + if (managerObject) + Object.DestroyImmediate(managerObject); + if (networkObject) + Object.DestroyImmediate(networkObject); + if (_predictionProfile) + Object.DestroyImmediate(_predictionProfile); + if (_networkProfile) + Object.DestroyImmediate(_networkProfile); + } + + private static GameObject BuildCompoundRig(string name) + { + var root = new GameObject($"{name}.Root"); + root.AddComponent(); + + var folder = new GameObject("Folder"); + folder.transform.SetParent(root.transform); + + var firstPiece = new GameObject("FirstPiece"); + firstPiece.AddComponent(); + firstPiece.transform.SetParent(folder.transform); + + var childPiece = new GameObject("ChildPiece"); + childPiece.AddComponent(); + childPiece.transform.SetParent(firstPiece.transform); + + var secondPiece = new GameObject("SecondPiece"); + secondPiece.AddComponent(); + secondPiece.transform.SetParent(root.transform); + + return root; + } + + private static PredictionManager CreateSpawnedPredictionManager( + GameObject managerObject, + NetworkManager networkManager) + { + var tickManager = new TickManager(20, networkManager, null, false); + SetField(typeof(NetworkManager), networkManager, "_clientTickManager", tickManager); + + var manager = managerObject.AddComponent(); + SetField(typeof(NetworkIdentity), manager, "k__BackingField", networkManager); + SetField(typeof(PredictionManager), manager, "k__BackingField", 20); + manager.SetIsSpawned(true, false); + return manager; + } + } + } +} diff --git a/Assets/PurrDictionTests/EditorTests/LocalGameObjectCullingTests.cs.meta b/Assets/PurrDictionTests/EditorTests/LocalGameObjectCullingTests.cs.meta new file mode 100644 index 00000000..55a2721c --- /dev/null +++ b/Assets/PurrDictionTests/EditorTests/LocalGameObjectCullingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4c340e066c364052b54f195fb310d0e4 diff --git a/Assets/PurrDictionTests/EditorTests/PredictionFrameDeliveryTests.cs b/Assets/PurrDictionTests/EditorTests/PredictionFrameDeliveryTests.cs new file mode 100644 index 00000000..948476e3 --- /dev/null +++ b/Assets/PurrDictionTests/EditorTests/PredictionFrameDeliveryTests.cs @@ -0,0 +1,53 @@ +using NUnit.Framework; + +namespace PurrNet.Prediction.Tests.Editor +{ + public sealed class PredictionFrameDeliveryTests + { + [Test] + public void ReliableFrameBlocksUntilItsTickIsAcknowledged() + { + ReliableFrameDeliveryState delivery = default; + + Assert.That(delivery.ShouldSuppress(0), Is.False); + + delivery.MarkSent(42); + + Assert.That(delivery.ShouldSuppress(0), Is.True); + Assert.That(delivery.ShouldSuppress(41), Is.True); + Assert.That(delivery.ShouldSuppress(42), Is.False); + Assert.That(delivery.ShouldSuppress(0), Is.False); + } + + [Test] + public void ClearingReliableFrameStartsANewAcknowledgementEpoch() + { + ReliableFrameDeliveryState delivery = default; + delivery.MarkSent(42); + + delivery.Clear(); + + Assert.That(delivery.ShouldSuppress(0), Is.False); + } + + [Test] + public void FrameDeliveryUsesReliableOnlyBeyondTheFragmentableBudget() + { + int maxUnreliableFrameBytes = PredictionManager.GetMaxUnreliableFrameBytes(1023); + + Assert.That(maxUnreliableFrameBytes, Is.EqualTo(256191)); + Assert.That(PredictionManager.RequiresReliableRecovery( + false, + maxUnreliableFrameBytes, + maxUnreliableFrameBytes), Is.False); + Assert.That(PredictionManager.RequiresReliableRecovery( + false, + maxUnreliableFrameBytes + 1, + maxUnreliableFrameBytes), Is.True); + Assert.That(PredictionManager.RequiresReliableRecovery( + true, + 0, + maxUnreliableFrameBytes), Is.True); + } + } +} diff --git a/Assets/PurrDictionTests/EditorTests/PredictionFrameDeliveryTests.cs.meta b/Assets/PurrDictionTests/EditorTests/PredictionFrameDeliveryTests.cs.meta new file mode 100644 index 00000000..f963e325 --- /dev/null +++ b/Assets/PurrDictionTests/EditorTests/PredictionFrameDeliveryTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c0377295521b4402a68694245bdcb7fa diff --git a/Assets/PurrDictionTests/SampleSceneUnity.unity b/Assets/PurrDictionTests/SampleSceneUnity.unity index 100b7c01..c6999fe7 100644 --- a/Assets/PurrDictionTests/SampleSceneUnity.unity +++ b/Assets/PurrDictionTests/SampleSceneUnity.unity @@ -973,10 +973,9 @@ MonoBehaviour: _updateViewMode: 1 _builtInSystems: -1 _predictedPrefabs: {fileID: 11400000, guid: 3bc69f3464f686748883b06b540e96d4, type: 2} + _predictionLODProfile: {fileID: 11400000, guid: 93afd3489a7fc45419d11b759e9d4ec8, type: 2} _inputQueueSettings: extrapolateForMissing: 1 - minInputs: 1 - maxInputs: 4 _validateDeterministicData: 0 --- !u!4 &1558659896 Transform: diff --git a/Packages/manifest.json b/Packages/manifest.json index d96d9fe3..b9042445 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -46,7 +46,7 @@ "com.unity.modules.vr": "1.0.0", "com.unity.modules.wind": "1.0.0", "com.unity.modules.xr": "1.0.0", - "dev.purrnet.purrnet": "https://github.com/PurrNet/PurrNet.git?path=/Assets/PurrNet#dev" + "dev.purrnet.purrnet": "https://github.com/PurrNet/PurrNet.git?path=/Assets/PurrNet#NETWORK_LOD_REWORK" }, "scopedRegistries": [ { diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index e7ac4ed4..52b11940 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -239,7 +239,7 @@ "hash": "610157ad762084380380148ba8ce14e266a6da97" }, "dev.purrnet.purrnet": { - "version": "https://github.com/PurrNet/PurrNet.git?path=/Assets/PurrNet#dev", + "version": "https://github.com/PurrNet/PurrNet.git?path=/Assets/PurrNet#NETWORK_LOD_REWORK", "depth": 0, "source": "git", "dependencies": { @@ -247,7 +247,7 @@ "com.unity.nuget.newtonsoft-json": "3.2.1", "com.unity.collections": "2.6.2" }, - "hash": "f922b1ec7fed1f3a7a55d09db983dcc9740bf840" + "hash": "dc88df3b9f50ef42a05e81d3ff4725db89ba1290" }, "com.unity.modules.accessibility": { "version": "1.0.0",