feat: Implement configuration compiler and related services

- Added ConfigCompilerService to compile JSON configurations into IR structures.
- Introduced IConfigCompilerService interface for compilation functionality.
- Created ProbeService for structured diagnostics and reporting.
- Developed RenderGraphService to build and validate render graphs.
- Enhanced JsonConfigService to support schema validation and migration.
- Introduced new interfaces for probing and rendering graph services.
- Added necessary IR types for scenes, resources, and render passes.
- Improved error handling and logging throughout the services.
This commit is contained in:
2026-01-08 18:56:31 +00:00
parent e4fbf36c34
commit af418dcdd2
20 changed files with 1673 additions and 47 deletions

View File

@@ -270,11 +270,13 @@ if(BUILD_SDL3_APP)
src/di/service_registry.cpp
src/events/event_bus.cpp
src/services/impl/json_config_service.cpp
src/services/impl/config_compiler_service.cpp
src/services/impl/command_line_service.cpp
src/services/impl/json_config_writer_service.cpp
src/services/impl/logger_service.cpp
src/services/impl/ecs_service.cpp
src/services/impl/platform_service.cpp
src/services/impl/probe_service.cpp
src/services/impl/script_engine_service.cpp
src/services/impl/lua_helpers.cpp
src/services/impl/scene_script_service.cpp
@@ -294,6 +296,7 @@ if(BUILD_SDL3_APP)
src/services/impl/lifecycle_service.cpp
src/services/impl/application_loop_service.cpp
src/services/impl/render_coordinator_service.cpp
src/services/impl/render_graph_service.cpp
src/services/impl/null_gui_service.cpp
src/services/impl/bgfx_gui_service.cpp
src/services/impl/bgfx_shader_compiler.cpp

View File

@@ -17,21 +17,21 @@ Treat JSON config as a declarative control plane that compiles into scene, resou
### Starter Plan: "Bootstrap Hosting"
- [x] Config version gating (`schema_version` / `configVersion` checks)
- [ ] JSON Schema validation (external schema + validator)
- [~] JSON-path diagnostics (path strings in exceptions, not full JSON Pointer coverage)
- [x] JSON Schema validation (external schema + validator)
- [~] JSON-path diagnostics (schema validator pointers + path strings; not full JSON Pointer coverage)
- [~] Layered config merges (supports `extends` + `@delete`; no profile/local/CLI yet)
- [x] Trace logging around config load, validation, and merge steps
- [ ] Migration stubs for future versions
- [~] Migration stubs for future versions (notes + stubbed hook)
### Pro Plan: "Graph Builder"
- [ ] Typed IRs: `SceneIR`, `ResourceIR`, `RenderGraphIR`
- [ ] Symbol tables + reference resolution with clear diagnostics
- [ ] Render graph DAG compile with cycle detection
- [ ] "Use before produce" validation for render pass inputs
- [ ] Explicit pass scheduling and backend submission planning
- [~] Typed IRs: `SceneIR`, `ResourceIR`, `RenderGraphIR`
- [~] Symbol tables + reference resolution with clear diagnostics
- [x] Render graph DAG compile with cycle detection
- [x] "Use before produce" validation for render pass inputs
- [~] Explicit pass scheduling and backend submission planning (schedule only)
### Ultra Plan: "Probe Fortress"
- [ ] Probe hooks: `OnLoadScene`, `OnCreatePipeline`, `OnDraw`, `OnPresent`, `OnFrameEnd`
- [~] Probe hooks: `OnLoadScene`, `OnCreatePipeline`, `OnDraw`, `OnPresent`, `OnFrameEnd`
- [x] Pipeline compatibility checks (mesh layout vs shader inputs) via shader pipeline validator
- [x] Sampler limits enforced from bgfx caps
- [ ] Shader uniform compatibility enforcement
@@ -51,13 +51,13 @@ Treat JSON config as a declarative control plane that compiles into scene, resou
| Feature | Status | Starter | Pro | Ultra | Enterprise |
| --- | --- | --- | --- | --- | --- |
| Config version gating (`schema_version` / `configVersion`) | Live | [x] | [ ] | [ ] | [ ] |
| JSON Schema validation | Planned | [x] | [ ] | [ ] | [ ] |
| JSON Schema validation | Live | [x] | [ ] | [ ] | [ ] |
| Layered config merges + deterministic rules | Partial | [x] | [ ] | [ ] | [ ] |
| JSON-path diagnostics | Partial | [x] | [ ] | [ ] | [ ] |
| IR compilation (scene/resources/render) | Planned | [ ] | [x] | [ ] | [ ] |
| Render graph DAG build + cycle checks | Planned | [ ] | [x] | [ ] | [ ] |
| Pass scheduling + submission planning | Planned | [ ] | [x] | [ ] | [ ] |
| Probe system + structured reports | Planned | [ ] | [ ] | [x] | [ ] |
| IR compilation (scene/resources/render) | Partial | [ ] | [x] | [ ] | [ ] |
| Render graph DAG build + cycle checks | Live | [ ] | [x] | [ ] | [ ] |
| Pass scheduling + submission planning | Partial | [ ] | [x] | [ ] | [ ] |
| Probe system + structured reports | Partial | [ ] | [ ] | [x] | [ ] |
| Pipeline compatibility checks | Live | [ ] | [ ] | [x] | [ ] |
| Sampler limits enforced | Live | [ ] | [ ] | [x] | [ ] |
| Shader uniform compatibility enforcement | Planned | [ ] | [ ] | [x] | [ ] |
@@ -68,10 +68,12 @@ Treat JSON config as a declarative control plane that compiles into scene, resou
| Hot-reload + rollback | Planned | [ ] | [ ] | [ ] | [x] |
## Deliverables Checklist
- [ ] `config/schema/` with versioned JSON Schema and migration notes
- [ ] `src/services/impl/config_compiler_service.*` for JSON -> IR compilation
- [ ] `src/services/impl/render_graph_service.*` for graph build and scheduling
- [ ] `src/services/interfaces/i_probe_service.hpp` plus report/event types
- [x] `config/schema/` with versioned JSON Schema and migration notes
- [x] `src/services/impl/config_compiler_service.*` for JSON -> IR compilation
- [x] `src/services/impl/render_graph_service.*` for graph build and scheduling
- [x] `src/services/interfaces/i_probe_service.hpp` plus report/event types
- [x] `src/services/impl/probe_service.*` for logging/queueing probe reports
- [x] `src/services/interfaces/config_ir_types.hpp` for typed IR payloads
- [~] Budget enforcement with clear failure modes and fallback resources (textures + GUI caches today)
- [ ] Cube demo config-only boot path

View File

@@ -0,0 +1,16 @@
# Config Schema Migrations
This folder tracks schema versions and how to migrate config JSON between them.
## Version 2 (current)
- File: runtime_config_v2.schema.json
- Supports the current runtime config shape plus optional render-graph sections.
## Stub: v2 -> v3
When bumping to v3, add a migration step in `JsonConfigService::ApplyMigrations` that:
- Detects `schema_version` or `configVersion` == 2
- Transforms renamed or restructured fields into v3 layout
- Emits trace logging for each field transformation
- Updates `schema_version` and `configVersion` to 3
Add notes here for each structural change so the migration remains deterministic.

View File

