Recursive AI

Compare library versions

OneScope is a free, open-source viewer for simulation recordings, with a desktop window or command-line control, from Recursive AI. MIT or Apache-2.0.

A module is the DLL that teaches OneScope to read a library’s replays, one per library version. OneScope debugs recorded simulation steps, such as physics; a fork is a branched copy of a recording.

  1. Unzip the example.
  2. Run dotnet run in its folder; it builds v1.dll and v2.dll and compares them. This zip path installs a private copy of the tool automatically; no global tool install is needed.
  3. To run the onescope command below directly, install the global tool, then run the command in the example folder.
Terminal
dotnet tool install --global RecursiveAI.OneScope.Tool

Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.

The examples use --headless, which renders offscreen but still needs a graphics device and driver. On Linux, a Vulkan software renderer such as lavapipe can supply it; ONEGAME_GPU_ADAPTER=software selects software rendering. Remove --headless to open the window.

Terminal
onescope open full.sim --headless \
  --module v1.dll \
  --run 'open {"path":"full.sim",
    "moduleAssembly":"v2.dll"}' \
  --run 'compare {"source":0,
    "other":1,"kind":"versions",
    "goto":"first"}'

full.sim and the module sources are in the download.

The command moves to step 1. Its difference result is:

JSON
{
  "entity": 2,
  "field": "Value",
  "source": "1",
  "other": "2"
}

The full program

This is a controlled example with two builds of a tiny counter module, not a comparison of two released physics libraries. Both builds use RecursiveAI.OneScope.Sdk 1.0.0. The second build defines CHANGED, which adds one to each child counter’s inspected value after step 0. Both module assemblies identify themselves as counter; OneScope loads them into separate contexts.

To compare two versions of your own library:

  1. Replace the demonstration Module.cs with your library’s viewer module implementation and reference it from the two module projects, adapting the launcher’s replay filename as needed. Keep the same module id in both builds.
  2. Give modules/v1/Module.csproj and modules/v2/Module.csproj package references to the respective released versions of your library. Keep each module’s dependencies in its own output folder; point the first --module and the second moduleAssembly at those output DLLs rather than the sample’s copied counter DLLs.

full.sim, the entire replay description, is:

JSON
{"entities":3,"bias":0}

The sample module interprets this as three child entities under one world root and three empty step inputs. Each step increments the counter. The root reports the entity count. Module.cs and both module project files are in the zip; Program.cs builds them and drives the tool. To build your own module project, install its SDK dependency:

Terminal
dotnet add package RecursiveAI.OneScope.Sdk
C#
using System.Diagnostics;
using System.Text.Json;
 
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);
 
foreach (string version in new[] { "v1", "v2" })
    Run("dotnet", "build", $"modules/{version}/Module.csproj", "--nologo", "-v:q");
string first = Path.GetFullPath("modules/v1/bin/Debug/net10.0/CounterModule.dll");
string second = Path.GetFullPath("modules/v2/bin/Debug/net10.0/CounterModule.dll");
File.Copy(first, "v1.dll", true);
File.Copy(second, "v2.dll", true);
var results = Open("full.sim", "open " + JsonSerializer.Serialize(new
    { path = "full.sim", moduleAssembly = second }),
    "compare {\"source\":0,\"other\":1,\"kind\":\"versions\",\"goto\":\"first\"}");
var comparison = results[^1];
Console.WriteLine($"Kind: {comparison.GetProperty("kind")}");
Console.WriteLine($"First divergent step: {comparison.GetProperty("agreement").GetProperty("firstDivergent")}");
Console.WriteLine($"Field: {comparison.GetProperty("difference").GetProperty("field")}");
Console.WriteLine($"Offset: {comparison.GetProperty("offset")}");
 
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;
}
JsonElement[] Open(string file, params string[] operations)
{
    var arguments = new List<string>
    {
        "open", file, "--headless", "--size", "640x480",
        "--artifacts", "output", "--module", first
    };
    foreach (string operation in operations)
        arguments.AddRange(["--run", operation]);
    return Run(tool, arguments.ToArray())
        .Split('\n', StringSplitOptions.RemoveEmptyEntries)
        .Select(line => JsonDocument.Parse(line).RootElement.Clone()).ToArray();
}

dotnet run prints:

Text
Kind: versions
First divergent step: 1
Field: Value
Offset: 0

If a file cannot be opened by a loaded module, the operation returns an error instead of a comparison; inspect the error code and message before reading comparison fields.

Variations

To set the alignment explicitly and return only one differing row, replace the final compare operation above with:

Text
compare {"source":0,"other":1,
  "kind":"versions","offset":0,
  "limit":1,"goto":"first"}

This is the operation string passed to --run, not a standalone shell command. In this example rowCount is 3, the returned rows contains one entry, and truncated is true.

The short answer

Open the same simulation replay twice, choosing each module assembly with moduleAssembly, then run compare with kind:"versions". OneScope loads module copies into separate load contexts. It compares the worlds through the rows each module exposes to the inspector rather than handing one module a world object created by the other.

This is useful when the format is readable by both versions but a change in the simulation may alter its results. Each module needs to run the library version you intend to test. The demonstration here changes a counter module’s implementation so that the comparison has a known difference. It is not evidence about compatibility between any two published library releases.

How do I compare simulation replays across library versions?

Keep each module and its version-specific dependencies in a separate folder. The module id must identify the same module on both sides. Open the first recording with one assembly and the second with the other. Explicitly naming moduleAssembly also lets you select that module when a bundled module would otherwise accept the file.

The sample compares identical input data. At step 0, both builds report the same child values. From step 1 onward, the changed build reports one more. The result identifies entity 2 and its Value field as the first difference. The inspector is the comparison surface: a value a module never exposes as a row cannot appear as a differing inspector row.

The result also contains world hashes and an agreement history. Read agreement.basis rather than assuming every kind of comparison has the same meaning. In this version comparison it is world hash. The first and last divergent steps summarize the scanned interval; rows describes the selected step. A first differing row and a first divergent simulation step answer different questions.

How do I align replay steps when versions start at different numbers?

The version pair can align recordings whose start step differs but whose step counts match. If one numbers its first state 0 and the other 1, the comparison can account for that difference. Steps in the result use the first source’s numbering, and otherStep gives the second source’s step. A row that merely reports each world’s own step number is not treated as a difference.

Pass offset to set the alignment explicitly. First check the recordings’ start and end ranges and what a step means in each module. The shared timeline cursor still puts sources at one step number; the version comparison’s alignment describes how the pair is compared.

Use goto:"first" to move to the first divergent step, or request the next, previous or last one. moved tells you whether a destination was found. Bound the returned inspector rows with limit; if truncated is true, rowCount is the full count, not the size of the returned array. The private agreement scan has a documented ceiling of 100,000 steps, so check agreement.complete before treating it as a scan of the whole recording. See comparison operations.

All onescope recipes