Phase 3 refactor in progress
Some checks failed
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 2m33s
CI / Windows Release Package (push) Has been cancelled

This commit is contained in:
Aiden
2026-05-11 16:48:52 +10:00
parent 0808171677
commit 06f3dd4942
12 changed files with 655 additions and 87 deletions

View File

@@ -40,6 +40,8 @@ set(APP_SOURCES
"${APP_DIR}/control/OscServer.h" "${APP_DIR}/control/OscServer.h"
"${APP_DIR}/control/RuntimeControlBridge.cpp" "${APP_DIR}/control/RuntimeControlBridge.cpp"
"${APP_DIR}/control/RuntimeControlBridge.h" "${APP_DIR}/control/RuntimeControlBridge.h"
"${APP_DIR}/control/RuntimeServiceLiveBridge.cpp"
"${APP_DIR}/control/RuntimeServiceLiveBridge.h"
"${APP_DIR}/control/RuntimeServices.cpp" "${APP_DIR}/control/RuntimeServices.cpp"
"${APP_DIR}/control/RuntimeServices.h" "${APP_DIR}/control/RuntimeServices.h"
"${APP_DIR}/videoio/decklink/DeckLinkAPI_h.h" "${APP_DIR}/videoio/decklink/DeckLinkAPI_h.h"
@@ -310,21 +312,34 @@ endif()
add_test(NAME RuntimeLiveStateTests COMMAND RuntimeLiveStateTests) add_test(NAME RuntimeLiveStateTests COMMAND RuntimeLiveStateTests)
add_executable(RuntimeSubsystemTests 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/LayerStackStore.cpp"
"${APP_DIR}/runtime/store/RuntimeConfigStore.cpp"
"${APP_DIR}/runtime/store/RuntimeStore.cpp"
"${APP_DIR}/runtime/store/ShaderPackageCatalog.cpp" "${APP_DIR}/runtime/store/ShaderPackageCatalog.cpp"
"${APP_DIR}/runtime/presentation/RuntimeStateJson.cpp" "${APP_DIR}/runtime/presentation/RuntimeStateJson.cpp"
"${APP_DIR}/runtime/presentation/RuntimeStatePresenter.cpp"
"${APP_DIR}/runtime/support/RuntimeJson.cpp" "${APP_DIR}/runtime/support/RuntimeJson.cpp"
"${APP_DIR}/runtime/support/RuntimeParameterUtils.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" "${APP_DIR}/shader/ShaderPackageRegistry.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/RuntimeSubsystemTests.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/tests/RuntimeSubsystemTests.cpp"
) )
target_include_directories(RuntimeSubsystemTests PRIVATE target_include_directories(RuntimeSubsystemTests PRIVATE
"${APP_DIR}" "${APP_DIR}"
"${APP_DIR}/platform"
"${APP_DIR}/runtime" "${APP_DIR}/runtime"
"${APP_DIR}/runtime/coordination"
"${APP_DIR}/runtime/events"
"${APP_DIR}/runtime/presentation" "${APP_DIR}/runtime/presentation"
"${APP_DIR}/runtime/snapshot"
"${APP_DIR}/runtime/store" "${APP_DIR}/runtime/store"
"${APP_DIR}/runtime/support" "${APP_DIR}/runtime/support"
"${APP_DIR}/runtime/telemetry"
"${APP_DIR}/shader" "${APP_DIR}/shader"
) )

View File

@@ -0,0 +1,77 @@
#include "RuntimeServiceLiveBridge.h"
#include "RuntimeServices.h"
#include <windows.h>
void RuntimeServiceLiveBridge::DrainServiceEvents(RuntimeServices& runtimeServices, RenderEngine& renderEngine)
{
std::vector<RuntimeServices::AppliedOscUpdate> appliedOscUpdates;
std::vector<RuntimeServices::CompletedOscCommit> completedOscCommits;
std::string oscError;
if (!runtimeServices.ApplyPendingOscUpdates(appliedOscUpdates, oscError) && !oscError.empty())
OutputDebugStringA(("OSC apply failed: " + oscError + "\n").c_str());
runtimeServices.ConsumeCompletedOscCommits(completedOscCommits);
std::vector<RenderEngine::OscOverlayUpdate> overlayUpdates;
overlayUpdates.reserve(appliedOscUpdates.size());
for (const RuntimeServices::AppliedOscUpdate& update : appliedOscUpdates)
{
overlayUpdates.push_back({ update.routeKey, update.layerKey, update.parameterKey, update.targetValue });
}
std::vector<RenderEngine::OscOverlayCommitCompletion> 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<RenderEngine::OscOverlayCommitRequest>& 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<RuntimeRenderState>& layerStates)
{
DrainServiceEvents(runtimeServices, renderEngine);
std::vector<RenderEngine::OscOverlayCommitRequest> commitRequests;
const bool resolved = renderEngine.ResolveRenderLayerStates(
useCommittedLayerStates,
renderWidth,
renderHeight,
oscSmoothing,
&commitRequests,
layerStates);
QueueServiceCommitRequests(runtimeServices, commitRequests);
return resolved;
}