@@ -0,0 +1,288 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "sdl3cpp.runtime.config.v2",
"title": "SDL3 C++ Runtime Config v2",
"type": "object",
"definitions": {
"float3": {
"type": "array",
"items": {"type": "number"},
"minItems": 3,
"maxItems": 3
}
},
"properties": {
"schema_version": {"type": "integer"},
"configVersion": {"type": "integer"},
"extends": {
"oneOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}}
]
},
"launcher": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"enabled": {"type": "boolean"}
},
"additionalProperties": true
},
"scripts": {
"type": "object",
"properties": {
"entry": {"type": "string"},
"lua_debug": {"type": "boolean"}
},
"additionalProperties": true
},
"paths": {
"type": "object",
"properties": {
"project_root": {"type": "string"},
"scripts": {"type": "string"},
"shaders": {"type": "string"}
},
"additionalProperties": true
},
"window": {
"type": "object",
"properties": {
"title": {"type": "string"},
"size": {
"type": "object",
"properties": {
"width": {"type": "integer", "minimum": 0},
"height": {"type": "integer", "minimum": 0}
},
"additionalProperties": true
},
"mouse_grab": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"grab_on_click": {"type": "boolean"},
"release_on_escape": {"type": "boolean"},
"start_grabbed": {"type": "boolean"},
"hide_cursor": {"type": "boolean"},
"relative_mode": {"type": "boolean"},
"grab_mouse_button": {"type": "string"},
"release_key": {"type": "string"}
},
"additionalProperties": true
}
},
"additionalProperties": true
},
"input": {
"type": "object",
"properties": {
"bindings": {"type": "object", "additionalProperties": true}
},
"additionalProperties": true
},
"scene": {
"type": "object",
"additionalProperties": true
},
"rendering": {
"type": "object",
"properties": {
"bgfx": {
"type": "object",
"properties": {
"renderer": {"type": "string"}
},
"additionalProperties": true
},
"materialx": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"parameters_enabled": {"type": "boolean"},
"document": {"type": "string"},
"shader_key": {"type": "string"},
"material": {"type": "string"},
"library_path": {"type": "string"},
"library_folders": {"type": "array", "items": {"type": "string"}},
"materials": {
"type": "array",
"items": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"document": {"type": "string"},
"shader_key": {"type": "string"},
"material": {"type": "string"},
"use_constant_color": {"type": "boolean"},
"constant_color": {"$ref": "#/definitions/float3"}
},
"additionalProperties": true
}
},
"use_constant_color": {"type": "boolean"},
"constant_color": {"$ref": "#/definitions/float3"}
},
"additionalProperties": true
},
"atmospherics": {
"type": "object",
"properties": {
"ambient_strength": {"type": "number"},
"fog_density": {"type": "number"},
"fog_color": {"$ref": "#/definitions/float3"},
"sky_color": {"$ref": "#/definitions/float3"},
"gamma": {"type": "number"},
"exposure": {"type": "number"},
"enable_tone_mapping": {"type": "boolean"},
"enable_shadows": {"type": "boolean"},
"enable_ssgi": {"type": "boolean"},
"enable_volumetric_lighting": {"type": "boolean"},
"pbr_roughness": {"type": "number"},
"pbr_metallic": {"type": "number"}
},
"additionalProperties": true
}
},
"additionalProperties": true
},
"budgets": {
"type": "object",
"properties": {
"vram_mb": {"type": "number", "minimum": 0},
"max_texture_dim": {"type": "number", "minimum": 0},
"gui_text_cache_entries": {"type": "number", "minimum": 0},
"gui_svg_cache_entries": {"type": "number", "minimum": 0}
},
"additionalProperties": true
},
"crash_recovery": {
"type": "object",
"properties": {
"heartbeat_timeout_ms": {"type": "number", "minimum": 0},
"heartbeat_poll_interval_ms": {"type": "number", "minimum": 0},
"memory_limit_mb": {"type": "number", "minimum": 0},
"gpu_hang_frame_time_multiplier": {"type": "number", "minimum": 0},
"max_consecutive_gpu_timeouts": {"type": "number", "minimum": 0},
"max_lua_failures": {"type": "number", "minimum": 0},
"max_file_format_errors": {"type": "number", "minimum": 0},
"max_memory_warnings": {"type": "number", "minimum": 0}
},
"additionalProperties": true
},
"gui": {
"type": "object",
"properties": {
"font": {
"type": "object",
"properties": {
"use_freetype": {"type": "boolean"},
"font_path": {"type": "string"},
"font_size": {"type": "number"}
},
"additionalProperties": true
},
"opacity": {"type": "number"}
},
"additionalProperties": true
},
"render": {
"type": "object",
"properties": {
"passes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"type": {"type": "string"},
"inputs": {
"type": "object",
"properties": {
"@delete": {"type": "array", "items": {"type": "string"}}
},
"additionalProperties": {"type": "string"}
},
"outputs": {
"type": "object",
"properties": {
"@delete": {"type": "array", "items": {"type": "string"}}
},
"additionalProperties": {
"anyOf": [
{"type": "string"},
{
"type": "object",
"properties": {
"format": {"type": "string"},
"usage": {"type": "string"}
},
"additionalProperties": true
}
]
}
},
"parameters": {"type": "object", "additionalProperties": true}
},
"additionalProperties": true
}
}
},
"additionalProperties": true
},
"materialx": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"document": {"type": "string"},
"shader_key": {"type": "string"},
"material": {"type": "string"},
"library_path": {"type": "string"},
"library_folders": {"type": "array", "items": {"type": "string"}},
"materials": {
"type": "array",
"items": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"document": {"type": "string"},
"shader_key": {"type": "string"},
"material": {"type": "string"},
"use_constant_color": {"type": "boolean"},
"constant_color": {"$ref": "#/definitions/float3"}
},
"additionalProperties": true
}
},
"use_constant_color": {"type": "boolean"},
"constant_color": {"$ref": "#/definitions/float3"}
},
"additionalProperties": true
},
"materialx_materials": {
"type": "array",
"items": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"document": {"type": "string"},
"shader_key": {"type": "string"},
"material": {"type": "string"},
"use_constant_color": {"type": "boolean"},
"constant_color": {"$ref": "#/definitions/float3"}
},
"additionalProperties": true
}
},
"config_file": {"type": "string"},
"lua_script": {"type": "string"},
"window_width": {"type": "integer", "minimum": 0},
"window_height": {"type": "integer", "minimum": 0},
"input_bindings": {"type": "object", "additionalProperties": true},
"mouse_grab": {"type": "object", "additionalProperties": true},
"gui_font": {"type": "object", "additionalProperties": true},
"gui_opacity": {"type": "number"}
},
"additionalProperties": true
}

View File

