Skip to content

Latest commit

 

History

History
599 lines (495 loc) · 32.3 KB

README.md

File metadata and controls

599 lines (495 loc) · 32.3 KB

NeuronIDE Runtime

Single source of project context. This document is intentionally written to be self-contained so that a person or a language model can understand the goal, the architecture, the current implementation status, and how to build/test the project without reading the source first.

1. What this project is

NeuronIDE is a tool for designing and running EEG experiments. It has two parts:

  • an editor (out of scope for this repository) that lets a researcher visually design an experiment and serializes it to a file, and
  • a runtime (this repository, Neuron-IDE-runtime) that reads that file, reconstructs the experiment as a scene, runs it, and records the data.

Running an experiment means:

  1. Reading EEG data from the participant's headset (the "cap") in real time.
  2. Rendering a graphical interface that drives the participant — e.g. telling them what to focus on, or, for SSVEP experiments, flickering on-screen objects at controlled frequencies.
  3. Emitting time markers for experiment events (e.g. "stimulus shown"), stamped at the exact moment the corresponding frame hits the screen.
  4. Persisting the EEG samples and the markers, with timestamps, to an output file for later offline analysis.

The hard requirement that shapes the whole design is temporal alignment: the EEG samples and the stimulus markers must share a common clock domain so that "this brain response happened N ms after that stimulus" is meaningful. See §4 Clock synchronization.

2. Tech stack

Concern Choice
Language C++20
Build system CMake (≥ 3.25), Ninja recommended
Experiment file format Protocol Buffers (proto3) — see protoFiles/neuronide.proto
Device config format JSON (config.json) parsed with nlohmann/json
EEG acquisition LSL — Lab Streaming Layer (liblsl)
Rendering / windowing SDL2 + SDL2_image, with vsync (SDL2_ttf planned, see Components)
Inter-thread queues moodycamel ConcurrentQueue (lock-free)
Python scripting (planned) pybind11 (ScriptComponent)
Testing GoogleTest + CTest
Tooling clang-format, clang-tidy, gcovr (coverage)

liblsl, concurrentqueue, nlohmann/json, and googletest are fetched automatically by CMake (FetchContent). SDL2 and Protobuf are expected to be installed on the system.

3. Architecture

The runtime starts by parsing the experiment file into a Scene, then runs the experiment across cooperating threads coordinated by a top-level Runtime object. The design follows a game-engine-like component model: a Scene owns SceneObjects, and each SceneObject owns Components that implement behavior.

Data flow

flowchart LR
    File[Experiment file<br/>protobuf] --> Parser
    Parser -->|builds| Scene

    subgraph Threads
        Renderer
        LSLReader
        DataWriter
    end

    Scene --> Renderer
    Cap[EEG headset<br/>LSL stream] --> LSLReader

    Renderer -->|Marker| MQ[(markerQueue)]
    LSLReader -->|EEGData| EQ[(eegQueue)]

    EQ --> DataWriter
    MQ --> DataWriter
    DataWriter --> Out[Output file<br/>CSV]
Loading
  • Parser reads the protobuf experiment file and produces a Scene (objects + components). Stateless entry point: Parser::parse(path).
  • Renderer owns the SDL window, runs the update→render loop, and uses vsync so it can timestamp markers at the actual moment of screen refresh. Components emit marker names during update; the Renderer stamps them with lsl::local_clock() right after SDL_RenderPresent and pushes Markers onto markerQueue.
  • LSLReader resolves and subscribes to the EEG LSL stream and continuously pushes EEGData samples onto eegQueue. It forwards only the channels the device config enables (see §5 Configuration boundary), in config declaration order.
  • DataWriter drains both queues and writes them to disk via a pluggable formatting strategy (currently CSV).

The two queues (eegQueue, markerQueue) are the only shared state between threads and are lock-free (moodycamel::ConcurrentQueue). Producers (Renderer, LSLReader) never block on consumers (DataWriter).

Component model

classDiagram
    class Scene {
        -string experimentName
        -vector~shared_ptr~SceneObject~~ objects
        +update(Context&) void
        +render(SDL_Renderer*) void
    }
    class SceneObject {
        +string name
        +bool isVisible
        +Transform transform
        -vector~unique_ptr~Component~~ components
        +update(Context&) void
        +render(SDL_Renderer*) void
    }
    class Component {
        <<abstract>>
        #weak_ptr~SceneObject~ owner
        +update(Context&)* void
        +render(SDL_Renderer*)* void
    }
    class BlinkComponent
    class SpriteRenderer
    class TextRenderer
    class ScriptComponent

    Scene *-- SceneObject
    SceneObject *-- Component
    Component <|-- BlinkComponent
    Component <|-- SpriteRenderer
    Component <|-- TextRenderer
    Component <|-- ScriptComponent
