A module teaches OneScope your replay format. This runnable counter example implements file loading, seeking, properties, and drawing.
Unzip the example, then dotnet run.
For a new project:
dotnet add package RecursiveAI.OneScope.SdkRequires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.
The example writes counter.replay and prints its state after seeking and rewinding.
Implement the module
The input is eight bytes: ASCII CNTR, followed by the little-endian 32-bit step count 10. The full program creates it directly, so no external files are needed. The project pins the SDK to 1.0.0.
using System.Buffers; using System.Buffers.Binary; using System.Numerics; using OneScope.Sdk; // CNTR + a little-endian int32 step count; each step increments the counter. byte[] bytes = [67, 78, 84, 82, 10, 0, 0, 0]; File.WriteAllBytes("counter.replay", bytes); var source = new CounterModule().Open(bytes, "counter.replay"); var history = new History(source); history.Seek(7); Console.WriteLine($"Step: {history.CurrentStep}"); Console.WriteLine($"Hash: {history.StateHash:x16}"); history.Seek(2); history.Seek(7); Console.WriteLine($"After rewind: {history.StateHash:x16}"); var saved = new ArrayBufferWriter<byte>(); source.WriteReplay(saved); Console.WriteLine($"Replay round trip: {bytes.AsSpan().SequenceEqual(saved.WrittenSpan)}"); 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 step count (8 bytes)."); int steps = BinaryPrimitives.ReadInt32LittleEndian(data.Span[4..]); if (steps < 0 || steps > 10000) throw new ScopeFormatException(name, "Expected 0..10000 steps at offset 4."); var inputs = new ReadOnlyMemory<byte>[steps]; return new CounterSource(name, Recording.Create(ReadOnlyMemory<byte>.Empty, inputs)); } } public sealed class CounterSource(string name, Recording recording) : IScopeSource { public SourceInfo Info { get; } = new() { ModuleId = "counter", Name = name, Dimensions = 2, Unit = LengthUnit.Metre, 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(); public IScopeSource WithRecording(Recording other) => new CounterSource(name, other); public void WriteReplay(IBufferWriter<byte> writer) { Span<byte> bytes = writer.GetSpan(8)[..8]; "CNTR"u8.CopyTo(bytes); BinaryPrimitives.WriteInt32LittleEndian(bytes[4..], checked((int)Recording.StepCount)); writer.Advance(8); } } public sealed class CounterWorld : 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); } 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(); into.Add(new(new EntityId(1), default, "counter", "Counter")); } public bool Inspect(EntityId entity, PropertyList into) { into.Clear(); if (entity.Value != 1) return false; into.Add("State", "Value", ScopeValue.OfInteger(Step)); return true; } public void Draw(DrawList into, in DrawRequest request) => into.Point(new Vector3(Step, 0, 0), 8, 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)); } }
Step: 7 Hash: 0000000000000007 After rewind: 0000000000000007 Replay round trip: True
CounterModule.Open throws ScopeFormatException for the wrong magic, size, or step count; the example accepts only zero through 10,000 steps.
Load it in the viewer
Install the shell separately from the SDK:
dotnet tool install --global RecursiveAI.OneScope.ToolFrom the download folder after dotnet run, load the assembly:
onescope open counter.replay --module bin/Debug/net10.0/project.dllThe counter appears as one entity, with its current value in the inspector and a point whose horizontal position is the count.
Variations
Inspect the counter without opening a window:
onescope open counter.replay --module bin/Debug/net10.0/project.dll --headless --run 'seek {"step":7}' --run 'inspect {"entity":1}'{"source":0,"step":7,"entity":1,"kind":"counter","name":"Counter","properties":[{"group":"State","name":"Value","kind":"integer","value":7}]}Related recipes
The short answer
Reference RecursiveAI.OneScope.Sdk and implement IScopeModule, IScopeSource, and IScopeWorld. The module recognizes files, the source describes the recording, and the world reconstructs and exposes the simulation state. Build the assembly and load it with onescope open file --module path/to/Module.dll.
The counter example is intentionally a complete, small format rather than an adapter for a large engine. Its entire state is the step counter, so its hash is just that value. That identity would not be a sufficient state hash for a physics or navigation world with many independent fields. For a real library, return the library’s own hash of its complete state, including the step.
How do I build a custom replay viewer in C#?
Separate the file reader from the world. CounterModule.Open checks the bytes before allocating the input array. Its format has a fixed length, magic bytes, and a bounded count. A real format needs corresponding checks for every length, count, and arithmetic operation. Throw ScopeFormatException for bytes your module does not recognize, because the shell offers files to loaded modules until one accepts them.
CounterSource declares the stable module ID, display name, dimensionality, length unit, time axis, and entity kinds. Those declarations let the shell create the tree and inspector without knowing the counter’s implementation. Its recording contains one empty input per step. Empty is a meaningful input here: there are no orders or pokes, and each step always increments by one.
CounterWorld implements the state-dependent work. CollectEntities clears and refills the entity tree; Inspect returns property rows for a known entity. Draw emits a neutral point in world units instead of making graphics calls. These are independent contracts: an entity can have inspectable properties even if its drawing is simple.
The SDK does not require your simulation library to reference the viewer. Keep the module alongside your library and adapt the library’s existing replay reader and deterministic stepping API. The module-authoring guide describes the contracts and the API reference lists their public members.
How does a replay viewer control time?
A seek reconstructs a state from a snapshot and the recorded step inputs. Your WriteSnapshot and Restore must therefore preserve everything needed for subsequent stepping, not only what is currently visible. The counter writes its nonnegative 64-bit step value. Restoring that value restores the whole toy world; a real world may also need velocities, sleeping flags, random-generator state, and other library-owned state.
The example seeks to seven, back to two, then to seven again. Both visits return the same state hash. That illustrates the obligation rather than adding another clock: OneScope time is an integer step index, and presentation speed does not redefine the simulation’s time step.
WithRecording supplies the same source over another recording, which is how a fork gets its own input history. WriteReplay must write the source’s recording in the library’s format. Here the count is enough because every input is empty. If you add a poke, you must extend both the step encoding and the replay writer; otherwise saving a changed run would discard its orders.
The example declares no secondary axes or pokes and rejects those calls explicitly. Additional features are opt-in: declare overlays, timeline tracks, fields, and poke parameters only when the world implements them. Live attachment is also separate from file opening; it requires ILiveModule and an ILiveSource that can append incoming records. A working file module does not automatically make an arbitrary running program attachable.