Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
77cf833
feat: prediction policies
BlenMiner Jul 8, 2026
9db1763
fix: avoid breaking deterministic identities with input that is a dif…
BlenMiner Jul 8, 2026
32a316b
GC pass
BlenMiner Jul 9, 2026
d4cf157
fix: harden prediction policy lifecycle
BlenMiner Jul 9, 2026
6aa4125
fix: synchronize prediction scenario clients
BlenMiner Jul 9, 2026
3ad5578
chore: remove unrelated scene churn
BlenMiner Jul 9, 2026
701010e
fix: synchronize scenario start ticks
BlenMiner Jul 9, 2026
d292859
fix: gate prediction scenarios on client readiness
BlenMiner Jul 9, 2026
2a03d9d
test: allow loaded clients to reach scenario tick
BlenMiner Jul 10, 2026
2d34d3d
test: assert verified bounce delivery
BlenMiner Jul 10, 2026
fc65950
dynamic policies
BlenMiner Jul 10, 2026
f475ad4
fix: preserve runtime prediction policy state
BlenMiner Jul 10, 2026
d07cc4d
ci: wait for prediction host readiness
BlenMiner Jul 10, 2026
01c7e39
ci: capture Unity temp allocation stacks
BlenMiner Jul 10, 2026
23aadf0
ci: make Unity allocation diagnostics opt-in
BlenMiner Jul 10, 2026
2badeef
fix: pooled policy/physics setup ordering inconsistencies
BlenMiner Jul 11, 2026
ac47bff
resolve policy before preserving root pose
BlenMiner Jul 12, 2026
b6aad48
more dynamic policy changes fixes
BlenMiner Jul 12, 2026
a9c3d44
stability
BlenMiner Jul 12, 2026
422c5e2
Merge branch 'dev' into PREDICTION_POLICIES
BlenMiner Jul 13, 2026
bb24841
enhance policy inspector and fix some timing issues
BlenMiner Jul 14, 2026
0512b96
tests race conditions
BlenMiner Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 135 additions & 2 deletions Assets/PredictionTests/Bootstrap.unity
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,10 @@ GameObject:
- component: {fileID: 120003}
- component: {fileID: 120004}
- component: {fileID: 120005}
- component: {fileID: 120006}
- component: {fileID: 120007}
- component: {fileID: 120008}
- component: {fileID: 120009}
m_Layer: 0
m_Name: PredictedSystems
m_TagString: Untagged
Expand Down Expand Up @@ -359,10 +363,71 @@ MonoBehaviour:
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e3f8a1c20d934b6e8a51f0c4d7b2101a, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Name:
m_EditorClassIdentifier:
_spawnDelay:
rawValue: 1077936128
--- !u!114 &120006
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 120000}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e3f8a1c20d934b6e8a51f0c4d7b21024, type: 3}
m_Name:
m_EditorClassIdentifier:
_spawnDelay:
rawValue: 1082130432
_spawnPosition: {x: 20, y: 5, z: 0}
--- !u!114 &120007
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 120000}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e3f8a1c20d934b6e8a51f0c4d7b21024, type: 3}
m_Name:
m_EditorClassIdentifier:
_spawnDelay:
rawValue: 1084227584
_spawnPosition: {x: -20, y: 3, z: 0}
--- !u!114 &120008
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 120000}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e3f8a1c20d934b6e8a51f0c4d7b21024, type: 3}
m_Name:
m_EditorClassIdentifier:
_spawnDelay:
rawValue: 1086324736
_spawnPosition: {x: 40, y: 3, z: 0}
--- !u!114 &120009
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 120000}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e3f8a1c20d934b6e8a51f0c4d7b21024, type: 3}
m_Name:
m_EditorClassIdentifier:
_spawnDelay:
rawValue: 1084227584
_spawnPosition: {x: 0, y: 8, z: 0}
_ownByFirstPlayer: 1
--- !u!1 &130000
GameObject:
m_ObjectHideFlags: 0
Expand All @@ -381,6 +446,10 @@ GameObject:
- component: {fileID: 130008}
- component: {fileID: 130009}
- component: {fileID: 130010}
- component: {fileID: 130011}
- component: {fileID: 130012}
- component: {fileID: 130013}
- component: {fileID: 130014}
m_Layer: 0
m_Name: Bootstrap
m_TagString: Untagged
Expand Down Expand Up @@ -554,6 +623,70 @@ MonoBehaviour:
_reconnectTimeout: 30
_stayDisconnectedSeconds: 1
_settleSeconds: 3
--- !u!114 &130011
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 130000}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e3f8a1c20d934b6e8a51f0c4d7b21027, type: 3}
m_Name:
m_EditorClassIdentifier:
_rig: {fileID: 120006}
_restSeconds: 3
_timeout: 90
--- !u!114 &130012
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 130000}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e3f8a1c20d934b6e8a51f0c4d7b21028, type: 3}
m_Name:
m_EditorClassIdentifier:
_rig: {fileID: 120007}
_minDivergence: 0.3
_convergedDistance: 0.15
_settleSeconds: 2
_timeout: 90
--- !u!114 &130013
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 130000}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e3f8a1c20d934b6e8a51f0c4d7b2102a, type: 3}
m_Name:
m_EditorClassIdentifier:
_rig: {fileID: 120008}
_minDivergence: 0.3
_convergedDistance: 0.15
_settleSeconds: 2
_timeout: 90
--- !u!114 &130014
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 130000}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e3f8a1c20d934b6e8a51f0c4d7b2102c, type: 3}
m_Name:
m_EditorClassIdentifier:
_rig: {fileID: 120009}
_restSeconds: 3
_timeout: 90
--- !u!1660057539 &9223372036854775807
SceneRoots:
m_ObjectHideFlags: 0
Expand Down
68 changes: 68 additions & 0 deletions Assets/PredictionTests/Scripts/Scenarios/OwnedRelayProbe.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Collections.Generic;
using PurrNet.Prediction;
using UnityEngine;