Loading

Components are built from protobuf messages by a ComponentRegistry (a singleton mapping a proto component type id → factory function). New component types self-register via the REGISTER_COMPONENT(typeId, creatorFunc) macro, so the parser does not need to know about concrete component classes. Each creator also receives a ComponentBuildContext, which resolves asset paths: a relative path in the .neuroz is taken relative to the folder holding the .neuroz, and an absolute one is used as-is.

Self-registration runs from static initializers that nothing else references, so a plain static link silently drops them. Executables therefore link the runtime_core target, which pulls in the whole archive. tests/registration_tests guards this, and every new component type gets a line there.

After construction, a component goes through two hooks, both before the first frame:

  1. onSceneReady(Scene&), called by Parser once every object exists, to resolve and subscribe to events.
  2. onRenderTargetReady(const std::shared_ptr<SDL_Renderer>&), called by the Renderer constructor once the render target exists, to create GPU resources such as textures, and again after SDL_RENDER_DEVICE_RESET, which loses every texture. Doing it there, not lazily on the first render(), keeps uploads from delaying the first stimulus frame and its marker. SDL frees a renderer's textures along with it, so a component holding textures keeps the target alive.

Images are decoded during parsing into the scene's ImageLibrary, once per file however many sprites show it, and the library uploads them just before the components' hook runs. The decoded pixels are kept, so a reset can rebuild the textures.

