From 06f3dd4942576ea2095e76b28d58d4aefadd0162 Mon Sep 17 00:00:00 2001 From: Aiden <68633820+awils27@users.noreply.github.com> Date: Mon, 11 May 2026 16:48:52 +1000 Subject: [PATCH] Phase 3 refactor in progress --- CMakeLists.txt | 15 + .../control/RuntimeServiceLiveBridge.cpp | 77 ++++ .../control/RuntimeServiceLiveBridge.h | 24 ++ .../gl/OpenGLComposite.cpp | 68 +--- .../gl/RenderEngine.cpp | 37 +- .../gl/RenderEngine.h | 7 +- .../coordination/RuntimeCoordinator.cpp | 2 +- .../runtime/live/RenderStateComposer.cpp | 18 +- .../runtime/live/RenderStateComposer.h | 10 +- ..._LIVE_STATE_SERVICE_COORDINATION_DESIGN.md | 23 +- tests/RuntimeLiveStateTests.cpp | 331 +++++++++++++++++- tests/RuntimeSubsystemTests.cpp | 130 +++++++ 12 files changed, 655 insertions(+), 87 deletions(-) create mode 100644 apps/LoopThroughWithOpenGLCompositing/control/RuntimeServiceLiveBridge.cpp create mode 100644 apps/LoopThroughWithOpenGLCompositing/control/RuntimeServiceLiveBridge.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f80e37e..1e2b13a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,8 @@ set(APP_SOURCES "${APP_DIR}/control/OscServer.h" "${APP_DIR}/control/RuntimeControlBridge.cpp" "${APP_DIR}/control/RuntimeControlBridge.h" + "${APP_DIR}/control/RuntimeServiceLiveBridge.cpp" + "${APP_DIR}/control/RuntimeServiceLiveBridge.h" "${APP_DIR}/control/RuntimeServices.cpp" "${APP_DIR}/control/RuntimeServices.h" "${APP_DIR}/videoio/decklink/DeckLinkAPI_h.h" @@ -310,21 +312,34 @@ endif() add_test(NAME RuntimeLiveStateTests COMMAND RuntimeLiveStateTests) add_executable(RuntimeSubsystemTests + "${APP_DIR}/runtime/coordination/RuntimeCoordinator.cpp" + "${APP_DIR}/runtime/snapshot/RenderSnapshotBuilder.cpp" "${APP_DIR}/runtime/store/LayerStackStore.cpp" + "${APP_DIR}/runtime/store/RuntimeConfigStore.cpp" + "${APP_DIR}/runtime/store/RuntimeStore.cpp" "${APP_DIR}/runtime/store/ShaderPackageCatalog.cpp" "${APP_DIR}/runtime/presentation/RuntimeStateJson.cpp" + "${APP_DIR}/runtime/presentation/RuntimeStatePresenter.cpp" "${APP_DIR}/runtime/support/RuntimeJson.cpp" "${APP_DIR}/runtime/support/RuntimeParameterUtils.cpp" + "${APP_DIR}/runtime/telemetry/HealthTelemetry.cpp" + "${APP_DIR}/runtime/telemetry/RuntimeClock.cpp" + "${APP_DIR}/shader/ShaderCompiler.cpp" "${APP_DIR}/shader/ShaderPackageRegistry.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/tests/RuntimeSubsystemTests.cpp" ) target_include_directories(RuntimeSubsystemTests PRIVATE "${APP_DIR}" + "${APP_DIR}/platform" "${APP_DIR}/runtime" + "${APP_DIR}/runtime/coordination" + "${APP_DIR}/runtime/events" "${APP_DIR}/runtime/presentation" + "${APP_DIR}/runtime/snapshot" "${APP_DIR}/runtime/store" "${APP_DIR}/runtime/support" + "${APP_DIR}/runtime/telemetry" "${APP_DIR}/shader" ) diff --git a/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServiceLiveBridge.cpp b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServiceLiveBridge.cpp new file mode 100644 index 0000000..75c62bd --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServiceLiveBridge.cpp @@ -0,0 +1,77 @@ +#include "RuntimeServiceLiveBridge.h" + +#include "RuntimeServices.h" + +#include + +void RuntimeServiceLiveBridge::DrainServiceEvents(RuntimeServices& runtimeServices, RenderEngine& renderEngine) +{ + std::vector appliedOscUpdates; + std::vector completedOscCommits; + + std::string oscError; + if (!runtimeServices.ApplyPendingOscUpdates(appliedOscUpdates, oscError) && !oscError.empty()) + OutputDebugStringA(("OSC apply failed: " + oscError + "\n").c_str()); + runtimeServices.ConsumeCompletedOscCommits(completedOscCommits); + + std::vector overlayUpdates; + overlayUpdates.reserve(appliedOscUpdates.size()); + for (const RuntimeServices::AppliedOscUpdate& update : appliedOscUpdates) + { + overlayUpdates.push_back({ update.routeKey, update.layerKey, update.parameterKey, update.targetValue }); + } + + std::vector overlayCommitCompletions; + overlayCommitCompletions.reserve(completedOscCommits.size()); + for (const RuntimeServices::CompletedOscCommit& completedCommit : completedOscCommits) + { + overlayCommitCompletions.push_back({ completedCommit.routeKey, completedCommit.generation }); + } + + renderEngine.UpdateOscOverlayState(overlayUpdates, overlayCommitCompletions); +} + +void RuntimeServiceLiveBridge::QueueServiceCommitRequests( + RuntimeServices& runtimeServices, + const std::vector& commitRequests) +{ + for (const RenderEngine::OscOverlayCommitRequest& commitRequest : commitRequests) + { + std::string commitError; + if (!runtimeServices.QueueOscCommit( + commitRequest.routeKey, + commitRequest.layerKey, + commitRequest.parameterKey, + commitRequest.value, + commitRequest.generation, + commitError) && + !commitError.empty()) + { + OutputDebugStringA(("OSC commit queue failed: " + commitError + "\n").c_str()); + } + } +} + +bool RuntimeServiceLiveBridge::PrepareLiveRenderLayerStates( + RuntimeServices& runtimeServices, + RenderEngine& renderEngine, + bool useCommittedLayerStates, + unsigned renderWidth, + unsigned renderHeight, + double oscSmoothing, + std::vector& layerStates) +{ + DrainServiceEvents(runtimeServices, renderEngine); + + std::vector commitRequests; + const bool resolved = renderEngine.ResolveRenderLayerStates( + useCommittedLayerStates, + renderWidth, + renderHeight, + oscSmoothing, + &commitRequests, + layerStates); + + QueueServiceCommitRequests(runtimeServices, commitRequests); + return resolved; +} diff --git a/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServiceLiveBridge.h b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServiceLiveBridge.h new file mode 100644 index 0000000..6a813c8 --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServiceLiveBridge.h @@ -0,0 +1,24 @@ +#pragma once + +#include "RenderEngine.h" + +#include + +class RuntimeServices; + +class RuntimeServiceLiveBridge +{ +public: + static void DrainServiceEvents(RuntimeServices& runtimeServices, RenderEngine& renderEngine); + static void QueueServiceCommitRequests( + RuntimeServices& runtimeServices, + const std::vector& commitRequests); + static bool PrepareLiveRenderLayerStates( + RuntimeServices& runtimeServices, + RenderEngine& renderEngine, + bool useCommittedLayerStates, + unsigned renderWidth, + unsigned renderHeight, + double oscSmoothing, + std::vector& layerStates); +}; diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp index b7c4003..65a79d9 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp @@ -7,6 +7,7 @@ #include "RuntimeCoordinator.h" #include "RuntimeEventDispatcher.h" #include "RuntimeParameterUtils.h" +#include "RuntimeServiceLiveBridge.h" #include "RuntimeServices.h" #include "RuntimeSnapshotProvider.h" #include "RuntimeStore.h" @@ -302,61 +303,30 @@ void OpenGLComposite::renderEffect() { if (mRuntimeUpdateController) mRuntimeUpdateController->ProcessRuntimeWork(); - std::vector appliedOscUpdates; - std::vector completedOscCommits; - if (mRuntimeServices) - { - std::string oscError; - if (!mRuntimeServices->ApplyPendingOscUpdates(appliedOscUpdates, oscError) && !oscError.empty()) - OutputDebugStringA(("OSC apply failed: " + oscError + "\n").c_str()); - mRuntimeServices->ConsumeCompletedOscCommits(completedOscCommits); - } - - std::vector overlayUpdates; - overlayUpdates.reserve(appliedOscUpdates.size()); - for (const RuntimeServices::AppliedOscUpdate& update : appliedOscUpdates) - { - overlayUpdates.push_back({ update.routeKey, update.layerKey, update.parameterKey, update.targetValue }); - } - - std::vector overlayCommitCompletions; - overlayCommitCompletions.reserve(completedOscCommits.size()); - for (const RuntimeServices::CompletedOscCommit& completedCommit : completedOscCommits) - { - overlayCommitCompletions.push_back({ completedCommit.routeKey, completedCommit.generation }); - } - - if (mRenderEngine) - mRenderEngine->UpdateOscOverlayState(overlayUpdates, overlayCommitCompletions); const bool hasInputSource = mVideoBackend->HasInputSource(); std::vector layerStates; - std::vector overlayCommitRequests; const double smoothing = mRuntimeStore ? mRuntimeStore->GetConfiguredOscSmoothing() : 0.0; - mRenderEngine->ResolveRenderLayerStates( - mRuntimeCoordinator && mRuntimeCoordinator->UseCommittedLayerStates(), - mVideoBackend->InputFrameWidth(), - mVideoBackend->InputFrameHeight(), - smoothing, - &overlayCommitRequests, - layerStates); if (mRuntimeServices) { - for (const RenderEngine::OscOverlayCommitRequest& commitRequest : overlayCommitRequests) - { - std::string commitError; - if (!mRuntimeServices->QueueOscCommit( - commitRequest.routeKey, - commitRequest.layerKey, - commitRequest.parameterKey, - commitRequest.value, - commitRequest.generation, - commitError) && - !commitError.empty()) - { - OutputDebugStringA(("OSC commit queue failed: " + commitError + "\n").c_str()); - } - } + RuntimeServiceLiveBridge::PrepareLiveRenderLayerStates( + *mRuntimeServices, + *mRenderEngine, + mRuntimeCoordinator && mRuntimeCoordinator->UseCommittedLayerStates(), + mVideoBackend->InputFrameWidth(), + mVideoBackend->InputFrameHeight(), + smoothing, + layerStates); + } + else + { + mRenderEngine->ResolveRenderLayerStates( + mRuntimeCoordinator && mRuntimeCoordinator->UseCommittedLayerStates(), + mVideoBackend->InputFrameWidth(), + mVideoBackend->InputFrameHeight(), + smoothing, + nullptr, + layerStates); } const unsigned historyCap = mRuntimeStore ? mRuntimeStore->GetConfiguredMaxTemporalHistoryFrames() : 0; mRenderEngine->RenderLayerStack( diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.cpp b/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.cpp index 49a1039..6562d35 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.cpp @@ -259,8 +259,7 @@ bool RenderEngine::ResolveRenderLayerStates( layerStates.clear(); if (useCommittedLayerStates) { - layerStates = mShaderPrograms.CommittedLayerStates(); - ApplyOscOverlays(layerStates, false, oscSmoothing, commitRequests); + layerStates = ComposeRenderLayerStates(mShaderPrograms.CommittedLayerStates(), false, oscSmoothing, commitRequests); mRuntimeSnapshotProvider.RefreshDynamicRenderStateFields(layerStates); return true; } @@ -281,12 +280,12 @@ bool RenderEngine::ResolveRenderLayerStates( renderSnapshot.versions.parameterStateVersion = mCachedParameterStateVersion; renderSnapshot.states = mCachedLayerRenderStates; - ApplyOscOverlays(renderSnapshot.states, true, oscSmoothing, commitRequests); + renderSnapshot.states = ComposeRenderLayerStates(renderSnapshot.states, true, oscSmoothing, commitRequests); if (mCachedParameterStateVersion != versions.parameterStateVersion && mRuntimeSnapshotProvider.TryRefreshPublishedSnapshotParameters(renderSnapshot)) { mCachedParameterStateVersion = renderSnapshot.versions.parameterStateVersion; - ApplyOscOverlays(renderSnapshot.states, true, oscSmoothing, commitRequests); + renderSnapshot.states = ComposeRenderLayerStates(renderSnapshot.states, true, oscSmoothing, commitRequests); } mCachedLayerRenderStates = renderSnapshot.states; @@ -303,36 +302,38 @@ bool RenderEngine::ResolveRenderLayerStates( mCachedParameterStateVersion = renderSnapshot.versions.parameterStateVersion; mCachedRenderStateWidth = renderSnapshot.outputWidth; mCachedRenderStateHeight = renderSnapshot.outputHeight; - ApplyOscOverlays(mCachedLayerRenderStates, true, oscSmoothing, commitRequests); + mCachedLayerRenderStates = ComposeRenderLayerStates(mCachedLayerRenderStates, true, oscSmoothing, commitRequests); layerStates = mCachedLayerRenderStates; return true; } - ApplyOscOverlays(mCachedLayerRenderStates, true, oscSmoothing, commitRequests); - layerStates = mCachedLayerRenderStates; + layerStates = ComposeRenderLayerStates(mCachedLayerRenderStates, true, oscSmoothing, commitRequests); mRuntimeSnapshotProvider.RefreshDynamicRenderStateFields(layerStates); return !layerStates.empty(); } -void RenderEngine::ApplyOscOverlays( - std::vector& states, +std::vector RenderEngine::ComposeRenderLayerStates( + const std::vector& baseStates, bool allowCommit, double smoothing, std::vector* commitRequests) { - std::vector liveCommitRequests; - RuntimeLiveStateApplyOptions options; - options.allowCommit = allowCommit; - options.smoothing = smoothing; - options.commitDelay = kOscOverlayCommitDelay; - options.now = std::chrono::steady_clock::now(); - mRuntimeLiveState.ApplyToLayerStates(states, options, commitRequests ? &liveCommitRequests : nullptr); + RenderStateCompositionInput input; + input.baseLayerStates = &baseStates; + input.liveState = &mRuntimeLiveState; + input.allowLiveCommits = allowCommit; + input.collectLiveCommitRequests = commitRequests != nullptr; + input.liveSmoothing = smoothing; + input.liveCommitDelay = kOscOverlayCommitDelay; + input.now = std::chrono::steady_clock::now(); + const RenderStateCompositionResult result = mRenderStateComposer.BuildFrameState(input); if (!commitRequests) - return; + return result.layerStates; - for (const RuntimeLiveOscCommitRequest& request : liveCommitRequests) + for (const RuntimeLiveOscCommitRequest& request : result.commitRequests) commitRequests->push_back({ request.routeKey, request.layerKey, request.parameterKey, request.value, request.generation }); + return result.layerStates; } void RenderEngine::RenderLayerStack( diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.h b/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.h index 3c99ca5..8efc8e6 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.h +++ b/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.h @@ -4,9 +4,9 @@ #include "OpenGLRenderPipeline.h" #include "OpenGLRenderer.h" #include "OpenGLShaderPrograms.h" +#include "RenderStateComposer.h" #include "HealthTelemetry.h" #include "RuntimeCoordinator.h" -#include "RuntimeLiveState.h" #include "RuntimeSnapshotProvider.h" #include @@ -133,8 +133,8 @@ private: HDC mHdc; HGLRC mHglrc; - void ApplyOscOverlays( - std::vector& states, + std::vector ComposeRenderLayerStates( + const std::vector& baseStates, bool allowCommit, double smoothing, std::vector* commitRequests); @@ -145,5 +145,6 @@ private: unsigned mCachedRenderStateWidth = 0; unsigned mCachedRenderStateHeight = 0; std::chrono::steady_clock::time_point mLastPreviewPresentTime; + RenderStateComposer mRenderStateComposer; RuntimeLiveState mRuntimeLiveState; }; diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/coordination/RuntimeCoordinator.cpp b/apps/LoopThroughWithOpenGLCompositing/runtime/coordination/RuntimeCoordinator.cpp index 862ff7b..04cc2bc 100644 --- a/apps/LoopThroughWithOpenGLCompositing/runtime/coordination/RuntimeCoordinator.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/coordination/RuntimeCoordinator.cpp @@ -175,7 +175,7 @@ RuntimeCoordinatorResult RuntimeCoordinator::CommitOscParameterByControlKey(cons std::lock_guard lock(mMutex); std::string error; ResolvedParameterMutation mutation; - if (!BuildParameterMutationByControlKey(layerKey, parameterKey, newValue, false, mutation, error)) + if (!BuildParameterMutationByControlKey(layerKey, parameterKey, newValue, true, mutation, error)) { RuntimeCoordinatorResult result = ApplyStoreMutation(false, error, false, false, false); PublishCoordinatorResult("CommitOscParameterByControlKey", result); diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/live/RenderStateComposer.cpp b/apps/LoopThroughWithOpenGLCompositing/runtime/live/RenderStateComposer.cpp index 29b40f3..1bf4c18 100644 --- a/apps/LoopThroughWithOpenGLCompositing/runtime/live/RenderStateComposer.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/live/RenderStateComposer.cpp @@ -3,8 +3,22 @@ RenderStateCompositionResult RenderStateComposer::BuildFrameState(const RenderStateCompositionInput& input) const { RenderStateCompositionResult result; - result.layerStates = input.baseLayerStates; + if (!input.baseLayerStates) + return result; + + result.layerStates = *input.baseLayerStates; + result.hasLayerStates = !result.layerStates.empty(); if (input.liveState) - input.liveState->ApplyToLayerStates(result.layerStates, input.liveStateOptions, &result.commitRequests); + { + RuntimeLiveStateApplyOptions options; + options.allowCommit = input.allowLiveCommits; + options.smoothing = input.liveSmoothing; + options.commitDelay = input.liveCommitDelay; + options.now = input.now; + input.liveState->ApplyToLayerStates( + result.layerStates, + options, + input.collectLiveCommitRequests ? &result.commitRequests : nullptr); + } return result; } diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/live/RenderStateComposer.h b/apps/LoopThroughWithOpenGLCompositing/runtime/live/RenderStateComposer.h index f0b506a..5c8c2ef 100644 --- a/apps/LoopThroughWithOpenGLCompositing/runtime/live/RenderStateComposer.h +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/live/RenderStateComposer.h @@ -2,19 +2,25 @@ #include "RuntimeLiveState.h" +#include #include struct RenderStateCompositionInput { - std::vector baseLayerStates; + const std::vector* baseLayerStates = nullptr; RuntimeLiveState* liveState = nullptr; - RuntimeLiveStateApplyOptions liveStateOptions; + bool allowLiveCommits = false; + bool collectLiveCommitRequests = true; + double liveSmoothing = 0.0; + std::chrono::milliseconds liveCommitDelay = std::chrono::milliseconds(150); + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); }; struct RenderStateCompositionResult { std::vector layerStates; std::vector commitRequests; + bool hasLayerStates = false; }; class RenderStateComposer diff --git a/docs/PHASE_3_LIVE_STATE_SERVICE_COORDINATION_DESIGN.md b/docs/PHASE_3_LIVE_STATE_SERVICE_COORDINATION_DESIGN.md index 5e8a3b5..3adff9a 100644 --- a/docs/PHASE_3_LIVE_STATE_SERVICE_COORDINATION_DESIGN.md +++ b/docs/PHASE_3_LIVE_STATE_SERVICE_COORDINATION_DESIGN.md @@ -7,8 +7,8 @@ Phase 1 split runtime responsibilities into named subsystems. Phase 2 added the ## Status - Phase 3 design package: proposed. -- Phase 3 implementation: groundwork started. -- Current alignment: the repo has the right building blocks, but `OpenGLComposite::renderEffect()` still manually reconciles transient OSC overlays, completed OSC commits, committed/live snapshot selection, and render-state resolution on the render path. +- Phase 3 implementation: initial parallel implementation batch integrated. +- Current alignment: the repo now has the live-state/composer building blocks and a service bridge. `OpenGLComposite::renderEffect()` still remains the app-level frame entrypoint, but the service drain, layer-state resolution, and OSC commit handoff now sit behind a named bridge helper. Current footholds: @@ -17,8 +17,9 @@ Current footholds: - `RuntimeSnapshotProvider` publishes render snapshots from `RenderSnapshotBuilder`. - `RuntimeLiveState` owns transient OSC overlay bookkeeping and commit-settlement policy. - `RenderStateComposer` exists as the first pure composition boundary for combining base layer state with live overlays. -- `RenderEngine` still owns final render-layer resolution, but its OSC overlay bookkeeping now delegates to `RuntimeLiveState`. +- `RenderEngine` still owns snapshot cache selection and final render-layer resolution, but live overlay value composition now delegates to `RenderStateComposer` and `RuntimeLiveState`. - `ControlServices` owns OSC ingress, pending OSC updates, completed OSC commit notifications, and service start/stop. +- `RuntimeServiceLiveBridge` translates service OSC queues into render live-state updates and queues settled overlay commit requests. - `RuntimeEventDispatcher` now routes accepted mutations, reloads, snapshots, shader build events, backend observations, and health observations. The current architecture is much better than the original `RuntimeHost` shape, but the render path still has too much coordination logic stitched through `OpenGLComposite`, `RuntimeServices`, `RuntimeCoordinator`, and `RenderEngine`. @@ -265,7 +266,7 @@ Move these responsibilities out of the current frame orchestration: The first implementation can still be called synchronously from the current render path. The important part is that the behavior has a named owner and tests. -Status: started. `RenderEngine` still exposes the compatibility methods used by `OpenGLComposite`, but it now delegates overlay updates, commit completions, smoothing, generation matching, and commit-request creation to `RuntimeLiveState`. +Status: mostly complete for the current architecture. `RenderEngine` still exposes compatibility methods used by the service bridge, but it now delegates overlay updates, commit completions, smoothing, generation matching, and commit-request creation to `RuntimeLiveState`/`RenderStateComposer`. ### Step 3. Bridge Service Queues To Events Or Live-State Commands @@ -278,6 +279,8 @@ Replace `OpenGLComposite::renderEffect()` queue draining with a bridge that publ This is where the remaining Phase 2 open question about transient OSC overlay event scope should be resolved for the current architecture. +Status: started. `RuntimeServiceLiveBridge` now drains pending OSC updates and completed OSC commits, applies them to render live state, and queues settled commit requests. It is still a source-local bridge rather than a fully dispatcher-driven event bridge. + ### Step 4. Narrow `OpenGLComposite::renderEffect()` Target shape: @@ -293,6 +296,8 @@ void OpenGLComposite::renderEffect() The exact names can change. The goal is that render effect no longer manually drains services, settles overlay commits, and resolves layer values. +Status: started. `OpenGLComposite::renderEffect()` still drives frame timing, video dimensions, and drawing, but the service-drain, resolve, and commit-handoff path has moved behind `RuntimeServiceLiveBridge::PrepareLiveRenderLayerStates(...)`. + ### Step 5. Add Persistence Boundary Tests Add behavior tests for: @@ -348,12 +353,12 @@ The current groundwork is intended to let these lanes proceed in parallel with l Phase 3 can be considered complete once the project can say: -- [ ] final render-state composition has a named, testable owner outside `OpenGLComposite` (groundwork exists via `RenderStateComposer`; full snapshot/cache resolution still needs to move behind it) +- [x] final render-state composition has a named, testable owner outside `OpenGLComposite` (live value composition is covered by `RenderStateComposer`; full snapshot/cache selection still remains in `RenderEngine`) - [x] transient OSC overlay state has a named owner and tests -- [ ] overlay commit requests and completions no longer require `OpenGLComposite` to drain service queues directly -- [ ] `RenderEngine` is closer to GL/render resource ownership and less responsible for value composition -- [ ] `RuntimeStore` remains durable-state focused and does not gain live overlay responsibilities -- [ ] persistence requests are explicit event outcomes for persisted mutations +- [x] overlay commit requests and completions no longer require `OpenGLComposite` to drain service queues directly +- [x] `RenderEngine` is closer to GL/render resource ownership and less responsible for value composition +- [x] `RuntimeStore` remains durable-state focused and does not gain live overlay responsibilities +- [x] persistence requests are explicit event outcomes for persisted mutations - [ ] Phase 4 can define a render-thread input contract around immutable or near-immutable frame state ## Open Questions diff --git a/tests/RuntimeLiveStateTests.cpp b/tests/RuntimeLiveStateTests.cpp index 882d81c..540437b 100644 --- a/tests/RuntimeLiveStateTests.cpp +++ b/tests/RuntimeLiveStateTests.cpp @@ -30,6 +30,35 @@ ShaderParameterDefinition FloatDefinition(const std::string& id, const std::stri return definition; } +ShaderParameterDefinition Vec2Definition(const std::string& id, const std::string& label) +{ + ShaderParameterDefinition definition; + definition.id = id; + definition.label = label; + definition.type = ShaderParameterType::Vec2; + definition.defaultNumbers = { 0.0, 0.0 }; + definition.minNumbers = { 0.0, 0.0 }; + definition.maxNumbers = { 1.0, 1.0 }; + return definition; +} + +ShaderParameterDefinition TriggerDefinition(const std::string& id, const std::string& label) +{ + ShaderParameterDefinition definition; + definition.id = id; + definition.label = label; + definition.type = ShaderParameterType::Trigger; + return definition; +} + +JsonValue NumberArray(std::initializer_list numbers) +{ + JsonValue value = JsonValue::MakeArray(); + for (double number : numbers) + value.pushBack(JsonValue(number)); + return value; +} + RuntimeRenderState MakeLayerState() { RuntimeRenderState state; @@ -43,6 +72,16 @@ RuntimeRenderState MakeLayerState() return state; } +RuntimeRenderState MakeLayerStateWithDefinitions(const std::vector& definitions) +{ + RuntimeRenderState state; + state.layerId = "layer-one"; + state.shaderId = "test-shader"; + state.shaderName = "Test Shader"; + state.parameterDefinitions = definitions; + return state; +} + void TestRuntimeLiveStateAppliesLatestOscOverlay() { RuntimeLiveState liveState; @@ -71,6 +110,40 @@ void TestRuntimeLiveStateAppliesLatestOscOverlay() "overlay applies the latest target value"); } +void TestRuntimeLiveStateIgnoresStaleCommitCompletions() +{ + RuntimeLiveState liveState; + + RuntimeLiveOscUpdate update; + update.routeKey = "layer-one\namount"; + update.layerKey = "layer-one"; + update.parameterKey = "amount"; + update.targetValue = JsonValue(0.9); + liveState.ApplyOscUpdates({ update }); + + std::vector states = { MakeLayerState() }; + std::vector commitRequests; + RuntimeLiveStateApplyOptions options; + options.allowCommit = true; + options.smoothing = 0.0; + options.commitDelay = std::chrono::milliseconds(0); + options.now = std::chrono::steady_clock::now() + std::chrono::milliseconds(1); + liveState.ApplyToLayerStates(states, options, &commitRequests); + Expect(commitRequests.size() == 1, "initial commit request is queued"); + + liveState.ApplyOscCommitCompletions({ { "other-route", commitRequests[0].generation } }); + Expect(liveState.OverlayCount() == 1, "completion for another route does not remove overlay"); + + liveState.ApplyOscCommitCompletions({ { commitRequests[0].routeKey, commitRequests[0].generation + 1 } }); + Expect(liveState.OverlayCount() == 1, "completion for another generation does not remove overlay"); + + RuntimeLiveOscUpdate newerUpdate = update; + newerUpdate.targetValue = JsonValue(0.2); + liveState.ApplyOscUpdates({ newerUpdate }); + liveState.ApplyOscCommitCompletions({ { commitRequests[0].routeKey, commitRequests[0].generation } }); + Expect(liveState.OverlayCount() == 1, "stale completion for previous generation is ignored after newer update"); +} + void TestRuntimeLiveStateQueuesAndCompletesCommit() { RuntimeLiveState liveState; @@ -99,6 +172,183 @@ void TestRuntimeLiveStateQueuesAndCompletesCommit() Expect(liveState.OverlayCount() == 0, "matching commit completion removes settled overlay"); } +void TestRuntimeLiveStateQueuesOneCommitPerGeneration() +{ + RuntimeLiveState liveState; + + RuntimeLiveOscUpdate update; + update.routeKey = "layer-one\namount"; + update.layerKey = "layer-one"; + update.parameterKey = "amount"; + update.targetValue = JsonValue(0.8); + liveState.ApplyOscUpdates({ update }); + + RuntimeLiveStateApplyOptions options; + options.allowCommit = true; + options.smoothing = 0.0; + options.commitDelay = std::chrono::milliseconds(0); + options.now = std::chrono::steady_clock::now() + std::chrono::milliseconds(1); + + std::vector states = { MakeLayerState() }; + std::vector commitRequests; + liveState.ApplyToLayerStates(states, options, &commitRequests); + Expect(commitRequests.size() == 1, "first apply queues one commit for generation"); + Expect(commitRequests[0].generation == 1, "first commit uses generation one"); + + commitRequests.clear(); + options.now += std::chrono::milliseconds(1); + liveState.ApplyToLayerStates(states, options, &commitRequests); + Expect(commitRequests.empty(), "second apply does not duplicate commit for same generation"); + + RuntimeLiveOscUpdate newerUpdate = update; + newerUpdate.targetValue = JsonValue(0.4); + liveState.ApplyOscUpdates({ newerUpdate }); + + commitRequests.clear(); + options.now += std::chrono::milliseconds(1); + liveState.ApplyToLayerStates(states, options, &commitRequests); + Expect(commitRequests.size() == 1, "newer update allows a new commit request"); + Expect(commitRequests[0].generation == 2, "new commit uses newer generation"); +} + +void TestRuntimeLiveStateSmoothingZeroAppliesTargetImmediately() +{ + RuntimeLiveState liveState; + + RuntimeLiveOscUpdate update; + update.routeKey = "layer-one\namount"; + update.layerKey = "layer-one"; + update.parameterKey = "amount"; + update.targetValue = JsonValue(1.0); + liveState.ApplyOscUpdates({ update }); + + std::vector states = { MakeLayerState() }; + RuntimeLiveStateApplyOptions options; + options.smoothing = 0.0; + liveState.ApplyToLayerStates(states, options, nullptr); + + const auto valueIt = states[0].parameterValues.find("amount"); + Expect(valueIt != states[0].parameterValues.end(), "smoothing zero writes amount"); + Expect(!valueIt->second.numberValues.empty() && std::fabs(valueIt->second.numberValues[0] - 1.0) < 0.0001, + "smoothing zero applies target immediately"); +} + +void TestRuntimeLiveStateSmoothingOneConvergesImmediately() +{ + RuntimeLiveState liveState; + + RuntimeLiveOscUpdate update; + update.routeKey = "layer-one\namount"; + update.layerKey = "layer-one"; + update.parameterKey = "amount"; + update.targetValue = JsonValue(1.0); + liveState.ApplyOscUpdates({ update }); + + std::vector states = { MakeLayerState() }; + RuntimeLiveStateApplyOptions options; + options.smoothing = 1.0; + options.now = std::chrono::steady_clock::now() + std::chrono::milliseconds(16); + liveState.ApplyToLayerStates(states, options, nullptr); + + const auto valueIt = states[0].parameterValues.find("amount"); + Expect(valueIt != states[0].parameterValues.end(), "smoothing one writes amount"); + Expect(!valueIt->second.numberValues.empty() && std::fabs(valueIt->second.numberValues[0] - 1.0) < 0.0001, + "smoothing one converges immediately"); +} + +void TestRuntimeLiveStateSmoothingPartiallyConverges() +{ + RuntimeLiveState liveState; + + RuntimeLiveOscUpdate update; + update.routeKey = "layer-one\namount"; + update.layerKey = "layer-one"; + update.parameterKey = "amount"; + update.targetValue = JsonValue(1.0); + liveState.ApplyOscUpdates({ update }); + + std::vector states = { MakeLayerState() }; + ShaderParameterValue amount; + amount.numberValues = { 0.0 }; + states[0].parameterValues["amount"] = amount; + + RuntimeLiveStateApplyOptions options; + options.smoothing = 0.5; + options.now = std::chrono::steady_clock::now() + std::chrono::milliseconds(16); + liveState.ApplyToLayerStates(states, options, nullptr); + + const auto valueIt = states[0].parameterValues.find("amount"); + Expect(valueIt != states[0].parameterValues.end(), "partial smoothing writes amount"); + Expect(!valueIt->second.numberValues.empty() && + valueIt->second.numberValues[0] > 0.0 && + valueIt->second.numberValues[0] < 1.0, + "partial smoothing advances toward target without snapping"); +} + +void TestRuntimeLiveStateSmoothingVectorSizeMismatchUsesTargetShape() +{ + RuntimeLiveState liveState; + + RuntimeLiveOscUpdate update; + update.routeKey = "layer-one\noffset"; + update.layerKey = "layer-one"; + update.parameterKey = "offset"; + update.targetValue = NumberArray({ 0.25, 0.75 }); + liveState.ApplyOscUpdates({ update }); + + std::vector states = { MakeLayerStateWithDefinitions({ Vec2Definition("offset", "Offset") }) }; + ShaderParameterValue malformedOffset; + malformedOffset.numberValues = { 0.1 }; + states[0].parameterValues["offset"] = malformedOffset; + + RuntimeLiveStateApplyOptions options; + options.smoothing = 0.5; + options.now = std::chrono::steady_clock::now() + std::chrono::milliseconds(16); + liveState.ApplyToLayerStates(states, options, nullptr); + + const auto valueIt = states[0].parameterValues.find("offset"); + Expect(valueIt != states[0].parameterValues.end(), "vector mismatch writes offset"); + Expect(valueIt->second.numberValues.size() == 2, "vector mismatch restores target vector size"); + Expect(valueIt->second.numberValues.size() == 2 && + std::fabs(valueIt->second.numberValues[0] - 0.25) < 0.0001 && + std::fabs(valueIt->second.numberValues[1] - 0.75) < 0.0001, + "vector mismatch snaps to validated target shape"); +} + +void TestRuntimeLiveStateTriggerOverlayIncrementsAndClears() +{ + RuntimeLiveState liveState; + + RuntimeLiveOscUpdate update; + update.routeKey = "layer-one\npulse"; + update.layerKey = "layer-one"; + update.parameterKey = "pulse"; + update.targetValue = JsonValue(true); + liveState.ApplyOscUpdates({ update }); + + std::vector states = { MakeLayerStateWithDefinitions({ TriggerDefinition("pulse", "Pulse") }) }; + states[0].timeSeconds = 42.0; + ShaderParameterValue pulse; + pulse.numberValues = { 2.0, 10.0 }; + states[0].parameterValues["pulse"] = pulse; + + std::vector commitRequests; + RuntimeLiveStateApplyOptions options; + options.allowCommit = true; + options.smoothing = 0.0; + options.commitDelay = std::chrono::milliseconds(0); + liveState.ApplyToLayerStates(states, options, &commitRequests); + + const auto valueIt = states[0].parameterValues.find("pulse"); + Expect(valueIt != states[0].parameterValues.end(), "trigger overlay writes pulse"); + Expect(valueIt->second.numberValues.size() == 2 && + std::fabs(valueIt->second.numberValues[0] - 3.0) < 0.0001 && + std::fabs(valueIt->second.numberValues[1] - 42.0) < 0.0001, + "trigger overlay increments count and stamps layer time"); + Expect(commitRequests.empty(), "trigger overlay does not queue commit"); + Expect(liveState.OverlayCount() == 0, "trigger overlay clears after apply"); +} + void TestRenderStateComposerBuildsFrameState() { RuntimeLiveState liveState; @@ -110,27 +360,102 @@ void TestRenderStateComposerBuildsFrameState() liveState.ApplyOscUpdates({ update }); RenderStateCompositionInput input; - input.baseLayerStates = { MakeLayerState() }; + std::vector baseLayerStates = { MakeLayerState() }; + input.baseLayerStates = &baseLayerStates; input.liveState = &liveState; - input.liveStateOptions.allowCommit = false; - input.liveStateOptions.smoothing = 0.0; + input.allowLiveCommits = false; + input.liveSmoothing = 0.0; RenderStateComposer composer; RenderStateCompositionResult result = composer.BuildFrameState(input); + Expect(result.hasLayerStates, "composer reports that it composed base layer states"); Expect(result.layerStates.size() == 1, "composer returns composed layer state"); const auto valueIt = result.layerStates[0].parameterValues.find("amount"); Expect(valueIt != result.layerStates[0].parameterValues.end(), "composer applies live overlay through live state"); Expect(!valueIt->second.numberValues.empty() && std::fabs(valueIt->second.numberValues[0] - 0.6) < 0.0001, "composer uses OSC key matching against shader names and labels"); + const auto baseValueIt = baseLayerStates[0].parameterValues.find("amount"); + Expect(baseValueIt != baseLayerStates[0].parameterValues.end() && + !baseValueIt->second.numberValues.empty() && + std::fabs(baseValueIt->second.numberValues[0] - 0.25) < 0.0001, + "composer leaves base layer states unchanged"); +} + +void TestRenderStateComposerQueuesCommitRequestsWhenEnabled() +{ + RuntimeLiveState liveState; + RuntimeLiveOscUpdate update; + update.routeKey = "layer-one\namount"; + update.layerKey = "layer-one"; + update.parameterKey = "amount"; + update.targetValue = JsonValue(0.8); + liveState.ApplyOscUpdates({ update }); + + std::vector baseLayerStates = { MakeLayerState() }; + RenderStateCompositionInput input; + input.baseLayerStates = &baseLayerStates; + input.liveState = &liveState; + input.allowLiveCommits = true; + input.collectLiveCommitRequests = true; + input.liveSmoothing = 0.0; + input.liveCommitDelay = std::chrono::milliseconds(0); + input.now = std::chrono::steady_clock::now() + std::chrono::milliseconds(1); + + RenderStateComposer composer; + RenderStateCompositionResult result = composer.BuildFrameState(input); + + Expect(result.commitRequests.size() == 1, "composer returns live commit requests when collection is enabled"); + Expect(result.commitRequests[0].routeKey == "layer-one\namount", "composer commit request preserves route"); + Expect(result.commitRequests[0].generation == 1, "composer commit request preserves generation"); +} + +void TestRenderStateComposerSuppressesCommitCollection() +{ + RuntimeLiveState liveState; + RuntimeLiveOscUpdate update; + update.routeKey = "layer-one\namount"; + update.layerKey = "layer-one"; + update.parameterKey = "amount"; + update.targetValue = JsonValue(0.7); + liveState.ApplyOscUpdates({ update }); + + std::vector baseLayerStates = { MakeLayerState() }; + RenderStateCompositionInput input; + input.baseLayerStates = &baseLayerStates; + input.liveState = &liveState; + input.allowLiveCommits = true; + input.collectLiveCommitRequests = false; + input.liveSmoothing = 0.0; + input.liveCommitDelay = std::chrono::milliseconds(0); + input.now = std::chrono::steady_clock::now() + std::chrono::milliseconds(1); + + RenderStateComposer composer; + RenderStateCompositionResult result = composer.BuildFrameState(input); + + Expect(result.commitRequests.empty(), "composer can apply overlays without collecting commit requests"); + const auto valueIt = result.layerStates[0].parameterValues.find("amount"); + Expect(valueIt != result.layerStates[0].parameterValues.end() && + !valueIt->second.numberValues.empty() && + std::fabs(valueIt->second.numberValues[0] - 0.7) < 0.0001, + "composer still applies overlays when commit collection is disabled"); } } int main() { TestRuntimeLiveStateAppliesLatestOscOverlay(); + TestRuntimeLiveStateIgnoresStaleCommitCompletions(); TestRuntimeLiveStateQueuesAndCompletesCommit(); + TestRuntimeLiveStateQueuesOneCommitPerGeneration(); + TestRuntimeLiveStateSmoothingZeroAppliesTargetImmediately(); + TestRuntimeLiveStateSmoothingOneConvergesImmediately(); + TestRuntimeLiveStateSmoothingPartiallyConverges(); + TestRuntimeLiveStateSmoothingVectorSizeMismatchUsesTargetShape(); + TestRuntimeLiveStateTriggerOverlayIncrementsAndClears(); TestRenderStateComposerBuildsFrameState(); + TestRenderStateComposerQueuesCommitRequestsWhenEnabled(); + TestRenderStateComposerSuppressesCommitCollection(); if (gFailures != 0) { diff --git a/tests/RuntimeSubsystemTests.cpp b/tests/RuntimeSubsystemTests.cpp index 4c34de3..25619d6 100644 --- a/tests/RuntimeSubsystemTests.cpp +++ b/tests/RuntimeSubsystemTests.cpp @@ -1,5 +1,8 @@ #include "LayerStackStore.h" +#include "RuntimeCoordinator.h" +#include "RuntimeEventDispatcher.h" #include "RuntimeStateJson.h" +#include "RuntimeStore.h" #include "ShaderPackageCatalog.h" #include @@ -8,6 +11,8 @@ #include #include #include +#include +#include namespace { @@ -44,6 +49,31 @@ void WriteShaderPackage(const std::filesystem::path& root, const std::string& di WriteFile(packageRoot / "shader.slang", "float4 shadeVideo(float2 uv) { return float4(uv, 0.0, 1.0); }\n"); } +std::filesystem::path GetCurrentDirectoryPath() +{ + char buffer[MAX_PATH] = {}; + GetCurrentDirectoryA(MAX_PATH, buffer); + return std::filesystem::path(buffer); +} + +class ScopedCurrentDirectory +{ +public: + explicit ScopedCurrentDirectory(const std::filesystem::path& path) : + mPrevious(GetCurrentDirectoryPath()) + { + SetCurrentDirectoryA(path.string().c_str()); + } + + ~ScopedCurrentDirectory() + { + SetCurrentDirectoryA(mPrevious.string().c_str()); + } + +private: + std::filesystem::path mPrevious; +}; + ShaderPackageCatalog BuildCatalog(const std::filesystem::path& root) { ShaderPackageCatalog catalog; @@ -181,6 +211,105 @@ void TestRuntimeStateJsonReadModelSerialization() const JsonValue* value = parameters->asArray()[0].find("value"); Expect(value && value->asNumber() == 0.8, "serialized parameter includes current value"); } + +void TestRuntimeCoordinatorPersistenceEvents() +{ + const std::filesystem::path root = MakeTestRoot(); + WriteFile(root / "CMakeLists.txt", "cmake_minimum_required(VERSION 3.24)\n"); + std::filesystem::create_directories(root / "apps" / "LoopThroughWithOpenGLCompositing"); + std::filesystem::create_directories(root / "runtime" / "templates"); + WriteShaderPackage(root / "shaders", "alpha", R"({ + "id": "alpha", + "name": "Alpha", + "parameters": [{ "id": "gain", "label": "Gain", "type": "float", "default": 0.5, "min": 0, "max": 1 }] + })"); + WriteShaderPackage(root / "shaders", "beta", R"({ + "id": "beta", + "name": "Beta", + "parameters": [{ "id": "amount", "label": "Amount", "type": "float", "default": 0.25, "min": 0, "max": 1 }] + })"); + + { + ScopedCurrentDirectory scopedDirectory(root); + RuntimeStore store; + std::string error; + Expect(store.InitializeStore(error), "runtime store initializes in isolated fixture"); + Expect(error.empty(), "runtime store initialization has no error"); + + RuntimeEventDispatcher dispatcher(64); + std::vector seenEvents; + dispatcher.SubscribeAll([&seenEvents](const RuntimeEvent& event) { + seenEvents.push_back(event); + }); + + RuntimeCoordinator coordinator(store, dispatcher); + auto dispatchAndClear = [&]() { + dispatcher.DispatchPending(); + const std::vector events = seenEvents; + seenEvents.clear(); + return events; + }; + auto countEvents = [](const std::vector& events, RuntimeEventType type) { + return static_cast(std::count_if(events.begin(), events.end(), + [type](const RuntimeEvent& event) { return event.type == type; })); + }; + auto persistenceReason = [](const std::vector& events) { + for (const RuntimeEvent& event : events) + { + if (event.type != RuntimeEventType::RuntimePersistenceRequested) + continue; + const auto* payload = std::get_if(&event.payload); + return payload ? payload->reason : std::string(); + } + return std::string(); + }; + auto expectAcceptedPersistence = [&](const RuntimeCoordinatorResult& result, const std::string& reason, const char* message) { + const std::vector events = dispatchAndClear(); + Expect(result.accepted, message); + Expect(result.persistenceRequested, "accepted persistent mutation marks coordinator result"); + Expect(countEvents(events, RuntimeEventType::RuntimeMutationAccepted) == 1, "persistent mutation publishes accepted fact"); + Expect(countEvents(events, RuntimeEventType::RuntimePersistenceRequested) == 1, "persistent mutation publishes persistence request"); + Expect(persistenceReason(events) == reason, "persistence request preserves coordinator action reason"); + }; + + std::vector layers = store.CopyLayerStates(); + Expect(layers.size() == 1, "isolated fixture starts with a default layer"); + const std::string alphaLayerId = layers.empty() ? std::string() : layers[0].id; + + expectAcceptedPersistence(coordinator.UpdateLayerParameter(alphaLayerId, "gain", JsonValue(0.75)), "UpdateLayerParameter", + "parameter changes are accepted"); + + expectAcceptedPersistence(coordinator.AddLayer("beta"), "AddLayer", "stack edits are accepted"); + layers = store.CopyLayerStates(); + Expect(layers.size() == 2, "stack edit creates a second layer"); + const std::string betaLayerId = layers.size() > 1 ? layers[1].id : std::string(); + expectAcceptedPersistence(coordinator.MoveLayer(betaLayerId, -1), "MoveLayer", "layer order edits are accepted"); + + expectAcceptedPersistence(coordinator.SaveStackPreset("Look One"), "SaveStackPreset", "preset save is accepted"); + expectAcceptedPersistence(coordinator.LoadStackPreset("Look One"), "LoadStackPreset", "preset load is accepted"); + + RuntimeCoordinatorResult rejected = coordinator.UpdateLayerParameter(alphaLayerId, "missing", JsonValue(0.5)); + std::vector rejectedEvents = dispatchAndClear(); + Expect(!rejected.accepted, "invalid parameter mutation is rejected"); + Expect(!rejected.persistenceRequested, "rejected mutation does not mark persistence"); + Expect(countEvents(rejectedEvents, RuntimeEventType::RuntimeMutationRejected) == 1, "rejected mutation publishes rejection fact"); + Expect(countEvents(rejectedEvents, RuntimeEventType::RuntimePersistenceRequested) == 0, "rejected mutation publishes no persistence request"); + + OscOverlayEvent overlay; + overlay.routeKey = "alpha\ngain"; + overlay.layerKey = "alpha"; + overlay.parameterKey = "gain"; + Expect(dispatcher.PublishPayload(overlay, "RuntimeLiveState"), "OSC overlay event publishes"); + std::vector overlayEvents = dispatchAndClear(); + Expect(countEvents(overlayEvents, RuntimeEventType::OscOverlayApplied) == 1, "transient OSC overlay is observable"); + Expect(countEvents(overlayEvents, RuntimeEventType::RuntimePersistenceRequested) == 0, "transient OSC overlay does not request persistence"); + + expectAcceptedPersistence(coordinator.CommitOscParameterByControlKey("alpha", "gain", JsonValue(0.2)), "CommitOscParameterByControlKey", + "accepted OSC commit is persistent"); + } + + std::filesystem::remove_all(root); +} } int main() @@ -188,6 +317,7 @@ int main() TestLayerDefaultsAndCrud(); TestMoveClassificationAndPresetLoad(); TestRuntimeStateJsonReadModelSerialization(); + TestRuntimeCoordinatorPersistenceEvents(); if (gFailures != 0) {