A scalar is one number at each sample. A module is C# code that tells OneScope how to read and draw your simulation’s recorded run, called a replay.
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:
The zip contains sender.replay.
dotnet build -o bin/demo onescope open sender.replay \ --module bin/demo/Example.dll
Opens a window with a colored triangle, a point and a Temperature legend. This window is at step 3.
Console path — run the C# example without a window:
Unzip the example, then dotnet run.
Add a field to your own module
Put the field declaration, drawing calls and value reads in your own module’s world. This focused example uses a constant temperature of 3 degrees; the full program uses the step number as the value. Include TemperatureLayer.Heat in the source’s SourceInfo.Fields list so the viewer can name the field and show its range and legend.
Add the SDK reference to your module’s project file:
<PackageReference Include="RecursiveAI.OneScope.Sdk" Version="1.0.0" />using System.Numerics; using OneScope.Sdk; // Add these members to your own module's world. public sealed class TemperatureLayer { public static readonly FieldInfo Heat = new( "heat", "Temperature", ColorMap.Heat, 0, 10, "degrees"); public void Draw(DrawList draw) { draw.FieldMesh( [new(0, 0, 0), new(2, 0, 0), new(0, 2, 0)], [0, 1, 2], [3f, 3f, 3f], FieldDomain.Vertex, Heat, new DrawStyle(Color32.White)); draw.FieldPoints([new Vector3(1, 1, 0)], [3f], Heat, 12, new DrawStyle(Color32.White)); } public bool TryReadField( string field, EntityId entity, out double value) { value = 3; return field == "heat" && entity.Value == 1; } public bool TryReadFieldAt( string field, Vector3 point, out double value) { value = 3; return field == "heat"; } }
FieldMesh draws the triangle; FieldPoints draws a sample point. The read methods return the same temperature rather than asking a user to estimate it from a color.
The full program
In your own module, add the field to SourceInfo.Fields, draw its values and implement the two numeric-read methods shown above. The complete program below connects those pieces to a small replay reader and opens it in OneScope.
The C# launcher installs OneScope.Tool 1.0.0 in its build folder and loads the module from its own assembly.
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}", "field", "field {\"field\":\"heat\",\"entity\":1}", "field {\"field\":\"heat\",\"point\":[1,1,0]}"); Console.WriteLine($"Fields: {r[2].GetProperty("fields").GetArrayLength()}"); Console.WriteLine($"Entity temperature: {r[3].GetProperty("value")}"); Console.WriteLine($"Point temperature: {r[4].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, 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 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:
Fields: 1 Entity temperature: 3 Point temperature: 3
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.
A field read returns no_value when the module has no value at that entity or point.
Variations
Read a field at a point
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 'field {"field":"heat","point":[1,1,0]}'
The final operation returns:
{"source":0,"step":3,"field":"heat","point":[1,1,0],"value":3,"unit":"degrees","min":0,"max":10}Related recipes
- Track an entity across replay sources
- Inspect simulation replay entity properties
- Filter simulation replay diagnostic findings
The short answer
Declare the field in SourceInfo.Fields, then draw it with DrawList.FieldMesh or FieldPoints. A scalar field is one numeric value at each sample location. The shell maps those values to colors using the declared minimum, maximum and color map, and draws a legend in views that draw the field. Geometry alone is not a scalar-field declaration.
The example makes temperature equal to the step index. Its triangle and point therefore both read 3 at step 3. This is a small SDK example, not a heat solver: replace that value with your simulation’s temperature, density or other measurement. The field contract describes how a module supplies the data.
How do I visualize a scalar field in C#?
Choose the geometry that matches where your simulation stores values. FieldMesh accepts a value per vertex or per triangle. Per-vertex values blend across the triangle; per-triangle values produce a flat color. FieldPoints accepts a value per position. A point cloud does not require triangle connectivity, but it does need the same number of values as positions.
In the full program, DemoField.Info names the field heat, gives it the display name Temperature and sets a range from 0 to 10 degrees. The source declares that exact descriptor, and its world passes it to both drawing calls. The mesh has three vertices, one triangle and three values with FieldDomain.Vertex. The point draw has one position and one value. Those counts follow the storage domain rather than the number of entities.
Use a fixed declared range when comparing steps. Otherwise a color could represent a different value after each seek. The example’s range stays fixed while the numeric value changes. The shell uses the metadata for its legend, including the name, unit and endpoints; you do not need to draw a separate legend in the module.
How do color maps handle missing and out-of-range values?
The SDK supplies Viridis, Heat, CoolWarm and Grayscale. Values outside the declared range clamp to its endpoints. NaN means no data and draws gray. A missing measurement should not silently become zero if zero has physical meaning in your field.
ColorMaps.Evaluate is the shared mapping function used by the renderer and legend. The drawing contract also specifies bounded storage: a draw list accepts 1,024 field draws and 8M values. A wrong value count drops the mesh and increments DrawList.Dropped. If a field does not appear, check the domain, counts and dropped count before changing the camera.
How do I read the scalar value at an entity or point?
The field operation without a field ID lists the source’s declarations. With an ID, supply either entity or point, not both. The world implements TryReadField and TryReadFieldAt to return the values it paints. The example returns its step-based temperature from both methods, so the two reads agree.
The default implementations return no value. Adding only a drawing call does not create a queryable field. Likewise, the viewer does not infer interpolation from a mesh for your module: implement the point-read rule your simulation actually uses. An unknown field or an invalid combination of arguments returns bad_args; a declared field without a value at that location returns no_value.