View File

@@ -0,0 +1,24 @@
#pragma once
#include "RenderEngine.h"
#include <vector>
class RuntimeServices;
class RuntimeServiceLiveBridge
{
public:
static void DrainServiceEvents(RuntimeServices& runtimeServices, RenderEngine& renderEngine);
static void QueueServiceCommitRequests(
RuntimeServices& runtimeServices,
const std::vector<RenderEngine::OscOverlayCommitRequest>& commitRequests);
static bool PrepareLiveRenderLayerStates(
RuntimeServices& runtimeServices,
RenderEngine& renderEngine,
bool useCommittedLayerStates,
unsigned renderWidth,
unsigned renderHeight,
double oscSmoothing,
std::vector<RuntimeRenderState>& layerStates);
};

View File

@@ -7,6 +7,7 @@
#include "RuntimeCoordinator.h" #include "RuntimeCoordinator.h"
#include "RuntimeEventDispatcher.h" #include "RuntimeEventDispatcher.h"
#include "RuntimeParameterUtils.h" #include "RuntimeParameterUtils.h"
#include "RuntimeServiceLiveBridge.h"
#include "RuntimeServices.h" #include "RuntimeServices.h"
#include "RuntimeSnapshotProvider.h" #include "RuntimeSnapshotProvider.h"
#include "RuntimeStore.h" #include "RuntimeStore.h"
@@ -302,61 +303,30 @@ void OpenGLComposite::renderEffect()
{ {
if (mRuntimeUpdateController) if (mRuntimeUpdateController)
mRuntimeUpdateController->ProcessRuntimeWork(); mRuntimeUpdateController->ProcessRuntimeWork();
std::vector<RuntimeServices::AppliedOscUpdate> appliedOscUpdates;
std::vector<RuntimeServices::CompletedOscCommit> 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<RenderEngine::OscOverlayUpdate> overlayUpdates;
overlayUpdates.reserve(appliedOscUpdates.size());
for (const RuntimeServices::AppliedOscUpdate& update : appliedOscUpdates)
{
overlayUpdates.push_back({ update.routeKey, update.layerKey, update.parameterKey, update.targetValue });
}
std::vector<RenderEngine::OscOverlayCommitCompletion> 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(); const bool hasInputSource = mVideoBackend->HasInputSource();
std::vector<RuntimeRenderState> layerStates; std::vector<RuntimeRenderState> layerStates;
std::vector<RenderEngine::OscOverlayCommitRequest> overlayCommitRequests;
const double smoothing = mRuntimeStore ? mRuntimeStore->GetConfiguredOscSmoothing() : 0.0; const double smoothing = mRuntimeStore ? mRuntimeStore->GetConfiguredOscSmoothing() : 0.0;
if (mRuntimeServices)
{
RuntimeServiceLiveBridge::PrepareLiveRenderLayerStates(
*mRuntimeServices,
*mRenderEngine,
mRuntimeCoordinator && mRuntimeCoordinator->UseCommittedLayerStates(),
mVideoBackend->InputFrameWidth(),
mVideoBackend->InputFrameHeight(),
smoothing,
layerStates);
}
else
{
mRenderEngine->ResolveRenderLayerStates( mRenderEngine->ResolveRenderLayerStates(
mRuntimeCoordinator && mRuntimeCoordinator->UseCommittedLayerStates(), mRuntimeCoordinator && mRuntimeCoordinator->UseCommittedLayerStates(),
mVideoBackend->InputFrameWidth(), mVideoBackend->InputFrameWidth(),
mVideoBackend->InputFrameHeight(), mVideoBackend->InputFrameHeight(),
smoothing, smoothing,
&overlayCommitRequests, nullptr,
layerStates); 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());
}
}
} }
const unsigned historyCap = mRuntimeStore ? mRuntimeStore->GetConfiguredMaxTemporalHistoryFrames() : 0; const unsigned historyCap = mRuntimeStore ? mRuntimeStore->GetConfiguredMaxTemporalHistoryFrames() : 0;
mRenderEngine->RenderLayerStack( mRenderEngine->RenderLayerStack(

View File

@@ -259,8 +259,7 @@ bool RenderEngine::ResolveRenderLayerStates(
layerStates.clear(); layerStates.clear();
if (useCommittedLayerStates) if (useCommittedLayerStates)
{ {
layerStates = mShaderPrograms.CommittedLayerStates(); layerStates = ComposeRenderLayerStates(mShaderPrograms.CommittedLayerStates(), false, oscSmoothing, commitRequests);
ApplyOscOverlays(layerStates, false, oscSmoothing, commitRequests);
mRuntimeSnapshotProvider.RefreshDynamicRenderStateFields(layerStates); mRuntimeSnapshotProvider.RefreshDynamicRenderStateFields(layerStates);
return true; return true;
} }
@@ -281,12 +280,12 @@ bool RenderEngine::ResolveRenderLayerStates(
renderSnapshot.versions.parameterStateVersion = mCachedParameterStateVersion; renderSnapshot.versions.parameterStateVersion = mCachedParameterStateVersion;
renderSnapshot.states = mCachedLayerRenderStates; renderSnapshot.states = mCachedLayerRenderStates;
ApplyOscOverlays(renderSnapshot.states, true, oscSmoothing, commitRequests); renderSnapshot.states = ComposeRenderLayerStates(renderSnapshot.states, true, oscSmoothing, commitRequests);
if (mCachedParameterStateVersion != versions.parameterStateVersion && if (mCachedParameterStateVersion != versions.parameterStateVersion &&
mRuntimeSnapshotProvider.TryRefreshPublishedSnapshotParameters(renderSnapshot)) mRuntimeSnapshotProvider.TryRefreshPublishedSnapshotParameters(renderSnapshot))
{ {
mCachedParameterStateVersion = renderSnapshot.versions.parameterStateVersion; mCachedParameterStateVersion = renderSnapshot.versions.parameterStateVersion;
ApplyOscOverlays(renderSnapshot.states, true, oscSmoothing, commitRequests); renderSnapshot.states = ComposeRenderLayerStates(renderSnapshot.states, true, oscSmoothing, commitRequests);
} }
mCachedLayerRenderStates = renderSnapshot.states; mCachedLayerRenderStates = renderSnapshot.states;
@@ -303,36 +302,38 @@ bool RenderEngine::ResolveRenderLayerStates(
mCachedParameterStateVersion = renderSnapshot.versions.parameterStateVersion; mCachedParameterStateVersion = renderSnapshot.versions.parameterStateVersion;
mCachedRenderStateWidth = renderSnapshot.outputWidth; mCachedRenderStateWidth = renderSnapshot.outputWidth;
mCachedRenderStateHeight = renderSnapshot.outputHeight; mCachedRenderStateHeight = renderSnapshot.outputHeight;
ApplyOscOverlays(mCachedLayerRenderStates, true, oscSmoothing, commitRequests); mCachedLayerRenderStates = ComposeRenderLayerStates(mCachedLayerRenderStates, true, oscSmoothing, commitRequests);
layerStates = mCachedLayerRenderStates; layerStates = mCachedLayerRenderStates;
return true; return true;
} }
ApplyOscOverlays(mCachedLayerRenderStates, true, oscSmoothing, commitRequests); layerStates = ComposeRenderLayerStates(mCachedLayerRenderStates, true, oscSmoothing, commitRequests);
layerStates = mCachedLayerRenderStates;
mRuntimeSnapshotProvider.RefreshDynamicRenderStateFields(layerStates); mRuntimeSnapshotProvider.RefreshDynamicRenderStateFields(layerStates);
return !layerStates.empty(); return !layerStates.empty();
} }
void RenderEngine::ApplyOscOverlays( std::vector<RuntimeRenderState> RenderEngine::ComposeRenderLayerStates(
std::vector<RuntimeRenderState>& states, const std::vector<RuntimeRenderState>& baseStates,
bool allowCommit, bool allowCommit,
double smoothing, double smoothing,
std::vector<OscOverlayCommitRequest>* commitRequests) std::vector<OscOverlayCommitRequest>* commitRequests)
{ {
std::vector<RuntimeLiveOscCommitRequest> liveCommitRequests; RenderStateCompositionInput input;
RuntimeLiveStateApplyOptions options; input.baseLayerStates = &baseStates;
options.allowCommit = allowCommit; input.liveState = &mRuntimeLiveState;
options.smoothing = smoothing; input.allowLiveCommits = allowCommit;
options.commitDelay = kOscOverlayCommitDelay; input.collectLiveCommitRequests = commitRequests != nullptr;
options.now = std::chrono::steady_clock::now(); input.liveSmoothing = smoothing;
mRuntimeLiveState.ApplyToLayerStates(states, options, commitRequests ? &liveCommitRequests : nullptr); input.liveCommitDelay = kOscOverlayCommitDelay;
input.now = std::chrono::steady_clock::now();
const RenderStateCompositionResult result = mRenderStateComposer.BuildFrameState(input);
if (!commitRequests) 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 }); commitRequests->push_back({ request.routeKey, request.layerKey, request.parameterKey, request.value, request.generation });
return result.layerStates;
} }
void RenderEngine::RenderLayerStack( void RenderEngine::RenderLayerStack(

View File

@@ -4,9 +4,9 @@
#include "OpenGLRenderPipeline.h" #include "OpenGLRenderPipeline.h"
#include "OpenGLRenderer.h" #include "OpenGLRenderer.h"
#include "OpenGLShaderPrograms.h" #include "OpenGLShaderPrograms.h"
#include "RenderStateComposer.h"
#include "HealthTelemetry.h" #include "HealthTelemetry.h"
#include "RuntimeCoordinator.h" #include "RuntimeCoordinator.h"
#include "RuntimeLiveState.h"
#include "RuntimeSnapshotProvider.h" #include "RuntimeSnapshotProvider.h"
#include <windows.h> #include <windows.h>
@@ -133,8 +133,8 @@ private:
HDC mHdc; HDC mHdc;
HGLRC mHglrc; HGLRC mHglrc;
void ApplyOscOverlays( std::vector<RuntimeRenderState> ComposeRenderLayerStates(
std::vector<RuntimeRenderState>& states, const std::vector<RuntimeRenderState>& baseStates,
bool allowCommit, bool allowCommit,
double smoothing, double smoothing,
std::vector<OscOverlayCommitRequest>* commitRequests); std::vector<OscOverlayCommitRequest>* commitRequests);
@@ -145,5 +145,6 @@ private:
unsigned mCachedRenderStateWidth = 0; unsigned mCachedRenderStateWidth = 0;
unsigned mCachedRenderStateHeight = 0; unsigned mCachedRenderStateHeight = 0;
std::chrono::steady_clock::time_point mLastPreviewPresentTime; std::chrono::steady_clock::time_point mLastPreviewPresentTime;
RenderStateComposer mRenderStateComposer;
RuntimeLiveState mRuntimeLiveState; RuntimeLiveState mRuntimeLiveState;
}; };