/// <summary>
/// Sits on a PredictedIfOwned rigidbody. On the client that owns it the identity resolves to
/// FullPrediction (simulates live, unverified ticks); on every other client it resolves to
/// ServerRelay (kinematic, only verified ticks). The probe records which happened so the
/// scenario can assert the split.
/// </summary>
public class OwnedRelayProbe : PredictedIdentity<OwnedRelayProbe.ProbeState>
{
public struct ProbeState : IPredictedData<ProbeState>
{
public void Dispose() { }
}

public static readonly List<OwnedRelayProbe> instances = new();

public static int livePredictedTicks { get; private set; }
public static int unverifiedRelayTicks { get; private set; }

public static void ResetCounters()
{
livePredictedTicks = 0;
unverifiedRelayTicks = 0;
}

private PredictedTransform _predictedTransform;
private Rigidbody _body;

public Vector3 currentPosition => _predictedTransform.currentState.unityPosition;
public bool isKinematicBody => _body.isKinematic;

protected override PredictionPolicy ResolvePredictionPolicy() => PredictionPolicy.PredictedIfOwned;

private void Awake()
{
_predictedTransform = GetComponent<PredictedTransform>();
_body = GetComponent<Rigidbody>();
}

protected override void LateAwake()
{
instances.Add(this);
}

protected override void Destroyed()
{
instances.Remove(this);
}

protected override void Simulate(ref ProbeState state, float delta)
{
if (predictionManager.cachedIsServer)
return;

// A live tick outside reconciliation: only an owned (FullPrediction) body reaches this,
// since a relayed body simulates exclusively on verified reconcile frames.
if (!predictionManager.isReplaying && !predictionManager.isVerified)
{
if (IsOwner())
livePredictedTicks++;
else
unverifiedRelayTicks++;
}
}
}
11 changes: 11 additions & 0 deletions Assets/PredictionTests/Scripts/Scenarios/OwnedRelayProbe.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

113 changes: 113 additions & 0 deletions Assets/PredictionTests/Scripts/Scenarios/OwnedRelayScenario.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System;
using Cysharp.Threading.Tasks;
using PurrNet;
using PurrNet.Prediction;
using UnityEngine;

