# onescope docs, all pages (OneScope 1.0.0) Every page of https://recursiveai.org/onescope/docs/ as markdown, getting started first. Index: https://recursiveai.org/onescope/llms.txt # Getting started with OneScope Install the onescope tool, record a short OnePhysics replay, and open it headless: seek to a step, inspect the world and one body, and capture a frame. ## Install OneScope runs on .NET 10, so you need the .NET 10 SDK or runtime. This guide uses the 1.0.0 tool. ```shell dotnet tool install --global RecursiveAI.OneScope.Tool onescope --help ``` In the README’s words: “The shell is a onegame program: it opens a window on Linux, macOS and Windows, or runs headless and renders offscreen.” The physics and navigation modules are bundled, so OnePhysics and OnePath files open with no extra options. ## Record a replay Any OnePhysics or OnePath replay will do. This one comes from a short OnePhysics program: five balls dropped on the ground, recorded for 240 steps. The output below is from a run in `/tmp/Bounce`. ```shell dotnet new console -o Bounce cd Bounce dotnet add package RecursiveAI.OnePhysics --version 1.0.0 ``` ```csharp using RecursiveAI.OneMath; using RecursiveAI.OnePhysics; using RecursiveAI.OnePhysics.Physics2D; var world = new PhysicsWorld2D(); world.AddBox(new Vector2F(20, 1), at: new Vector2D(0, -1), type: BodyType.Static); using (var file = File.Create("run.oppr")) using (world.StartRecording(file)) { for (int i = 0; i < 5; i++) world.AddCircle(0.5f, at: new Vector2D(i - 2, 4 + i)); for (int step = 1; step <= 240; step++) { world.Step(1f / 60); if (step == 120) Console.WriteLine($"step 120, hash {world.ComputeHash():x16}"); } } Console.WriteLine($"recorded {world.StepCount} steps to run.oppr"); ``` ```text step 120, hash 96f47843cd094b33 recorded 240 steps to run.oppr ``` ## Seek, inspect, capture In the README’s words: “run operations after opening, print each result as JSON, and exit.” Each `--run` is one operation, and each result is one line. `--headless` opens no window and renders offscreen. ```shell onescope open run.oppr --headless --run 'seek {"step":120}' --run 'inspect {"entity":1}' --run 'capture_frame {"path":"frame.png"}' ``` ```text {"source":0,"name":"run.oppr","path":"/tmp/Bounce/run.oppr","module":"onephysics","dimensions":2,"unit":{"name":"m","metres":1},"startStep":0,"steps":240,"axes":[{"id":"time","name":"Time","length":240,"position":0}],"overlays":[{"id":0,"name":"Shapes","defaultOn":true},{"id":1,"name":"Bounds","defaultOn":false},{"id":2,"name":"Contacts","defaultOn":true},{"id":3,"name":"Joints","defaultOn":true},{"id":4,"name":"Centres of mass","defaultOn":false},{"id":5,"name":"Joint limits","defaultOn":true}],"tracks":[{"id":"contact","name":"Contacts begun","kind":"event"},{"id":"sleep","name":"Sleep and wake","kind":"event"},{"id":"joint_break","name":"Joints broken","kind":"event"},{"id":"awake","name":"Awake bodies","kind":"series"},{"id":"contacts","name":"Contact points","kind":"series"}],"pokes":[{"id":"impulse","name":"Impulse","role":"drag","params":[{"name":"target","kind":"entity"},{"name":"impulse","kind":"vector"}]},{"id":"set_velocity","name":"Set velocity","params":[{"name":"target","kind":"entity"},{"name":"velocity","kind":"vector"}]},{"id":"set_position","name":"Set position","role":"drag","params":[{"name":"target","kind":"entity"},{"name":"position","kind":"vector"}]},{"id":"wake","name":"Wake","params":[{"name":"target","kind":"entity"}]}],"label":"run.oppr","step":0,"hash":"5a09a642111ebae2","keyframeInterval":64,"scannedThrough":240,"views":2} {"step":120,"start":0,"steps":240,"sources":[{"source":0,"step":120,"hash":"96f47843cd094b33"}]} {"source":0,"step":120,"entity":1,"kind":"world","name":"World","properties":[{"group":"World","name":"Step","kind":"integer","value":120},{"group":"World","name":"Completed steps","kind":"integer","value":120},{"group":"World","name":"Time step","kind":"number","value":0.01666666753590107},{"group":"World","name":"Hash","kind":"text","value":"96f47843cd094b33"},{"group":"Counters","name":"Awake bodies","kind":"integer","value":5},{"group":"Counters","name":"Awake islands","kind":"integer","value":1},{"group":"Counters","name":"Asleep islands","kind":"integer","value":0},{"group":"Counters","name":"Broadphase pairs","kind":"integer","value":0},{"group":"Counters","name":"Manifolds","kind":"integer","value":5},{"group":"Counters","name":"Contact points","kind":"integer","value":5},{"group":"Counters","name":"Joints","kind":"integer","value":0},{"group":"Counters","name":"Sleep transitions","kind":"integer","value":0},{"group":"Counters","name":"Wake transitions","kind":"integer","value":0}]} {"path":"/tmp/Bounce/artifacts/frame.png","width":1280,"height":720,"scale":1,"sha256":"2a687c60112c6d36122c9ad2746427dffefeb295bd34802f57d7be398a100d87","renderer":"NVIDIA GeForce RTX 4070","target":"shell","step":120,"hashes":["96f47843cd094b33"]} ``` The hash at step 120, `96f47843cd094b33`, is the one the program printed from `ComputeHash()`: the README says “the world hash it reports is the library’s own.” The frame is the whole shell, drawn offscreen at 1280×720: ![The onescope shell at step 120 of 240: the entity list, a 2D and a 3D view of five balls resting on the ground, the world inspector, the timeline with contact events and the event log](https://recursiveai.org/onescope/frame-step-120.webp) To inspect a body, list the entities first. Entity 1 is the world; the bodies have their own ids. The first line of this run, the `open` result, is the same as above and left out here. ```shell onescope open run.oppr --headless --run 'seek {"step":120}' --run 'entities {"limit":3}' --run 'inspect {"entity":72057598332895233}' ``` ```text {"source":0,"step":120,"entities":[{"id":1,"parent":null,"kind":"world","name":"World"},{"id":72057598332895232,"parent":1,"kind":"body","name":"Body 0 (Static)"},{"id":72057598332895233,"parent":1,"kind":"body","name":"Body 1 (Awake)"}],"count":7,"truncated":true} {"source":0,"step":120,"entity":72057598332895233,"kind":"body","name":"Body 1 (Awake)","properties":[{"group":"Transform","name":"Position","kind":"vector","value":[-2.2531161,-0.00011538295,0]},{"group":"Transform","name":"Angle","kind":"number","value":0.5006986856460571},{"group":"Motion","name":"Linear velocity","kind":"vector","value":[-0.2670705,0,0]},{"group":"Motion","name":"Angular velocity","kind":"number","value":0.5342166423797607},{"group":"Body","name":"Type","kind":"text","value":"Dynamic"},{"group":"Body","name":"Mass","kind":"number","value":0.7853981852531433},{"group":"Body","name":"Awake","kind":"bool","value":true},{"group":"Solver","name":"Island","kind":"integer","value":0},{"group":"Solver","name":"Sleep time","kind":"number","value":0},{"group":"Solver","name":"Constraints","kind":"integer","value":1},{"group":"Solver","name":"Constraint colours","kind":"integer","value":1048576},{"group":"Solver","name":"External impulse","kind":"vector","value":[0,0,0]}]} ``` ## What each part does - **`open`** loads the file with the module that owns it and prints what the source declares: its time axis, overlays, timeline tracks and the pokes a fork accepts. - **`seek`** moves to a step. Time is a step index; in the README’s words, “Seeking to step *k* lands on exactly the state that playing straight to *k* gives, however you got there.” - **`entities`** and **`inspect`** read the world at the current step: the entity tree, then one entity’s properties, grouped as the inspector shows them. - **`capture_frame`** writes a PNG under `./artifacts` (`--artifacts ` moves it) and reports its SHA-256, the renderer and the step. Two runs of this guide wrote byte-identical frames; the changelog says headless captures “are byte-identical from run to run on one renderer.” - The replay file is only read. The README: “The original file and the running program are never changed.” ## Drive it over the Model Context Protocol A shell opened with `--live` serves the same operations on a Unix socket, and other `onescope` commands drive it: ```shell onescope open run.oppr --headless --live /tmp/onescope.sock & onescope seek '{"step":120}' --socket /tmp/onescope.sock ``` ```text {"step":120,"start":0,"steps":240,"sources":[{"source":0,"step":120,"hash":"96f47843cd094b33"}]} ``` `onescope mcp --socket ` bridges that socket to stdio for an agent. The README’s client configuration: ```json { "mcpServers": { "onescope": { "command": "onescope", "args": ["mcp", "--socket", "/tmp/onescope.sock"] } } } ``` ## Next - [The shell and its operations](https://recursiveai.org/onescope/docs/shell/): every operation, its arguments and its JSON result - [Time, history and forks](https://recursiveai.org/onescope/docs/history/) - [Live attach](https://recursiveai.org/onescope/docs/live-attach/): watch a running program that calls the probe - [Writing a module](https://recursiveai.org/onescope/docs/module-authoring/): add your own library # onescope A viewer for deterministic libraries. Open a scene, snapshot or replay, or attach to a running program, and watch the world: scrub its history, inspect any entity, compare runs, and fork from any frame to poke at it. One shell, one module per library; physics and navigation modules are bundled. Everything the window does is also a command-line operation and an MCP tool, so an agent can drive it the same way. Time is a step index. Seeking to step *k* lands on exactly the state that playing straight to *k* gives, however you got there, and the world hash it reports is the library's own. A fork re-simulates from the original's bits, records each poke into its own replay, and shows where it diverges. The original file and the running program are never changed. ## Install ``` dotnet tool install --global RecursiveAI.OneScope.Tool onescope --help ``` Requires the .NET 10 SDK or runtime. The shell is a [onegame](https://github.com/recursive-ai-oss/onegame) program: it opens a window on Linux, macOS and Windows, or runs headless and renders offscreen. ## Use it ``` # Open a replay (physics and navigation files open with no extra options) onescope open run.oppr # Scrub, inspect, capture: run operations after opening, print each result as JSON, and exit onescope open run.oppr --headless --run 'seek {"step":120}' --run 'inspect {"entity":1}' --run 'capture_frame {"path":"frame.png"}' # Watch a running program that called the probe onescope attach # lists programs with a probe onescope attach 48213 # opens the shell on its live stream # Fork from the current frame, poke the fork, step it, and find where it diverges onescope open run.oppr --live /tmp/onescope.sock & onescope fork --socket /tmp/onescope.sock onescope entities '{"source":1,"limit":5}' --socket /tmp/onescope.sock # entity ids onescope poke '{"source":1,"poke":"impulse","args":{"target":,"impulse":[0,5,0]},"step":true}' --socket /tmp/onescope.sock onescope diff '{"source":1}' --socket /tmp/onescope.sock # Compare two versions of a library on one replay, each module in its own load context onescope open run.oppr --run 'open {"path":"run.oppr","moduleAssembly":"/path/to/other/module.dll"}' --run 'compare' # Save everything (sources, forks with their pokes, views) and reopen it later onescope session_save '{"path":"work.oscope"}' --socket /tmp/onescope.sock onescope open work.oscope ``` In the window: the entity tree, inspector, one or more 2D and 3D scene views, a timeline with a track per source (forks hang below it as branches from the step they diverge at), an event log and a compare panel. Drag a body in a fork to poke it. ## MCP `onescope open --live ` serves the operations over MCP on a Unix socket, and `onescope mcp --socket ` bridges it to stdio. In an MCP client's configuration: ```json { "mcpServers": { "onescope": { "command": "onescope", "args": ["mcp", "--socket", "/tmp/onescope.sock"] } } } ``` Every operation (`open`, `attach`, `sources`, `seek`, `step`, `play`, `entities`, `inspect`, `overlay`, `fork`, `poke`, `diff`, `compare`, `close`, `capture_frame`, `capture_sequence`, `session_save`, and the rest) takes and returns JSON. They are listed with their arguments and results in [docs/shell.md](https://recursiveai.org/onescope/docs/shell/). ## Watch your own program A host program references `RecursiveAI.OneScope.Probe` and calls it once. The probe listens on a local socket in a per-user directory, sends the library's own replay bytes, and costs one branch per call when nothing is connected. It never blocks or throws into the host, and a viewer that falls behind is dropped. See [docs/live-attach.md](https://recursiveai.org/onescope/docs/live-attach/) and `samples/`. ## Add a library A module is a package built against `RecursiveAI.OneScope.Sdk` (BCL only) that opens the library's files into a deterministic world and describes it as data: entities and properties, a neutral draw list in world units, timeline tracks, overlays, pokes and findings. The library never depends on onescope; the module lives in the library's repository. Start with [docs/module-authoring.md](https://recursiveai.org/onescope/docs/module-authoring/); the full public surface is in [docs/api.md](https://recursiveai.org/onescope/docs/api/). Modules load from a path with `--module` into their own load context. ## Packages | Package | What it is | |---|---| | `RecursiveAI.OneScope.Sdk` | The module contract, the draw list and the time model. BCL only; AOT and trim safe. | | `RecursiveAI.OneScope.Probe` | What a host calls to stream a replay. BCL only; AOT, trim and WebAssembly safe. | | `RecursiveAI.OneScope.Attach` | The viewer side of a probe, and the `.oscope` session file. BCL only. | | `RecursiveAI.OneScope.Tool` | The `onescope` command and shell. | ## Build and test ``` dotnet build OneScope.slnx -c Release dotnet test --project tests/OneScope.Sdk.Tests -c Release dotnet test --project tests/OneScope.Attach.Tests -c Release dotnet test --project tests/OneScope.App.Tests -c Release ``` Build output goes to `artifacts/` (ignored), or to the directory named by `ONESCOPE_ARTIFACTS`. The bundled physics and navigation modules, and `RecursiveAI.OnePath` 1.2.0, are restored from nuget.org; until they are published there, point `ONESCOPE_FEEDS` at a folder that holds them (several folders are separated by `;`). Captures are compared with goldens that come from the lavapipe software renderer; on another renderer the tests check that three runs agree with each other. Documentation index: [docs/README.md](https://github.com/recursive-ai-oss/onescope-csharp/blob/d681be56420836ea0d8874416367bc8181235813/docs/README.md). Licensed under `MIT OR Apache-2.0`, © Recursive AI, LLC. # The shell and its operations `onescope` is a onegame program. One process holds the opened sources, the scene views and the UI. Everything the shell does is an operation, and the operations are the same from the window, the command line and MCP. Results are JSON. ## Running it ``` onescope open [--module ]... [--headless] [--live [path]] [--run " [json]"]... [--size WxH] [--artifacts ] onescope open # reopen a saved session onescope attach # list running programs that have a probe, as JSON onescope attach [--dir ] [same options as open] # open the shell on a running program's live stream onescope [json | --name value ...] --socket # drive a running shell, prints the operation's JSON onescope mcp --socket # stdio MCP bridge for an agent ``` `open` loads the file with the first loaded module that accepts it. A module is an assembly built against `RecursiveAI.OneScope.Sdk`; `--module` loads one from a path into its own load context (the framework, onegame and the SDK are shared with the host). `--headless` renders offscreen; without it the shell opens a window. `--live` serves the operations on a Unix socket, one JSON-RPC (MCP) message per line; `onegame mcp --socket ` bridges to it as well. Errors are `{"ok":false,"error":{"code","message"}}` with exit code 1. Exit codes: 0 ok, 1 an operation failed, 2 usage, 4 could not run. ## Time Time is the step index. `seek` moves every source to the same step (a source that ends or starts elsewhere stops at its end or start) and lands on exactly the state that stepping straight there gives, so the `hash` in a result is the module's world hash at that step. Playback speed (`play`) is presentation only: it counts presentation frames at 60 per second, never a clock, and only decides which step the next frame shows. A source's other axes (`axes`) are positions inside one step; `seek` with `axis` and `position` moves a scratch view and leaves the time cursor and `hash` alone, and reports `viewAxis`, `viewPosition` and `viewHash`. ## Operations Hashes are 16 lowercase hex digits. Entity ids are the module's unsigned 64-bit integers; an argument that takes one accepts the JSON integer or the same digits as a string (some JSON clients read numbers as doubles, which cannot hold every id). Every operation that names a `source` or `view` defaults to the active one. | Operation | Arguments | Result | |---|---|---| | `open` | `path`, `module`?, `moduleAssembly`?, `keyframeInterval`?, `scan`? (a module named by `moduleAssembly` opens this file even when a bundled one would also accept it; the source then reports `moduleAssembly`) | the new source: `source`, `name`, `path`, `module`, `dimensions`, `unit {name, metres}`, `steps`, `axes`, `overlays`, `tracks`, `pokes`, `step`, `hash`, `keyframeInterval`, `scannedThrough`, `views` | | `sources` | | `step`, `start`, `steps`, `speed`, `sources[]` (the `open` fields plus `keyframes`, `keyframeBytes`, `findings`, and for a live source `live {state: streaming|ended|disconnected|failed|capped|closed, endedBecause?, follow, hostPid, library, format, firstStep, newestStep, recordBytes, capBytes}`, and when the file recorded hashes `recordedHashes {through, matches: all|so far|no, firstMismatch?}`), and `attaching[]`: attaches still connecting or failed (`attach`, `state`, `target`, `startFrame`, `frame`, and for a failure `errorCode`, `error`) | | `attach` | none: list. `pid` or `socket` to connect; `dir`?, `keyframeInterval`?, `moduleAssembly`?, `wait`? (ms the call may block, default 100), `timeout`? (ms the connection may take, default 30000), `capBytes`? (received record bytes kept, default 268435456) | list: `probes[] {pid, socket, module, library, format, moduleLoaded}`, `removedStale[]`, `unreadable[]`. Connect: the new source as for `open`, plus `live`, when it is ready within `wait`; otherwise `{attach, state: "connecting", target, startFrame, frame}` and the source appears when the host has taken its snapshot. Errors within `wait`: `not_found`, `timeout`, `bad_stream`, `no_module`, `bad_format`; later ones are listed under `sources.attaching` | | `follow` | `source`? (default the first live one), `on`? | `source`, `live`, `step`; on moves the cursor to the newest step | | `session_save` | `path` (inside the output folder), `embed`? (embed opened files too) | `path`, `bytes`, `sha256`, `cursor`, `sources[] {source, name, kind: referenced\|embedded, live, parent?, forkStep?, bytes}`, `layoutBytes` | | `session_open` | `path`, `moduleAssembly`? | `path`, `step`, `views`, `sources[] {source, label, module, startStep, steps, step, hash, parent?, forkStep?, divergence?}`. Replaces everything open; nothing changes on failure (`not_found`, `bad_session`) | | `seek` | `step`, or `axis` + `position` (+ `source`) | `step`, `steps`, `sources[] {source, step, hash, viewAxis?, viewPosition?, viewHash?}` | | `step` | `delta`? (default 1, negative goes back), `axis`?, `source`? | as `seek`; the step is clamped to the recording | | `play` | `speed`? (steps per second, 0 pauses, negative reverses), `frames`? (run this many presentation frames now), `loop`? | `speed`, `playing`, `loop`, `frames`, `moved`, `step`, `hashes[]` | | `offset` | `source`?, `offset`? (integer; omit to report) | `source`, `offset`, `step` (shared), `sourceStep`, `start`, `steps`, `hash`. At shared step s the source shows its own step s + offset | | `close` | `source`? (default the active one) | `closed[] {source, label, fork}` (the source and every fork made from it, by their old numbers), `renumbered[] {from, to}`, `sources`, `active` (-1 when none are left), `views`, `step` | | `entities` | `source`?, `kind`?, `limit`? | `entities[] {id, parent, kind, name}`, `count`, `truncated` | | `inspect` | `entity`, `source`? | `entity`, `kind`, `name`, `properties[] {group, name, kind, value}`; also selects the entity in the shell | | `overlay` | `view`?, `overlay`? (name or id), `on`? (toggles when omitted), `wireframe`?, `grid`? | `view`, `kind`, `wireframe`, `grid`, `overlays[] {id, name, on}` | | `axes` | `source`? | `step`, `axes[] {id, name, length, position}` | | `findings` | `source`?, `from`?, `to`?, `severity`?, `limit`? | `scannedThrough`, `findings[] {step, code, severity, entities, text, suggestedChange}`, `count`, `truncated` | | `fork` | `source`? (default the active one), `step`? (default the shared cursor), `view`? (default true: add a view of the fork) | the new source (every `open` field plus `fork {parent, forkStep, divergence, pokedSteps, pending}`) and `branch`, its number | | `poke` | `source`, `poke` (id or name), `args` (object by parameter name, or array in order), `step`? (run one step now) | `source`, `poke`, `appliesAtStep`, `pending`, `step`, `hash`. Without `poke`: the source's declarations with each poke's parameters | | `diff` | `source` (a fork), `other`? (default its parent), `step`? (default the cursor, at most the shorter recording) | `source`, `other`, `step`, `equal`, `hash`, `otherHash` (at that step), `divergence`, `difference {entity, field, value, otherValue}` or null | | `replay_save` | `source`, `path` (a new file in the output folder) | `path`, `bytes`, `sha256`, `steps`, `divergence` | | `pick` | `x`, `y` (window pixels), `view`? | `view`, `source`, `entity` or null, `point` (world) | | `project` | `entity`, `view`? or `source`? | `view`, `entity`, `x`, `y` (window pixels), `onScreen`, `world` | | `view` | nothing to list; `add` (`2d`/`3d`), or `index` with `remove`, `source`, `target`, `halfHeight` (2D), `distance`, `yaw`, `pitch` (3D), `frame` (the moving bodies; see Framing), `frameSelection` (the selected entity), `wireframe`, `grid`, `active` | `active`, `views[]` | | `timeline` | `fit`? (`all` or `fork`, with `source`?), `from`? and `to`?, `zoom`? (factor above 1 shows fewer steps) with `center`?, `pan`? (steps); nothing reports | `from`, `to`, `zoomed`, `axisFrom`, `axisTo`, `step`. View only: no world moves | | `layout` | `entities`?, `inspector`?, `timeline`?, `log`?, `compare`? (booleans) | the panel states | | `compare` | `source`?, `other`?, `kind`? (`fork`, `versions`, `shared`), `map`? (`identity`, `links`), `offset`?, `step`?, `goto`? (`first`, `next`, `previous`, `last`), `limit`? (1 to 1000, default 50) | the pair, the agreement along the shared steps, and the differing fields at a step: see Compare | | `capture_frame` | `path`? (inside the output folder), `size`? (`720p`, `1080p`, `WxH`), `scale`?, `view`? (that view alone, full size), `presented`? (the window's own frame; error `presented_unavailable` with the reason when the window cannot give one: minimized, covered, no swapchain image, surface lost; `rejected` when the shell is headless) | `path`, `width`, `height`, `scale`, `sha256`, `renderer`, `target`, `step`, `hashes[]`; over MCP the PNG travels as image content | | `capture_sequence` | `dir`, `count` (1 to 3600), `stride`? (default 1), `axis`?, `source`?, `size`?, `scale`?, `view`? | `dir`, `frames`, `ended`, `manifest`, `renderer`, `step`; `manifest.json` lists each frame's `step`, `position`, `hash` and `sha256` | onegame's own `info`, `ui_tree`, `ui_query`, `ui_click`, `ui_hit`, `ui_scale`, `input_pointer`, `input_key`, `input_text`, `window`, `render_graph` and `quit` are served unchanged (`docs/interfaces/control.md` in onegame). ## Sources that number their steps differently Two runs of one replay can count differently (one library version starts at step 0, another at step 1). `offset` says how far a source's own step numbers run ahead of the shared cursor, so both views show the same moment; `compare` sets it for its pair. The shared axis then spans every source in shared numbers (it can start below 0). `sources` and `seek` report a non-zero offset next to the source's own step, and a fork inherits its parent's. ``` $ onescope offset '{"source":1,"offset":5}' --socket /run/user/1000/os.sock {"source":1,"offset":5,"step":105,"sourceStep":110,"start":-5,"steps":10000,"hash":"dad4cab43acbca19"} $ onescope seek '{"step":100}' --socket /run/user/1000/os.sock {"step":100,"start":-5,"steps":10000,"sources":[{"source":0,"step":100,"hash":"42065a69e337bef3"},{"source":1,"step":105,"offset":5,"hash":"dea099ed4b87dfcc"}]} ``` `close` removes a source and the forks made from it; the sources that remain keep their order and are numbered again from 0. Views of the closed sources go with them, and a compare pair that named one is forgotten. A live source's connection is dropped and the program is not told anything. ``` $ onescope close '{"source":1}' --socket /run/user/1000/os.sock {"closed":[{"source":1,"label":"run","fork":false},{"source":3,"label":"run fork 1 @50","fork":true}],"renumbered":[{"from":2,"to":1}],"sources":2,"active":0,"views":4,"step":50} ``` ## Forks and pokes `fork` makes a new source whose recording starts as a copy of its parent's up to a step. Opened files are read-only: `poke` on one fails with `read_only`. A poke is queued on a fork and takes effect on its next step; `step` with the fork as `source` (or active) at the end of its recording runs new steps in it, the first one carrying the queued pokes, and the shared cursor follows. Seeking a fork back and forth replays its recording, so it gives the hashes the straight run gave. `diff` compares a fork with its parent at a step and names the first differing entity and field in the module's order. `divergence` is the first step whose recorded input differs, so the two worlds are equal at it and differ from the next step. `replay_save` writes the fork's replay; opened again, it gives the fork's hashes at every step. A fork of a fork is allowed; `Parent` chains. A press in a scene view selects the entity under it, which is outlined in every view of the same file. On a fork, and when the module declares a drag poke (`PokeRole.DragToPoint`), the press also grabs the entity: each pointer move is one step of the fork with that poke aimed at the pointer (on the view plane in 2D, on the plane through the grabbed point facing the camera in 3D), and releasing ends the drag. Elsewhere a drag moves the camera. `pick` and `project` give an agent the same hit test and the pixel to press, so it can script a drag with `input_pointer`. Error codes: `bad_args` (the message names the argument and what it must be), `not_found` (a file, source, view, entity or overlay), `no_source`, `no_module`, `bad_stream`, `timeout`, `bad_session`, `bad_format` (the file is not one any loaded module opens; the message names the file and each module's complaint), `bad_module`, `too_large`, `path_outside` (an output path leaving the output folder), `rejected`, `read_only` (poking an opened file), `unknown_op`. ## Live sources and sessions `attach` makes a running program's probe stream a source whose steps are the host's step numbers (`startStep` is the host's step at the attach); it plays, scrubs and forks like a file, and its history keeps growing while you scrub (`follow` decides whether the cursor goes with it). `session_save` writes the open sources, the cursor and the layout to a `.oscope` file (format in `session-file.md`): opened files by path and SHA-256, forks and live sources embedded with the history received so far. `session_open` (or `onescope open x.oscope`) replaces the shell's state with it: forks come back as working forks that can be poked and stepped again, and views, cameras, panels, selection and the event log come back as they were, so a capture taken after reopening is byte-identical to one taken before saving. A live source also has a viewer-side cap: the viewer keeps at most `capBytes` of received records (each counted with 48 bytes of bookkeeping). When the next record would pass it the viewer stops and disconnects: the source's state is `capped`, `endedBecause` says where, the event log gets an error row, and what was received stays usable (seek, fork, save). The program never notices beyond a viewer that left. The layout is a small JSON object (`version 1`: panels, active source and view, selection, speed, source labels, views with cameras, log rows); a layout that is not valid, or that points at a source or view that is not there, is refused as `bad_session` and leaves the shell as it was. Other errors: `bad_session` also covers a malformed or hostile session file, a missing module, or a referenced file that changed. ## The window The shell is laid out for windows from 960x540: the scene views take what the timeline and the log leave, and those two give up height first (down to a floor). The toolbar buttons are short, the **Pokes** panel can be collapsed to its title (**[-]**) and scrolls when the column is short, and a 2D view shows at least its framed area in both directions, so a narrow view is not cut. The toolbar has the transport, Fork, **Frame** and **Frame sel.**, and the overlay toggles. Beside the inspector, a **Pokes** panel appears when the active source is a fork: one button per poke the module declares, then a field for each of its typed parameters (an entity takes an id or, when empty, the selected entity; a vector takes `x,y,z` and starts at the centre of the active view; integers, numbers, text and booleans as typed), **Apply** and **Step**. With *Step after poke* on, Apply queues the poke and runs one step of the fork (as `poke` with `step:true`); with it off the poke waits for **Step**. A refused entry goes to the event log and changes nothing. The panel is built from the module's declarations, so a module's new poke shows up without an app change. Pressing and dragging a body on a fork is the drag poke, as before. Above the timeline, **Fit all**, **Fit fork**, **Zoom in/out** and **<< >>** change which steps it spans; the wheel zooms around the pointer, and `+`, `-`, `0` (fit all) and `f` (fit fork) do the same with the timeline focused. A zoomed timeline shows its range at the bottom right; clicking it seeks to the step under the pointer. `Fit fork` spans from a little before the fork step to a little after the end of the fork's recording, so a five-step fork in a ten-thousand-step history takes the whole width. Zoom is a view setting: it is not saved in a session and never touches a world. ### Framing A new view, `view … frame` and the **Frame** button place the camera on what the view draws with its own overlays, leaving out static and sensor entities and drawings that belong to no entity (a floor, a trigger zone, bounds) while anything else is drawn. A module that tags nothing (draw items with no entity) is framed without items much larger than the typical one (eight times the median extent), which drops a ground plane. **Frame sel.** (`frameSelection`) frames the selected entity and falls back to the same default when the view draws nothing for it. ## Pictures The shell draws the panels with onegame's UI, the 2D views with the 2D renderer's canvas (rotated, rounded rectangles clipped to the view) and the 3D views with the 3D renderer: solids become one lit mesh, lines, points, arrows and wireframes are the renderer's debug lines, the ground grid is labelled in the source's length unit. A 3D view is rendered by the 3D renderer into an offscreen image that the 2D renderer samples directly (the picture never leaves the GPU), and and the labels and the timeline are painted over everything. Forks hang below the time axis as branches: a line from the step they were forked at to the end of their recording, joined to the time axis, with a marker at the divergence step. Captures of the same state are byte-identical run to run on the same renderer; the committed goldens in `tests/OneScope.App.Tests/goldens` come from lavapipe (`VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.json`). Which graphics device a process uses is onegame's pick, in a fixed order, and `ONEGAME_GPU_ADAPTER` (`software`, `discrete`, `integrated` or part of a device name) pins it; the tests pin `software` on Linux and Windows unless it is set. The goldens match exactly on the llvmpipe build that drew them and within one step per channel on another. Triangles and meshes in a 2D view are filled by one-pixel strips because the canvas has rectangles and rounded rectangles only. ## Compare `compare` looks at two sources at once and makes them the **compare pair**: the **Compare** panel, the *agree* row under the timeline's time axis and the outline of the selected entity in both sources' views follow it. Three kinds of pair, chosen from the sources (or `kind`): - `fork`: a fork and its original (the default for a fork), or any two sources on one module copy. The first difference is the module's own finder's. - `versions`: two copies of one module id, each in its own load context. Open one file twice, each time with `moduleAssembly` naming a folder's module dll; the shell pairs the two sources by itself. The worlds are compared through their inspector rows, since a module can't be handed a world made by another copy of itself. If the two copies number the replay differently (one starts at step 0, the other at 1) and play the same number of steps, the pair aligns their starts: `offset` is how far the second source's step numbers run ahead of the first's, steps are reported in the first source's numbering, and a row that only shows each world's own step number is not a difference. Pass `offset` to set it. The shared cursor still puts both sources at one step number. - `shared` (`kind:"shared"`): only the entities both sides hold (a partial world against a whole one), paired by `map` (`identity`, or the declared `links`); roots are left out. Entities held by one side are counted (`onlySource`, `onlyOther`), not listed. The result: `kind`, `step` and `otherStep`, `offset`, `equal` and the two world hashes at the step, `divergence` (the first step whose input differs; forks), and `agreement {basis, scannedThrough, complete, divergentSteps, firstDivergent, lastDivergent, nextDivergent, previousDivergent, divergentHere, runs, runsTruncated}`: the pair is played privately from the start of the shared steps (up to 100,000; 10,000 for `shared`) and a step *agrees* when the world hashes are equal (for `shared`, when the shared entities are). `difference` is the first differing entity and field; `rows[] {entity, otherEntity?, field, source, other}` lists every differing inspector row at the step, at most `limit`, with the full number in `rowCount` (`truncated` says when the list is cut). With `goto`, the shared cursor first moves to the first, last, next or previous divergent step (`moved` says whether there was one). `diff` between two copies of one module id works as well, by the same inspector rows. In the window, the **Compare** button shows a panel with the pair, whether the worlds agree at the cursor, **|< First / < Prev / Next > / Last >|**, and the differing fields; choosing a row selects that entity. The panel shows by itself for a fork, and when `compare` is called or one file is opened through two module copies. ## Every operation `open`, `attach`, `follow`, `session_save`, `session_open`, `sources`, `seek`, `step`, `play`, `offset`, `close`, `timeline`, `entities`, `inspect`, `overlay`, `axes`, `findings`, `fork`, `poke`, `diff`, `compare`, `replay_save`, `pick`, `project`, `view`, `layout`, `capture_frame`, `capture_sequence` are in the table above. `field`, `links`, `selection`, `track`, `ghost`, `open_real` and `diff_shared` are in `fields-links-ghosts.md`. `info`, `ui_tree`, `ui_query`, `ui_click`, `ui_hit`, `ui_scale`, `input_pointer`, `input_key`, `input_text`, `window`, `render_graph` and `quit` are onegame's control operations, forwarded unchanged. `onescope --help` prints the same list, and a test keeps it equal to what the shell registers. ## Bundled modules The app carries the OnePhysics module (`RecursiveAI.OnePhysics.Scope`) and offers it first to every file, so `onescope open run.oppr` works with no `--module`. It opens OnePhysics replays, world snapshots and scene text, 2D and 3D (units: metres). Entities are the world, each body and each joint; overlays are shapes (coloured by state: static, kinematic, awake, asleep, sensor), bounds, contacts, joints and centres of mass; the pokes are `impulse`, `set_velocity`, `set_position` and `wake` on a body. A replay played here gives the library's own world hash after every step. `testdata/physics-*` holds small inputs written by `tools/GenPhysicsData` with the library's recorder, each replay with its per-step hashes (`.hashes`). The app also carries the OnePath module (`RecursiveAI.OnePath.Scope`), second in line. It opens crowd replays (`.oprp`, 2D when the mesh is flat, otherwise 3D), navigation-mesh build inputs (`.opbi`) and grid runs (`.opgr`). A build input has one empty time step and a `stage` axis of seven positions (voxels, filtered spans, compact spans, regions, contours, polygons, detail); a grid run has an `iteration` axis inside each step, the pops of the next slice of an A\*, JPS or flow-field search. Entities are the crowd, its agents and the mesh polygons; the stages; the grid's cells and its start and goal. The pokes are `set_target` (the drag poke: drag an agent), `add_agent`, `remove_agent`, `stop_agent` and `teleport_agent` on a crowd; `move_goal` (the drag poke), `move_start`, `set_wall` and `set_area` on a grid. `testdata/path-*` and `testdata/combo-*` come from `tools/GenPathData`, each crowd replay with the crowd's per-step hashes (`.hashes`); `samples/CrowdHost` is a program with a probe whose crowd the shell attaches to. The `combo-physics` and `combo-crowd` pair is one scenario run by both libraries, for a session that holds a physics world and a crowd on one time axis. Until the module package is on nuget.org, restore needs the folder that holds it: set `ONESCOPE_FEEDS` to that folder (several are separated by `;`) before `dotnet restore`. The navigation module was built against an early preview of the SDK; `DrawStyle` keeps its four-argument constructor so such a build still loads. # Time, history and forks Time is a step index. Nothing in the SDK reads a clock; playback speed belongs to the viewer. ## Recording and worlds A `Recording` is the module's setup bytes plus one opaque input per step. It has a `StartStep`: 0 for a replay from the beginning, or the host's step for a live attach, whose setup is the host's snapshot. The world at step k is the setup with the inputs for steps `StartStep` to k-1 applied in order, so every world is a pure function of the recording. `InputAt`, `Fork` and `FirstDifference` take step numbers, not positions, so a history that starts at step 1502 shows step 1502 first and its hashes line up with the host's by step number. A module that writes a replay must write its start step (the test module takes it from the start snapshot). One input holds at most `Recording.MaxInputBytes` (16 MiB, the probe's largest record). A module reading a file must still bound the step count and total size it accepts before allocating. A recording may carry `ExpectedHashes`, the per-step world hashes its file recorded: entry i is the hash at step `StartStep` + i. The first time `History` plays a step with an expected hash it compares; on a difference it raises one `replay.hash-mismatch` error finding at that step and sets `FirstHashMismatch`. A replay that does not reproduce is then visible on the timeline and in the event log. Forks carry no expected hashes. A step's input is the concatenation of self-delimiting encoded pokes (empty on most steps). Only the module interprets the bytes. ## Keyframes and seeking `History` keeps a snapshot of its start step and of every N-th step it plays through (`KeyframeInterval`, default 64). `Seek(k)` restores the nearest keyframe at or before k (or just steps on when the cursor is already between that keyframe and k) and re-simulates. The result has the same `StateHash` as stepping straight to k, whether the cursor moved forward, backward, randomly, or after keyframes were evicted. Keyframes are created as steps are played, so the first visit to a stretch costs its length; later seeks cost one restore plus fewer than N steps. Memory is bounded by `MaxKeyframes` (default 1024). Beyond it, the interior keyframe whose neighbours are closest together is dropped. The start step and the newest keyframe stay. `CurrentStep` runs from `StartStep` to `EndStep` (`StepCount` is `EndStep` - `StartStep`). Timeline tracks and findings are recorded the first time a step is run and ignored when it is re-simulated. `Scan()` plays through the recording once so the whole timeline is filled. ## Axes A source declares the axes it can be stepped along. `time` is always one. Another axis has positions 0 to P inside one step (a build stage, a solver iteration). `Seek(axis, position)` leaves the time cursor and `StateHash` alone: the view is a scratch world, restored from the cursor's snapshot and run partway through the step that follows. The same (step, position) gives the same hash by any path. ## Forks `History.Fork(step)` creates a `Branch` with its own recording (the original's inputs before `step`), its own source and history, seeded with the original's keyframes and timeline data up to `step`. `Branch.Poke` queues pokes; `Branch.Step` runs a step with them as its input and appends that input to the branch's recording. Writing the branch's source gives a replay that reproduces the branch. The original recording, history and world are never touched. `Branch.DivergenceStep` is the first step whose recorded input differs from the original's. The two worlds are equal through that step and may differ from the next one. ## Diff `IScopeWorld.FirstDifference` returns the first differing entity and field, in the module's fixed order, or null. `History.Diff(other, step)` moves both histories to the step and compares. ## What a fork's pokes can carry A module declares each poke with its parameters (`PokeInfo`). A poke with `PokeRole.DragToPoint` takes an entity and a vector and is the one the app calls when an entity is dragged with the pointer. Draw items carry the entity they show (`DrawStyle.Entity`), which is how the app finds the entity under the pointer and outlines the selected one. # Live attach A viewer connects to a probe (see `probe-wire.md`) and turns its stream into a source that the history re-simulates locally. 1. `ProbeFinder.List` lists the running probes: socket path, module id, library version, format version and process id. 2. `LiveSession.Connect` connects, reads the handshake, asks for the stream and reads the snapshot. The host takes the snapshot at its next recorded step, so it blocks for as long as that takes (up to `timeoutMilliseconds`, default 5000); the shell runs it on a worker thread so its window keeps drawing (`attach` returns `connecting` and `sources.attaching` shows it). `maxRecordBytes` (default 256 MiB, each record counted with `LiveSession.RecordOverheadBytes`) bounds what the viewer keeps: past it `Pump` stops, `Capped` is true and the connection closes. It finds the loaded module whose id the handshake names; that module must implement `ILiveModule` (in the SDK, `OneScope.Sdk`, so one module serves files and live streams). 3. `ILiveModule.OpenLive` gets the library version, the format version and the snapshot, and returns an `ILiveSource`: a source whose world starts at the snapshot and whose recording is writable, with `Recording.StartStep` set to the host's step at the snapshot. 4. A reader thread receives frames into a bounded queue. `LiveSession.Pump`, on the thread that owns the history, hands each record to `ILiveSource.AppendRecord`, which appends to the recording. The history sees new steps as they arrive. If the viewer stops pumping the queue fills, the reader waits, and the host's probe drops the viewer. A live history starts at the host's step at the attach (`History.StartStep`) and its step numbers are the host's, so the viewer's hash at step s equals the host's hash at step s bit for bit. Seeking, forking and diffing work as for a file. Saving a live source writes a replay that starts from the snapshot, so a session reopens to the same hashes. In the app, `attach` connects (by pid or socket) and makes the stream a source. The shell moves arrived records into the history on its own thread (every operation, and every frame of a window), so the history grows while the user scrubs. A live source has a follow flag: on, the shared cursor moves to the host's newest step as records arrive; seeking off the newest step turns it off, seeking to it (or `follow` with `on`) turns it on. `sources` shows `live {state, follow, firstStep, newestStep}`; when the stream ends (the host closed its probe, dropped a slow viewer, exited or sent bad bytes) the source stays, with what was received, and `state` says why. When the host disconnects the viewer, closes its probe, or dies, `LiveSession.State` says which (`Disconnected`, `Ended`) and everything already received stays usable. Attaching again makes a new session from a fresh snapshot. ## A OnePhysics host `samples/PhysicsHost` is a small program that hosts a OnePhysics world (2D or 3D) behind a probe: `physicshost <2d|3d> `. The world records itself with `world.StartRecording(feed)`, where `feed` is the OnePhysics module's `LiveFeedStream`; `feed.Record` is set to the probe's `Record` and the probe's snapshot source calls `feed.WriteSnapshot`. The host also calls `probe.Poll()` after each step so a viewer that attaches while nothing is being recorded still gets its snapshot. It writes `step hash` lines when it ends; a viewer's hashes at the same step numbers are equal, whenever it attached. # Probe wire format (version 1) What a host program's probe and a viewer say to each other over a local socket. The probe carries the library's own replay bytes without reading them; this file covers only the framing around them. All integers are little endian. ## Where the socket is A Unix-domain socket named `p-.sock` in a per-user directory, on every platform (Windows 10 and later support Unix-domain sockets; a named-pipe transport is not used). The directory is, in order: `$ONESCOPE_SOCKET_DIR`; on Unix `$XDG_RUNTIME_DIR/onescope`; otherwise `onescope/probes` under the user's local application data. On Unix the directory is created mode 0700 and tightened if it already exists wider, and the socket is mode 0600, so only the user can connect. Listing the directory and connecting to each socket is how `onescope attach` finds running programs. A socket nobody listens on (the host was killed) is deleted by the listing and reported as removed, never shown as running. A probe never starts on its own: the host creates it with `ScopeProbe.Attach`. Where sockets are unavailable, or the path is too long, the probe stays inert and the host is not told. ## Handshake (probe to viewer, sent on connect) | Offset | Size | Field | |---|---|---| | 0 | 4 | magic `OSPB` | | 4 | 2 | protocol version, 1 | | 6 | 2 | flags, 0 | | 8 | 4 | replay-format version the records use (i32) | | 12 | 4 | host process id | | 16 | 2 | module id length, 1 to 256 | | 18 | 2 | library version length, 0 to 256 | | 20 | n | module id, UTF-8 | | 20+n | m | library version, UTF-8 | The longest handshake is 532 bytes. A reader checks the magic, version, flags and both lengths from the first 20 bytes before reading the strings, rejects invalid UTF-8, and rejects trailing bytes. ## Asking for the stream (viewer to probe) The viewer sends one byte, `1`. Any other value, or closing without sending, ends the connection and changes nothing in the host, which is what a listing does. This byte is the only thing the host ever reads from a viewer. ## Stream (probe to viewer) Frames, each `u8 kind, u32 length, length bytes`: | Kind | Meaning | Limit | |---|---|---| | 1 snapshot | the library's snapshot of the whole world; always the first frame | 64 MiB by default | | 2 record | exactly the bytes the host passed to `Record` | 16 MiB by default | | 3 end | the host closed its probe; length 0 | | A reader checks the kind (order included) and the length against its limits before allocating. A closed socket between frames means the host dropped the viewer or died. A closed socket inside a frame is an error. ## What the host guarantees - `Record` after the host applied a record: the snapshot is taken on the host's thread at the first `Record` or `Poll` after a viewer asks, so it already contains that call's record, which is then not sent. Records after it follow in order. `Poll` lets a viewer attach at step 0, before any record exists. - One streaming viewer at a time; a second that asks while one is attached is closed. - With no viewer attached `Record` is one branch. With one, the bytes are copied into a buffer that the viewer's own thread drains. The buffer holds at most 16 MiB by default; a viewer that falls behind, stops reading (a socket write times out after 5 s), disconnects, or sends a record the viewer's limit would refuse is closed. Attaching again starts from a fresh snapshot. - The probe never throws into the host and never changes the host's world. # Writing a module A module teaches the shell one library. It is a package in the library's own repository, built against `RecursiveAI.OneScope.Sdk` (BCL only, no other dependency). The library itself never references onescope. This page is the contract; every public member has an XML doc comment with its units, limits and what it throws, and [api.md](https://recursiveai.org/onescope/docs/api/) lists all of them. `tests/OneScope.TestModule` is a small complete module that uses every feature. ## The three interfaces ```csharp public sealed class MyModule : IScopeModule // also ILiveModule, to accept a live attach { public string Id => "mylib"; // stable; the probe handshake names it public string Name => "My library"; public IScopeSource Open(ReadOnlyMemory data, string name); // throws ScopeFormatException for bytes it does not own } ``` `Open` recognises its library's files (replay, snapshot, scene) by their own magic bytes and returns an `IScopeSource`; the shell offers a file to each loaded module in turn and takes the first that opens it. Parse the bytes as untrusted: check lengths before allocating, keep arithmetic `checked`, and throw `ScopeFormatException` with a message that says what was expected and where. An `IScopeSource` holds the declarations (`SourceInfo`) and the `Recording`, and makes worlds: - `Info`: id, name, dimensions (2 or 3), `LengthUnit` (grids and rulers use it), the axes, entity kinds, overlays (ids 0 to 63), pokes, timeline tracks and fields. - `Recording`: the setup bytes plus one opaque input per step, starting at `StartStep` (0 for a replay; the host's step for a live attach). The shell never reads an input; only your world does. A step's input is the concatenation of self-delimiting encoded pokes (empty on most steps). - `CreateWorld()`: a fresh world at the recording's start. `WithRecording(r)`: the same source over another recording (a fork's). `WriteReplay(writer)`: the source's recording in the library's own replay format, so a fork can be saved and replayed by the library itself. An `IScopeWorld` is one deterministic world: | Member | What it must do | |---|---| | `Step`, `Hash()` | The step reached and a 64-bit hash of the whole state. Equal states hash equal on every platform; the shell compares it with the file's recorded hashes. | | `Advance(input, sink)` | Apply the step's input, then run one step. Report events, markers, series samples and findings through `sink` (it only records the first time a step is reached). | | `AdvanceAxis(axis, position, input)` | Run part of the step along another axis (a build stage, a solver iteration) on a scratch world; the time state must not change. | | `WriteSnapshot`, `Restore` | The exact state, so the shell can keyframe and seek. Restore then step must equal stepping straight through. | | `CollectEntities`, `Inspect` | The entity tree (`EntityId` is an unsigned 64-bit id, 0 is none) and the property rows for the inspector. | | `Draw(list, request)` | Write this frame's draw list; `request.IsOn(overlay)` says which overlays are on. | | `EncodePoke(call, writer)` | Turn a declared poke into the bytes your `Advance` will apply. | | `FirstDifference(other)` | The first differing entity and field in a fixed order, or null. The desync finder your library has is the natural source. | Optional, with defaults: `TryReadField`/`TryReadFieldAt` (values of a declared field), `CollectLinks` (an entity that continues one in another source) and `CompareShared` (diff over only the entities two worlds both hold). ## What the shell does with them [history.md](https://recursiveai.org/onescope/docs/history/) has the exact semantics. In short: `History` keeps keyframe snapshots every N steps (default 64) and re-simulates between them, so `Seek(k)` gives the same hash as stepping to `k`; memory is bounded; a fork copies the recording up to its step, appends every poke to its own recording, and reports the first step whose input differs from the original's. Nothing in the SDK reads a clock. ## The draw list `DrawList` takes lines, points, triangles, meshes, instanced boxes, spheres, capsules and cylinders, arrows and labels, in the source's length unit (Z is 0 for 2D). Each item carries a `DrawStyle`: colour, overlay id, layer, flags (`Wireframe`, `OnTop`) and the `EntityId` it shows, which is how the shell picks the entity under the pointer and outlines the selection. Colour by state (static, kinematic, awake, asleep, sensor) is the module's choice. The list is cleared and refilled each frame and allocates nothing once warm; reuse your own scratch buffers the same way. Field meshes and points carry a per-vertex or per-triangle scalar with a named colour map ([fields-links-ghosts.md](https://recursiveai.org/onescope/docs/fields-links-ghosts/)). ## Pokes, tracks, findings, overlays - **Pokes** are declared as data (`PokeInfo`: id, name, typed parameters). Mark one `PokeRole.DragToPoint` (an entity and a vector) to make dragging a body in the window use it. A poke only ever applies to a fork. - **Tracks** (`TrackInfo`): `Event` (contact begin, joint break), `Marker` and `Series` (energy, step time). Write them from `Advance` through the `TimelineSink`. - **Findings**: code, severity, entities, text and a suggested change, at a step; the shell shows them on the timeline and in the event log. - **Overlays**: named toggles; draw an item under an overlay id and the shell hides it when that overlay is off. ## Live attach Implement `ILiveModule` as well: `OpenLive` gets the host's snapshot and returns an `ILiveSource` that takes each replay record as it arrives. The host feeds the probe the library's replay bytes ([live-attach.md](https://recursiveai.org/onescope/docs/live-attach/), [probe-wire.md](https://recursiveai.org/onescope/docs/probe-wire/)); the probe never interprets them. A module that can already read the library's replay can usually read the stream with a small adapter. ## Packaging and loading Reference the SDK with a version range that starts at the release you built against. The shell loads the module either as a bundled package or from a path (`onescope open file --module path/to/Module.dll`) into its own collectible `AssemblyLoadContext`; the framework, onegame and the SDK assembly are shared with the host, and everything else the module needs loads from its own folder. Two versions of one module can therefore play one replay side by side (`compare`). Keep the module's public types to the contract; do not reflect over the host. ## Checking your module Drive it through the shell, the way a user or agent will, and check the three things the viewer promises: the shell's hash at every seek equals the library's own hash for that step; a fork's saved replay reproduces the fork's hashes; a planted difference is named by the first differing entity and field. The test module's tests in this repository do each against a small simulation, and `tests/OneScope.App.Tests/BundledModuleBindingTests.cs` shows a cheap check that a module built on an older SDK still binds to the current one. ## Changes before 1.0 The first stable release removed the 0.1 names `HistoryOptions` (use `ScopeHistoryOptions`) and the `History` constructor that took it, and `DrawStyle`'s four-value `Deconstruct`. `ScopeHistoryOptions` is sealed. `DrawStyle`'s four-argument constructor and `Recording.Create(setup, inputs)` stay. The bundled modules were built on earlier previews and bind unchanged: a test compiles every method of each against the current SDK. # Fields, links, shared-entity diffs and ghost sources Four parts of the SDK contract that a module declares as data. Each is optional: a module built before they existed still loads and runs (every addition is a new member with a default). ## Scalar fields A module declares fields in `SourceInfo.Fields` (`FieldInfo`: id, name, `ColorMap`, min, max, unit) and paints them with `DrawList.FieldMesh` (one value per vertex or per triangle) or `DrawList.FieldPoints` (one value per point). The draw item carries `DrawItem.Field` (1 plus the index into `DrawList.FieldBindings`); a renderer that ignores fields draws the item in its own colour. - Colour maps are `Viridis`, `Heat`, `CoolWarm` and `Grayscale`; `ColorMaps.Evaluate` is the one function the app, the legend and a module's own tests share. Values outside min..max clamp; NaN is "no data" and draws grey. - The shell colours a per-vertex mesh by blending across each triangle, a per-triangle mesh flat, and shows a legend (name, unit, colour bar, min and max) in the bottom-right corner of every view that draws a field. - `IScopeWorld.TryReadField(field, entity, out value)` and `TryReadFieldAt(field, point, out value)` give the same values the module paints. They default to "no value". - Operation `field`: no `field` lists the source's declared fields; `field` with `entity` or `point` returns `{source, step, field, entity|point, value, unit, min, max}`. Errors: `bad_args` (unknown field, neither or both of entity and point), `no_value`. - Limits: 1024 field draws and 8M values per list; a wrong value count drops the mesh and counts it in `DrawList.Dropped`. ## Links A world lists transfers with `IScopeWorld.CollectLinks`: an `EntityLink` says one of its entities continues an entity of another source (`Continues`) or is continued by one (`ContinuedBy`), from a step on. Sources name each other by `SourceInfo.LinkKey` (the name when unset). - The shell resolves every source's links to (source, entity) pairs and a step. When the shared cursor is at or past the step, a selection of the departing entity moves to the arriving one in the other source; when it is before the step, back again. The active source, the inspector and the entity highlight follow. A link whose other source is not open is ignored; among several sources with a key, an opened file wins over its forks. - A view with `track` on (operation `track`) re-centres on the selected entity at every seek and switches to the source the selection moved to (the same dimensions only). - Operations: `links` (the resolved pairs, whether the cursor has crossed each), `selection` (source, entity, name and properties of the selected entity), `track {view, on}`. ## Shared-entity diff `IScopeWorld.CompareShared(other, map)` compares only the entities both worlds hold. `EntityMap.Identity` pairs equal ids; `EntityMap.FromPairs` pairs explicitly (for example from links). The default implementation (`SharedDiff.Compare`) compares inspector rows (group and name) of each shared entity; root entities (no parent) are skipped because a root usually aggregates the whole world. The result `SharedDifference` has the first difference (in the first world's entity order) and counts: shared, only in the first, only in the second. Operation `diff_shared {source, other?, step?, map?}` (`other` defaults to a fork's parent; `map` is `identity` or `links`) returns `{source, other, step, map, equal, shared, onlySource, onlyOther, hashSource, hashOther, difference {entity, otherEntity?, field, source, other} | null}`. Entities held by only one side are not compared; they are counted. ## Ghost sources A pose cache (`pose-cache.md`) opens as a source of the module `oscope.posecache` with `SourceInfo.Ghost` set. It plays through the same history as any source, so it scrubs on the shared time axis; frames before and after its range hold the first and last pose. It has one axis (time), no pokes and no tracks. `fork` on it fails with `ghost_source`; `poke` fails with `read_only`; `diff` and `diff_shared` refuse it. - Opening a ghost over an existing source of the module the cache names (any module when it names none, same dimensions) draws it over every view of that source, as translucent outlines, and gives it no view of its own. With no such source it gets views like any source. - `sources` shows `ghost {module, libraryVersion, scene, replay, offer {op: "open_real", args, text, available, path?}}`. `available` is true when the named replay is a plain file name that exists next to the cache. - Operation `open_real {source}` opens that replay with the named module, moves it to the ghost's cursor, draws the ghost over it and returns the new source (`{source, path, module, ghost, step, hash}`). - Operation `ghost` lists ghosts and the views they are over; with `source` and `on` it attaches (`view`? names one view) or detaches. - Ghost positions are drawn as stored; a cache in a different length unit than the source it is drawn over is not rescaled. # Test module replay format (version 1) The test module is a small integer simulation: up to 64 bodies bouncing in a 10 m box (lengths in millimetres), with a stage axis (apply input, gravity, move, bookkeeping). All integers are little endian. ``` magic 4 bytes "OSTM" version u16 1 flags u16 bit 0: hash rows present; bit 1: a start snapshot present; bit 2: a link block follows the setup; other bits must be 0 setup length u32 6, or 10 when flag bit 2 is set setup 6 bytes u8 dimensions (2 or 3), u8 body count (1..64), u32 seed link block 4 bytes (only with flag bit 2) u8 role: 1 sends a body away, 2 receives it, 3 neither (declares the density and occupancy fields only); u8 body index (roles 1 and 2); u16 transfer step step count u32 at most 16 777 216 start snapshot u32 length, then the world snapshot, only if flag bit 1: the state a live attach began from; its first 8 bytes are the host's step at that moment, which is the recording's start step hash rows u64 x (step count + 1), only if flag bit 0; row i is the world hash at the start step + i per step u16 input length, then that many bytes ``` A step input is a sequence of operations: `1 body dx dy dz` (impulse), `2 body x y z` (teleport), `3 body` (remove), `4 gravity`, `5 x y z` (spawn a body at rest; it takes the next free index, and does nothing when the world already holds 64 bodies). `body` is a u8 index, the rest are i32. Files written before spawn existed stay valid. Every length is checked against the bytes that remain before anything is allocated; trailing bytes are an error. Errors are `ScopeFormatException`, naming the input and what was expected at which offset. A world with a link block also declares the two scalar fields (`density`, per grid vertex, and `occupancy`, per triangle, both over an 8 by 8 floor grid) and their overlays. Role 1 removes the body at the transfer step and role 2 holds it from that step on (it is absent before, and appears at the middle of the box with a fixed velocity); the sender declares the link as `ContinuedBy` and the receiver as `Continues`, with link keys `tm-left` and `tm-right`. `TestGhost` writes a pose cache from a run, with an offset, as a stand-in for a reference engine's dump. `testdata/test-10000.ostm` (3D, 24 bodies, 10 000 steps) and `testdata/test-2d-600.ostm` are produced by `tools/GenTestData`, as are `test-field-2d.ostm`, `test-field-3d.ostm`, the linked pair `test-link-left.ostm` and `test-link-right.ostm` (body 3 moves at step 150) and the pose cache `test-2d-ghost.ospc` (the 2D replay's bodies for 40 frames from step 200, drawn 400 mm to the right). ## Live stream The test module is also a live module. Its probe snapshot is the 6-byte setup followed by the world snapshot (a 64-bit host step, gravity, then per body (as many as the world holds, up to 64, spawned ones included) alive, still count, position and velocity as 32-bit integers). Each record is one step's input. A viewer's world starts at the snapshot at the host's step, so the viewer's step numbers are the host's and hashes compare by step number. A live source written as a replay sets flag bit 1 and holds the snapshot, so reopening it reproduces the same hashes. # Public API of the 1.0 libraries Generated by `tools/ApiList` from the built assemblies; every member has an XML doc comment in the source (units, limits, exceptions). ## RecursiveAI.OneScope.Sdk ### static class `AxisIds` - `const string Time` ### struct `AxisInfo` - `AxisInfo(string Id, string Name, int PositionCount)` - `string Id { get; init; }` - `string Name { get; init; }` - `int PositionCount { get; init; }` ### sealed class `Branch` - `History Parent { get; }` - `History History { get; }` - `long ForkStep { get; }` - `int PendingPokes { get; }` - `long? DivergenceStep { get; }` - `void ClearPending()` - `void Poke(in PokeCall call)` - `bool Step()` ### struct `Color32` - `Color32(byte R, byte G, byte B, byte A = 255)` - `byte R { get; init; }` - `byte G { get; init; }` - `byte B { get; init; }` - `byte A { get; init; }` - `static Color32 White { get; }` - `static Color32 Grey { get; }` ### enum `ColorMap` Values: Viridis, Heat, CoolWarm, Grayscale ### static class `ColorMaps` - `static Color32 NoData { get; }` - `static Color32 Evaluate(ColorMap map, float t)` - `static Color32 Evaluate(in FieldInfo field, float value)` - `static float Normalize(in FieldInfo field, float value)` ### struct `Difference` - `Difference(EntityId Entity, string Field, string Left, string Right)` - `EntityId Entity { get; init; }` - `string Field { get; init; }` - `string Left { get; init; }` - `string Right { get; init; }` ### enum `DrawFlags` Values: None, Wireframe, OnTop ### struct `DrawItem` - `DrawKind Kind` - `DrawFlags Flags` - `byte Overlay` - `int Layer` - `Color32 Color` - `EntityId Entity` - `Vector3 A` - `Vector3 B` - `int Field` - `Vector3 C` - `Quaternion Rotation` - `Vector3 Size` - `int First` - `int Count` ### enum `DrawKind` Values: Line, Point, Triangle, Mesh, Box, Sphere, Capsule, Cylinder, Arrow, Label ### sealed class `DrawList` - `DrawList()` - `const int MaxItems` - `const int MaxVertices` - `const int MaxChars` - `const int MaxFieldBindings` - `const int MaxFieldValues` - `ReadOnlySpan Items { get; }` - `ReadOnlySpan Vertices { get; }` - `ReadOnlySpan Indices { get; }` - `ReadOnlySpan TextChars { get; }` - `int Dropped { get; }` - `IReadOnlyList FieldBindings { get; }` - `ReadOnlySpan FieldValues { get; }` - `void Arrow(Vector3 from, Vector3 to, float headSize, in DrawStyle style)` - `void Box(Vector3 center, Vector3 halfExtents, Quaternion rotation, in DrawStyle style)` - `void Capsule(Vector3 a, Vector3 b, float radius, in DrawStyle style)` - `void Clear()` - `void Cylinder(Vector3 a, Vector3 b, float radius, in DrawStyle style)` - `Color32 FieldColorAt(in DrawItem item, int indexPosition)` - `void FieldMesh(ReadOnlySpan vertices, ReadOnlySpan indices, ReadOnlySpan values, FieldDomain domain, in FieldInfo field, in DrawStyle style)` - `void FieldPoints(ReadOnlySpan positions, ReadOnlySpan values, in FieldInfo field, float pixels, in DrawStyle style)` - `float FieldValueAt(in DrawItem item, int indexPosition)` - `float FieldValueOf(in DrawItem item)` - `void Label(Vector3 at, ReadOnlySpan text, in DrawStyle style)` - `void Line(Vector3 a, Vector3 b, in DrawStyle style)` - `void Mesh(ReadOnlySpan vertices, ReadOnlySpan indices, in DrawStyle style)` - `void Point(Vector3 at, float pixels, in DrawStyle style)` - `void Sphere(Vector3 center, float radius, in DrawStyle style)` - `ReadOnlySpan TextOf(in DrawItem item)` - `void Triangle(Vector3 a, Vector3 b, Vector3 c, in DrawStyle style)` ### struct `DrawRequest` - `DrawRequest(ulong Overlays)` - `ulong Overlays { get; init; }` - `bool IsOn(int overlay)` ### struct `DrawStyle` - `DrawStyle(Color32 Color, int Overlay = 0, int Layer = 0, DrawFlags Flags = None, EntityId Entity = default)` - `DrawStyle(Color32 color, int overlay, int layer, DrawFlags flags)` - `Color32 Color { get; init; }` - `int Overlay { get; init; }` - `int Layer { get; init; }` - `DrawFlags Flags { get; init; }` - `EntityId Entity { get; init; }` ### struct `EntityId` - `EntityId(ulong Value)` - `ulong Value { get; init; }` - `static EntityId None { get; }` - `bool IsSome { get; }` ### struct `EntityInfo` - `EntityInfo(EntityId Id, EntityId Parent, string Kind, string Name)` - `EntityId Id { get; init; }` - `EntityId Parent { get; init; }` - `string Kind { get; init; }` - `string Name { get; init; }` ### struct `EntityKindInfo` - `EntityKindInfo(string Id, string Name)` - `string Id { get; init; }` - `string Name { get; init; }` ### struct `EntityLink` - `EntityLink(EntityId Entity, LinkDirection Direction, string Source, EntityId Other, long Step)` - `EntityId Entity { get; init; }` - `LinkDirection Direction { get; init; }` - `string Source { get; init; }` - `EntityId Other { get; init; }` - `long Step { get; init; }` ### sealed class `EntityMap` - `static EntityMap Identity { get; }` - `bool IsIdentity { get; }` - `int PairCount { get; }` - `static EntityMap FromPairs(IEnumerable> pairs)` - `bool TryMap(EntityId left, out EntityId right)` ### struct `FieldBinding` - `FieldBinding(FieldInfo Field, FieldDomain Domain, int ValueStart, int ValueCount, int VertexBase)` - `FieldInfo Field { get; init; }` - `FieldDomain Domain { get; init; }` - `int ValueStart { get; init; }` - `int ValueCount { get; init; }` - `int VertexBase { get; init; }` ### enum `FieldDomain` Values: Vertex, Element ### struct `FieldInfo` - `FieldInfo(string Id, string Name, ColorMap Map, double Min, double Max, string Unit = )` - `string Id { get; init; }` - `string Name { get; init; }` - `ColorMap Map { get; init; }` - `double Min { get; init; }` - `double Max { get; init; }` - `string Unit { get; init; }` ### sealed class `Finding` - `Finding(long Step, string Code, Severity Severity, IReadOnlyList Entities, string Text, string SuggestedChange)` - `long Step { get; init; }` - `string Code { get; init; }` - `Severity Severity { get; init; }` - `IReadOnlyList Entities { get; init; }` - `string Text { get; init; }` - `string SuggestedChange { get; init; }` ### struct `GhostInfo` - `GhostInfo(string ModuleId, string LibraryVersion, string Scene, string Replay)` - `string ModuleId { get; init; }` - `string LibraryVersion { get; init; }` - `string Scene { get; init; }` - `string Replay { get; init; }` ### sealed class `History` - `History(IScopeSource source, ScopeHistoryOptions options = default)` - `const string MismatchCode` - `IScopeSource Source { get; }` - `Recording Recording { get; }` - `long CurrentStep { get; }` - `long StartStep { get; }` - `long EndStep { get; }` - `long StepCount { get; }` - `long? FirstHashMismatch { get; }` - `IScopeWorld World { get; }` - `IScopeWorld View { get; }` - `string ViewAxis { get; }` - `int ViewPosition { get; }` - `ulong StateHash { get; }` - `int KeyframeCount { get; }` - `long KeyframeBytes { get; }` - `IEnumerable KeyframeSteps { get; }` - `long ScannedThrough { get; }` - `IReadOnlyList Tracks { get; }` - `IReadOnlyList Findings { get; }` - `IReadOnlyList Branches { get; }` - `Branch Adopt(History child, long forkStep)` - `Difference? Diff(History other, long step)` - `Branch Fork(long step)` - `void Scan(long throughStep = 9223372036854775807)` - `void Seek(long step)` - `void Seek(string axis, long position)` - `bool Step()` - `bool Step(string axis, long delta = 1)` ### interface `ILiveModule` : IScopeModule - `ILiveSource OpenLive(string libraryVersion, int formatVersion, ReadOnlyMemory snapshot, string name)` ### interface `ILiveSource` - `IScopeSource Source { get; }` - `void AppendRecord(ReadOnlySpan record)` ### interface `IScopeModule` - `string Id { get; }` - `string Name { get; }` - `IScopeSource Open(ReadOnlyMemory data, string name)` ### interface `IScopeSource` - `SourceInfo Info { get; }` - `Recording Recording { get; }` - `IScopeWorld CreateWorld()` - `IScopeSource WithRecording(Recording recording)` - `void WriteReplay(IBufferWriter writer)` ### interface `IScopeWorld` - `long Step { get; }` - `void Advance(ReadOnlySpan input, TimelineSink sink)` - `void AdvanceAxis(string axis, int position, ReadOnlySpan input)` - `void CollectEntities(List into)` - `void CollectLinks(List into)` - `SharedDifference CompareShared(IScopeWorld other, EntityMap map)` - `void Draw(DrawList into, in DrawRequest request)` - `void EncodePoke(in PokeCall call, IBufferWriter into)` - `Difference? FirstDifference(IScopeWorld other)` - `ulong Hash()` - `bool Inspect(EntityId entity, PropertyList into)` - `void Restore(ReadOnlySpan snapshot)` - `bool TryReadField(string field, EntityId entity, out double value)` - `bool TryReadFieldAt(string field, Vector3 point, out double value)` - `void WriteSnapshot(IBufferWriter writer)` ### struct `LengthUnit` - `LengthUnit(string Name, double Metres)` - `string Name { get; init; }` - `double Metres { get; init; }` - `static LengthUnit Metre { get; }` - `static LengthUnit Millimetre { get; }` - `static LengthUnit Centimetre { get; }` - `static LengthUnit Kilometre { get; }` - `static LengthUnit Inch { get; }` - `static LengthUnit Foot { get; }` - `static LengthUnit Pixel { get; }` - `bool HasPhysicalSize { get; }` ### enum `LinkDirection` Values: Continues, ContinuedBy ### struct `OverlayInfo` - `OverlayInfo(int Id, string Name, bool DefaultOn)` - `int Id { get; init; }` - `string Name { get; init; }` - `bool DefaultOn { get; init; }` ### struct `ParamInfo` - `ParamInfo(string Name, ValueKind Kind)` - `string Name { get; init; }` - `ValueKind Kind { get; init; }` ### struct `PokeCall` - `PokeCall(string Poke, ScopeValue[] Args)` - `string Poke { get; init; }` - `ScopeValue[] Args { get; init; }` ### struct `PokeInfo` - `PokeInfo(string Id, string Name, ParamInfo[] Params)` - `string Id { get; init; }` - `string Name { get; init; }` - `ParamInfo[] Params { get; init; }` - `PokeRole Role { get; init; }` ### enum `PokeRole` Values: None, DragToPoint ### sealed class `PoseCache` - `const ushort Version` - `const int MaxBytes` - `const int MaxEntities` - `const int MaxFrames` - `const int MaxTextBytes` - `static ReadOnlySpan Magic { get; }` - `PoseCacheInfo Info { get; }` - `IReadOnlyList Entities { get; }` - `int FrameCount { get; }` - `long StartStep { get; }` - `static PoseCache Create(PoseCacheInfo info, IReadOnlyList entities, float[] poses)` - `static bool HasMagic(ReadOnlySpan data)` - `static PoseCache Parse(ReadOnlyMemory data, string name)` - `bool TryGetPose(int frame, int entity, out Vector3 position, out Quaternion rotation)` - `void Write(IBufferWriter writer)` ### struct `PoseCacheInfo` - `PoseCacheInfo(int Dimensions, LengthUnit Unit, long StartStep, string ModuleId, string LibraryVersion, string Scene, string Replay, string Label)` - `int Dimensions { get; init; }` - `LengthUnit Unit { get; init; }` - `long StartStep { get; init; }` - `string ModuleId { get; init; }` - `string LibraryVersion { get; init; }` - `string Scene { get; init; }` - `string Replay { get; init; }` - `string Label { get; init; }` ### struct `PoseEntity` - `PoseEntity(EntityId Id, string Name, PoseShape Shape, Vector3 Size)` - `EntityId Id { get; init; }` - `string Name { get; init; }` - `PoseShape Shape { get; init; }` - `Vector3 Size { get; init; }` ### enum `PoseShape` Values: Point, Sphere, Box, Capsule ### struct `Property` - `Property(string Group, string Name, ScopeValue Value)` - `string Group { get; init; }` - `string Name { get; init; }` - `ScopeValue Value { get; init; }` ### sealed class `PropertyList` - `PropertyList()` - `const int MaxRows` - `IReadOnlyList Rows { get; }` - `void Add(string group, string name, ScopeValue value)` - `void Clear()` ### sealed class `Recording` - `const int MaxInputBytes` - `ReadOnlyMemory Setup { get; }` - `long StartStep { get; }` - `long StepCount { get; }` - `long EndStep { get; }` - `ReadOnlyMemory ExpectedHashes { get; }` - `bool IsWritable { get; }` - `void Append(ReadOnlySpan input)` - `static Recording Create(ReadOnlyMemory setup, IEnumerable> inputs)` - `static Recording Create(ReadOnlyMemory setup, IEnumerable> inputs, long startStep, ReadOnlyMemory expectedHashes = default)` - `long? FirstDifference(Recording other)` - `Recording Fork(long step)` - `ReadOnlyMemory InputAt(long step)` ### sealed class `ScopeFormatException` : Exception - `ScopeFormatException(string input, string message)` - `string Input { get; }` ### sealed class `ScopeHistoryOptions` - `ScopeHistoryOptions()` - `const int DefaultKeyframeInterval` - `int KeyframeInterval { get; init; }` - `int MaxKeyframes { get; init; }` - `int MaxTrackSamples { get; init; }` - `int MaxFindings { get; init; }` ### struct `ScopeValue` - `ValueKind Kind { get; }` - `bool Equals(ScopeValue other)` - `bool Equals(object obj)` - `bool GetBool()` - `EntityId GetEntity()` - `int GetHashCode()` - `long GetInteger()` - `double GetNumber()` - `string GetText()` - `Vector3 GetVector()` - `static ScopeValue OfBool(bool value)` - `static ScopeValue OfEntity(EntityId value)` - `static ScopeValue OfInteger(long value)` - `static ScopeValue OfNumber(double value)` - `static ScopeValue OfText(string value)` - `static ScopeValue OfVector(Vector3 value)` - `string ToString()` ### enum `Severity` Values: Info, Warning, Error ### static class `SharedDiff` - `static SharedDifference Compare(IScopeWorld left, IScopeWorld right, EntityMap map)` ### struct `SharedDifference` - `SharedDifference(Difference? First, int Shared, int OnlyLeft, int OnlyRight)` - `Difference? First { get; init; }` - `int Shared { get; init; }` - `int OnlyLeft { get; init; }` - `int OnlyRight { get; init; }` ### sealed class `SourceInfo` - `SourceInfo()` - `string ModuleId { get; init; }` - `string Name { get; init; }` - `int Dimensions { get; init; }` - `LengthUnit Unit { get; init; }` - `IReadOnlyList Axes { get; init; }` - `IReadOnlyList EntityKinds { get; init; }` - `IReadOnlyList Overlays { get; init; }` - `IReadOnlyList Pokes { get; init; }` - `IReadOnlyList Tracks { get; init; }` - `IReadOnlyList Fields { get; init; }` - `string LinkKey { get; init; }` - `string EffectiveLinkKey { get; }` - `GhostInfo? Ghost { get; init; }` ### sealed class `TimelineSink` - `static TimelineSink None { get; }` - `bool IsRecording { get; }` - `long Step { get; }` - `void Event(string track, EntityId entity, string text)` - `void Finding(string code, Severity severity, IReadOnlyList entities, string text, string suggestedChange = default)` - `void Marker(string track, string label)` - `void Sample(string track, double value)` ### sealed class `TrackData` - `TrackInfo Info { get; }` - `IReadOnlyList Samples { get; }` - `bool Truncated { get; }` ### struct `TrackInfo` - `TrackInfo(string Id, string Name, TrackKind Kind)` - `string Id { get; init; }` - `string Name { get; init; }` - `TrackKind Kind { get; init; }` ### enum `TrackKind` Values: Event, Marker, Series ### struct `TrackSample` - `TrackSample(long Step, double Value, EntityId Entity, string Text)` - `long Step { get; init; }` - `double Value { get; init; }` - `EntityId Entity { get; init; }` - `string Text { get; init; }` ### enum `ValueKind` Values: None, Bool, Integer, Number, Text, Vector, Entity ## RecursiveAI.OneScope.Probe ### interface `ISnapshotSource` - `void WriteSnapshot(IBufferWriter writer)` ### static class `ProbeDirectory` - `const string OverrideVariable` - `const string SocketExtension` - `static string Default()` - `static void Prepare(string directory)` ### sealed class `ProbeFormatException` : Exception - `ProbeFormatException(string input, string message)` - `string Input { get; }` ### enum `ProbeFrameKind` Values: Snapshot, Record, End ### struct `ProbeHandshake` - `ProbeHandshake(ProbeInfo Info, int ProcessId)` - `ProbeInfo Info { get; init; }` - `int ProcessId { get; init; }` ### struct `ProbeInfo` - `ProbeInfo(string ModuleId, string LibraryVersion, int FormatVersion)` - `string ModuleId { get; init; }` - `string LibraryVersion { get; init; }` - `int FormatVersion { get; init; }` ### struct `ProbeLimits` - `ProbeLimits(int MaxSnapshotBytes, int MaxRecordBytes)` - `int MaxSnapshotBytes { get; init; }` - `int MaxRecordBytes { get; init; }` - `static ProbeLimits Default { get; }` ### sealed class `ProbeOptions` - `ProbeOptions()` - `string Directory { get; init; }` - `bool Listen { get; init; }` - `int MaxBufferedBytes { get; init; }` - `int MaxSnapshotBytes { get; init; }` - `int SendTimeoutMilliseconds { get; init; }` ### static class `ProbeWire` - `const ushort ProtocolVersion` - `const byte AttachCommand` - `const int HandshakeHeaderLength` - `const int MaxStringBytes` - `const int MaxHandshakeLength` - `const int FrameHeaderLength` - `static int HandshakeLength(in ProbeInfo info)` - `static ProbeHandshake ParseHandshake(ReadOnlySpan bytes, string input)` - `static ValueTuple ReadFrameHeader(ReadOnlySpan header, in ProbeLimits limits, bool first, string input)` - `static int ReadHandshakeStringBytes(ReadOnlySpan header, string input)` - `static void WriteFrameHeader(Span destination, ProbeFrameKind kind, int length)` - `static int WriteHandshake(Span destination, in ProbeInfo info, int processId)` ### sealed class `ScopeProbe` - `ProbeInfo Info { get; }` - `ISnapshotSource Snapshots { get; }` - `bool IsConnected { get; }` - `bool IsListening { get; }` - `string SocketPath { get; }` - `int DroppedViewers { get; }` - `string LastDropReason { get; }` - `static ScopeProbe Attach(ProbeInfo info, ISnapshotSource snapshots)` - `static ScopeProbe Attach(ProbeInfo info, ISnapshotSource snapshots, ProbeOptions options)` - `void Dispose()` - `void Poll()` - `void Record(ReadOnlySpan record)` ## RecursiveAI.OneScope.Attach ### enum `ClientState` Values: Streaming, Ended, Disconnected, Failed, Closed ### sealed class `LiveSession` - `const int RecordOverheadBytes` - `const long DefaultMaxRecordBytes` - `string Name { get; }` - `ProbeHandshake Handshake { get; }` - `ILiveSource Live { get; }` - `History History { get; }` - `ClientState State { get; }` - `long MaxRecordBytes { get; }` - `long RecordBytes { get; }` - `bool Capped { get; }` - `ProbeFormatException StreamError { get; }` - `static LiveSession Connect(string socketPath, IReadOnlyList modules, ScopeHistoryOptions options = default, ProbeLimits? limits = default, long maxRecordBytes = 268435456, int timeoutMilliseconds = 5000)` - `void Dispose()` - `int Pump(int max = 2147483647)` ### sealed class `ProbeClient` - `static long MaxQueuedBytes { get; }` - `ProbeHandshake Handshake { get; }` - `byte[] Snapshot { get; }` - `ClientState State { get; }` - `ProbeFormatException Error { get; }` - `static ProbeClient Connect(string socketPath, ProbeLimits? limits = default, int timeoutMilliseconds = 5000)` - `void Dispose()` - `int Drain(List into, int max = 2147483647)` ### sealed class `ProbeEntry` - `ProbeEntry(string SocketPath, ProbeHandshake Handshake)` - `string SocketPath { get; init; }` - `ProbeHandshake Handshake { get; init; }` - `int ProcessId { get; }` ### static class `ProbeFinder` - `static ProbeListing List(string directory = default, int timeoutMilliseconds = 2000)` - `static ProbeHandshake ReadHandshake(string socketPath, int timeoutMilliseconds = 2000)` ### sealed class `ProbeListing` - `ProbeListing(IReadOnlyList Running, IReadOnlyList Removed, IReadOnlyList Unreadable)` - `IReadOnlyList Running { get; init; }` - `IReadOnlyList Removed { get; init; }` - `IReadOnlyList Unreadable { get; init; }` ### sealed class `RestoredSession` - `RestoredSession(long CursorStep, ReadOnlyMemory Layout, IReadOnlyList Sources)` - `long CursorStep { get; init; }` - `ReadOnlyMemory Layout { get; init; }` - `IReadOnlyList Sources { get; init; }` ### sealed class `RestoredSource` - `RestoredSource(int Index, string Name, IScopeSource Source, History History, int ParentIndex, long ForkStep, long? DivergenceStep, Branch Branch = default)` - `int Index { get; init; }` - `string Name { get; init; }` - `IScopeSource Source { get; init; }` - `History History { get; init; }` - `int ParentIndex { get; init; }` - `long ForkStep { get; init; }` - `long? DivergenceStep { get; init; }` - `Branch Branch { get; init; }` ### sealed class `SessionFile` - `SessionFile()` - `const ushort Version` - `const int HeaderLength` - `const int SourceHeaderLength` - `const int MaxStringBytes` - `long CursorStep { get; set; }` - `List Sources { get; }` - `ReadOnlyMemory Layout { get; set; }` - `static SessionFile Load(string path, SessionLimits? limits = default)` - `static SessionFile Parse(ReadOnlyMemory bytes, string input, SessionLimits? limits = default)` - `void Write(IBufferWriter writer)` ### struct `SessionLimits` - `SessionLimits(long MaxFileBytes, int MaxSources, int MaxLayoutBytes)` - `long MaxFileBytes { get; init; }` - `int MaxSources { get; init; }` - `int MaxLayoutBytes { get; init; }` - `static SessionLimits Default { get; }` ### static class `SessionRestore` - `static RestoredSession Open(SessionFile file, IReadOnlyList modules, Func readFile)` ### sealed class `SessionSource` - `string ModuleId { get; }` - `string Name { get; }` - `SessionSourceKind Kind { get; }` - `int ParentIndex { get; }` - `long ForkStep { get; }` - `int KeyframeInterval { get; }` - `ReadOnlyMemory Replay { get; }` - `string Path { get; }` - `byte[] Sha256 { get; }` - `static SessionSource Embed(IScopeSource source, int keyframeInterval = 64)` - `static SessionSource EmbedFork(IScopeSource source, int parentIndex, long forkStep, int keyframeInterval = 64)` - `static SessionSource Reference(string moduleId, string name, string path, ReadOnlySpan fileBytes, int keyframeInterval = 64)` ### enum `SessionSourceKind` Values: Embedded, Referenced # Session file `.oscope` (version 1) The open sources of a session, the forks with their poke replays, the shared time cursor, and an opaque blob the shell uses for layout and cameras. All integers are little endian. Reading a session and writing it again gives the same bytes. ## Header (24 bytes) | Offset | Size | Field | |---|---|---| | 0 | 4 | magic `OSCP` | | 4 | 2 | version, 1 | | 6 | 2 | flags, 0 | | 8 | 8 | time cursor, an absolute step number, 0 or more (i64) | | 16 | 4 | source count, at most 4096 | | 20 | 4 | layout length, at most 16 MiB | ## Sources Source count records follow the header, parents before their forks. Each has a 27-byte fixed part, then three strings, then a payload: | Offset | Size | Field | |---|---|---| | 0 | 1 | kind: 0 embedded, 1 referenced | | 1 | 4 | parent source index, or -1; always an earlier source (i32) | | 5 | 8 | fork step, an absolute step number of the parent; 0 when there is no parent (i64) | | 13 | 4 | keyframe interval for the source's history, at least 1 | | 17 | 2 | module id length, 1 to 4096 | | 19 | 2 | name length, 0 to 4096 | | 21 | 2 | path length, 0 to 4096; 0 for embedded, at least 1 for referenced | | 23 | 4 | payload length | then the module id, name and path as UTF-8, then the payload: - **Embedded:** the source's replay in the module's own replay format. A fork is always embedded, because its replay holds the pokes; its parent index and fork step say what it was forked from. - **Referenced:** a 32-byte SHA-256 of the file as it was when saved. Opening the session reads the file by its path and refuses one whose hash differs. After the last source come exactly layout-length bytes of layout, then the end of the file. The SDK never interprets them. ## Limits and errors The reader enforces a file size limit (1 GiB by default), the source and layout limits above, and checks every length against the bytes that remain before allocating. Errors are `ScopeFormatException` naming the input and what was expected at which offset or source. ## Opening `SessionRestore.Open` opens each source with the module whose id it names, builds a history for each at the saved cursor, and for a fork computes its divergence from its parent's recording. A fork reopens from its own replay, so it reproduces the hashes it had when saved. # Pose cache (version 1) A pose cache holds the positions and orientations of a fixed set of things over consecutive steps, with no simulation behind them: a reference engine's pose dump, a baked clip. The shell opens one as a **ghost source** (see `fields-links-ghosts.md`). The reader is `OneScope.Sdk.PoseCache`; a module or a tool can write one with `PoseCache.Create` and `Write`. All integers and floats are little endian. Frame k is the world at step `start step` + k. ``` magic 4 bytes "OSPC" version u16 1 flags u16 0 (any other value is an error) dimensions u8 2 or 3 reserved u8 + u16 zero (any other value is an error, so a parsed cache always writes back to the same bytes) entity count u32 1 to 65 536 frame count u32 1 to 4 194 304 start step i64 0 or more metres per unit f64 0 (a unit with no physical size) or a positive finite number text x 6 u16 length (at most 1024) + UTF-8 bytes, in this order: unit name, module id, library version, scene, replay, label entities entity count x: u64 id not 0, unique u8 shape 0 point, 1 sphere, 2 box, 3 capsule 3 bytes zero f32 x 3 size: sphere radius in x; box half extents; capsule radius in x and half length in y (axis is local Y); finite u16 + bytes name (at most 1024 bytes of UTF-8) poses frame count x entity count x 7 x f32: position x y z, rotation x y z w ``` The poses are the rest of the file: its length must equal frame count x entity count x 28 exactly, so a count cannot claim more than the file holds. A NaN position x means the entity is absent in that frame (the rotation is then ignored); every other value must be finite. A zero rotation reads as the identity. The origin fields (module id, library version, scene, replay) are free text, each possibly empty. When `replay` is a plain file name (no directory part), the shell looks for it next to the cache and offers to open it with the named module; any other value offers nothing, so a cache can never point the shell at another folder. Limits: the file is at most 256 MiB. Every length is checked against the bytes that remain before anything is allocated, sizes are computed with checked arithmetic, and every error is a `ScopeFormatException` that names the input, what was expected and the offset. Reading the bytes and writing them again gives the same bytes. # Fuzzing the file formats `tools/Fuzz` is a coverage-guided harness for the three formats onescope parses from untrusted bytes: the probe handshake and stream frame headers (`handshake`), the `.oscope` session file (`session`) and the pose cache (`posecache`). It uses SharpFuzz with AFL++, and it is a test tool that never ships. A typed format error (`ScopeFormatException`, `ProbeFormatException`) is the right answer to bad input. Anything else is a finding: another exception, a hang (10 s per input), or an input that makes the parser allocate far more than the input's size. A session or pose cache that parses must also write back to the same bytes. ## Run ``` TARGETS="handshake session posecache" tools/Fuzz/run-afl.sh ``` Needs the .NET SDK, `curl`, `make` and `gcc`. On first use it builds AFL++ and installs the SharpFuzz tool into ``, then publishes the harness, writes seed corpora (`Fuzz seeds `, plus the pose caches in `testdata/`), instruments the assemblies and fuzzes each target. It prints executions, bitmap coverage, crashes and hangs from AFL's stats. Crashing inputs are in `/out-/default/crashes`. Replay one input without the fuzzer: ``` dotnet /app/Fuzz.dll replay ``` Every crash found becomes a minimized file in `testdata/` and a test that parses it and expects the typed error (see the `fuzz-posecache-*` files). # Building and releasing ## What a build needs Everything the app, samples and tests reference comes from nuget.org: onegame's packages, the libraries the bundled modules sit on, and the modules themselves. `tools/check-packages.sh` reads the pinned versions (`Directory.Packages.props` and the tests' `VersionOverride` attributes) for `RecursiveAI.OnePhysics`, `RecursiveAI.OnePhysics.Scope`, `RecursiveAI.OnePath` and `RecursiveAI.OnePath.Scope` and asks nuget.org whether each exists. When one is missing it fails with a single message naming it, so a missing package is not mistaken for a restore problem. The `app` jobs of the CI workflow and the tool release run it first. The three libraries (SDK, probe and attach) and their tests need none of these packages. To build before those packages are published, put them in a folder and point `ONESCOPE_FEEDS` at it (several folders are separated by `;`): ``` export ONESCOPE_FEEDS=/path/to/feed dotnet build OneScope.slnx ``` The SDK, probe and attach projects have no package references at all. ## The A/B tests Playing one replay through two builds of a module in separate load contexts is tested with builds that need nothing but nuget.org's final packages. The test module is built twice with one rule changed (`tests/OneScope.TestModule.RuleB`): the two builds agree up to the step where the rule differs, the compare operation names that step, and the diff names the first differing entity and field. The physics module is loaded twice from two folders, each its own context, and both reproduce the library's recorded hash at every step. Adding a physics build that differs from the current one means another published version of the module, and only the final release of each is on nuget.org. ## Package validation When `ONESCOPE_FEEDS` is set, packing the SDK, probe and attach packages is checked against the last 0.x preview (`0.3.0-preview.1`), so a binary break for a module built on it is listed. The breaks intended for 1.0 are recorded in `src/OneScope.Sdk/CompatibilitySuppressions.xml`; any new one fails the pack. Once 1.0 is published, point `PackageValidationBaselineVersion` at it and delete the suppressions file. ## Continuous integration `.github/workflows/ci.yml` has two groups of jobs, each on Linux, macOS and Windows: - **`libraries`**: restores, builds and tests only the SDK, probe and attach test projects, then packs the three libraries. They need nothing but the .NET SDK and nuget.org's test packages, so these jobs go green on a clean runner before any module package exists. - **`app`**: `tools/check-packages.sh` (strict), then the whole solution, the app's tests and, on Linux, the tool pack. It can only pass once the physics and navigation modules are on nuget.org. Golden images come from the lavapipe software renderer: the Linux `app` job installs it and compares pixels exactly (within one step per channel on a different llvmpipe build); macOS and Windows draw on the machine's renderer, which differs in the last bits, so there the tests check that three runs of each capture are byte-identical and skip the comparison with the goldens. ## Releasing The packages are released in an order, because each step builds on the one before it: | Step | What | How | Needs | |---|---|---|---| | 1 | `RecursiveAI.OneScope.Sdk`, `.Probe`, `.Attach` | tag `v` | the `libraries` jobs of `ci.yml` green on the commit | | 2 | `RecursiveAI.OnePath` | the onepath repository | step 1 is on nuget.org | | 3 | `RecursiveAI.OnePath.Scope` and `RecursiveAI.OnePhysics.Scope` | their own repositories, each built on the SDK from step 1 | steps 1 and 2 are on nuget.org | | 4 | `RecursiveAI.OneScope.Tool` | tag `tool-v` | step 3 is on nuget.org, and the whole `ci.yml` run green on the commit (`app` jobs and `check-packages.sh` included) | Because the app's pins must name published packages, the commit that gets the `tool-v` tag is later than the one that got `v`: pin the final module versions, wait for CI, then tag. `CHANGELOG.md` keeps an `Unreleased` section. A library release adds `## [] - ` above it, a tool release `## [tool ] - `; the section becomes that GitHub release's notes. `tools/release-checks.sh` holds the checks the workflow makes on a tag, so they can be run by hand: `kind` (`libraries` or `tool`), `tag-form` (`v1.2.3` or `tool-v1.2.3`, with an optional suffix such as `-rc.1`), `changelog` (the section for the tag), `version` (the tag equals the version of the packages it publishes), `notes` (prints the section) and `ci ` (the CI requirement above, through the `gh` command). `.github/workflows/release.yml` runs on either tag form, in three jobs: 1. **verify**: the tag is well formed, its commit is on `main`, CI is green as the table says (for a library tag: the three `libraries` jobs, by job, even while the app jobs are red; for a tool tag: the whole run), and the changelog has the version. 2. **publish** (environment `nuget`): the version check, then (tool only) `check-packages.sh`, build and test of what the tag publishes, pack with `.snupkg` symbols, a provenance attestation, and a push to nuget.org with a short-lived key from trusted publishing (`NuGet/login`, user `recursiveai.org`); no API key is stored. Pushes use `--skip-duplicate`. 3. **release**: a GitHub release named after the tag, with the changelog section as notes and the packages attached; a pre-release when the version has a suffix. Every action is pinned by commit, with the version in a comment, because the publish job can obtain a publishing key. One-time setup, done by an owner of the nuget.org account: a trusted publishing policy for this repository, the workflow file `release.yml` and the environment `nuget`; and a repository environment named `nuget`. # Changelog What changed in each release, newest first. The libraries (`RecursiveAI.OneScope.Sdk`, `.Probe` and `.Attach`) are released with a `v` tag, under a `## []` heading; the tool (`RecursiveAI.OneScope.Tool`) with a `tool-v` tag, under `## [tool ]`. ## [Unreleased] ## [tool 1.0.0] - 2026-11-11 The first stable release of the `onescope` tool (`RecursiveAI.OneScope.Tool`): a viewer for the deterministic libraries, built on onegame and bundling the physics and navigation modules. It runs in a window or headless, on the desktop platforms onegame supports. - **Open and attach.** Open a scene, snapshot or replay file, or attach to a running game, test or benchmark that hosts the probe. A live source is re-simulated locally, so the host is never changed or slowed by the viewer. - **Scrub.** One timeline across every open source, with tracks for events, markers, series and findings. Seeking to any step goes through keyframes and lands on the same hash as playing straight to it. - **Inspect.** Entity tree, inspector, selection that follows entities across sources, fields drawn as colour maps, and overlays declared by the module. - **Fork and poke.** Fork any source at any step, poke the fork (drag, spawn, delete, set a parameter), and step it. Every poke is recorded into the fork's own replay, and the fork is drawn on the timeline as a branch from its divergence step. Originals and live processes are never changed. - **Compare.** A fork against its original, the same replay through two versions of a library side by side, a shared-entity diff that names the first differing entity and field, and ghost tracks from recorded pose dumps. - **Sessions.** `.oscope` files keep the open sources, forks, layout and cameras. - **MCP and CLI.** Every action is one operation with JSON results, reachable from the UI, the command line and MCP, with a window or headless. Headless captures (`capture_frame`, `capture_sequence`) are byte-identical from run to run on one renderer. - **Bundled modules.** `RecursiveAI.OnePhysics.Scope` 1.1.0 (2D and 3D worlds, contacts, joints, islands and sleep; contact-normal arrows scale with body size) and `RecursiveAI.OnePath.Scope` 1.0.0 (navmesh build stages stepped one at a time, paths and search frontiers, flow fields, crowds), on `RecursiveAI.OnePhysics` 1.0.0 and `RecursiveAI.OnePath` 1.2.0. - **Other modules** load from a path into their own collectible load context. - Built on `RecursiveAI.OneScope.Sdk`, `.Probe` and `.Attach` 1.0.0 and onegame 1.25.1. ## [1.0.0] - 2026-11-11 The first stable release of `RecursiveAI.OneScope.Sdk`, `RecursiveAI.OneScope.Probe` and `RecursiveAI.OneScope.Attach`, all at 1.0.0. They have no package dependencies beyond .NET, and are NativeAOT- and trim-clean, so any program can reference them. - **Sdk: the module contract.** A library plugs into the viewer by implementing `IScopeModule`, `IScopeSource` and `IScopeWorld`. A world advances by one input per step, snapshots and restores itself, and hashes its state. It lists its entities and inspector rows, and draws into a neutral `DrawList` (2D or 3D lines, points, triangles, meshes, shapes, arrows and labels, with overlays and entity tags). It also declares pokes, diffs two worlds, and reports timeline events, series and findings. - **History.** Seeking to any step goes through keyframes plus deterministic re-simulation, and lands on the same hash as playing straight to that step. Forks are writable recordings that never touch their source. Sources can also step along axes other than time (build stages, solver iterations). - **Fields, links, units, ghost sources and shared-entity diffs** for modules that need them, as default interface members. - **Probe.** A host program streams a handshake, one snapshot and then its replay records over a local socket, only while a viewer is connected. With nobody connected, each recorded call costs one branch. The wire format is in `docs/probe-wire.md`. - **Attach.** Lists running probes, connects, and turns the stream into a live source that re-simulates locally. It also reads and writes session files (`.oscope`, `docs/session-file.md`). - **Formats owned by onescope:** the probe handshake v1, the session file v1 and the pose cache v1, each fuzzed. Changes since 0.3.0-preview.1: `HistoryOptions` and its `History` constructor are removed (use `ScopeHistoryOptions`), `DrawStyle.Deconstruct` with four values is removed, and `ScopeHistoryOptions` is sealed. The public surface is listed in `docs/api.md`.