View File

@@ -175,7 +175,7 @@ RuntimeCoordinatorResult RuntimeCoordinator::CommitOscParameterByControlKey(cons
std::lock_guard<std::mutex> lock(mMutex); std::lock_guard<std::mutex> lock(mMutex);
std::string error; std::string error;
ResolvedParameterMutation mutation; 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); RuntimeCoordinatorResult result = ApplyStoreMutation(false, error, false, false, false);
PublishCoordinatorResult("CommitOscParameterByControlKey", result); PublishCoordinatorResult("CommitOscParameterByControlKey", result);

View File

@@ -3,8 +3,22 @@
RenderStateCompositionResult RenderStateComposer::BuildFrameState(const RenderStateCompositionInput& input) const RenderStateCompositionResult RenderStateComposer::BuildFrameState(const RenderStateCompositionInput& input) const
{ {
RenderStateCompositionResult result; RenderStateCompositionResult result;
result.layerStates = input.baseLayerStates; if (!input.baseLayerStates)
return result;
result.layerStates = *input.baseLayerStates;
result.hasLayerStates = !result.layerStates.empty();
if (input.liveState) 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; return result;
} }

View File

@@ -2,19 +2,25 @@
#include "RuntimeLiveState.h" #include "RuntimeLiveState.h"
#include <chrono>
#include <vector> #include <vector>
struct RenderStateCompositionInput struct RenderStateCompositionInput
{ {
std::vector<RuntimeRenderState> baseLayerStates; const std::vector<RuntimeRenderState>* baseLayerStates = nullptr;
RuntimeLiveState* liveState = 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 struct RenderStateCompositionResult
{ {
std::vector<RuntimeRenderState> layerStates; std::vector<RuntimeRenderState> layerStates;
std::vector<RuntimeLiveOscCommitRequest> commitRequests; std::vector<RuntimeLiveOscCommitRequest> commitRequests;
bool hasLayerStates = false;
}; };
class RenderStateComposer class RenderStateComposer

View File

@@ -7,8 +7,8 @@ Phase 1 split runtime responsibilities into named subsystems. Phase 2 added the
## Status ## Status
- Phase 3 design package: proposed. - Phase 3 design package: proposed.
- Phase 3 implementation: groundwork started. - Phase 3 implementation: initial parallel implementation batch integrated.
- 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. - 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: Current footholds:
@@ -17,8 +17,9 @@ Current footholds:
- `RuntimeSnapshotProvider` publishes render snapshots from `RenderSnapshotBuilder`. - `RuntimeSnapshotProvider` publishes render snapshots from `RenderSnapshotBuilder`.
- `RuntimeLiveState` owns transient OSC overlay bookkeeping and commit-settlement policy. - `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. - `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. - `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. - `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`. 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. 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 ### 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. 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()` ### Step 4. Narrow `OpenGLComposite::renderEffect()`
Target shape: 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. 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 ### Step 5. Add Persistence Boundary Tests
Add behavior tests for: 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: 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 - [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 - [x] 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 - [x] `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 - [x] `RuntimeStore` remains durable-state focused and does not gain live overlay responsibilities
- [ ] persistence requests are explicit event outcomes for persisted mutations - [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 - [ ] Phase 4 can define a render-thread input contract around immutable or near-immutable frame state
## Open Questions ## Open Questions

View File

@@ -30,6 +30,35 @@ ShaderParameterDefinition FloatDefinition(const std::string& id, const std::stri
return definition; 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<double> numbers)
{
JsonValue value = JsonValue::MakeArray();
for (double number : numbers)
value.pushBack(JsonValue(number));
return value;
}
RuntimeRenderState MakeLayerState() RuntimeRenderState MakeLayerState()
{ {
RuntimeRenderState state; RuntimeRenderState state;
@@ -43,6 +72,16 @@ RuntimeRenderState MakeLayerState()
return state; return state;
} }
RuntimeRenderState MakeLayerStateWithDefinitions(const std::vector<ShaderParameterDefinition>& definitions)
{
RuntimeRenderState state;
state.layerId = "layer-one";
state.shaderId = "test-shader";
state.shaderName = "Test Shader";
state.parameterDefinitions = definitions;
return state;
}
void TestRuntimeLiveStateAppliesLatestOscOverlay() void TestRuntimeLiveStateAppliesLatestOscOverlay()
{ {
RuntimeLiveState liveState; RuntimeLiveState liveState;
@@ -71,6 +110,40 @@ void TestRuntimeLiveStateAppliesLatestOscOverlay()
"overlay applies the latest target value"); "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<RuntimeRenderState> states = { MakeLayerState() };
std::vector<RuntimeLiveOscCommitRequest> 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() void TestRuntimeLiveStateQueuesAndCompletesCommit()
{ {
RuntimeLiveState liveState; RuntimeLiveState liveState;
@@ -99,6 +172,183 @@ void TestRuntimeLiveStateQueuesAndCompletesCommit()
Expect(liveState.OverlayCount() == 0, "matching commit completion removes settled overlay"); 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<RuntimeRenderState> states = { MakeLayerState() };
std::vector<RuntimeLiveOscCommitRequest> 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<RuntimeRenderState> 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<RuntimeRenderState> 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<RuntimeRenderState> 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<RuntimeRenderState> 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<RuntimeRenderState> 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<RuntimeLiveOscCommitRequest> 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() void TestRenderStateComposerBuildsFrameState()
{ {
RuntimeLiveState liveState; RuntimeLiveState liveState;
@@ -110,27 +360,102 @@ void TestRenderStateComposerBuildsFrameState()
liveState.ApplyOscUpdates({ update }); liveState.ApplyOscUpdates({ update });
RenderStateCompositionInput input; RenderStateCompositionInput input;
input.baseLayerStates = { MakeLayerState() }; std::vector<RuntimeRenderState> baseLayerStates = { MakeLayerState() };
input.baseLayerStates = &baseLayerStates;
input.liveState = &liveState; input.liveState = &liveState;
input.liveStateOptions.allowCommit = false; input.allowLiveCommits = false;
input.liveStateOptions.smoothing = 0.0; input.liveSmoothing = 0.0;
RenderStateComposer composer; RenderStateComposer composer;
RenderStateCompositionResult result = composer.BuildFrameState(input); 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"); Expect(result.layerStates.size() == 1, "composer returns composed layer state");
const auto valueIt = result.layerStates[0].parameterValues.find("amount"); 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 != 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, 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"); "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<RuntimeRenderState> 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<RuntimeRenderState> 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() int main()
{ {
TestRuntimeLiveStateAppliesLatestOscOverlay(); TestRuntimeLiveStateAppliesLatestOscOverlay();
TestRuntimeLiveStateIgnoresStaleCommitCompletions();
TestRuntimeLiveStateQueuesAndCompletesCommit(); TestRuntimeLiveStateQueuesAndCompletesCommit();
TestRuntimeLiveStateQueuesOneCommitPerGeneration();
TestRuntimeLiveStateSmoothingZeroAppliesTargetImmediately();
TestRuntimeLiveStateSmoothingOneConvergesImmediately();
TestRuntimeLiveStateSmoothingPartiallyConverges();
TestRuntimeLiveStateSmoothingVectorSizeMismatchUsesTargetShape();
TestRuntimeLiveStateTriggerOverlayIncrementsAndClears();
TestRenderStateComposerBuildsFrameState(); TestRenderStateComposerBuildsFrameState();
TestRenderStateComposerQueuesCommitRequestsWhenEnabled();
TestRenderStateComposerSuppressesCommitCollection();
if (gFailures != 0) if (gFailures != 0)
{ {

View File

@@ -1,5 +1,8 @@
#include "LayerStackStore.h" #include "LayerStackStore.h"
#include "RuntimeCoordinator.h"
#include "RuntimeEventDispatcher.h"
#include "RuntimeStateJson.h" #include "RuntimeStateJson.h"
#include "RuntimeStore.h"
#include "ShaderPackageCatalog.h" #include "ShaderPackageCatalog.h"
#include <chrono> #include <chrono>
@@ -8,6 +11,8 @@
#include <iostream> #include <iostream>
#include <map> #include <map>
#include <string> #include <string>
#include <variant>
#include <windows.h>
namespace 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"); 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 BuildCatalog(const std::filesystem::path& root)
{ {
ShaderPackageCatalog catalog; ShaderPackageCatalog catalog;
@@ -181,6 +211,105 @@ void TestRuntimeStateJsonReadModelSerialization()
const JsonValue* value = parameters->asArray()[0].find("value"); const JsonValue* value = parameters->asArray()[0].find("value");
Expect(value && value->asNumber() == 0.8, "serialized parameter includes current 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<RuntimeEvent> seenEvents;
dispatcher.SubscribeAll([&seenEvents](const RuntimeEvent& event) {
seenEvents.push_back(event);
});
RuntimeCoordinator coordinator(store, dispatcher);
auto dispatchAndClear = [&]() {
dispatcher.DispatchPending();
const std::vector<RuntimeEvent> events = seenEvents;
seenEvents.clear();
return events;
};
auto countEvents = [](const std::vector<RuntimeEvent>& events, RuntimeEventType type) {
return static_cast<std::size_t>(std::count_if(events.begin(), events.end(),
[type](const RuntimeEvent& event) { return event.type == type; }));
};
auto persistenceReason = [](const std::vector<RuntimeEvent>& events) {
for (const RuntimeEvent& event : events)
{
if (event.type != RuntimeEventType::RuntimePersistenceRequested)
continue;
const auto* payload = std::get_if<RuntimePersistenceRequestedEvent>(&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<RuntimeEvent> 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<RuntimeStore::LayerPersistentState> 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<RuntimeEvent> 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<RuntimeEvent> 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() int main()
@@ -188,6 +317,7 @@ int main()
TestLayerDefaultsAndCrud(); TestLayerDefaultsAndCrud();
TestMoveClassificationAndPresetLoad(); TestMoveClassificationAndPresetLoad();
TestRuntimeStateJsonReadModelSerialization(); TestRuntimeStateJsonReadModelSerialization();
TestRuntimeCoordinatorPersistenceEvents();
if (gFailures != 0) if (gFailures != 0)
{ {