public class OwnedRelayScenario : Scenario
{
[SerializeField] private PolicyBallRig _rig;
[SerializeField] private float _restSeconds = 3f;
[SerializeField] private float _timeout = 90f;

private const int DigestChannel = 1100;

private int _prefabId;

public override void Setup(ScenarioContext ctx, NetworkManager manager)
{
var floor = new GameObject("OwnedRelayFloor");
var floorCollider = floor.AddComponent<BoxCollider>();
floorCollider.size = new Vector3(12f, 1f, 12f);
floor.transform.position = new Vector3(_rig.spawnPosition.x, -0.5f, _rig.spawnPosition.z);

var ball = new GameObject("OwnedRelayBall");
ball.SetActive(false);
var rb = ball.AddComponent<Rigidbody>();
rb.collisionDetectionMode = CollisionDetectionMode.ContinuousDynamic;
ball.AddComponent<SphereCollider>();
ball.AddComponent<PredictedTransform>();
var predictedRb = ball.AddComponent<PredictedRigidbody>();
predictedRb.configuredPredictionPolicy = PredictionPolicy.PredictedIfOwned;
ball.AddComponent<OwnedRelayProbe>();
DontDestroyOnLoad(ball);

_prefabId = ctx.predictionManager.predictedPrefabs.prefabs.Count;
PredictionTestUtils.RegisterPrefab(ctx, ball);
_rig.ballPrefab = ball;
_rig.requiredPlayers = ctx.expectedConnections;
OwnedRelayProbe.ResetCounters();
}

public override async UniTask<ScenarioResult> RunScenario(ScenarioContext ctx)
{
try
{
await UniTaskUtils.WaitWithTimeout(
() => _rig.hasSpawned && OwnedRelayProbe.instances.Count > 0,
_timeout,
ctx.cancellationToken);
}
catch (TimeoutException)
{
return ScenarioResult.Fail($"owned/relay ball never spawned: spawned={_rig.hasSpawned}");
}

var probe = OwnedRelayProbe.instances[0];
var lastPosition = probe.currentPosition;
double lastMove = Time.realtimeSinceStartupAsDouble;

try
{
await UniTaskUtils.WaitWithTimeout(
() =>
{
var now = Time.realtimeSinceStartupAsDouble;
var position = probe.currentPosition;
if ((position - lastPosition).sqrMagnitude > 0.0001f)
{
lastPosition = position;
lastMove = now;
}
return now - lastMove >= _restSeconds;
},
_timeout,
ctx.cancellationToken);
}
catch (TimeoutException)
{
return ScenarioResult.Fail($"owned/relay ball never settled: pos={probe.currentPosition}");
}

if (ctx.role == NetworkRole.Client)
{
bool owns = probe.IsOwner();
if (owns)
{
if (OwnedRelayProbe.livePredictedTicks < 50)
return ScenarioResult.Fail($"owner did not predict live ticks ({OwnedRelayProbe.livePredictedTicks})");
if (probe.isKinematicBody)
return ScenarioResult.Fail("owned body is kinematic; should be dynamically predicted");
}
else
{
if (OwnedRelayProbe.unverifiedRelayTicks > 0)
return ScenarioResult.Fail($"non-owner simulated {OwnedRelayProbe.unverifiedRelayTicks} unverified ticks; should relay only");
if (!probe.isKinematicBody)
return ScenarioResult.Fail("relayed body is not kinematic on the non-owning client");
}
}

var rest = probe.currentPosition;
var digest = $"count={PredictionTestUtils.CountInstances(ctx.predictionManager, _prefabId)};" +
$"pos={Quantize(rest.x):F1},{Quantize(rest.y):F1},{Quantize(rest.z):F1}";

return await DigestExchange.Compare(ctx, DigestChannel, digest, 30f);
}

private static float Quantize(float value)
{
value = Mathf.Round(value * 10f) * 0.1f;
return value == 0f ? 0f : value;
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading