Per-frame system execution order, dependency graph, and show-thread fallback coverage for the Entity editor.
The authoritative order lives in code — Engine::update() for the editor
thread and Engine::showThreadMain() for the show thread. This doc is
the readable mirror.
For why the threading is split this way, see
docs/adr/0014-editor-show-thread-split.md.
Engine::update(). Runs at editor framerate (vsync-bound, ~60 Hz under
load, thousands of Hz in --headless --script mode with no decode work).
1. Timeline::update(dt)
└─ Advances m_currentTime when Playing. Atomic write.
2. SectionScheduler::tick()
└─ Break-crossing DETECTION runs on the show thread now (NEW-08).
tick() applies the catch-up: advances ClipPlaybackPhase
continuation while parked, drops a stale at-break latch on
manual resume, resets post-break anchors on a playback scrub.
└─ The editor applies a show-detected crossing via handleBreakAt()
from drainRendererToDirector (step 10).
└─ Wall-clock-anchored (steady_clock), NOT dt-accumulator.
3. AnimationSystem::update(dt)
└─ Clip branch: evaluates keyframe tracks; writes Transform + MediaLayer fields.
└─ OA branch: evaluates ObjectAnimationLayer keyframe tracks (PositionX/Y/Z,
RotationX/Y/Z, ScaleX/Y/Z); writes ObjectAnimationOutput. Skips re-evaluation
for Locked layers with frozen=true (set by SectionScheduler at a section break).
See ADR-0016.
3.5 TextSystem::update()
└─ For each active TextLayerState with dirty=true: rasterizes the text string
to a video-pool texture via TextRasterizer (DirectWrite + D2D on Windows).
Clears dirty flag after successful rasterize. Allocates a video-pool slot
on first use; slot freed by the on_destroy<TextLayerState> observer
(TextSystem::onTextLayerDestroyed) when the entity is deleted. Writes
TextLayerState::textureSlot, bakedWidth, bakedHeight.
└─ Static-per-frame: the last-baked texture remains valid during editor stalls,
so no show-thread fallback is needed (text doesn't animate — it only changes
on explicit authoring commands).
4. drainContentScannerDeltas()
└─ Folds filesystem-watcher deltas into MediaBin.
5. DecodeSystem::update()
└─ Maps timeline frame → media frame per clip; sets atomic
worker->targetFrame. Lazily creates per-clip DecodeWorker
threads. Reads Clip + FrameBuffer; writes none.
5.5 AudioSystem::update()
└─ Per active Clip+AudioSource: creates/destroys AudioDecodeWorker
threads lazily, steers seekTarget (atomic) on playhead jumps,
mirrors gain/mute/solo from AudioSource into the AudioMixer slot,
gates active flag from the clip's active window + ClipPlaybackPhase
continuation state. Reads Clip + AudioSource + ClipPlaybackPhase;
writes only atomic worker fields — never the registry. Show-thread
fallback fires on editor stalls (same 50ms heartbeat gate as
DecodeSystem).
5.6 SeekSyncController::tick()
└─ No-op when m_seekSyncGate is clear (99.9% of ticks — returns on
first branch). When the gate is active (engaged by Timeline::play()
on every ->Playing transition), polls two injectable readiness
predicates: videoReady (∀ active Clips: DecodeSystem::isClipReadyAt)
and audioReady (∀ active Clip+AudioSources: AudioSystem::isWorkerSeekReady).
Releases the gate by calling Timeline::setSeekSyncGate(false) when
both predicates return true. Falls back to releasing after
kPrerollTimeoutMs (3000 ms) so a broken decoder can never hang
playback indefinitely (ADR-0026).
└─ Must tick AFTER AudioSystem::update (step 5.5) so worker seek
state and ring-buffer fill are current when the readiness predicate
fires. Must tick BEFORE m_lastEditorTickNs.store (step 6) so the
gate release happens on the same editor tick that sees the ready
state.
└─ Show-thread fallback: not needed. The gate holds Timeline::update
from advancing m_currentTime. During an editor stall, the show-
thread Timeline::update also reads m_seekSyncGate (acquire order)
and respects the hold — so the gate extends across editor stalls
without SeekSyncController needing a show-thread presence.
6. m_lastEditorTickNs.store(now)
└─ Heartbeat the show thread polls for stall detection.
7. ImGui new-frame + UI rendering
└─ Editor windows draw. Per-window state writes (mostly through
CommandDispatcher).
8. CommandDispatcher::processQueue(Editor + Either affinity)
└─ Drains pending commands; writes registry.
9. buildSceneSnapshot()
└─ Bakes registry state into bus::SceneSnapshot::clipCatalog +
screens + outputs. Single-producer publish to D2R bus.
10. drainRendererToDirector()
└─ Reads R2D replies from show thread (e.g.
ScreenRenderTargetAllocated → writes Screen::renderTargetSlot).
11. beginEditorFrame / endEditorFrame
└─ Editor swap chain Present.
- SectionScheduler before AnimationSystem: section-fade multipliers are read by AnimationSystem in some paths.
- AnimationSystem before DecodeSystem: not strictly required — Transform doesn't drive decode — but conceptually animation should be evaluated before any system that might read its outputs.
- All five tick systems (1-5) before buildSceneSnapshot (9): the snapshot is the contract. Anything that wants to be visible to the show thread next frame must have written its state before the bake.
- Command dispatch (8) before buildSceneSnapshot (9): commands that mutate the registry need to land before the snapshot so the show thread sees them on this frame, not next.
Engine::showThreadMain(). Runs independently from the editor at output
framerate (typically vsync on the primary output, 60 Hz).
1. Timeline::update(dt)
└─ Advances Timeline::m_currentTime when Playing. Runs every show
frame unconditionally (ee99a99) — playback pace is decoupled
from editor health.
1.1 SectionScheduler break-crossing detection (NEW-08, the `SectionDetect` zone)
└─ When Playing and not already at-break, finds the first
Section::breakFrame the playhead just crossed, snaps + pauses
the playhead, raises Timeline::sectionAtBreak(), and posts an
R2D bus::SectionBreakDetected. Show-thread-local last-seen
state; sections read live via Timeline::copySectionsAndRate();
no registry writes. The editor applies the crossing via
SectionScheduler::handleBreakAt (editor step 10).
1.2 if (editor heartbeat > 50ms stale):
└─ DecodeSystem::update() ← show-thread fallback (a9bcd8b)
└─ AudioSystem::update() ← show-thread fallback (Phase D audio)
2. CommandDispatcher::processQueue(Show affinity)
└─ Drains Play / Pause / Seek / SectionGo. Writes atomic
playback state on Timeline; never writes the registry.
3. Drain D2R bus
└─ Pull latest SceneSnapshot (latest-wins; older snapshots
superseded if a newer one arrived before drain).
4. beginShowFrame
└─ Reset show-side command allocator; open command list.
└─ Open Tracy D3D12 zone (cross-function, closed at endShowFrame).
5. PlaybackPresenter::present()
└─ Uploads decoded frames to GPU textures; caches color-space
tags show-thread-locally (no registry reads in hot path).
6. CompositorSystem::update(renderFrame) [three-pass — ADR-0018 + issue #54]
PASS 1 (producer):
└─ Per active GenerativeLayerSnapshot:
ensureGenerativeRenderTarget(); beginComposeTarget(slot);
drawMuncherPlayfield(gl) in layer-local NDC; endComposeTarget().
Posts R2D GenerativeLayerRenderTargetAllocated on first
allocation per layer entity.
PASS 1.5 (per-layer effect chains, issue #54):
└─ For each ContentLayerSnapshot with non-empty `effects`:
ensureEffectPingTarget(); ping-pong two compose targets
through the chain; write final slot to `postEffectsSlot`.
Posts R2D EffectChainRenderTargetAllocated (side 0/1) on
first allocation per layer entity.
└─ Kind-blind — works identically for Video and Compose
sourceKinds. PASS 1.5 is a no-op for layers without effects.
PASS 2 (unified composite):
└─ Per visible screen: ensureScreenRenderTarget(); for each
ContentLayerSnapshot in rf.contentLayers (pre-sorted by
zOrder) matching this screen, drawTexturedQuad with
sourceKind dispatching the descriptor pool
(Video → video texture, Compose → generative layer RT).
When `postEffectsSlot >= 0`, PASS 2 reads from that
Compose slot instead of `sourceSlot`.
└─ Posts R2D ScreenRenderTargetAllocated on first
allocation per screen entity.
└─ rf.contentLayers is built show-side by
PlaybackTimeAuthority::buildRenderFrame from activeClips +
generativeLayers, with per-layer effects folded in from
scene.layerEffects (issue #54).
7. OutputManager::renderOutputs()
└─ Per enabled OutputDisplay: composite the assigned Screen's
compose target to that output's swap chain, with InputRegion
UV cropping and any per-output calibration overlay.
8. endShowFrame
└─ Close Tracy D3D12 zone. Execute command list.
└─ Present each enabled output swap chain.
- PlaybackPresenter before CompositorSystem: compositor reads textures the presenter just uploaded.
- CompositorSystem before OutputManager: outputs read screen compose targets that the compositor just drew into.
- Show-thread fallback (1) before everything else: catches up stalled editor state so compositor reads aren't using a frozen snapshot.
These don't appear in the per-frame ordering — they run in parallel and talk to the editor/show pair via atomic fields or message queues.
| Worker | Owned by | Communicates via |
|---|---|---|
Decode #N (per clip) |
DecodeSystem |
atomic targetFrame; FrameRingBuffer |
ContentScanner |
ContentScanner |
delta queue, drained on editor thread step 4 |
MediaProbe |
MediaProbeWorker |
result queue, drained on editor thread |
Transcode |
TranscodeManager |
result queue, drained on editor thread |
OSC (plugin) |
OscReceiverPlugin |
CommandDispatcher::enqueue |
When the editor thread stalls (Win32 modal dialog, OS resize/move loop,
slow project load), Engine::update() stops running, so every editor-
tick system stops with it. The show thread polls m_lastEditorTickNs
and, when stale, takes over critical time-driven systems so the
projector output stays alive.
Constraint per ADR-0014: systems called from the show thread must not write the registry. That's why only some systems have fallbacks.
| System | Editor-tick site | Show-thread fallback? | Notes |
|---|---|---|---|
Timeline::update |
step 1 | yes since ee99a99 |
Writes only atomic m_currentTime — show-safe. |
SectionScheduler |
editor step 2 / show step 1.1 | yes via detector-on-show split (2026-05-20) | Break-crossing detection runs on the show thread (SectionDetect, show step 1.1) — it snaps + pauses the playhead and posts R2D SectionBreakDetected. The editor applies it via handleBreakAt (registry writes: ClipPlaybackPhase, scheduler latch). Continuation phase is re-derived show-side from the wall-clock anchor in mapToMediaFrameFromCatalog using the active RateSource time (Phase G, 2026-05-21) — same clock domain as the editor-side seed — so it never freezes during a stall and stays in sync with the audio crystal when audio is active. NEW-08 closed. |
AnimationSystem::update |
step 3 | yes via snapshot-bake (2026-05-11) | Editor still writes Transform + MediaLayer (Clip branch) and ObjectAnimationOutput (OA branch) for UI surfaces. Clip tracks are baked into ClipCatalogEntry; OA tracks into ObjectAnimationLayerSnapshot. Show thread re-evaluates both per render frame in buildRenderFrame. Animation stays alive during editor stalls. NEW-07 closed. OA freeze for Locked layers at section breaks handled via ObjectAnimationLayer::frozen (ADR-0016). End-of-layer behavior follows ObjectAnimationLayer::endBehavior (ADR-0020): Hold keeps the last evaluated values applied past the layer's active window (default); Reset clears the override. After-end-Hold layers ride the snapshot to keep the show thread in sync during stalls; after-end-Reset layers are filtered out editor-side. |
TextSystem::update |
step 3.5 | no -- not needed | Rasterizes dirty Text layers to video-pool textures. Static-per-frame: text content only changes on explicit authoring commands, never on playback. The last-baked texture remains valid during editor stalls so output stays correct. Writes TextLayerState::textureSlot/bakedWidth/bakedHeight; clears dirty. |
drainContentScannerDeltas |
step 4 | no -- not needed | Filesystem-watcher updates can wait until stall ends. |
DecodeSystem::update |
step 5 | yes since a9bcd8b |
Writes only atomic worker->targetFrame — show-safe. |
AudioSystem::update |
step 5.5 | yes (Phase D audio) | Writes only atomic worker fields (seekTarget, active) and MixSource mixer-slot fields — no registry writes. Show-thread fallback fires on the same 50ms heartbeat stale gate as DecodeSystem so audio keeps advancing during editor stalls. |
SeekSyncController::tick |
step 5.6 | no — not needed | Polls readiness predicates and releases Timeline::m_seekSyncGate when all active decoders reach the parked frame. The gate itself is an std::atomic<bool> read by both the show-thread and editor-thread Timeline::update; both respect the hold without SeekSyncController needing a show-thread presence. During an editor stall the gate stays held (no tick = no release), which is correct — audio and video decode workers keep running independently, so by the time the editor resumes the predicates may already be satisfied and the gate releases on the first tick. Timeout failsafe (3000 ms) guards against indefinite holds (ADR-0026). |
Every editor-tick system that drives the projector output now has a
stall path. NEW-07 was closed 2026-05-11 by the snapshot-bake approach
in docs/design/animation-snapshot-bake.md: keyframe tracks travel
through bus::ClipCatalogEntry and the show thread re-evaluates them
per render frame.
NEW-08 was closed 2026-05-20 with the detector-on-show / applier-on-
editor split (see docs/design/section-scheduler-snapshot-bake.md).
Because SectionScheduler is a state machine — not a stateless evaluator
like AnimationSystem — the snapshot-bake shape alone was not enough: the
show thread runs the detector (and snaps the playhead immediately so
output never sails past the break), while the registry-mutating apply
stays on the editor thread, reached by an R2D SectionBreakDetected
message. Continuation phase rides a wall-clock anchor (steady_clock),
re-derived show-side, so it advances even with no editor ticks.
Decide affinity up front:
-
Editor-affinity (writes registry, runs from
Engine::update): most systems. Add to the appropriate step above based on its dependencies. -
Show-affinity (snapshot reads only, runs from
Engine::showThreadMain): reserved for systems that need to fire per render frame. Today this isPlaybackPresenterandCompositorSystem.
If the system is editor-affinity AND time-driven (its work is what the
user sees on the projector each frame), ask: "what happens if
Engine::update stops for 5 seconds while the user drags the editor
window?" If the answer is "user-visible content freeze on output,"
the system needs one of:
- A show-thread fallback in
Engine::showThreadMain(only if its per-frame work can be done without writing the registry). - An editor-thread snapshot-bake that publishes its results into
SceneSnapshot::clipCatalog, so the show thread reads pre-computed values.
See ADR-0014's "Show-Thread Fallback Pattern" section for the full checklist.
docs/adr/0014-editor-show-thread-split.md— threading architecture rationaledocs/reference/ECS_PRINCIPLES.md— the ECS rulesdocs/reference/ENTITY_ARCHETYPES.md— what data the systems are reading and writinginclude/entity/systems/CLAUDE.md— operator-facing rule sheet