Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
9 changes: 7 additions & 2 deletions .github/workflows/prediction-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
mode: [server, host]

env:
CLIENT_COUNT: ${{ inputs.clientCount || '3' }}
CLIENT_COUNT: ${{ inputs.clientCount || '5' }}
CONNECT_DELAY: ${{ inputs.connectionDelaySeconds || '1' }}
PROFILE_TIMEOUT_SECONDS: ${{ inputs.profileTimeoutSeconds || '420' }}

Expand Down Expand Up @@ -137,6 +137,7 @@ jobs:
echo "::group::Launching $MODE process ($PROFILE)"
"${RUN[@]}" "$BIN" -batchmode -nographics \
-role "$MODE" -count "$TOTAL_COUNT" -port "$PORT" \
-connectTimeout 180 \
"${LATENCY_ARGS[@]}" \
-results "$RESULTS_DIR/$MODE.json" \
-logFile "$LOGS_DIR/$MODE.log" &
Expand All @@ -157,14 +158,18 @@ jobs:
for i in $(seq 1 "$EXTERNAL_CLIENTS"); do
echo "::group::Launching client $i ($PROFILE)"
"${RUN[@]}" "$BIN" -batchmode -nographics \
-role client -port "$PORT" \
-role client -count "$TOTAL_COUNT" -port "$PORT" \
-connectTimeout 180 \
"${LATENCY_ARGS[@]}" \
-results "$RESULTS_DIR/client-$i.json" \
-logFile "$LOGS_DIR/client-$i.log" &
PID=$!
CLIENT_PIDS+=("$PID")
echo "Client $i PID: $PID"
echo "::endgroup::"
if [ "$i" -lt "$EXTERNAL_CLIENTS" ]; then
sleep 1
fi
done

ALL_PIDS=("$SERVER_PID" "${CLIENT_PIDS[@]}")
Expand Down
139 changes: 136 additions & 3 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 @@ -417,7 +486,7 @@ MonoBehaviour:
m_EditorClassIdentifier:
_networkManager: {fileID: 100002}
_predictionManager: {fileID: 110002}
_connectionTimeout: 30
_connectionTimeout: 180
_timeBetweenScenarios: 0.1
_editorRole: 2
_editorExpectedConnections: 2
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
8 changes: 6 additions & 2 deletions Assets/PredictionTests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ Multi-process end-to-end tests for the prediction pipeline, modeled after PurrNe
| `StaticModuleReuseScenario` | Tick-pooled predicted identities rerun static module setup on reuse and reset list-backed module state |
| `DynamicModuleShapeScenario` | Reused predicted identities start with no stale dynamic modules, then add/remove different dynamic module shapes |
| `ProjectileChainReconnectScenario` | A client reconnects during an active projectile/VFX burst, stressing full-sync while dynamic modules and pooled effects churn |
| `ServerRelayScenario` | ServerRelay bodies remain kinematic on clients and only execute verified ticks |
| `SoftCorrectionScenario` | 3D soft-corrected bodies tolerate local divergence and converge without replay simulation |
| `SoftCorrection2DScenario` | 2D soft-corrected bodies follow the same convergence and replay guarantees |
| `OwnedRelayScenario` | PredictedIfOwned resolves to live prediction for the owner and server relay for non-owners |

All scenarios fail on unexpected Unity `Error`, `Assert`, or `Exception` logs during the active scenario. They run with simulated latency (40–80ms by default, configurable on the `Bootstrap` object or via `-latencyMin`/`-latencyMax`; `-latencyMax 0` disables) so rollback depth resembles real conditions instead of a clean localhost.

Expand All @@ -30,8 +34,8 @@ Build `StandaloneLinux64`/`StandaloneWindows64` with this scene first, then:

```
PurrDictionTests -batchmode -nographics -role host -count 3 -results host.json -logFile host.log
PurrDictionTests -batchmode -nographics -role client -results client-1.json -logFile client-1.log
PurrDictionTests -batchmode -nographics -role client -results client-2.json -logFile client-2.log
PurrDictionTests -batchmode -nographics -role client -count 3 -results client-1.json -logFile client-1.log
PurrDictionTests -batchmode -nographics -role client -count 3 -results client-2.json -logFile client-2.log
```

