Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
branches: [ "main", "master" ]

env:
BUILD_DEPS: cmake ninja-build libsdl2-dev protobuf-compiler
BUILD_DEPS: cmake ninja-build libsdl2-dev libsdl2-image-dev protobuf-compiler

jobs:
build-and-test:
Expand Down
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ You are in the **runtime** repo. Read the system overview below first.
- Layout: `include/` headers, `src/` impl, `src/parser/Parser.cpp` turns a `.neuroz`/`.pb` file
into a `Scene`, `scene/components/` component classes, `tests/{unit,component}_tests/`.
- New component: subclass `Component`, add a static creator, and
`REGISTER_COMPONENT(NeuronIDE::Component::k<Name>, Creator)` in its `.cpp`. The proto oneof
entry must exist first — which means a backend change too.
`REGISTER_COMPONENT(NeuronIDE::Component::k<Name>, Creator)` in its `.cpp`, then add it to
`tests/registration_tests` (static self-registration is silently dropped unless executables
link the `runtime_core` target). The proto oneof entry must exist first — which means a
backend change too.
- Test scenes: write `.pbtxt`, then
`protoc --encode=NeuronIDE.Scene protoFiles/neuronide.proto < x.pbtxt > x.pb`.
- Read README §3 (architecture: Renderer / LSLReader / DataWriter threads) and §5 (what goes in
Expand Down Expand Up @@ -96,7 +98,9 @@ launcher#1.

### Known cross-repo gaps (as of 2026-10-09 — fix them or update this list)

- Proto copies have diverged: backend lacks `MarkerEmitterComponent` (backend#23).
- Proto copies have diverged: backend lacks `MarkerEmitterComponent` (backend#23), and the
runtime reserved `SpriteRenderer.texture_path` (field 1) in favour of `img_path`; the backend
and launcher copies must follow.
- Experiment flow graph is not in the proto, so it isn't saved, exported or run (epic frontend#59).
- Scene object color and LSL marker are not persisted (frontend#56, backend#24).
- Media files (images, audio, scripts) can't be uploaded, so the runtime can't find them (backend#25).
Expand Down
98 changes: 93 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ EEG samples and the stimulus markers must share a **common clock domain** so tha
| Experiment file format | Protocol Buffers (proto3) — see `protoFiles/neuronide.proto` |
| Device config format | JSON (`config.json`) parsed with [nlohmann/json](https://github.com/nlohmann/json) |
| EEG acquisition | [LSL — Lab Streaming Layer](https://github.com/sccn/liblsl) (`liblsl`) |
| Rendering / windowing | SDL2 (+ SDL2_image), with vsync |
| Rendering / windowing | SDL2 + SDL2_image, with vsync (SDL2_ttf planned, see [Components](#components)) |
| Inter-thread queues | [moodycamel ConcurrentQueue](https://github.com/cameron314/concurrentqueue) (lock-free) |
| Python scripting (planned)| pybind11 (`ScriptComponent`) |
| Testing | GoogleTest + CTest |
Expand Down Expand Up @@ -138,7 +138,28 @@ classDiagram
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.
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 window exists, to create GPU resources such as
textures. 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.

Anything that can fail on bad input (a missing image, for instance) fails during
parsing, before the window opens.

### Core data structures

Expand Down Expand Up @@ -359,7 +380,7 @@ correct when `SDL_RenderPresent` waits for the refresh.
| `Scene` / `SceneObject` | Implemented | component containers |
| `Component` (base) | Implemented | abstract `update` / `render` |
| `ComponentRegistry` | Implemented | proto-type → factory, macro-based self-registration |
| `specifiic components` | **Planned** | defined in `neuronide.proto`, not yet implemented in C++ |
| Scene components | **Partial** | 3 of the 9 MVP components have a C++ class, see [Components](#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 |
Expand All @@ -371,6 +392,72 @@ correct when `SDL_RenderPresent` waits for the refresh.
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 on it is rejected at parse time) | — |
| SpriteRenderer | Draws an image: visual stimuli, photos, icons. | `SpriteRenderer` (`renderer = 1`), `img_path`; field 1 (`texture_path`) is reserved | `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 | [#17][rt-17], [backend#25][be-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][rt-18], [backend#25][be-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][rt-34] implements it; changes requested | [#16][rt-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][be-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][be-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][rt-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](#component-model)), but assets can't be uploaded or exported yet
([backend#25][be-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.

[rt-16]: https://github.com/KN-Neuron/Neuron-IDE-runtime/issues/16
[rt-17]: https://github.com/KN-Neuron/Neuron-IDE-runtime/issues/17
[rt-18]: https://github.com/KN-Neuron/Neuron-IDE-runtime/issues/18
[rt-34]: https://github.com/KN-Neuron/Neuron-IDE-runtime/pull/34
[be-23]: https://github.com/KN-Neuron/Neuron-IDE-backend/issues/23
[be-25]: https://github.com/KN-Neuron/Neuron-IDE-backend/issues/25

## 8. Repository layout

```
Expand All @@ -393,7 +480,8 @@ Neuron-IDE-runtime/ # the C++ runtime (git repo)
├── src/ # one CMake subdirectory (static lib) per module
└── tests/
├── unit_tests/ # CTest label: "unit"
└── component_tests/ # CTest label: "component"
├── 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
Expand All @@ -407,7 +495,7 @@ All commands are run from the `Neuron-IDE-runtime/` directory.

```bash
sudo apt update
sudo apt install cmake ninja-build clang-format clang-tidy-20 libsdl2-dev protobuf-compiler gcovr
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.
```

Expand Down
3 changes: 2 additions & 1 deletion cmake/Dependencies.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ FetchContent_MakeAvailable(googletest liblsl concurrentqueue nlohmann_json)
set(CMAKE_C_FLAGS "${BACKUP_C_FLAGS}")
set(CMAKE_CXX_FLAGS "${BACKUP_CXX_FLAGS}")

# 5. SDL2 (System installed)
# 5. SDL2 + SDL2_image (System installed)
find_package(SDL2 REQUIRED)
find_package(SDL2_image REQUIRED)

# 6. Protobuf (System installed)
find_package(Protobuf REQUIRED)
14 changes: 11 additions & 3 deletions include/parser/Parser.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
#include <memory>
#include <string>

#include "scene/components/ComponentBuildContext.hpp"

class Scene;
class SceneObject;
class Component;
Expand All @@ -18,13 +20,19 @@ class Parser {
public:
Parser() = default;

// Relative asset paths in the scene (e.g. sprite images) resolve against the folder holding
// filePath.
static std::shared_ptr<Scene> parse(const std::string& filePath);
static std::shared_ptr<Scene> parseStream(std::istream& stream);
// Relative asset paths resolve against context.assetRoot: the working directory by default.
static std::shared_ptr<Scene> parseStream(std::istream& stream,
const ComponentBuildContext& context = {});

private:
static std::shared_ptr<SceneObject> buildSceneObject(const NeuronIDE::SceneObject& protoObj);
static std::shared_ptr<SceneObject> buildSceneObject(const NeuronIDE::SceneObject& protoObj,
const ComponentBuildContext& context);
static std::unique_ptr<Component> buildComponent(const NeuronIDE::Component& protoComp,
const std::shared_ptr<SceneObject>& owner);
const std::shared_ptr<SceneObject>& owner,
const ComponentBuildContext& context);
};

#endif // PARSER_HPP
1 change: 1 addition & 0 deletions include/scene/Scene.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class Scene {
void render(SDL_Renderer* renderer);

void onSceneReady();
void onRenderTargetReady(const std::shared_ptr<SDL_Renderer>& target);
Delegate& resolveEvents(std::string_view objectName, std::string_view eventName);
};

Expand Down
4 changes: 3 additions & 1 deletion include/scene/components/BlinkComponent.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace NeuronIDE {
class Component;
}
struct ComponentBuildContext;

class BlinkComponent : public Component {
public:
Expand All @@ -19,7 +20,8 @@ class BlinkComponent : public Component {
void render(SDL_Renderer* renderer) override;

static std::unique_ptr<Component> createBlinker(const NeuronIDE::Component& protoComp,
const std::shared_ptr<SceneObject>& owner);
const std::shared_ptr<SceneObject>& owner,
const ComponentBuildContext& context);

private:
double blinkFrequencyHz = 0.0;
Expand Down
8 changes: 7 additions & 1 deletion include/scene/components/Component.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ class Component {
virtual void update(const Context& context) = 0;
virtual void render(SDL_Renderer* renderer) = 0;

virtual void onSceneReady(Scene& scene) { (void)scene; }
virtual void onSceneReady(Scene& scene) { (void)scene; }

// Called once the render target this scene is drawn to exists, before the first frame: the
// place to create GPU resources such as textures. SDL frees a renderer's textures along with
// it, so a component holding any keeps `target` alive for as long as it does.
virtual void onRenderTargetReady(const std::shared_ptr<SDL_Renderer>& target) { (void)target; }

virtual Delegate* getEvent(std::string_view eventName) {
(void)eventName;
return nullptr;
Expand Down
24 changes: 24 additions & 0 deletions include/scene/components/ComponentBuildContext.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#ifndef COMPONENTBUILDCONTEXT_HPP
#define COMPONENTBUILDCONTEXT_HPP

#include <filesystem>

// What a component creator may need beyond its own proto message and owner. Parser builds one per
// scene and hands it to every creator.
struct ComponentBuildContext {
// Folder that relative asset paths in the experiment file resolve against: the folder holding
// the .neuroz file. Empty for a scene parsed from a bare stream, so relative paths resolve
// against the working directory.
std::filesystem::path assetRoot;

// An absolute path is returned as-is and a relative one is taken relative to assetRoot. An
// empty path stays empty, so the caller can still reject it.
[[nodiscard]] std::filesystem::path resolveAsset(const std::filesystem::path& path) const {
if (path.empty() || path.is_absolute()) {
return path;
}
return assetRoot / path;
}
};

#endif // COMPONENTBUILDCONTEXT_HPP
7 changes: 5 additions & 2 deletions include/scene/components/ComponentRegistry.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ namespace NeuronIDE {
class Component;
}
class SceneObject;
struct ComponentBuildContext;

using ComponentCreatorFunc = std::function<std::unique_ptr<Component>(
const NeuronIDE::Component&, const std::shared_ptr<SceneObject>&)>;
const NeuronIDE::Component&, const std::shared_ptr<SceneObject>&,
const ComponentBuildContext&)>;

class ComponentRegistry {
public:
Expand All @@ -31,7 +33,8 @@ class ComponentRegistry {
void registerCreator(int typeId, ComponentCreatorFunc creator);

std::unique_ptr<Component> build(const NeuronIDE::Component& protoComp,
const std::shared_ptr<SceneObject>& owner);
const std::shared_ptr<SceneObject>& owner,
const ComponentBuildContext& context);

private:
ComponentRegistry() = default;
Expand Down
6 changes: 4 additions & 2 deletions include/scene/components/MarkerEmitterComponent.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include "scene/components/Component.hpp"

class Scene;
struct ComponentBuildContext;

namespace NeuronIDE {
class Component;
Expand All @@ -36,8 +37,9 @@ class MarkerEmitterComponent : public Component {
void render(SDL_Renderer* renderer) override;
void onSceneReady(Scene& scene) override;

static std::unique_ptr<Component> createMarkerEmitter(
const NeuronIDE::Component& protoComp, const std::shared_ptr<SceneObject>& owner);
static std::unique_ptr<Component> createMarkerEmitter(const NeuronIDE::Component& protoComp,
const std::shared_ptr<SceneObject>& owner,
const ComponentBuildContext& context);

private:
struct ActiveSubscription {
Expand Down
Loading