Compare OnePhysics or OnePath replays; the C# example drives OneScope’s command-line tool.
dotnet tool install --global RecursiveAI.OneScope.ToolRequires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.
onescope open run.oppr --headless \ --run 'open {"path":"other.oppr"}' \ --run 'compare { "source":0,"other":1, "goto":"first"}'
run.oppr and the deliberately changed other.oppr are in the download.
Unzip the example, then dotnet run.
The command’s comparison result (the two opening metadata results are omitted):
{ "source": 0, "other": 1, "kind": "fork", "map": "identity", "step": 61, "otherStep": 61, "offset": 0, "moved": true, "from": 0, "to": 61, "equal": false, "hash": "d5df020044564a28", "otherHash": "204914b381281821", "divergence": 60, "agreement": { "basis": "world hash", "scannedThrough": 61, "complete": true, "divergentSteps": 1, "firstDivergent": 61, "lastDivergent": 61, "nextDivergent": null, "previousDivergent": null, "divergentHere": true, "runs": [ [ 61, 61 ] ], "runsTruncated": false }, "difference": { "entity": 72057598332895233, "field": "linear velocity y", "source": "0 [0x00000000]", "other": "6.2026978 [0x40c67c80]" }, "shared": 3, "onlySource": 0, "onlyOther": 0, "rowCount": 5, "truncated": false, "rows": [ { "entity": 1, "field": "Hash", "source": "d5df020044564a28", "other": "204914b381281821" }, { "entity": 72057598332895233, "field": "Position", "source": "(0, -4.35065E-05, 0)", "other": "(0, 0.10435855, 0)" }, { "entity": 72057598332895233, "field": "Linear velocity", "source": "(0, 0, 0)", "other": "(0, 6.2026978, 0)" }, { "entity": 72057598332895233, "field": "Sleep time", "source": "0.11666666716337204", "other": "0" }, { "entity": 72057598332895233, "field": "External impulse", "source": "(0, 0, 0)", "other": "(0, 5, 0)" } ] }
The full program
run.oppr is in the download: a 2D recording of one ball falling onto a static ground box, with 120 simulation steps. The ball’s entity ID is 72057598332895233. The ground is 20 × 1 meters at (0, −1); the ball starts at (0, 4), with radius 0.5 meters. It was recorded with OnePhysics 1.0.0 at 1/60 second per step.
The C# launcher installs OneScope.Tool 1.0.0 into its build folder and compares the two saved recordings through the CLI. other.oppr was created beforehand by forking at step 60 and applying an upward impulse; the program does not create the difference itself.
OnePhysics and OnePath replay readers are bundled. A module is a .NET assembly that teaches OneScope to read another format, simulate its steps and expose entities and properties. Load one with --module. To record your own OnePhysics .oppr, wrap simulation steps in world.StartRecording(stream), as shown in Record a replay.
On a server with no display, --headless renders offscreen, but still needs a working graphics device and driver. On Linux, a Vulkan software renderer such as lavapipe can supply it; set ONEGAME_GPU_ADAPTER=software to select software rendering. No desktop window is required. The capture documentation describes renderer selection.
using System.Diagnostics; using System.Text.Json; // Install the published command-line tool into the build folder. string toolDir = Path.GetFullPath("obj/onescope-tool"); string tool = Path.Combine(toolDir, OperatingSystem.IsWindows() ? "onescope.exe" : "onescope"); if (!File.Exists(tool)) Run("dotnet", "tool", "install", "RecursiveAI.OneScope.Tool", "--version", "1.0.0", "--tool-path", toolDir); // Results: [0] opens run.oppr, [1] opens other.oppr, [2] compares them. var results = Open( "open {\"path\":\"other.oppr\"}", "compare {\"source\":0,\"other\":1,\"goto\":\"first\",\"limit\":2}"); var comparison = results[^1]; Console.WriteLine("first divergent step: " + comparison.GetProperty("agreement").GetProperty("firstDivergent")); Console.WriteLine("field: " + comparison.GetProperty("difference").GetProperty("field")); Console.WriteLine($"rows: {comparison.GetProperty("rowCount")} " + $"(shown: {comparison.GetProperty("rows").GetArrayLength()})"); JsonElement[] Open(params string[] operations) { var arguments = new List<string> { "open", "run.oppr", "--headless", "--size", "640x480" }; foreach (string operation in operations) arguments.AddRange(["--run", operation]); string output = Run(tool, arguments.ToArray()); return output.Split('\n', StringSplitOptions.RemoveEmptyEntries) .Select(line => JsonDocument.Parse(line).RootElement.Clone()) .ToArray(); } string Run(string command, params string[] arguments) { var start = new ProcessStartInfo(command) { RedirectStandardOutput = true, RedirectStandardError = true }; foreach (string argument in arguments) start.ArgumentList.Add(argument); using var process = Process.Start(start)!; var output = process.StandardOutput.ReadToEndAsync(); var errors = process.StandardError.ReadToEndAsync(); process.WaitForExit(); if (process.ExitCode != 0) throw new InvalidOperationException(output.Result + errors.Result); return output.Result; }
dotnet run prints:
first divergent step: 61 field: linear velocity y rows: 5 (shown: 2)
If an operation fails, the CLI returns an ok:false error result and exits with code 1.
Variations
Compare only shared entities
onescope open run.oppr --headless \ --run 'open {"path":"other.oppr"}' \ --run 'diff_shared { "source":0,"other":1,"step":61}'
The sample reports equal:false, two shared bodies and a differing Position field. World roots are excluded from shared-entity comparisons.
Related recipes
- View a physics replay in C#
- Render replay frames to PNG headless
- Fork and change a simulation replay
The short answer
Use OneScope’s compare operation to locate disagreement, then read the differing entity and field. Start with a recording and a compatible module for each run. goto:first moves to the first divergent step found in the shared time range. The result separates a state hash mismatch from the inspector rows that explain how the visible state differs.
The download includes a controlled desync recorded in advance: other.oppr is a fork of the falling-ball recording at step 60 with an upward impulse applied before the next step. The C# program compares the saved files; it does not fork or poke them. The first differing state is step 61. That is an example of different inputs producing different states, not evidence that the physics library is nondeterministic. other.oppr is that changed branch saved as a separate recording.
How do I debug a determinism desync in C#?
Preserve the replay and identify the module assembly used to play it. OneScope reports the module’s world hash at each step. Its compare panel can show the first, previous, next or last divergent step and list differing fields at the cursor. The command-line operation returns the same information as JSON.
For a fork and its parent, the module’s own difference finder supplies the first differing entity and field. In this sample it reports linear velocity y. The inspector rows also expose position and velocity changes. rowCount is the total number of differing rows; limit controls how many are returned. A truncated list is not a claim that the other rows agree.
To investigate a library change, open the same replay twice with moduleAssembly naming each version’s module DLL. Each copy loads in its own context. The shell recognizes two copies of one module ID as a versions pair, and compares their inspector rows rather than handing a world object from one module copy to the other. You must supply compatible module assemblies for those versions; the download does not bundle a second physics release.
The versions comparison can align starts when the modules number the same replay differently. An explicit offset controls that alignment. Check this before treating an off-by-one step difference as a state error. The compare reference describes alignment and the returned agreement range.
What is deterministic simulation testing?
Deterministic simulation testing checks whether the same initial state and recorded inputs produce the same state at each simulation step. A screenshot comparison answers a different question: whether a renderer produced the same image. OneScope’s agreement scan uses world hashes, except for shared-entity comparisons, which compare the shared entities instead.
The scan is bounded: the documented maximum is 100,000 shared steps, or 10,000 for a shared-entity pair. Inspect scannedThrough and complete rather than assuming a result covers an arbitrarily long recording. Matching the scanned range does not establish agreement beyond it.
What causes desync in games?
Different inputs or initial states, floating-point differences, uninitialized state and unstable collection iteration order can cause desyncs (desync debugging guide).
For this example the cause is known: the branch receives an impulse that the original never receives. Separate that case from the question “do the same recorded inputs behave differently under two library versions?” A fork’s divergence marks the first step whose recorded input differs. The worlds can still agree at that step and differ only after the next simulation step, as steps 60 and 61 demonstrate here.
If the worlds deliberately contain different entity sets, use diff_shared instead of treating every missing body as a failure. It skips roots, compares shared entities and counts entities present on only one side. Identity mapping pairs equal IDs; link mapping uses declared links. This narrows the question to the state that both sources actually contain. It does not compare state the module never exposes, or diagnose the line of simulation code that wrote a differing value.