Anything that can fail on bad input (a missing image, for instance) fails during parsing, before the window opens. The window is created hidden and shown only once the scene is on the GPU, so a failed upload (an image larger than the GPU's texture limit) doesn't flash it at the participant either. A sprite that fails to draw stops the run before its frame is presented and marked.

Core data structures

struct EEGData {                 // one EEG sample
    double              timestamp;   // in the local_clock() domain (see §4)
    std::vector<double> channels;    // enabled channels only, in config order (see §5)
};

struct Marker {                  // one experiment event
    std::string eventName;
    double      timestamp;       // local_clock() at vsync
};

struct Context {                 // passed to Component::update each frame
    double                    timestamp;   // per-frame delta time
    std::vector<std::string>* markers;     // sink: components append marker names here
};

4. Clock synchronization (critical)

EEG analysis depends on EEG samples and stimulus markers being comparable in time. The Renderer stamps markers with lsl::local_clock() (this machine's clock). LSL pull_sample(), however, returns timestamps in the sender's clock domain. If left unconverted, samples and markers would live in different clocks and could not be aligned.

LSLReader therefore enables LSL inlet post-processing (post_clocksync | post_dejitter | post_monotonize) so the timestamps it stores are already mapped into the local lsl::local_clock() domain — the same clock the Renderer uses. This is the single most important correctness property of the data path.

5. Configuration boundary: protobuf vs JSON

The runtime is fed by two config inputs and they are not interchangeable. The split is deliberate and should be respected when adding new settings, otherwise the same experiment stops being portable between labs.

The rule: the protobuf experiment file describes what the experiment does; the device config.json describes what the hardware is. A new field belongs in protobuf if changing it changes the experiment's meaning for analysis, and in JSON if it only changes how this particular machine acquires or stores the data.

Goes in the protobuf experiment file (.neuroz) Goes in the device config (config.json)
Experiment name, scene objects, transforms, visibility Device name, montage standard (10-20, …)
Components and their parameters (e.g. blink frequency) LSL stream identity: name, type, source_id
Stimulus timing, trial structure, marker/event names Expected stream shape: channel count, sample rate
Anything the editor authors and versions with the study Channel table: index, label, enabled, unit
Reference / ground electrodes, impedance check thresholds
Participant display: monitor, fullscreen, window size (display)
Output format for DataWriter (output.format)

Consequences of the split:

  • The same experiment file runs on a different cap by swapping only config.json — no re-export from the editor.
  • The runtime can validate the incoming LSL stream (channel count, sample rate) before the experiment starts, because expectations are declared per device.
  • Electrode-level knowledge (which channel is Oz, which are enabled) lives in one place, so LSLReader and, later, DataWriter agree on channel order.

The JSON is parsed 1:1 into DeviceConfig: every JSON key maps onto exactly one field, and nesting in the file is the nesting in the struct. Keep it that way — channels is a top-level key, so it is a top-level DeviceConfig field (not tucked under lsl), even though LSLReader is its main consumer.

{
  "config_version": "1.1",              // "MAJOR.MINOR", checked first (see below)
  "device_name": "OpenBCI Cyton 8ch",
  "montage_standard": "10-20",
  "lsl_stream": {                       // -> DeviceConfig::lsl (LSLConfig)
    "name": "obci_eeg1",
    "type": "EEG",
    "source_id": "cyton-a1b2c3",
    "expected_channel_count": 8,        // must equal channels.size()
    "expected_sample_rate_hz": 250
  },
  "reference": { "label": "linked_mastoids", "scheme": "physical" },
  "ground": { "label": "Fpz" },
  "channels": [                         // -> DeviceConfig::channels
    { "index": 0, "label": "Fz", "enabled": true,  "unit": "microvolts" },
    { "index": 1, "label": "Oz", "enabled": false, "unit": "microvolts" }
    // ... one entry per expected_channel_count, indices unique and in range
  ],
  "impedance_check": { "supported": true, "threshold_kohm": 5.0 },
  "display": {                          // -> DeviceConfig::display; every key optional
    "index": 1,                         // monitor facing the participant (default 0)
    "fullscreen": true,                 // native-resolution fullscreen (default true)
    "width": 1280, "height": 720        // window size, used only when not fullscreen
  },
  "output": { "format": "csv" }         // -> DataFormatStrategyFactory, defaults to csv
}

config_version, device_name, montage_standard, lsl_stream and channels are required; reference, ground, impedance_check, display and output default when absent. Unlike the other optional sections, each key inside display is optional on its own ("display": { "index": 1 } is complete). If output is present it must carry a non-empty format; whether that format is known is decided by DataFormatStrategyFactory when the writer is built, not by config validation. Channels with "enabled": false stay in the config (they document the cap) but are not acquired: LSLReader drops them from every sample.

Validation contract

Mapping and validation are separate jobs. ConfigParser only turns JSON into structs — presence of keys, types, array shape. Every semantic rule (non-empty stream identity, positive rate, channel count matching the stream, unique in-range indices) lives on the config types themselves as validate(), because none of those rules are about JSON and they must hold for any producer:

DeviceConfig config = ConfigParser::parse("config.json");  // already validated
DeviceConfig config;      // hand-assembled: no guarantees
config.lsl.name = ...;
config.validate();        // throws std::invalid_argument on the first broken rule

A DeviceConfig returned by ConfigParser has passed validate(). One you assemble yourself has not — call it before handing the config to a consumer. LSLReader validates in its constructor rather than trusting its caller, since it indexes into raw samples with the configured channel offsets.

Rules that need more context than the config carries stay with the consumer, not in validate(): "at least one channel is enabled" is an LSLReader precondition (a fully disabled cap is a valid config, just nothing to acquire), and "stream shape matches the live LSL stream" can only be checked against a resolved stream at runtime.

Schema versioning

config_version is "MAJOR.MINOR" and is the first thing ConfigParser validates — on an unsupported schema every later complaint would be a misleading "missing field" message instead of "your config is newer than this runtime".

  • MAJOR — breaking change: a field moved, was renamed, or changed meaning. A runtime rejects any major other than ConfigParser::kSupportedConfigMajor.
  • MINOR — additive, backward-compatible change: new optional keys. Any minor of the supported major is accepted, and unknown keys are ignored, so a 1.7 file still runs on a runtime that only knows 1.0.
  • A version that cannot be compared ("1", "v1", "1.2.3", empty) is rejected rather than assumed — an unparseable version is worse than none.

Bump MINOR when adding optional keys, MAJOR when moving or renaming any existing one, and raise kSupportedConfigMajor in the same commit that lands the breaking parser change.

6. Thread lifecycle conventions

Threads use C++20 std::jthread + std::stop_token for cooperative cancellation. Two ownership patterns are in use:

  • Self-owned thread (LSLReader, DataWriter): the class owns its jthread. start(...) spawns the worker (and first calls stop() so it is restartable); stop() requests stop, joins, and releases resources; the destructor calls stop(). The worker loop body takes the stop_token.
  • Caller-owned thread (Renderer): the class exposes render(stop_token) and the caller owns the thread driving it. This makes the loop trivially testable by passing an injected std::stop_source.

LSLReader additionally resolves its stream lazily on the worker thread (so start() returns immediately instead of blocking the runtime while waiting for the cap), uses a blocking pull with a finite timeout (no busy-wait, low latency, periodic stop-token checks), and catches lsl::lost_error to re-resolve a dropped stream rather than letting an exception terminate the process. Recovery is deliberately ours: the inlet is created with liblsl's recover flag off, so a lost cap surfaces as lsl::lost_error instead of being silently reconnected, and the re-resolved stream is re-validated (channel count, sample rate) before acquisition continues. Config errors (mismatched stream shape, no enabled channels) stay fatal — they are logged and the worker exits instead of retrying forever.

LSLReader reports where it is as a typed AcquisitionState: Idle → Resolving → Streaming (set on the first queued sample, not on resolve), back to Resolving after a lost stream, or Failed for good. waitWhileResolving(deadline, stop_token) blocks until the state changes; failure() holds the reason, and an optional callback passed to start() fires once on the reader thread on failure. A Failed state survives stop() so the reason can be read after joining.

Runtime::run() sequence

Runtime builds everything that can reject the configuration (both config files, output directory, format strategy, LSLReader) before it creates the window, so a bad config never flashes a window at the participant. run() then:

  1. Acquisition gate. Starts LSLReader and waits for the first EEG sample, handling window events meanwhile. No stimulus is shown without EEG: a stream that fails validation, or delivers nothing within the acquisition timeout (default 30 s), throws; closing the window or requestStop() returns without recording.
  2. Recording. Creates <experiment>_<YYYYMMDDTHHMMSS>[_N].<ext> in the output directory and runs the render loop. Format strategies must create the file exclusively — an existing recording is never overwritten.
  3. Shutdown. Stops LSLReader, then DataWriter (producer before consumer, so the writer drains everything). If acquisition failed for good mid-experiment, the failure callback has already stopped the render loop, and run() throws once the recording is closed.

run() is single-shot and must be called on the thread that constructed the Runtime (SDL handles window events only there); both are enforced with std::logic_error. The default render target opens the window (hidden until the scene is uploaded) on display.index and refuses to run without vsync — SDL silently drops PRESENTVSYNC when the driver can't honour it, and marker timestamps are only correct when SDL_RenderPresent waits for the refresh.

7. Implementation status

Area / class Status Notes
Parser Implemented protobuf → Scene
Scene / SceneObject Implemented component containers
Component (base) Implemented abstract update / render
ComponentRegistry Implemented proto-type → factory, macro-based self-registration
Scene components Partial 3 of the 9 MVP components have a C++ class, see Components
Renderer Implemented SDL + vsync, marker timestamping
LSLReader Implemented LSL inlet → eegQueue, clock-synced (see §4); driven by DeviceConfig, enabled channels only, re-resolves lost streams, reports AcquisitionState
ConfigParser Implemented config.json → DeviceConfig (1:1 mapping, major-version checked, see §5), nlohmann/json
Config validate() Implemented semantic rules on the config types themselves, independent of JSON (see §5)
DataWriter Implemented strategy-based; CSVFormatStrategy
DataFormatStrategyFactory Implemented output.format → format strategy; unknown formats rejected
Runtime orchestration Implemented owns SDL session, parses both config files, gates the experiment on live EEG, records to a fresh file, drives the render loop, stops workers (see §6)

The class diagram in older docs is partly aspirational; the table above reflects the actual code.

Components

The MVP set of components an experiment is built from, and where each one stands. Status as of 2026-10-10, checked against main, open PRs and neuronide.proto. Update the row in the same PR that changes it.

Component What it does Proto (neuronide.proto) C++ (scene/components/) Status Tracking
Transform Position, size, rotation. Exactly one per object. x/y is the top-left corner in window pixels, rotation is degrees clockwise about the centre. SceneObject.transform, a field rather than a Component SceneObject::Transform Partial: parsed onto the object and drawn by SpriteRenderer, but not required (an object without one sits at all zeros; a sprite with an image on it is rejected at parse time, as is a non-finite value) —
SpriteRenderer Draws an image: visual stimuli, photos, icons. SpriteRenderer (renderer = 1), img_path. The runtime does not read texture_path SpriteRenderer, registered Implemented: any SDL2_image format (PNG, JPEG, …), stretched over the Transform and rotated. Decoded at parse time, so a missing or unreadable file stops the run before the window opens; uploaded to the GPU before the first frame. Objects showing the same file share one decoded image. An empty img_path draws nothing #17, backend#25
TextRenderer Draws text in a chosen font and size: instructions, letters, numbers. TextRenderer (text = 2) — Planned: needs SDL2_ttf, which is not a dependency yet. The message has no text color #18, backend#25
BlinkComponent Flickers the object at a set frequency in Hz. The basis for SSVEP. BlinkComponent (blinker = 3) BlinkComponent, registered and built by the parser In review: update() is an empty TODO on main, so nothing blinks yet. PR #34 implements it; changes requested #16
MarkerEmitter Emits an LSL marker on a scene event, ideally when an object appears or disappears. MarkerEmitterComponent (marker_emitter = 5). Missing from the backend's copy MarkerEmitterComponent, registered Implemented, nothing to bind to: bindings resolve and push markers (tested against a test-only event source), but no production component exposes an event and objects have no appear/disappear events, so any binding in a real .neuroz fails at parse time backend#23
Lifetime Keeps the object visible for N ms, then hides it. — — Not started —
ResponseListener While its object is active, records which key was pressed and the reaction time. — — Not started —
ScriptComponent Custom logic in Python, outside the standard components. ScriptComponent (script = 4) — Planned: needs pybind11, which is not a dependency yet backend#25
AudioSource Plays a sound. Possibly post-MVP. — — Not started —
  • Implemented: registered class, behaviour complete, covered by tests.
  • In review: the implementation is in an open PR.
  • Partial: exists, with the gaps listed in its row.
  • Planned: the proto message exists, the C++ class does not. The parser drops such components silently (ComponentRegistry::build returns nullptr and Parser skips it), so a scene that uses one loads without error and the component does nothing.
  • Not started: neither a proto message nor a class. Adding the message comes first and is a cross-team change (backend proto and dataclasses, launcher copy, frontend editor; see CLAUDE.md).

Open questions before the remaining components

  • Event sources. MarkerEmitter binds to events that a component exposes through getEvent(), and nothing exposes one yet. The obvious first sources are an object appearing or disappearing, a Lifetime timeout and a ResponseListener response.
  • Who owns visibility. Blink, Lifetime and scripts all write SceneObject::isVisible. If appear/disappear events follow that flag, a 15 Hz blinker emits 30 markers a second, and the 0 Hz branch in PR #34 overrides a script that hides the object. Keeping "the object is shown in this trial" (Lifetime, scripts; drives the events) separate from "flicker phase" (Blink; affects drawing only) avoids both.
  • Frame-locked timing. The screen only changes at vsync, and markers are stamped there. Flicker phase and Lifetime durations should come from the frame being presented (frame count × refresh period), not from summing Context::timestamp. That value is a loop delta measured at the top of the loop, so it jitters, and a frame near an on/off edge can land on either side. Frequencies above half the refresh rate can't be shown at all and should be rejected. Dropped frames should be detected and logged.
  • Input and reaction time. The render loop polls SDL events itself and handles only SDL_QUIT, and Context carries no input. ResponseListener needs key events passed in, stamped with when the key was pressed, not with the vsync time the Renderer gives markers. With one poll per frame, reaction-time resolution is one frame (~16.7 ms at 60 Hz). One option is to record the response as a marker (e.g. response:<key>) with its own timestamp and compute reaction time offline against the onset marker.
  • Media files. Sprite, Text (fonts), Script and Audio all reference files by path. The runtime resolves relative paths against the .neuroz folder (see Component model), but assets can't be uploaded or exported yet (backend#25). Until they can, the launcher has to place them next to the .neuroz by hand.
  • Transform units. Transform is in window pixels, so a stimulus's physical size depends on the lab monitor, which comes from config.json. Decide whether to stay with pixels or move to normalized or visual-angle units before more renderers land.

8. Repository layout

Neuron-IDE-runtime/       # the C++ runtime (git repo)
  ├── README.md             # this file, code context
  ├── CMakeLists.txt        # top-level: deps, warnings, static analysis, coverage
  ├── cmake/                # Dependencies / CompilerWarnings / StaticAnalysis / Coverage
  ├── protoFiles/
  │   ├── neuronide.proto   # experiment file schema
  │   └── tests/            # .pbtxt fixtures + compiled .pb
  ├── include/              # public headers, mirrored by src/
  │   ├── data_structures/  # EEGData, Marker, Context
  │   ├── config/           # ConfigParser + DeviceConfig / LSLConfig / ChannelConfig / ConfigVersion
  │   ├── parser/           # Parser
  │   ├── scene/            # Scene, SceneObject, components/
  │   ├── renderer/         # Renderer
  │   ├── lslreader/        # LSLReader
  │   ├── datawriter/       # DataWriter, IDataFormatStrategy, CSVFormatStrategy
  │   └── Runtime.hpp
  ├── src/                  # one CMake subdirectory (static lib) per module
  └── tests/
      ├── unit_tests/       # CTest label: "unit"
      ├── component_tests/  # CTest label: "component"
      └── registration_tests/ # separate binary: every component is linked in (label "component")

Each src/<module>/ builds a static library; runtime_core links them together and the NeuronIDE executable links runtime_core.

9. Build, test, and tooling

All commands are run from the Neuron-IDE-runtime/ directory.

Dependencies (Linux)

sudo apt update
sudo apt install cmake ninja-build clang-format clang-tidy-20 libsdl2-dev libsdl2-image-dev protobuf-compiler gcovr
# GTest, LSL, concurrentqueue and nlohmann/json are fetched automatically by CMake.

Configure & build

Use the Ninja generator: it builds on all CPU cores by default and rebuilds only what changed.

cmake -B build -G Ninja
cmake --build build
./build/src/NeuronIDE config.json experiment.neuroz  # waits for the EEG stream, then records and runs the experiment

-G Ninja is needed only the first time you configure a build directory; CMake remembers it. A build/ already configured with the default generator (Make) can't be switched, so delete it first (rm -rf build). If you stay on Make, build with cmake --build build --parallel; without --parallel, Make compiles one file at a time.

NeuronIDE takes the path to a device config.json (defaults to config.json in the working directory).

Faster builds without clang-tidy

By default clang-tidy runs on every file during the build and takes several times longer than compiling it. While iterating, turn it off. The setting is remembered in build/; configure with =ON to turn it back on.

cmake -B build -G Ninja -DNEURON_IDE_ENABLE_CLANG_TIDY=OFF -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
cmake --build build

CI still runs clang-tidy and fails on any warning, so run the same check before pushing. It reads the compile_commands.json that CMAKE_EXPORT_COMPILE_COMMANDS writes:

git ls-files 'src/*.cpp' 'tests/*.cpp' | xargs -P "$(nproc)" -n 1 clang-tidy-20 -p build --quiet

Lines like 43669 warnings generated. count warnings in third-party headers, which .clang-tidy filters out; only error: lines are problems.

Tests

cd build
ctest --output-on-failure          # all tests
ctest -L unit --output-on-failure  # unit tests only
ctest -L component --output-on-failure

Note: LSLReader and Runtime unit tests open local LSL streams and exercise a real outlet→inlet round-trip over loopback; they need loopback multicast to be available.

Formatting & static analysis

  • Style: .clang-format (Google base, 4-space indent, 100 col, aligned declarations/assignments). Checks: .clang-tidy (bugprone-*, cppcoreguidelines-*, performance-*, readability-*).
  • Auto-format every tracked source/header:
    cmake --build build --target format
  • clang-tidy runs as part of the build; see Faster builds without clang-tidy to turn it off.

Coverage

cmake -B build -G Ninja -DNEURON_IDE_ENABLE_COVERAGE=ON
cmake --build build
cmake --build build --target coverage     # runs tests + gcovr
xdg-open build/coverage/index.html

Working with protobuf fixtures

Author a .pbtxt (see protoFiles/tests/test_scene.pbtxt), then encode it:

protoc --encode=NeuronIDE.Scene protoFiles/neuronide.proto \
  < protoFiles/tests/test_scene.pbtxt > protoFiles/tests/test_scene.pb

10. Contribution conventions

  • Branches: <type>/<description>, e.g. feat/setup-project.
  • Commits: <type>(optional scope): description, e.g. feat(parser): create Parser class.
  • Types: feat, fix, style (clang config), test, ci (.github).