@@ -6,10 +6,13 @@
#include "services/interfaces/i_application_loop_service.hpp"
#include "services/interfaces/i_lifecycle_service.hpp"
#include "services/impl/json_config_service.hpp"
#include "services/impl/config_compiler_service.hpp"
#include "services/impl/lifecycle_service.hpp"
#include "services/impl/application_loop_service.hpp"
#include "services/impl/render_coordinator_service.hpp"
#include "services/impl/render_graph_service.hpp"
#include "services/impl/platform_service.hpp"
#include "services/impl/probe_service.hpp"
#include "services/impl/sdl_window_service.hpp"
#include "services/impl/sdl_input_service.hpp"
#include "services/impl/ecs_service.hpp"
@@ -33,6 +36,9 @@
#include "services/impl/logger_service.hpp"
#include "services/impl/pipeline_compiler_service.hpp"
#include "services/interfaces/i_platform_service.hpp"
#include "services/interfaces/i_probe_service.hpp"
#include "services/interfaces/i_render_graph_service.hpp"
#include "services/interfaces/i_config_compiler_service.hpp"
#include <iostream>
#include <stdexcept>
#include <utility>
@@ -209,10 +215,28 @@ void ServiceBasedApp::RegisterServices() {
// Event bus (needed by window service)
registry_.RegisterService<events::IEventBus, events::EventBus>();
// Probe service (structured diagnostics)
registry_.RegisterService<services::IProbeService, services::impl::ProbeService>(
registry_.GetService<services::ILogger>());
// Configuration service
registry_.RegisterService<services::IConfigService, services::impl::JsonConfigService>(
registry_.GetService<services::ILogger>(), runtimeConfig_);
registry_.GetService<services::ILogger>(),
runtimeConfig_,
registry_.GetService<services::IProbeService>());
auto configService = registry_.GetService<services::IConfigService>();
// Render graph service (DAG build + scheduling)
registry_.RegisterService<services::IRenderGraphService, services::impl::RenderGraphService>(
registry_.GetService<services::ILogger>(),
registry_.GetService<services::IProbeService>());
// Config compiler service (JSON -> IR)
registry_.RegisterService<services::IConfigCompilerService, services::impl::ConfigCompilerService>(
registry_.GetService<services::IConfigService>(),
registry_.GetService<services::IRenderGraphService>(),
registry_.GetService<services::IProbeService>(),
registry_.GetService<services::ILogger>());
// ECS service (entt registry)
registry_.RegisterService<services::IEcsService, services::impl::EcsService>(
registry_.GetService<services::ILogger>());
@@ -283,7 +307,8 @@ void ServiceBasedApp::RegisterServices() {
registry_.GetService<services::IConfigService>(),
registry_.GetService<services::IPlatformService>(),
registry_.GetService<services::ILogger>(),
registry_.GetService<services::IPipelineCompilerService>());
registry_.GetService<services::IPipelineCompilerService>(),
registry_.GetService<services::IProbeService>());
// Graphics service (facade)
registry_.RegisterService<services::IGraphicsService, services::impl::GraphicsService>(

View File

@@ -275,11 +275,13 @@ std::optional<bgfx::RendererType::Enum> RecommendFallbackRenderer(
BgfxGraphicsBackend::BgfxGraphicsBackend(std::shared_ptr<IConfigService> configService,
std::shared_ptr<IPlatformService> platformService,
std::shared_ptr<ILogger> logger,
std::shared_ptr<IPipelineCompilerService> pipelineCompiler)
std::shared_ptr<IPipelineCompilerService> pipelineCompiler,
std::shared_ptr<IProbeService> probeService)
: configService_(std::move(configService)),
platformService_(std::move(platformService)),
logger_(std::move(logger)),
pipelineCompiler_(std::move(pipelineCompiler)) {
pipelineCompiler_(std::move(pipelineCompiler)),
probeService_(std::move(probeService)) {
if (logger_) {
logger_->Trace("BgfxGraphicsBackend", "BgfxGraphicsBackend",
"configService=" + std::string(configService_ ? "set" : "null") +
@@ -1178,11 +1180,23 @@ void BgfxGraphicsBackend::Draw(GraphicsDeviceHandle device, GraphicsPipelineHand
GraphicsBufferHandle vertexBuffer, GraphicsBufferHandle indexBuffer,
uint32_t indexOffset, uint32_t indexCount, int32_t vertexOffset,
const std::array<float, 16>& modelMatrix) {
auto reportError = [&](const std::string& code, const std::string& message) {
if (!probeService_) {
return;
}
ProbeReport report{};
report.severity = ProbeSeverity::Error;
report.code = code;
report.message = message;
probeService_->Report(report);
};
auto pipelineIt = pipelines_.find(pipeline);
if (pipelineIt == pipelines_.end()) {
if (logger_) {
logger_->Error("BgfxGraphicsBackend::Draw: Pipeline not found");
}
reportError("DRAW_PIPELINE_MISSING", "Draw call missing pipeline");
return;
}
auto vertexIt = vertexBuffers_.find(vertexBuffer);
@@ -1191,6 +1205,7 @@ void BgfxGraphicsBackend::Draw(GraphicsDeviceHandle device, GraphicsPipelineHand
if (logger_) {
logger_->Error("BgfxGraphicsBackend::Draw: Buffer handles not found");
}
reportError("DRAW_BUFFER_MISSING", "Draw call missing vertex or index buffer");
return;
}
const auto& vb = vertexIt->second;
@@ -1211,6 +1226,7 @@ void BgfxGraphicsBackend::Draw(GraphicsDeviceHandle device, GraphicsPipelineHand
logger_->Error("BgfxGraphicsBackend::Draw: Invalid negative vertex offset (" +
std::to_string(vertexOffset) + ")");
}
reportError("DRAW_VERTEX_OFFSET_NEGATIVE", "Draw call has negative vertex offset");
return;
}
@@ -1220,6 +1236,7 @@ void BgfxGraphicsBackend::Draw(GraphicsDeviceHandle device, GraphicsPipelineHand
std::to_string(vertexOffset) + ") exceeds vertex buffer size (" +
std::to_string(vb->vertexCount) + ")");
}
reportError("DRAW_VERTEX_OFFSET_RANGE", "Draw call vertex offset exceeds vertex buffer size");
return;
}
@@ -1231,6 +1248,7 @@ void BgfxGraphicsBackend::Draw(GraphicsDeviceHandle device, GraphicsPipelineHand
") exceeds index buffer size (" +
std::to_string(ib->indexCount) + ")");
}
reportError("DRAW_INDEX_RANGE", "Draw call index range exceeds index buffer size");
return;
}

View File

@@ -5,6 +5,7 @@
#include "../interfaces/i_logger.hpp"
#include "../interfaces/i_platform_service.hpp"
#include "../interfaces/i_pipeline_compiler_service.hpp"
#include "../interfaces/i_probe_service.hpp"
#include "../../core/vertex.hpp"
#include <bgfx/bgfx.h>
#include <array>
@@ -19,7 +20,8 @@ public:
BgfxGraphicsBackend(std::shared_ptr<IConfigService> configService,
std::shared_ptr<IPlatformService> platformService,
std::shared_ptr<ILogger> logger,
std::shared_ptr<IPipelineCompilerService> pipelineCompiler);
std::shared_ptr<IPipelineCompilerService> pipelineCompiler,
std::shared_ptr<IProbeService> probeService = nullptr);
~BgfxGraphicsBackend() override;
void Initialize(void* window, const GraphicsConfig& config) override;
@@ -157,6 +159,7 @@ private:
std::shared_ptr<IPlatformService> platformService_;
std::shared_ptr<ILogger> logger_;
std::shared_ptr<IPipelineCompilerService> pipelineCompiler_;
std::shared_ptr<IProbeService> probeService_;
bgfx::VertexLayout vertexLayout_;
std::unordered_map<GraphicsPipelineHandle, std::unique_ptr<PipelineEntry>> pipelines_;
std::unordered_map<GraphicsBufferHandle, std::unique_ptr<VertexBufferEntry>> vertexBuffers_;

View File