Optional args: `-port`, `-serverHost`, `-connectTimeout`. Exit code is non-zero if any scenario fails. CI runs this via `.github/workflows/prediction-tests.yml` (server and host matrix, IL2CPP).
64 changes: 43 additions & 21 deletions Assets/PredictionTests/Scripts/PredictionBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ public class PredictionBootstrap : Scenario
{
private const int MaxUnexpectedLogsPerScenario = 8;
private const int MaxUnexpectedLogLength = 700;
private const float ScenarioStartLeadSeconds = 2f;

[SerializeField] private NetworkManager _networkManager;
[SerializeField] private PredictionManager _predictionManager;
[SerializeField] private float _connectionTimeout = 30f;
[SerializeField] private float _connectionTimeout = 180f;
[SerializeField] private float _timeBetweenScenarios = 0.1f;

[Header("Editor overrides (used when -role / -count are absent)")]
Expand Down Expand Up @@ -48,6 +49,8 @@ public class PredictionBootstrap : Scenario

private void Awake()
{
ScenarioSequencer.Reset();

var prefabs = ScriptableObject.CreateInstance<PredictedPrefabs>();
prefabs.autoGenerate = false;
prefabs.searchAllIfNoFolder = false;
Expand Down Expand Up @@ -143,27 +146,24 @@ private void LoadArgs()
return;
}

if (_role != NetworkRole.Client)
if (CommandLineUtils.TryGetArgument("-count", out var countString))
{
if (CommandLineUtils.TryGetArgument("-count", out var countString))
if (!int.TryParse(countString, out _expectedConnections) || _expectedConnections < 1)
{
if (!int.TryParse(countString, out _expectedConnections))
{
Debug.LogError($"Could not parse -count value '{countString}'");
Application.Quit(-1);
return;
}
Debug.LogError($"Could not parse positive -count value '{countString}'");
Application.Quit(-1);
return;
}
else
{
}
else
{
#if UNITY_EDITOR
_expectedConnections = _editorExpectedConnections;
_expectedConnections = _editorExpectedConnections;
#else
Debug.LogError("Expected -count argument");
Application.Quit(-1);
return;
Debug.LogError("Expected -count argument");
Application.Quit(-1);
return;
#endif
}
}

CommandLineUtils.TryGetArgument("-results", out _resultsPath);
Expand Down Expand Up @@ -334,16 +334,25 @@ private async void RunScenarios()
if (_scenarios.Length > 0)
{
if (await RunOne(0, ctx))
{
anyFailed = true;
if (ctx.isServer)
ScenarioSequencer.IssueSequenceComplete();
return;
}
}

if (ctx.isServer)
{
for (var i = 1; i < _scenarios.Length; i++)
{
ScenarioSequencer.IssueStart(i);
ScenarioSequencer.IssuePrepare(i);
await ScenarioSequencer.WaitForAllReady(ctx, i);

var startTick = GetScenarioStartTick();
ScenarioSequencer.IssueStart(i, startTick);

if (await RunOne(i, ctx))
if (await RunOne(i, ctx, startTick))
anyFailed = true;

await ScenarioSequencer.WaitForAllAcks(ctx, i);
Expand All @@ -361,12 +370,18 @@ private async void RunScenarios()
{
for (var i = 1; i < _scenarios.Length; i++)
{
await ScenarioSequencer.WaitForStart(ctx, i);
await ScenarioSequencer.WaitForPrepare(ctx, i);

if (ScenarioSequencer.SequenceComplete)
break;

if (await RunOne(i, ctx))
ScenarioSequencer.AckLocalReady(ctx, i);
var startTick = await ScenarioSequencer.WaitForStart(ctx, i);

if (ScenarioSequencer.SequenceComplete)
break;

if (await RunOne(i, ctx, startTick))
anyFailed = true;

ScenarioSequencer.AckLocalDone(ctx, i);
Expand Down Expand Up @@ -427,14 +442,21 @@ private void Update()
_activePerformanceSampler?.SampleFrame(_predictionManager);
}

private async UniTask<bool> RunOne(int i, ScenarioContext ctx)
private ulong GetScenarioStartTick()
{
var leadTicks = (ulong)Mathf.Max(1, Mathf.CeilToInt(_predictionManager.tickRate * ScenarioStartLeadSeconds));
return _predictionManager.time.tick + leadTicks;
}

private async UniTask<bool> RunOne(int i, ScenarioContext ctx, ulong scheduledStartTick = 0)
{
_dataSent = 0;
_dataReceived = 0;
_unexpectedLogs.Clear();
_activeScenarioIndex = i;

var scenario = _scenarios[i];
scenario.PrepareRun(ctx, scheduledStartTick);
Debug.Log($"[PredictionTests] {_role} starting scenario {i}: {scenario.GetType().Name}");

long startTick = DateTime.Now.Ticks;
Expand Down
2 changes: 2 additions & 0 deletions Assets/PredictionTests/Scripts/Scenario.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ public abstract class Scenario : MonoBehaviour
{
public virtual void Setup(ScenarioContext ctx, NetworkManager manager) { }

public virtual void PrepareRun(ScenarioContext ctx, ulong startTick) { }

public abstract UniTask<ScenarioResult> RunScenario(ScenarioContext ctx);

/// <summary>
Expand Down
Loading
Loading