A replay is a recorded run. Its module—the C# code that reads it—can declare that an entity, an object in the simulation, continues in another recording.
dotnet tool install --global RecursiveAI.OneScope.ToolRequires .NET 10 (net10.0); .NET 9 and earlier aren’t supported. The window and the no-window (headless) example both need a working graphics renderer; without one, OneScope cannot start.
Download and unzip the example first.
Window path — build the module, then open the replay:
dotnet build -o bin/demo onescope open sender.replay \ --module bin/demo/Example.dll \ --run 'open {"path":"receiver.replay"}' \ --run 'inspect {"entity":1}' \ --run 'track {"view":0,"on":true}' \ --run 'seek {"step":5}'
Moves selection and the tracked camera to source 1 at step 5.
Console path — run the C# example without a window:
Unzip the example, then dotnet run.
The full program
The example declares one object leaving the sender at step 5 and arriving in the receiver. It selects the object, enables camera tracking and verifies that seeking backward reverses the selection transfer.
The C# launcher installs OneScope.Tool 1.0.0 in its build folder and loads the module from its own assembly. The recordings and module are included example code, not a bundled OneScope file format.
The included eight-byte inputs are 43 4E 54 52 01 00 00 00 (sender) and 43 4E 54 52 02 00 00 00 (receiver): CNTR plus a file-role number. The reader creates ten simulation steps.
using System.Buffers; using System.Buffers.Binary; using System.Numerics; using System.Diagnostics; using System.Text.Json; using OneScope.Sdk; // CNTR + little-endian role: 0 stays, 1 sends, 2 receives; ten empty steps. File.WriteAllBytes("sender.replay", [67, 78, 84, 82, 1, 0, 0, 0]); File.WriteAllBytes("receiver.replay", [67, 78, 84, 82, 2, 0, 0, 0]); string assembly = typeof(CounterModule).Assembly.Location; 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); var r = Open("open {\"path\":\"receiver.replay\"}", "inspect {\"source\":0,\"entity\":1}", "track {\"view\":0,\"on\":true}", "seek {\"step\":5}", "selection", "links", "view", "seek {\"step\":4}", "selection"); Console.WriteLine($"At step 5: source {r[5].GetProperty("source")}, entity {r[5].GetProperty("entity")}"); Console.WriteLine($"At step 4: source {r[9].GetProperty("source")}, entity {r[9].GetProperty("entity")}"); Console.WriteLine(r[7].GetProperty("views")[0]); JsonElement[] Open(params string[] operations) { var args = new List<string> { "open", "sender.replay", "--module", assembly, "--headless", "--size", "640x480" }; foreach (string operation in operations) args.AddRange(["--run", operation]); return Run(tool, args.ToArray()).Split('\n', StringSplitOptions.RemoveEmptyEntries) .Select(s => JsonDocument.Parse(s).RootElement.Clone()).ToArray(); } string Run(string command, params string[] arguments) { var start = new ProcessStartInfo(command) { RedirectStandardOutput = true, RedirectStandardError = true }; foreach (string arg in arguments) start.ArgumentList.Add(arg); 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; } public sealed class CounterModule : IScopeModule { public string Id => "counter"; public string Name => "Counter"; public IScopeSource Open(ReadOnlyMemory<byte> data, string name) { if (data.Length != 8 || !data.Span[..4].SequenceEqual("CNTR"u8)) throw new ScopeFormatException(name, "Expected CNTR and an int32 role (8 bytes)."); int steps = 10; int role = data.Span[4]; if (role < 0 || role > 2 || data.Span[5..].IndexOfAnyExcept((byte)0) >= 0) throw new ScopeFormatException(name, "Expected role 0..2 and zero padding at offset 4."); var inputs = new ReadOnlyMemory<byte>[steps]; return new CounterSource(name, Recording.Create(ReadOnlyMemory<byte>.Empty, inputs), role); } } public sealed class CounterSource(string name, Recording recording, int role) : IScopeSource { public SourceInfo Info { get; } = new() { ModuleId = "counter", Name = name, Dimensions = 2, Unit = LengthUnit.Metre, LinkKey = role != 2 ? "sender" : "receiver", Fields = [DemoField.Info], Axes = [new AxisInfo(AxisIds.Time, "Time", 0)], EntityKinds = [new EntityKindInfo("counter", "Counter")], Overlays = [new OverlayInfo(0, "Counter", true)] }; public Recording Recording { get; } = recording; public IScopeWorld CreateWorld() => new CounterWorld(role); public IScopeSource WithRecording(Recording other) => new CounterSource(name, other, role); public void WriteReplay(IBufferWriter<byte> writer) { Span<byte> bytes = writer.GetSpan(8)[..8]; "CNTR"u8.CopyTo(bytes); BinaryPrimitives.WriteInt32LittleEndian(bytes[4..], role); writer.Advance(8); } } public sealed class CounterWorld(int role) : IScopeWorld { public long Step { get; private set; } public ulong Hash() => (ulong)Step; // This toy's entire state is its counter. public void Advance(ReadOnlySpan<byte> input, TimelineSink sink) { if (!input.IsEmpty) throw new ScopeFormatException("counter", "Expected empty step input."); Step = checked(Step + 1); if (Step == 3) sink.Finding("warm", Severity.Warning, [new EntityId(1)], "Temperature reached 3.", "Check the heat input."); if (Step == 7) sink.Finding("hot", Severity.Error, [new EntityId(1)], "Temperature reached 7."); } public void AdvanceAxis(string axis, int position, ReadOnlySpan<byte> input) => throw new ArgumentException("No secondary axes.", nameof(axis)); public void WriteSnapshot(IBufferWriter<byte> writer) { BinaryPrimitives.WriteInt64LittleEndian(writer.GetSpan(8), Step); writer.Advance(8); } public void Restore(ReadOnlySpan<byte> snapshot) { if (snapshot.Length != 8 || BinaryPrimitives.ReadInt64LittleEndian(snapshot) < 0) throw new ScopeFormatException("counter", "Expected a nonnegative int64 snapshot."); Step = BinaryPrimitives.ReadInt64LittleEndian(snapshot); } public void CollectEntities(List<EntityInfo> into) { into.Clear(); if (Present) into.Add(new(new EntityId(1), default, "counter", "Counter")); } public bool Inspect(EntityId entity, PropertyList into) { into.Clear(); if (entity.Value != 1 || !Present) return false; into.Add("State", "Value", ScopeValue.OfInteger(Step)); return true; } bool Present => role == 0 || (role == 1 ? Step < 5 : Step >= 5); public void CollectLinks(List<EntityLink> into) { into.Clear(); if (role == 0) return; into.Add(new(new EntityId(1), role == 1 ? LinkDirection.ContinuedBy : LinkDirection.Continues, role == 1 ? "receiver" : "sender", new EntityId(1), 5)); } public bool TryReadField(string field, EntityId entity, out double value) { value = Step; return field == "heat" && entity.Value == 1 && Present; } public bool TryReadFieldAt(string field, Vector3 point, out double value) { value = Step; return field == "heat"; } public void Draw(DrawList into, in DrawRequest request) { if (!Present) return; into.FieldMesh([new(0, 0, 0), new(2, 0, 0), new(0, 2, 0)], [0, 1, 2], [(float)Step, (float)Step, (float)Step], FieldDomain.Vertex, DemoField.Info, new DrawStyle(Color32.White, Entity: new EntityId(1))); into.FieldPoints([new Vector3(1, 1, 0)], [(float)Step], DemoField.Info, 12, new DrawStyle(Color32.White, Entity: new EntityId(1))); } public void EncodePoke(in PokeCall call, IBufferWriter<byte> into) => throw new ArgumentException("This counter declares no pokes."); public Difference? FirstDifference(IScopeWorld other) { if (other is not CounterWorld counter) throw new ArgumentException("Expected a counter."); return Step == counter.Step ? null : new Difference(new EntityId(1), "value", Step.ToString(System.Globalization.CultureInfo.InvariantCulture), counter.Step.ToString(System.Globalization.CultureInfo.InvariantCulture)); } } public static class DemoField { public static readonly FieldInfo Info = new("heat", "Temperature", ColorMap.Heat, 0, 10, "degrees"); }
dotnet run prints:
At step 5: source 1, entity 1 At step 4: source 0, entity 1 {"index":0,"kind":"2d","source":1,"wireframe":false,"grid":true,"target":[0.5,0.5,0],"halfHeight":1.12}
Variations
Follow the transfer backward
Run from the same example folder:
dotnet build -o bin/demo onescope open sender.replay \ --module bin/demo/Example.dll \ --run 'open {"path":"receiver.replay"}' \ --run 'inspect {"entity":1}' \ --run 'track {"view":0,"on":true}' \ --run 'seek {"step":5}' \ --run 'seek {"step":4}' \ --run 'selection'
The final operation returns:
{"step":4,"source":0,"sourceName":"sender.replay","entity":1,"kind":"counter","name":"Counter","properties":[{"group":"State","name":"Value","value":4}]}Related recipes
- Visualize simulation scalar fields in C#
- Inspect simulation replay entity properties
- Filter simulation replay diagnostic findings
The short answer
Give each source a stable SourceInfo.LinkKey, and return EntityLink declarations from IScopeWorld.CollectLinks. Select the departing entity and enable track on its view. At the transfer step, the shell moves selection to the arriving entity and re-centers the tracked view on it. Seeking backward before that step reverses the selection transfer.
The example’s sender and receiver both use entity ID 1, but IDs alone do not establish continuity. Their link keys and direction declarations identify the relationship. The transfer occurs at step 5: the sender stops listing the entity there, and the receiver starts listing it. This is a deliberately small two-source replay, not an automatic matching algorithm.
How do I track an entity across replay sources?
Keep the source’s link key stable across file names and display labels. The sender in this example uses sender; the receiver uses receiver. Its world declares ContinuedBy toward the receiver, while the receiver declares Continues toward the sender. Each declaration includes its local entity, the other source key, the other entity and the transfer step.
Open both sources before following the transfer. links reports the resolved pairs and whether the shared cursor has crossed each transfer. selection reports the selected source and entity with its name and properties. Those operations let you distinguish a resolved continuity relationship from two unrelated objects that happen to have the same numeric ID.
The link contract says that the active source, inspector and entity highlight follow selection. You do not need to inspect the receiving entity manually at every boundary. In the full program, selection becomes source 1/entity 1 at step 5, then returns to source 0/entity 1 at step 4. This verifies both directions of the same boundary.
How do I make the camera follow the selected entity?
Selection transfer and camera tracking are separate settings. track with a view index and on: true re-centers that view on the selected entity at every seek. It also switches the view to the source selection moved to, provided the dimensions match. A selected entity moving to a different source is not by itself an instruction to change every camera.
Enable tracking after selecting the entity you want. The example inspects entity 1 in the sender while the shared cursor is still at step 0, enables tracking on view 0 and seeks to step 5. The reported view then belongs to source 1. You can keep another view untracked to examine a fixed location while the tracked view follows the transfer.
Tracking does not change the recording or cause a physical transfer. The replay module owns which entities exist at each step, what they draw and what their properties mean. The link only declares that the selected identity continues elsewhere. A module must still reconstruct the correct state on both sides of the boundary.
Why isn’t a replay entity transfer being followed?
A link to a source that is not open is ignored. Check the resolved links result, both sources’ effective keys and the step before assuming the camera is at fault. If several sources share a key, an opened file wins over its forks. Use distinct keys for unrelated worlds rather than relying on file opening order.
Also check the view’s dimensions and tracking setting. The documented camera switch is same-dimensions only. A valid transfer can update selection and the inspector without providing a compatible tracked camera. Finally, inspect an entity while it exists: the sample’s sender no longer holds entity 1 at or after step 5, so a fresh inspection there should target the receiver instead.