Recursive AI

Fork and change a simulation replay

OneScope is a free, open-source viewer for deterministic libraries from Recursive AI. MIT or Apache-2.0.

Try changed inputs in OnePhysics or OnePath replays without changing the original.

Terminal
dotnet tool install --global RecursiveAI.OneScope.Tool

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

Terminal
onescope open run.oppr --headless \
  --run 'fork {"step":60}' \
  --run 'poke {"source":1,"poke":"impulse",
    "args":[72057598332895233,[0,5,0]],
    "step":true}'

A poke is a changed simulation input: this impulse advances the fork to step 61. run.oppr is in the download; entities lists its IDs, including ball 72057598332895233.

Unzip the example, then dotnet run.

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, runs the operations headless, checks the exit code and prints selected JSON fields. Headless still needs a working graphics renderer.

C#
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] forks, [2] pokes, [3] diffs, [4] saves the fork.
var results = Open(
    "fork {\"step\":60}",
    "poke {\"source\":1,\"poke\":\"impulse\",\"args\":[72057598332895233,[0,5,0]],\"step\":true}",
    "diff {\"source\":1}",
    "replay_save {\"source\":1,\"path\":\"branch.oppr\"}");
var poke = results[2];
var difference = results[3];
Console.WriteLine($"fork {poke.GetProperty("source")}: step {poke.GetProperty("step")}");
Console.WriteLine("input divergence: " + difference.GetProperty("divergence"));
Console.WriteLine("first differing field: " +
    difference.GetProperty("difference").GetProperty("field"));
Console.WriteLine($"branch.oppr: {results[4].GetProperty("steps")} steps");
 
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:

Text
fork 1: step 61
input divergence: 60
first differing field: linear velocity y
branch.oppr: 61 steps

Poking an opened recording instead of a fork fails with read_only.

Variations

Queue an input, then step explicitly

Terminal
onescope open run.oppr --headless \
  --run 'fork {"step":60}' \
  --run 'poke {"source":1,"poke":"impulse",
    "args":[72057598332895233,[0,5,0]]}' \
  --run 'step {"source":1}' \
  --run 'diff {"source":1}'

The resulting fork hash is 204914b381281821, the same as applying the poke with step:true.


The short answer

Fork at the simulation step you want to change, apply a declared poke to the fork, then simulate its next step. A poke is an input such as an impulse or a new velocity. The original file stays read-only. Use diff to see where the branch differs and replay_save to keep the changed replay.

The example changes one ball’s motion. It does not edit saved coordinates in place or overlay an animation on top of the old recording. The fork re-simulates from the original’s bits and records the new input in its own replay. Reopening that saved replay reproduces the fork’s hashes at every step, as described in the history documentation.

What is an example of what-if simulation?

The supplied recording drops a ball onto a static ground box. At step 60, the branch is identical to its parent. The example asks what happens if the ball receives an upward impulse at that point. It applies [0,5,0] to the sample’s ball entity, simulates one step, then compares the two states at step 61.

The difference finder reports linear velocity y. The fork hash is 204914b381281821; the parent’s hash at step 61 is d5df020044564a28. Those values describe this input and package version, not every replay that contains a ball. Use the entity IDs returned by entities when working with your own recording.

A poke without step:true is queued. It takes effect when the fork runs its next new step, not when you merely move the cursor through its existing recording. The variation makes that boundary explicit by separating poke and step. Use a source number deliberately: source 0 is the opened recording in this session, and source 1 is its first fork.

The fork begins with a copy of its parent’s recording up to the fork step. Its divergence is the first step whose recorded input differs. That can be one less than the first differing state: here the input divergence is 60 and the changed world is observed at 61. Treating the two numbers as interchangeable makes it easy to inspect the wrong frame.

What is the purpose of a model in what-if analysis?

In OneScope, the module supplies the simulation model and declares the edits it supports. The bundled physics module offers impulse, set velocity, set position and wake pokes on bodies. The viewer does not infer new operations from arbitrary inspector properties. Check a source’s pokes declarations, including their parameter names and types, before building a different experiment.

An impulse experiment tests a changed physical input under the same library model. It does not establish how a real object would behave outside that model, or compare a second version of the library automatically. Use a versions comparison when the question is a library change, rather than an input change.

You can fork a fork to explore another change, and seeking backward or forward replays each branch’s own recording. Save a branch with replay_save when you need the changed simulation as a separate replay file. A session save serves a different purpose: it preserves the workspace with sources, forks and views. Neither operation changes the parent recording. That separation lets you keep an untouched baseline while sharing a reproducible alternative.

All onescope recipes