File layout
When a LABO run starts, each enabled stream stands ready but doesn't open a file yet. The CSV is created the first time the writer actually emits a row — see Lazy file creation below. This page is the reference for where those files live, how they're named, and what governs the layout.
The run directory
Every session (one "run" of an experience) gets its own directory created under the configured data store path. All streams for that session write into the same directory.
<dataStorePath>/
└── <runDirectory>/
├── UpdateLoop.csv
├── FixedInterval.csv
├── ParticipantTracking.csv
├── EyeTracking.csv
├── BodyTracking.csv
├── FaceTracking.csv
├── LeftHandTracking.csv
├── RightHandTracking.csv
├── InputCapture.csv
├── EventPoints.csv
├── Variables.csv
├── Expressions.csv
├── AgentsData.csv
├── InteractivesData_<ObjectName>.csv (one per running interactive)
└── ...
The run directory is created by DataDirectoryUtility when the run initialisation completes. Each writer subscribes to the RunInitializationCompleted event, marks itself ready, and waits — the file is opened lazily on first row emission.
File names
Default file names per stream:
| Stream | Default filename | Notes |
|---|---|---|
| ExperienceState (render loop) | UpdateLoop.csv | Driven by UpdateLoopDataCapture. |
| ExperienceState (physics loop) | FixedInterval.csv | Driven by FixedIntervalDataCapture. Independent file. |
| Participant | ParticipantTracking.csv | Configurable on the SO. |
| Body | BodyTracking.csv | |
| Face | FaceTracking.csv | |
| Hand (left) | LeftHandTracking.csv | Two separate writers, one per hand. |
| Hand (right) | RightHandTracking.csv | Fallback UnknownHandTracking.csv if skeleton-type is null. |
| Eye | EyeTracking.csv | |
| Events | EventPoints.csv | |
| Variables | Variables.csv | |
| Expressions | Expressions.csv | |
| Agents | AgentsData.csv | Only when USING_AGENTS is defined. |
| Input | InputCapture.csv | Configurable on the SO. |
| Interactives | InteractivesData_<ObjectName>.csv | One file per running interactive. |
Streams whose config SO exposes a fileName field (most do) allow overriding the default. If the configured name is blank, the writer falls back to the default.
Update loop & Fixed interval — two files, one writer class
Unlike the historical "Both" mode (one writer producing two files), the current pipeline uses two independent configs (UpdateLoopDataCapture + FixedIntervalDataCapture) and creates two writer instances sharing the LaboExperienceStateDataWriter class. Each writes its own file:
UpdateLoop.csv— one row perLateUpdate(render frame)FixedInterval.csv— one row perFixedUpdate(physics tick)
Enable one, the other, or both — they're independent. Joining the two together is usually not what you want — they're on different clocks. Pick the one that matches what you're analysing:
- Render-rate continuous signals →
UpdateLoop.csv - Physics-rate continuous signals →
FixedInterval.csv
Hand tracking — two files
Hand tracking uses two independent writer instances, one per hand, because the left and right hands may have different skeletons assigned (or only one hand may be assigned). Each writer produces its own file:
LeftHandTracking.csvRightHandTracking.csv
If only one hand is configured, only that one file exists. The filename is derived from skeleton.skeletonType — fallback is UnknownHandTracking.csv if the skeleton is null. To produce a combined per-frame view, join both files to UpdateLoop.csv on FrameNumber.
Lazy file creation — writers with no data leave no file
SessionBoundCsvWriterBase does not open the CSV file when the run initialises. The file is created only the first time WriteDataRow() is actually called. Consequences:
- A writer that's enabled but never produces a row leaves no file behind. Empty CSVs do not exist.
- "Where's my file?" usually has two possible answers: (a) the writer was gated out at startup, or (b) the writer was enabled but no triggers fired during the run (irregular streams) / no samples qualified (regular with
recordModefiltering).
This change post-dates earlier docs that described eager file creation on run-init. If you see references to "files created at session start," they're stale.
Headers and append behaviour
- The header row is written once when the file is first opened (i.e. on the first row emission), via the writer's
BuildHeader(). Header is never rewritten mid-run. - Rows are appended as they're produced. Writes are batched and flushed every N rows (configurable per writer via
flushEveryNRowson the config SO; default 30). A crash or force-quit can lose at most the most recent unflushed batch. - Files are closed cleanly when the writer's
OnDisable/OnDestroyfires — normally when the run ends (RunInitializationResetevent) or the editor exits play mode. - After a run ends, the next run opens fresh files in a new run directory. Same writer instances survive across runs.
Gated streams — files may not exist
A writer only produces a file if its gate conditions allow at least one row. If the gate fails, no file is created.
| Stream | Gate |
|---|---|
| UpdateLoop / FixedInterval | Respective config SO present, enabledLogging = true |
| Body | ExperienceUtilities.CaptureBodyData enabled and a SilicoSkeletonBody is assigned |
| Face | ExperienceUtilities.CaptureFaceData enabled and a SilicoSkeletonFace is assigned |
| Hand (per side) | Hand capture enabled in DataCaptureSettings, skeleton assigned, skeleton initialized == true |
| Eye | Eye capture enabled, eyes tracked, head skeleton assigned |
| Participant | ParticipantTrackingDataCapture SO present, enabledLogging = true |
| Input | KeyCodeDataCapture SO present, enabledLogging = true |
| Events / Variables / Expressions / Agents | Respective config SO present, enabledLogging = true, plus USING_AGENTS for Agents |
| Interactives | Per-object: interactive.isRunning AND shared InteractivesDataCapture.enabledLogging = true |
Plus the master gate: DataDirectoryUtility.SaveData must be true. In editor, that requires the application state to be running (not None / View) and saveSettings.saveTestData to be On. In builds, always true.
If an analysis script expects a file that doesn't exist for this run, the most common causes are: gated out, never triggered, or editor saveTestData was off.
Where the run directory lives
The data store root is resolved at runtime from StateUtility.State.dataStore.runDataPath. It's configured in the LABO editor settings; the default varies by platform but is typically under the project's local Application.persistentDataPath or a user-configured folder.
Each run directory is typically named with a timestamp and/or run identifier — check DataDirectoryUtility for the exact format if you need to parse or filter directories in bulk.
Encoding
- File encoding: UTF-8 without BOM (
StreamWriterdefault). - Line endings: native to the platform the run was recorded on. Expect
\r\non Windows recordings,\non macOS/Linux. If you process recordings cross-platform, pandas and R handle both transparently; raw string diffs may not. - Field separator: comma. Commas inside string values are replaced with semicolons at write time (see Column conventions).
- Quoting: none. No field is double-quoted; the comma-to-semicolon substitution is the only escaping LABO does.
Further reading
- Regular vs irregular — what each stream captures.
- Column conventions — how columns are named and encoded.
- Join keys — aligning rows across files from the same run.