@@ -0,0 +1,579 @@
#include "config_compiler_service.hpp"
#include <rapidjson/document.h>
#include <rapidjson/error/en.h>
#include <algorithm>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
namespace sdl3cpp::services::impl {
namespace {
bool IsErrorSeverity(ProbeSeverity severity) {
return severity == ProbeSeverity::Error || severity == ProbeSeverity::Fatal;
}
std::string JoinPath(const std::string& base, const std::string& segment) {
if (base.empty()) {
return "/" + segment;
}
return base + "/" + segment;
}
bool ParsePassOutputReference(const std::string& value, std::string& passId, std::string& outputName) {
const std::string prefix = "@pass.";
if (value.rfind(prefix, 0) != 0) {
return false;
}
const std::string remainder = value.substr(prefix.size());
const size_t dot = remainder.find('.');
if (dot == std::string::npos) {
return false;
}
passId = remainder.substr(0, dot);
outputName = remainder.substr(dot + 1);
return !passId.empty() && !outputName.empty();
}
} // namespace
ConfigCompilerService::ConfigCompilerService(std::shared_ptr<IConfigService> configService,
std::shared_ptr<IRenderGraphService> renderGraphService,
std::shared_ptr<IProbeService> probeService,
std::shared_ptr<ILogger> logger)
: configService_(std::move(configService)),
renderGraphService_(std::move(renderGraphService)),
probeService_(std::move(probeService)),
logger_(std::move(logger)) {
if (logger_) {
logger_->Trace("ConfigCompilerService", "ConfigCompilerService", "initialized=true");
}
}
void ConfigCompilerService::Initialize() {
if (!configService_) {
throw std::runtime_error("ConfigCompilerService requires a config service");
}
const std::string configJson = configService_->GetConfigJson();
if (configJson.empty()) {
if (logger_) {
logger_->Warn("ConfigCompilerService::Initialize: Config JSON is empty; skipping compile");
}
return;
}
lastResult_ = Compile(configJson);
if (logger_) {
const std::string status = lastResult_.success ? "success" : "errors";
logger_->Info("ConfigCompilerService::Initialize: Config compile " + status +
" (diagnostics=" + std::to_string(lastResult_.diagnostics.size()) + ")");
}
}
ConfigCompilerResult ConfigCompilerService::Compile(const std::string& configJson) {
ConfigCompilerResult result;
result.success = true;
rapidjson::Document document;
document.Parse(configJson.c_str());
if (document.HasParseError()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"CONFIG_JSON_PARSE",
"",
std::string("JSON parse error: ") + rapidjson::GetParseError_En(document.GetParseError()),
"offset=" + std::to_string(document.GetErrorOffset()));
return result;
}
if (!document.IsObject()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"CONFIG_JSON_ROOT",
"",
"JSON root must be an object");
return result;
}
auto getObjectMember = [&](const rapidjson::Value& parent,
const char* name,
const std::string& path) -> const rapidjson::Value* {
if (!parent.HasMember(name)) {
return nullptr;
}
const auto& value = parent[name];
if (!value.IsObject()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"CONFIG_JSON_TYPE",
path,
std::string("Expected object for ") + name);
return nullptr;
}
return &value;
};
auto getArrayMember = [&](const rapidjson::Value& parent,
const char* name,
const std::string& path) -> const rapidjson::Value* {
if (!parent.HasMember(name)) {
return nullptr;
}
const auto& value = parent[name];
if (!value.IsArray()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"CONFIG_JSON_TYPE",
path,
std::string("Expected array for ") + name);
return nullptr;
}
return &value;
};
const std::string scenePath = "/scene";
if (const auto* sceneValue = getObjectMember(document, "scene", scenePath)) {
const std::string entitiesPath = JoinPath(scenePath, "entities");
if (const auto* entitiesValue = getArrayMember(*sceneValue, "entities", entitiesPath)) {
for (rapidjson::SizeType i = 0; i < entitiesValue->Size(); ++i) {
const auto& entry = (*entitiesValue)[i];
const std::string entityPath = entitiesPath + "/" + std::to_string(i);
if (!entry.IsObject()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"SCENE_ENTITY_TYPE",
entityPath,
"Scene entity must be an object");
continue;
}
SceneEntityIR entity;
entity.jsonPath = entityPath;
if (entry.HasMember("id") && entry["id"].IsString()) {
entity.id = entry["id"].GetString();
} else {
AddDiagnostic(result,
ProbeSeverity::Error,
"SCENE_ENTITY_ID_MISSING",
entityPath,
"Scene entity requires a string id");
continue;
}
if (entry.HasMember("components")) {
if (!entry["components"].IsObject()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"SCENE_COMPONENTS_TYPE",
JoinPath(entityPath, "components"),
"Scene components must be an object");
} else {
const auto& components = entry["components"];
for (auto it = components.MemberBegin(); it != components.MemberEnd(); ++it) {
if (it->name.IsString()) {
entity.componentTypes.emplace_back(it->name.GetString());
}
}
}
}
result.scene.entities.push_back(std::move(entity));
}
}
}
const std::string assetsPath = "/assets";
std::unordered_set<std::string> textureIds;
std::unordered_set<std::string> shaderIds;
if (const auto* assetsValue = getObjectMember(document, "assets", assetsPath)) {
if (const auto* texturesValue = getObjectMember(*assetsValue, "textures", JoinPath(assetsPath, "textures"))) {
for (auto it = texturesValue->MemberBegin(); it != texturesValue->MemberEnd(); ++it) {
const std::string id = it->name.GetString();
const std::string texturePath = JoinPath(JoinPath(assetsPath, "textures"), id);
if (!it->value.IsObject()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"ASSET_TEXTURE_TYPE",
texturePath,
"Texture entry must be an object");
continue;
}
if (!it->value.HasMember("uri") || !it->value["uri"].IsString()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"ASSET_TEXTURE_URI",
JoinPath(texturePath, "uri"),
"Texture entry requires a string uri");
continue;
}
TextureIR texture;
texture.id = id;
texture.uri = it->value["uri"].GetString();
texture.jsonPath = texturePath;
result.resources.textures.push_back(std::move(texture));
textureIds.insert(id);
}
}
if (const auto* meshesValue = getObjectMember(*assetsValue, "meshes", JoinPath(assetsPath, "meshes"))) {
for (auto it = meshesValue->MemberBegin(); it != meshesValue->MemberEnd(); ++it) {
const std::string id = it->name.GetString();
const std::string meshPath = JoinPath(JoinPath(assetsPath, "meshes"), id);
if (!it->value.IsObject()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"ASSET_MESH_TYPE",
meshPath,
"Mesh entry must be an object");
continue;
}
if (!it->value.HasMember("uri") || !it->value["uri"].IsString()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"ASSET_MESH_URI",
JoinPath(meshPath, "uri"),
"Mesh entry requires a string uri");
continue;
}
MeshIR mesh;
mesh.id = id;
mesh.uri = it->value["uri"].GetString();
mesh.jsonPath = meshPath;
result.resources.meshes.push_back(std::move(mesh));
}
}
if (const auto* shadersValue = getObjectMember(*assetsValue, "shaders", JoinPath(assetsPath, "shaders"))) {
for (auto it = shadersValue->MemberBegin(); it != shadersValue->MemberEnd(); ++it) {
const std::string id = it->name.GetString();
const std::string shaderPath = JoinPath(JoinPath(assetsPath, "shaders"), id);
if (!it->value.IsObject()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"ASSET_SHADER_TYPE",
shaderPath,
"Shader entry must be an object");
continue;
}
if (!it->value.HasMember("vs") || !it->value["vs"].IsString() ||
!it->value.HasMember("fs") || !it->value["fs"].IsString()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"ASSET_SHADER_PATHS",
shaderPath,
"Shader entry requires vs and fs string paths");
continue;
}
ShaderIR shader;
shader.id = id;
shader.vertexPath = it->value["vs"].GetString();
shader.fragmentPath = it->value["fs"].GetString();
shader.jsonPath = shaderPath;
result.resources.shaders.push_back(std::move(shader));
shaderIds.insert(id);
}
}
}
const std::string materialsPath = "/materials";
if (const auto* materialsValue = getObjectMember(document, "materials", materialsPath)) {
for (auto it = materialsValue->MemberBegin(); it != materialsValue->MemberEnd(); ++it) {
const std::string id = it->name.GetString();
const std::string materialPath = JoinPath(materialsPath, id);
if (!it->value.IsObject()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"MATERIAL_TYPE",
materialPath,
"Material entry must be an object");
continue;
}
if (!it->value.HasMember("shader") || !it->value["shader"].IsString()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"MATERIAL_SHADER",
JoinPath(materialPath, "shader"),
"Material requires a shader id");
continue;
}
MaterialIR material;
material.id = id;
material.shader = it->value["shader"].GetString();
material.jsonPath = materialPath;
if (!shaderIds.empty() && shaderIds.find(material.shader) == shaderIds.end()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"MATERIAL_SHADER_UNKNOWN",
JoinPath(materialPath, "shader"),
"Material references unknown shader: " + material.shader);
}
if (it->value.HasMember("textures")) {
const auto& texturesValue = it->value["textures"];
if (!texturesValue.IsObject()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"MATERIAL_TEXTURES_TYPE",
JoinPath(materialPath, "textures"),
"Material textures must be an object");
} else {
for (auto texIt = texturesValue.MemberBegin(); texIt != texturesValue.MemberEnd(); ++texIt) {
const std::string uniform = texIt->name.GetString();
if (!texIt->value.IsString()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"MATERIAL_TEXTURE_REF",
JoinPath(JoinPath(materialPath, "textures"), uniform),
"Material texture mapping must be a string id");
continue;
}
const std::string textureId = texIt->value.GetString();
material.textures.emplace(uniform, textureId);
if (!textureIds.empty() && textureIds.find(textureId) == textureIds.end()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"MATERIAL_TEXTURE_UNKNOWN",
JoinPath(JoinPath(materialPath, "textures"), uniform),
"Material references unknown texture: " + textureId);
}
}
}
}
result.resources.materials.push_back(std::move(material));
}
}
const std::string renderPath = "/render";
if (const auto* renderValue = getObjectMember(document, "render", renderPath)) {
const std::string passesPath = JoinPath(renderPath, "passes");
if (const auto* passesValue = getArrayMember(*renderValue, "passes", passesPath)) {
std::unordered_map<std::string, std::unordered_set<std::string>> outputsByPass;
for (rapidjson::SizeType i = 0; i < passesValue->Size(); ++i) {
const auto& passValue = (*passesValue)[i];
const std::string passPath = passesPath + "/" + std::to_string(i);
if (!passValue.IsObject()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_PASS_TYPE",
passPath,
"Render pass must be an object");
continue;
}
RenderPassIR pass;
pass.jsonPath = passPath;
if (passValue.HasMember("id") && passValue["id"].IsString()) {
pass.id = passValue["id"].GetString();
} else {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_PASS_ID",
JoinPath(passPath, "id"),
"Render pass requires a string id");
continue;
}
if (passValue.HasMember("type") && passValue["type"].IsString()) {
pass.type = passValue["type"].GetString();
}
outputsByPass.emplace(pass.id, std::unordered_set<std::string>{});
if (passValue.HasMember("inputs")) {
const auto& inputsValue = passValue["inputs"];
if (!inputsValue.IsObject()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_INPUTS_TYPE",
JoinPath(passPath, "inputs"),
"Render pass inputs must be an object");
} else {
for (auto inputIt = inputsValue.MemberBegin(); inputIt != inputsValue.MemberEnd(); ++inputIt) {
const std::string inputName = inputIt->name.GetString();
const std::string inputPath = JoinPath(JoinPath(passPath, "inputs"), inputName);
if (!inputIt->value.IsString()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_INPUT_REF",
inputPath,
"Render pass input must be a string reference");
continue;
}
const std::string inputValue = inputIt->value.GetString();
RenderPassInputIR input;
input.name = inputName;
input.jsonPath = inputPath;
if (inputValue == "@swapchain") {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_INPUT_SWAPCHAIN",
inputPath,
"Render pass input cannot reference @swapchain");
} else {
std::string passId;
std::string outputName;
if (ParsePassOutputReference(inputValue, passId, outputName)) {
input.ref.type = RenderResourceRefType::PassOutput;
input.ref.passId = passId;
input.ref.outputName = outputName;
input.ref.jsonPath = inputPath;
} else {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_INPUT_REF_FORMAT",
inputPath,
"Render pass input must use @pass.<id>.<output>");
}
}
pass.inputs.push_back(std::move(input));
}
}
}
if (passValue.HasMember("outputs")) {
const auto& outputsValue = passValue["outputs"];
if (!outputsValue.IsObject()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_OUTPUTS_TYPE",
JoinPath(passPath, "outputs"),
"Render pass outputs must be an object");
} else {
for (auto outputIt = outputsValue.MemberBegin(); outputIt != outputsValue.MemberEnd(); ++outputIt) {
const std::string outputName = outputIt->name.GetString();
const std::string outputPath = JoinPath(JoinPath(passPath, "outputs"), outputName);
RenderPassOutputIR output;
output.name = outputName;
output.jsonPath = outputPath;
bool outputValid = true;
if (outputIt->value.IsString()) {
const std::string outputValue = outputIt->value.GetString();
if (outputValue == "@swapchain") {
output.isSwapchain = true;
} else {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_OUTPUT_REF",
outputPath,
"Render pass output must be an object or @swapchain");
outputValid = false;
}
} else if (outputIt->value.IsObject()) {
const auto& outputObject = outputIt->value;
if (outputObject.HasMember("format") && outputObject["format"].IsString()) {
output.format = outputObject["format"].GetString();
}
if (outputObject.HasMember("usage") && outputObject["usage"].IsString()) {
output.usage = outputObject["usage"].GetString();
}
} else {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_OUTPUT_TYPE",
outputPath,
"Render pass output must be an object or string");
outputValid = false;
}
if (outputValid) {
pass.outputs.push_back(std::move(output));
if (!pass.id.empty() && !output.isSwapchain) {
outputsByPass[pass.id].insert(outputName);
}
}
}
}
}
result.renderGraph.passes.push_back(std::move(pass));
}
for (const auto& pass : result.renderGraph.passes) {
for (const auto& input : pass.inputs) {
if (input.ref.type != RenderResourceRefType::PassOutput) {
continue;
}
if (input.ref.passId == pass.id) {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_INPUT_SELF_REFERENCE",
input.jsonPath,
"Render pass input references its own output");
continue;
}
auto passIt = outputsByPass.find(input.ref.passId);
if (passIt == outputsByPass.end()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_INPUT_UNKNOWN_PASS",
input.jsonPath,
"Render pass input references unknown pass: " + input.ref.passId);
continue;
}
if (!input.ref.outputName.empty() &&
passIt->second.find(input.ref.outputName) == passIt->second.end()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_INPUT_UNKNOWN_OUTPUT",
input.jsonPath,
"Render pass input references unknown output: " + input.ref.outputName);
}
}
}
}
}
if (renderGraphService_) {
const auto buildResult = renderGraphService_->BuildGraph(result.renderGraph);
MergeRenderGraphDiagnostics(result, buildResult);
}
if (logger_) {
logger_->Trace("ConfigCompilerService", "Compile",
"diagnostics=" + std::to_string(result.diagnostics.size()) +
", success=" + std::string(result.success ? "true" : "false"));
}
return result;
}
void ConfigCompilerService::AddDiagnostic(ConfigCompilerResult& result,
ProbeSeverity severity,
const std::string& code,
const std::string& jsonPath,
const std::string& message,
const std::string& details) {
ProbeReport report;
report.severity = severity;
report.code = code;
report.jsonPath = jsonPath;
report.message = message;
report.details = details;
result.diagnostics.push_back(report);
if (IsErrorSeverity(severity)) {
result.success = false;
}
if (probeService_) {
probeService_->Report(report);
}
if (logger_) {
if (severity == ProbeSeverity::Warn) {
logger_->Warn("ConfigCompilerService::Compile: " + message + " (" + code + ")");
} else if (IsErrorSeverity(severity)) {
logger_->Error("ConfigCompilerService::Compile: " + message + " (" + code + ")");
} else {
logger_->Info("ConfigCompilerService::Compile: " + message + " (" + code + ")");
}
}
}
void ConfigCompilerService::MergeRenderGraphDiagnostics(ConfigCompilerResult& result,
const RenderGraphBuildResult& renderGraphBuild) {
result.renderGraphBuild = renderGraphBuild;
for (const auto& report : renderGraphBuild.diagnostics) {
result.diagnostics.push_back(report);
if (IsErrorSeverity(report.severity)) {
result.success = false;
}
}
}
} // namespace sdl3cpp::services::impl

