A replay is a recorded run; its module supplies the reader and property list. An entity is an object in that run, selected by its numeric ID.
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 'seek {"step":3}' \ --run 'entities' \ --run 'inspect {"entity":1}'
The final inspect operation prints:
{ "source": 0, "step": 3, "entity": 1, "kind": "counter", "name": "Counter", "properties": [ { "group": "State", "name": "Value", "kind": "integer", "value": 3 } ] }
Console path — run the C# example without a window:
Unzip the example, then dotnet run.
The full program
The example seeks to step 3, lists entity 1 and reads its State/Value property. Your module supplies the property groups and values; OneScope returns them as JSON.
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 00 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, 0, 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("seek {\"step\":3}", "entities {\"kind\":\"counter\",\"limit\":10}", "inspect {\"entity\":1}"); Console.WriteLine($"Entities: {r[2].GetProperty("count")}"); var p = r[3].GetProperty("properties")[0]; Console.WriteLine($"{p.GetProperty("group")}/{p.GetProperty("name")}: {p.GetProperty("value")}"); 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:
Entities: 1 State/Value: 3
Inspecting an entity absent at the chosen step returns not_found and exit code 1.
Variations
Filter the entity list
Run from the same example folder:
dotnet build -o bin/demo onescope open sender.replay \ --module bin/demo/Example.dll \ --run 'seek {"step":3}' \ --run 'entities {"kind":"counter","limit":1}'
The final operation returns:
{"source":0,"step":3,"entities":[{"id":1,"parent":null,"kind":"counter","name":"Counter"}],"count":1,"truncated":false}Related recipes
- Visualize simulation scalar fields in C#
- Track an entity across replay sources
- Filter simulation replay diagnostic findings
The short answer
Use seek to choose the simulation step, entities to obtain entity IDs and inspect to read one entity’s properties. Each property row has a group, name, kind and JSON value. The inspection also selects that entity in the shell, so the command-line result and the window’s selected object refer to the same entity.
The download’s counter module exposes one row: group State, name Value, integer value 3 at step 3. A real module supplies its own rows, such as a transform, velocity or diagnostic state; the viewer does not manufacture a fixed property set for every library.
How do I inspect entity properties at a replay step?
Seek before listing or inspecting. An entity can exist at one step and be absent at another, and its property values come from the current reconstructed world. The example keeps entity 1 present throughout its ten-step recording. At step 3, entities reports ID 1 and inspect returns the State/Value row shown above.
A step is not a video timestamp. OneScope’s time axis is the recorded simulation step index. Seeking moves the sources to that shared cursor, with each source bounded by its own recording range. The reported world hash belongs to the module’s world state, not to the JSON property list or a rendered image. Two different property displays are not a substitute for comparing world hashes.
For multiple recordings, pass source explicitly in inspection operations. A numeric entity ID identifies an object within a source; it is not a global cross-file identity. Selection and source activation can change as you work, especially when a module declares continuation links. Scripts should not depend on whichever source was last selected interactively.
How do I list and filter replay entities?
entities returns each entity’s ID, parent, kind and name. Use the kind filter to narrow a large list, and limit to cap the returned array. The sample variation filters for the counter kind. The result’s count describes matching entities, while truncated tells you whether some matches were omitted from the array.
Use the returned ID rather than parsing the display name. The name is presentation text chosen by the module. IDs are the arguments expected by inspect, field reads and other entity operations. The parent relationship allows the module to expose a hierarchy, but a parent’s presence does not mean its properties include all of its children’s state.
The module implements CollectEntities and Inspect separately. In your own module, keep those methods consistent for the current step: list the objects that can actually be inspected, clear reusable output collections and return false for an unknown entity. The example returns false for IDs other than 1.
What do the inspector’s property groups and value kinds mean?
Groups organize related rows; names label individual measurements inside the group. Keep both when processing results. Two groups can reasonably have rows with the same name, so selecting a row solely by its name can lose context. The sample prints both parts as State/Value.
The kind field tells a JSON consumer how the module represents the value. The sample’s row is an integer; other modules can expose other supported SDK value kinds. Do not convert every value to a floating-point number or assume that a vector has already been formatted as text. Read the JSON value according to its kind and the module’s documented semantics.
Inspection is a read operation, even though it changes UI selection. It does not apply an impulse, edit the scene or write the replay. Changing a property requires a module-declared poke and a fork workflow, not an update to the inspector JSON. The shell operation reference documents those separate operations.