Skip to content
Merged
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
6 changes: 4 additions & 2 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
110 changes: 103 additions & 7 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,36 @@ 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 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

Expand Down Expand Up @@ -346,8 +375,8 @@ bad config never flashes a window at the participant. `run()` then:

`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 on
`display.index` and **refuses to run without vsync** — SDL silently drops
`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.

Expand All @@ -359,7 +388,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 +400,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 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][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 +488,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 +503,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)
19 changes: 10 additions & 9 deletions include/Runtime.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ class Runtime {

// Parses and validates both input files and builds every worker before the
// render target is created, so a bad configuration fails without opening a
// window. The constructing thread becomes the only thread allowed to call
// run().
// window. The render target's window, if it has one, is shown only after
// the scene has been uploaded to it. The constructing thread becomes the
// only thread allowed to call run().
explicit Runtime(const RuntimePaths& paths);
Runtime(const RuntimePaths& paths, const RenderTargetFactory& renderTargetFactory,
std::chrono::milliseconds acquisitionTimeout = kDefaultAcquisitionTimeout);
Expand Down Expand Up @@ -77,10 +78,10 @@ class Runtime {
// returned.
const std::string& outputPath() const noexcept { return outputFilePath; }

// Opens a window on display.index (fullscreen or display.width x height)
// with an accelerated renderer, and throws std::runtime_error unless that
// renderer actually presents in sync with the display refresh. Marker
// timestamps are only correct when it does.
// Creates a hidden window on display.index (fullscreen or display.width x
// height) with an accelerated renderer, and throws std::runtime_error
// unless that renderer actually presents in sync with the display refresh.
// Marker timestamps are only correct when it does.
static RenderTargetFactory defaultRenderTargetFactory();

private:
Expand All @@ -106,11 +107,11 @@ class Runtime {
std::thread::id ownerThread;
bool hasRun = false;

// Declared before every member holding SDL objects, so SDL_Quit runs only
// after they are gone. Textures need no ordering against sdlRenderer:
// each one keeps its SDL_Renderer alive (see Image).
SdlSession sdlSession;

// Declared before `scene` on purpose: members are destroyed in reverse
// order, so the scene - and any SDL_Texture its components hold - is
// released while the SDL_Renderer that owns those textures is still alive.
std::shared_ptr<SDL_Renderer> sdlRenderer;

std::shared_ptr<Scene> scene;
Expand Down
24 changes: 24 additions & 0 deletions include/Utf8Path.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#ifndef UTF8PATH_HPP
#define UTF8PATH_HPP

#include <filesystem>
#include <string>
#include <string_view>

// On Windows, std::filesystem converts plain std::string with the system code page (e.g. cp1250),
// while protobuf strings and SDL file names are UTF-8, so "twarz_ż.png" would arrive garbled.
// These convert through UTF-8 explicitly. On Linux both are a plain byte copy.
namespace utf8 {

inline std::filesystem::path toPath(std::string_view text) {
return {std::u8string(text.begin(), text.end())};
}

inline std::string toString(const std::filesystem::path& path) {
const std::u8string text = path.u8string();
return {text.begin(), text.end()};
}

} // namespace utf8

#endif // UTF8PATH_HPP
14 changes: 11 additions & 3 deletions include/parser/Parser.hpp
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
#ifndef PARSER_HPP
#define PARSER_HPP

#include <filesystem>
#include <iostream>
#include <memory>
#include <string>

class Scene;
class SceneObject;
class Component;
class ComponentBuildContext;

namespace NeuronIDE {
class SceneObject;
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 assetRoot: the working directory by default.
static std::shared_ptr<Scene> parseStream(std::istream& stream,
const std::filesystem::path& assetRoot = {});

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
6 changes: 6 additions & 0 deletions include/renderer/Renderer.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ class Renderer {

void render(const std::stop_token& stoken);

// Handles every pending window event and returns true when the operator asked to close the
// window. Call it whenever events are polled outside render(), so a device reset is not lost.
bool pollEvents();

private:
void prepareScene();

struct SDLWindowDeleter {
void operator()(SDL_Window* window) const;
};
Expand Down
Loading