View File

@@ -0,0 +1,42 @@
#pragma once
#include "../interfaces/i_config_compiler_service.hpp"
#include "../interfaces/i_config_service.hpp"
#include "../interfaces/i_logger.hpp"
#include "../interfaces/i_probe_service.hpp"
#include "../interfaces/i_render_graph_service.hpp"
#include "../../di/lifecycle.hpp"
#include <memory>
namespace sdl3cpp::services::impl {
class ConfigCompilerService : public IConfigCompilerService, public di::IInitializable {
public:
ConfigCompilerService(std::shared_ptr<IConfigService> configService,
std::shared_ptr<IRenderGraphService> renderGraphService,
std::shared_ptr<IProbeService> probeService,
std::shared_ptr<ILogger> logger);
void Initialize() override;
ConfigCompilerResult Compile(const std::string& configJson) override;
const ConfigCompilerResult& GetLastResult() const override { return lastResult_; }
private:
void AddDiagnostic(ConfigCompilerResult& result,
ProbeSeverity severity,
const std::string& code,
const std::string& jsonPath,
const std::string& message,
const std::string& details = "");
void MergeRenderGraphDiagnostics(ConfigCompilerResult& result,
const RenderGraphBuildResult& renderGraphBuild);
std::shared_ptr<IConfigService> configService_;
std::shared_ptr<IRenderGraphService> renderGraphService_;
std::shared_ptr<IProbeService> probeService_;
std::shared_ptr<ILogger> logger_;
ConfigCompilerResult lastResult_{};
};
} // namespace sdl3cpp::services::impl

View File

@@ -1,7 +1,9 @@
#include "json_config_service.hpp"
#include "../interfaces/i_logger.hpp"
#include <rapidjson/document.h>
#include <rapidjson/error/en.h>
#include <rapidjson/istreamwrapper.h>
#include <rapidjson/schema.h>
#include <rapidjson/stringbuffer.h>
#include <rapidjson/writer.h>
#include <rapidjson/prettywriter.h>
@@ -33,20 +35,22 @@ std::filesystem::path NormalizeConfigPath(const std::filesystem::path& path) {
return canonicalPath;
}
rapidjson::Document ParseJsonDocument(const std::filesystem::path& configPath) {
std::ifstream configStream(configPath);
rapidjson::Document ParseJsonDocument(const std::filesystem::path& jsonPath,
const std::string& description) {
std::ifstream configStream(jsonPath);
if (!configStream) {
throw std::runtime_error("Failed to open config file: " + configPath.string());
throw std::runtime_error("Failed to open " + description + ": " + jsonPath.string());
}
rapidjson::IStreamWrapper inputWrapper(configStream);
rapidjson::Document document;
document.ParseStream(inputWrapper);
if (document.HasParseError()) {
throw std::runtime_error("Failed to parse JSON config at " + configPath.string());
throw std::runtime_error("Failed to parse " + description + " at " + jsonPath.string() +
": " + rapidjson::GetParseError_En(document.GetParseError()));
}
if (!document.IsObject()) {
throw std::runtime_error("JSON config must contain an object at the root");
throw std::runtime_error("JSON " + description + " must contain an object at the root");
}
return document;
}
@@ -82,6 +86,23 @@ std::vector<std::filesystem::path> ExtractExtendPaths(const rapidjson::Value& do
return paths;
}
std::filesystem::path ResolveSchemaPath(const std::filesystem::path& configPath) {
const std::filesystem::path schemaFile = "runtime_config_v2.schema.json";
std::vector<std::filesystem::path> candidates;
if (!configPath.empty()) {
candidates.push_back(configPath.parent_path() / "schema" / schemaFile);
}
candidates.push_back(std::filesystem::current_path() / "config" / "schema" / schemaFile);
std::error_code ec;
for (const auto& candidate : candidates) {
if (!candidate.empty() && std::filesystem::exists(candidate, ec)) {
return candidate;
}
}
return {};
}
void ApplyDeleteDirectives(rapidjson::Value& target,
const rapidjson::Value& overlay,
const std::shared_ptr<ILogger>& logger,
@@ -162,7 +183,7 @@ rapidjson::Document LoadConfigDocumentRecursive(const std::filesystem::path& con
"configPath=" + pathKey, "Loading config document");
}
rapidjson::Document document = ParseJsonDocument(normalizedPath);
rapidjson::Document document = ParseJsonDocument(normalizedPath, "config file");
auto extendPaths = ExtractExtendPaths(document, normalizedPath);
if (document.HasMember(kExtendsKey)) {
document.RemoveMember(kExtendsKey);
@@ -209,9 +230,9 @@ std::optional<int> ReadVersionField(const rapidjson::Value& document,
configPath.string());
}
void ValidateSchemaVersion(const rapidjson::Value& document,
const std::filesystem::path& configPath,
const std::shared_ptr<ILogger>& logger) {
std::optional<int> ValidateSchemaVersion(const rapidjson::Value& document,
const std::filesystem::path& configPath,
const std::shared_ptr<ILogger>& logger) {
const auto schemaVersion = ReadVersionField(document, kSchemaVersionKey, configPath);
const auto configVersion = ReadVersionField(document, kConfigVersionKey, configPath);
if (schemaVersion && configVersion && *schemaVersion != *configVersion) {
@@ -224,23 +245,98 @@ void ValidateSchemaVersion(const rapidjson::Value& document,
logger->Warn("JsonConfigService::LoadFromJson: Missing schema version in " +
configPath.string() + "; assuming version " + std::to_string(kExpectedSchemaVersion));
}
return;
return std::nullopt;
}
if (logger) {
logger->Trace("JsonConfigService", "ValidateSchemaVersion",
"version=" + std::to_string(*activeVersion) +
", configPath=" + configPath.string());
}
if (*activeVersion != kExpectedSchemaVersion) {
throw std::runtime_error("Unsupported schema version " + std::to_string(*activeVersion) +
" in " + configPath.string() +
"; expected " + std::to_string(kExpectedSchemaVersion));
return activeVersion;
}
bool ApplyMigrations(rapidjson::Document& document,
int fromVersion,
int toVersion,
const std::filesystem::path& configPath,
const std::shared_ptr<ILogger>& logger,
const std::shared_ptr<IProbeService>& probeService) {
if (fromVersion == toVersion) {
return true;
}
if (logger) {
logger->Warn("JsonConfigService::ApplyMigrations: No migration path from v" +
std::to_string(fromVersion) + " to v" + std::to_string(toVersion) +
" for " + configPath.string());
}
if (probeService) {
ProbeReport report{};
report.severity = ProbeSeverity::Error;
report.code = "CONFIG_MIGRATION_MISSING";
report.jsonPath = "";
report.message = "No migration path from v" + std::to_string(fromVersion) +
" to v" + std::to_string(toVersion) +
" (see config/schema/MIGRATIONS.md)";
probeService->Report(report);
}
return false;
}
void ValidateSchemaDocument(const rapidjson::Document& document,
const std::filesystem::path& configPath,
const std::shared_ptr<ILogger>& logger,
const std::shared_ptr<IProbeService>& probeService) {
const auto schemaPath = ResolveSchemaPath(configPath);
if (schemaPath.empty()) {
if (logger) {
logger->Warn("JsonConfigService::ValidateSchemaDocument: Schema file not found for " +
configPath.string());
}
return;
}
rapidjson::Document schemaDocument = ParseJsonDocument(schemaPath, "schema file");
rapidjson::SchemaDocument schema(schemaDocument);
rapidjson::SchemaValidator validator(schema);
if (!document.Accept(validator)) {
const std::string docPointer = validator.GetInvalidDocumentPointer().String();
const std::string schemaPointer = validator.GetInvalidSchemaPointer().String();
const std::string keyword = validator.GetInvalidSchemaKeyword();
const std::string message = "JSON schema validation failed at " + docPointer +
" (schema " + schemaPointer + ", keyword=" + keyword + ")";
if (logger) {
logger->Error("JsonConfigService::ValidateSchemaDocument: " + message +
" configPath=" + configPath.string());
}
if (probeService) {
ProbeReport report{};
report.severity = ProbeSeverity::Error;
report.code = "CONFIG_SCHEMA_INVALID";
report.jsonPath = docPointer;
report.message = message;
report.details = "schemaPointer=" + schemaPointer + ", keyword=" + keyword;
probeService->Report(report);
}
throw std::runtime_error("JSON schema validation failed for " + configPath.string() +
" at " + docPointer + " (schema " + schemaPointer +
", keyword=" + keyword + ")");
}
if (logger) {
logger->Trace("JsonConfigService", "ValidateSchemaDocument",
"schemaPath=" + schemaPath.string() +
", configPath=" + configPath.string(),
"Schema validation passed");
}
}
} // namespace
JsonConfigService::JsonConfigService(std::shared_ptr<ILogger> logger, const char* argv0)
: logger_(std::move(logger)), configJson_(), config_(RuntimeConfig{}) {
JsonConfigService::JsonConfigService(std::shared_ptr<ILogger> logger,
const char* argv0,
std::shared_ptr<IProbeService> probeService)
: logger_(std::move(logger)),
probeService_(std::move(probeService)),
configJson_(),
config_(RuntimeConfig{}) {
if (logger_) {
logger_->Trace("JsonConfigService", "JsonConfigService",
"argv0=" + std::string(argv0 ? argv0 : ""));
@@ -250,10 +346,14 @@ JsonConfigService::JsonConfigService(std::shared_ptr<ILogger> logger, const char
logger_->Info("JsonConfigService initialized with default configuration");
}
JsonConfigService::JsonConfigService(std::shared_ptr<ILogger> logger, const std::filesystem::path& configPath, bool dumpConfig)
JsonConfigService::JsonConfigService(std::shared_ptr<ILogger> logger,
const std::filesystem::path& configPath,
bool dumpConfig,
std::shared_ptr<IProbeService> probeService)
: logger_(std::move(logger)),
probeService_(std::move(probeService)),
configJson_(),
config_(LoadFromJson(logger_, configPath, dumpConfig, &configJson_)) {
config_(LoadFromJson(logger_, probeService_, configPath, dumpConfig, &configJson_)) {
if (logger_) {
logger_->Trace("JsonConfigService", "JsonConfigService",
"configPath=" + configPath.string() +
@@ -262,8 +362,13 @@ JsonConfigService::JsonConfigService(std::shared_ptr<ILogger> logger, const std:
logger_->Info("JsonConfigService initialized from config file: " + configPath.string());
}
JsonConfigService::JsonConfigService(std::shared_ptr<ILogger> logger, const RuntimeConfig& config)
: logger_(std::move(logger)), configJson_(BuildConfigJson(config, {})), config_(config) {
JsonConfigService::JsonConfigService(std::shared_ptr<ILogger> logger,
const RuntimeConfig& config,
std::shared_ptr<IProbeService> probeService)
: logger_(std::move(logger)),
probeService_(std::move(probeService)),
configJson_(BuildConfigJson(config, {})),
config_(config) {
if (logger_) {
logger_->Trace("JsonConfigService", "JsonConfigService",
"config.width=" + std::to_string(config.width) +
@@ -298,6 +403,7 @@ std::filesystem::path JsonConfigService::FindScriptPath(const char* argv0) {
}
RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr<ILogger> logger,
std::shared_ptr<IProbeService> probeService,
const std::filesystem::path& configPath,
bool dumpConfig,
std::string* configJson) {
@@ -307,7 +413,22 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr<ILogger> logger,
std::unordered_set<std::string> visitedPaths;
rapidjson::Document document = LoadConfigDocumentRecursive(configPath, logger, visitedPaths);
ValidateSchemaVersion(document, configPath, logger);
const auto activeVersion = ValidateSchemaVersion(document, configPath, logger);
if (activeVersion && *activeVersion != kExpectedSchemaVersion) {
const bool migrated = ApplyMigrations(document,
*activeVersion,
kExpectedSchemaVersion,
configPath,
logger,
probeService);
if (!migrated) {
throw std::runtime_error("Unsupported schema version " + std::to_string(*activeVersion) +
" in " + configPath.string() +
"; expected " + std::to_string(kExpectedSchemaVersion) +
" (see config/schema/MIGRATIONS.md)");
}
}
ValidateSchemaDocument(document, configPath, logger, probeService);
if (dumpConfig || configJson) {
rapidjson::StringBuffer buffer;

View File

@@ -2,6 +2,7 @@
#include "../interfaces/i_config_service.hpp"
#include "../interfaces/i_logger.hpp"
#include "../interfaces/i_probe_service.hpp"
#include "../interfaces/config_types.hpp"
#include <cstdint>
#include <filesystem>
@@ -23,8 +24,11 @@ public:
*
* @param logger Logger service for logging
* @param argv0 First command-line argument (for finding default script path)
* @param probeService Probe service for diagnostics (optional)
*/
JsonConfigService(std::shared_ptr<ILogger> logger, const char* argv0);
JsonConfigService(std::shared_ptr<ILogger> logger,
const char* argv0,
std::shared_ptr<IProbeService> probeService = nullptr);
/**
* @brief Construct by loading configuration from JSON.
@@ -32,17 +36,24 @@ public:
* @param logger Logger service for logging
* @param configPath Path to JSON configuration file
* @param dumpConfig Whether to print loaded config to stdout
* @param probeService Probe service for diagnostics (optional)
* @throws std::runtime_error if config file cannot be loaded or is invalid
*/
JsonConfigService(std::shared_ptr<ILogger> logger, const std::filesystem::path& configPath, bool dumpConfig);
JsonConfigService(std::shared_ptr<ILogger> logger,
const std::filesystem::path& configPath,
bool dumpConfig,
std::shared_ptr<IProbeService> probeService = nullptr);
/**
* @brief Construct with explicit configuration.
*
* @param logger Logger service for logging
* @param config Runtime configuration to use
* @param probeService Probe service for diagnostics (optional)
*/
JsonConfigService(std::shared_ptr<ILogger> logger, const RuntimeConfig& config);
JsonConfigService(std::shared_ptr<ILogger> logger,
const RuntimeConfig& config,
std::shared_ptr<IProbeService> probeService = nullptr);
// IConfigService interface implementation
uint32_t GetWindowWidth() const override {
@@ -144,12 +155,14 @@ public:
private:
std::shared_ptr<ILogger> logger_;
std::shared_ptr<IProbeService> probeService_;
std::string configJson_;
RuntimeConfig config_;
// Helper methods moved from main.cpp
std::filesystem::path FindScriptPath(const char* argv0);
static RuntimeConfig LoadFromJson(std::shared_ptr<ILogger> logger,
std::shared_ptr<IProbeService> probeService,
const std::filesystem::path& configPath,
bool dumpConfig,
std::string* configJson);

View File

@@ -0,0 +1,63 @@
#include "probe_service.hpp"
#include <utility>
namespace sdl3cpp::services::impl {
ProbeService::ProbeService(std::shared_ptr<ILogger> logger)
: logger_(std::move(logger)) {
if (logger_) {
logger_->Trace("ProbeService", "ProbeService", "initialized=true");
}
}
void ProbeService::Report(const ProbeReport& report) {
reports_.push_back(report);
LogReport(report);
}
std::vector<ProbeReport> ProbeService::DrainReports() {
std::vector<ProbeReport> drained = std::move(reports_);
reports_.clear();
return drained;
}
void ProbeService::ClearReports() {
reports_.clear();
}
std::string ProbeService::SeverityName(ProbeSeverity severity) const {
switch (severity) {
case ProbeSeverity::Info:
return "info";
case ProbeSeverity::Warn:
return "warn";
case ProbeSeverity::Error:
return "error";
case ProbeSeverity::Fatal:
return "fatal";
default:
return "unknown";
}
}
void ProbeService::LogReport(const ProbeReport& report) const {
if (!logger_) {
return;
}
const std::string summary = "severity=" + SeverityName(report.severity) +
", code=" + report.code +
", jsonPath=" + report.jsonPath +
", renderPass=" + report.renderPass +
", resourceId=" + report.resourceId;
if (report.severity == ProbeSeverity::Warn) {
logger_->Warn("ProbeService::Report: " + report.message + " (" + summary + ")");
} else if (report.severity == ProbeSeverity::Error || report.severity == ProbeSeverity::Fatal) {
logger_->Error("ProbeService::Report: " + report.message + " (" + summary + ")");
} else {
logger_->Info("ProbeService::Report: " + report.message + " (" + summary + ")");
}
}
} // namespace sdl3cpp::services::impl

View File

@@ -0,0 +1,26 @@
#pragma once
#include "../interfaces/i_probe_service.hpp"
#include "../interfaces/i_logger.hpp"
#include <memory>
#include <vector>
namespace sdl3cpp::services::impl {
class ProbeService : public IProbeService {
public:
explicit ProbeService(std::shared_ptr<ILogger> logger);
void Report(const ProbeReport& report) override;
std::vector<ProbeReport> DrainReports() override;
void ClearReports() override;
private:
std::string SeverityName(ProbeSeverity severity) const;
void LogReport(const ProbeReport& report) const;
std::shared_ptr<ILogger> logger_;
std::vector<ProbeReport> reports_{};
};
} // namespace sdl3cpp::services::impl

View File

@@ -0,0 +1,174 @@
#include "render_graph_service.hpp"
#include <queue>
#include <unordered_map>
#include <unordered_set>
namespace sdl3cpp::services::impl {
namespace {
bool IsErrorSeverity(ProbeSeverity severity) {
return severity == ProbeSeverity::Error || severity == ProbeSeverity::Fatal;
}
} // namespace
RenderGraphService::RenderGraphService(std::shared_ptr<ILogger> logger,
std::shared_ptr<IProbeService> probeService)
: logger_(std::move(logger)),
probeService_(std::move(probeService)) {
if (logger_) {
logger_->Trace("RenderGraphService", "RenderGraphService", "initialized=true");
}
}
RenderGraphBuildResult RenderGraphService::BuildGraph(const RenderGraphIR& renderGraph) {
RenderGraphBuildResult result;
result.success = true;
if (renderGraph.passes.empty()) {
return result;
}
std::unordered_map<std::string, size_t> passIndex;
passIndex.reserve(renderGraph.passes.size());
for (size_t i = 0; i < renderGraph.passes.size(); ++i) {
const auto& pass = renderGraph.passes[i];
if (pass.id.empty()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_PASS_ID_MISSING",
pass.jsonPath,
"Render pass is missing an id");
continue;
}
const auto [it, inserted] = passIndex.emplace(pass.id, i);
if (!inserted) {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_PASS_ID_DUPLICATE",
pass.jsonPath,
"Duplicate render pass id: " + pass.id);
}
}
std::vector<std::vector<size_t>> edges(renderGraph.passes.size());
std::vector<size_t> indegree(renderGraph.passes.size(), 0);
for (size_t i = 0; i < renderGraph.passes.size(); ++i) {
const auto& pass = renderGraph.passes[i];
for (const auto& input : pass.inputs) {
if (input.ref.type != RenderResourceRefType::PassOutput) {
continue;
}
if (input.ref.passId.empty()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_INPUT_MISSING_PASS",
input.jsonPath,
"Render pass input is missing a pass reference");
continue;
}
auto it = passIndex.find(input.ref.passId);
if (it == passIndex.end()) {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_INPUT_UNKNOWN_PASS",
input.jsonPath,
"Render pass input references unknown pass: " + input.ref.passId);
continue;
}
if (it->second == i) {
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_INPUT_SELF_REFERENCE",
input.jsonPath,
"Render pass input references its own output");
continue;
}
edges[it->second].push_back(i);
indegree[i] += 1;
}
}
std::queue<size_t> ready;
for (size_t i = 0; i < indegree.size(); ++i) {
if (indegree[i] == 0) {
ready.push(i);
}
}
while (!ready.empty()) {
const size_t current = ready.front();
ready.pop();
result.passOrder.push_back(renderGraph.passes[current].id);
for (size_t neighbor : edges[current]) {
if (indegree[neighbor] > 0) {
indegree[neighbor] -= 1;
}
if (indegree[neighbor] == 0) {
ready.push(neighbor);
}
}
}
if (result.passOrder.size() != renderGraph.passes.size()) {
std::string remaining;
for (size_t i = 0; i < renderGraph.passes.size(); ++i) {
if (indegree[i] > 0) {
if (!remaining.empty()) {
remaining += ", ";
}
remaining += renderGraph.passes[i].id;
}
}
AddDiagnostic(result,
ProbeSeverity::Error,
"RG_CYCLE_DETECTED",
"/render/passes",
"Render graph cycle detected",
"remainingPasses=" + remaining);
}
if (logger_) {
logger_->Trace("RenderGraphService", "BuildGraph",
"passCount=" + std::to_string(renderGraph.passes.size()) +
", scheduled=" + std::to_string(result.passOrder.size()));
}
return result;
}
void RenderGraphService::AddDiagnostic(RenderGraphBuildResult& result,
ProbeSeverity severity,
const std::string& code,
const std::string& jsonPath,
const std::string& message,
const std::string& details) const {
ProbeReport report;
report.severity = severity;
report.code = code;
report.jsonPath = jsonPath;
report.message = message;
report.details = details;
result.diagnostics.push_back(report);
if (IsErrorSeverity(severity)) {
result.success = false;
}
if (probeService_) {
probeService_->Report(report);
}
if (logger_) {
if (severity == ProbeSeverity::Warn) {
logger_->Warn("RenderGraphService::BuildGraph: " + message + " (" + code + ")");
} else if (IsErrorSeverity(severity)) {
logger_->Error("RenderGraphService::BuildGraph: " + message + " (" + code + ")");
} else {
logger_->Info("RenderGraphService::BuildGraph: " + message + " (" + code + ")");
}
}
}
} // namespace sdl3cpp::services::impl

View File

@@ -0,0 +1,29 @@
#pragma once
#include "../interfaces/i_render_graph_service.hpp"
#include "../interfaces/i_logger.hpp"
#include "../interfaces/i_probe_service.hpp"
#include <memory>
namespace sdl3cpp::services::impl {
class RenderGraphService : public IRenderGraphService {
public:
RenderGraphService(std::shared_ptr<ILogger> logger,
std::shared_ptr<IProbeService> probeService = nullptr);
RenderGraphBuildResult BuildGraph(const RenderGraphIR& renderGraph) override;
private:
void AddDiagnostic(RenderGraphBuildResult& result,
ProbeSeverity severity,
const std::string& code,
const std::string& jsonPath,
const std::string& message,
const std::string& details = "") const;
std::shared_ptr<ILogger> logger_;
std::shared_ptr<IProbeService> probeService_;
};
} // namespace sdl3cpp::services::impl

View File

@@ -0,0 +1,107 @@
#pragma once
#include "probe_types.hpp"
#include <string>
#include <unordered_map>
#include <vector>
namespace sdl3cpp::services {
struct SceneEntityIR {
std::string id;
std::vector<std::string> componentTypes;
std::string jsonPath;
};
struct SceneIR {
std::vector<SceneEntityIR> entities;
};
struct TextureIR {
std::string id;
std::string uri;
std::string jsonPath;
};
struct MeshIR {
std::string id;
std::string uri;
std::string jsonPath;
};
struct ShaderIR {
std::string id;
std::string vertexPath;
std::string fragmentPath;
std::string jsonPath;
};
struct MaterialIR {
std::string id;
std::string shader;
std::unordered_map<std::string, std::string> textures;
std::string jsonPath;
};
struct ResourceIR {
std::vector<TextureIR> textures;
std::vector<MeshIR> meshes;
std::vector<ShaderIR> shaders;
std::vector<MaterialIR> materials;
};
enum class RenderResourceRefType {
None,
PassOutput,
Swapchain
};
struct RenderResourceRef {
RenderResourceRefType type = RenderResourceRefType::None;
std::string passId;
std::string outputName;
std::string jsonPath;
};
struct RenderPassInputIR {
std::string name;
RenderResourceRef ref;
std::string jsonPath;
};
struct RenderPassOutputIR {
std::string name;
bool isSwapchain = false;
std::string format;
std::string usage;
std::string jsonPath;
};
struct RenderPassIR {
std::string id;
std::string type;
std::vector<RenderPassInputIR> inputs;
std::vector<RenderPassOutputIR> outputs;
std::string jsonPath;
};
struct RenderGraphIR {
std::vector<RenderPassIR> passes;
};
struct RenderGraphBuildResult {
std::vector<std::string> passOrder;
std::vector<ProbeReport> diagnostics;
bool success = true;
};
struct ConfigCompilerResult {
SceneIR scene;
ResourceIR resources;
RenderGraphIR renderGraph;
RenderGraphBuildResult renderGraphBuild;
std::vector<ProbeReport> diagnostics;
bool success = true;
};
} // namespace sdl3cpp::services

View File

@@ -0,0 +1,29 @@
#pragma once
#include "config_ir_types.hpp"
#include <string>
namespace sdl3cpp::services {
/**
* @brief Compiles JSON configuration into validated IR structures.
*/
class IConfigCompilerService {
public:
virtual ~IConfigCompilerService() = default;
/**
* @brief Compile config JSON into IR.
*
* @param configJson JSON payload
* @return Compilation result (diagnostics included)
*/
virtual ConfigCompilerResult Compile(const std::string& configJson) = 0;
/**
* @brief Get the last compilation result.
*/
virtual const ConfigCompilerResult& GetLastResult() const = 0;
};
} // namespace sdl3cpp::services

View File

@@ -0,0 +1,35 @@
#pragma once
#include "probe_types.hpp"
#include <vector>
namespace sdl3cpp::services {
/**
* @brief Probe service interface for structured diagnostics.
*/
class IProbeService {
public:
virtual ~IProbeService() = default;
/**
* @brief Record a probe report.
*
* @param report Report data
*/
virtual void Report(const ProbeReport& report) = 0;
/**
* @brief Drain all queued reports.
*
* @return Collected reports (clears internal storage)
*/
virtual std::vector<ProbeReport> DrainReports() = 0;
/**
* @brief Clear all queued reports.
*/
virtual void ClearReports() = 0;
};
} // namespace sdl3cpp::services

View File

@@ -0,0 +1,23 @@
#pragma once
#include "config_ir_types.hpp"
namespace sdl3cpp::services {
/**
* @brief Builds and validates render graphs.
*/
class IRenderGraphService {
public:
virtual ~IRenderGraphService() = default;
/**
* @brief Build a render graph schedule.
*
* @param renderGraph Render graph IR
* @return Schedule and diagnostics
*/
virtual RenderGraphBuildResult BuildGraph(const RenderGraphIR& renderGraph) = 0;
};
} // namespace sdl3cpp::services

View File

@@ -0,0 +1,30 @@
#pragma once
#include <string>
namespace sdl3cpp::services {
/**
* @brief Severity levels for probe reports.
*/
enum class ProbeSeverity {
Info,
Warn,
Error,
Fatal
};
/**
* @brief Structured probe report for diagnostics and policy handling.
*/
struct ProbeReport {
ProbeSeverity severity = ProbeSeverity::Info;
std::string code;
std::string jsonPath;
std::string renderPass;
std::string resourceId;
std::string message;
std::string details;
};
} // namespace sdl3cpp::services