diff --git a/CMakeLists.txt b/CMakeLists.txt index 187faa0..2aaa4f0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 8fc5ea2..d54878a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/config/schema/MIGRATIONS.md b/config/schema/MIGRATIONS.md new file mode 100644 index 0000000..0f58f65 --- /dev/null +++ b/config/schema/MIGRATIONS.md @@ -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. diff --git a/config/schema/runtime_config_v2.schema.json b/config/schema/runtime_config_v2.schema.json new file mode 100644 index 0000000..8960fc0 --- /dev/null +++ b/config/schema/runtime_config_v2.schema.json @@ -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 +} diff --git a/src/app/service_based_app.cpp b/src/app/service_based_app.cpp index 2ff4066..b4b9d39 100644 --- a/src/app/service_based_app.cpp +++ b/src/app/service_based_app.cpp @@ -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 #include #include @@ -209,10 +215,28 @@ void ServiceBasedApp::RegisterServices() { // Event bus (needed by window service) registry_.RegisterService(); + // Probe service (structured diagnostics) + registry_.RegisterService( + registry_.GetService()); + // Configuration service registry_.RegisterService( - registry_.GetService(), runtimeConfig_); + registry_.GetService(), + runtimeConfig_, + registry_.GetService()); auto configService = registry_.GetService(); + + // Render graph service (DAG build + scheduling) + registry_.RegisterService( + registry_.GetService(), + registry_.GetService()); + + // Config compiler service (JSON -> IR) + registry_.RegisterService( + registry_.GetService(), + registry_.GetService(), + registry_.GetService(), + registry_.GetService()); // ECS service (entt registry) registry_.RegisterService( registry_.GetService()); @@ -283,7 +307,8 @@ void ServiceBasedApp::RegisterServices() { registry_.GetService(), registry_.GetService(), registry_.GetService(), - registry_.GetService()); + registry_.GetService(), + registry_.GetService()); // Graphics service (facade) registry_.RegisterService( diff --git a/src/services/impl/bgfx_graphics_backend.cpp b/src/services/impl/bgfx_graphics_backend.cpp index f9eeade..d831195 100644 --- a/src/services/impl/bgfx_graphics_backend.cpp +++ b/src/services/impl/bgfx_graphics_backend.cpp @@ -275,11 +275,13 @@ std::optional RecommendFallbackRenderer( BgfxGraphicsBackend::BgfxGraphicsBackend(std::shared_ptr configService, std::shared_ptr platformService, std::shared_ptr logger, - std::shared_ptr pipelineCompiler) + std::shared_ptr pipelineCompiler, + std::shared_ptr 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& 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; } diff --git a/src/services/impl/bgfx_graphics_backend.hpp b/src/services/impl/bgfx_graphics_backend.hpp index ddf0cf4..949e201 100644 --- a/src/services/impl/bgfx_graphics_backend.hpp +++ b/src/services/impl/bgfx_graphics_backend.hpp @@ -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 #include @@ -19,7 +20,8 @@ public: BgfxGraphicsBackend(std::shared_ptr configService, std::shared_ptr platformService, std::shared_ptr logger, - std::shared_ptr pipelineCompiler); + std::shared_ptr pipelineCompiler, + std::shared_ptr probeService = nullptr); ~BgfxGraphicsBackend() override; void Initialize(void* window, const GraphicsConfig& config) override; @@ -157,6 +159,7 @@ private: std::shared_ptr platformService_; std::shared_ptr logger_; std::shared_ptr pipelineCompiler_; + std::shared_ptr probeService_; bgfx::VertexLayout vertexLayout_; std::unordered_map> pipelines_; std::unordered_map> vertexBuffers_; diff --git a/src/services/impl/config_compiler_service.cpp b/src/services/impl/config_compiler_service.cpp new file mode 100644 index 0000000..c07902c --- /dev/null +++ b/src/services/impl/config_compiler_service.cpp @@ -0,0 +1,579 @@ +#include "config_compiler_service.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include + +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 configService, + std::shared_ptr renderGraphService, + std::shared_ptr probeService, + std::shared_ptr 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 textureIds; + std::unordered_set 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> 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{}); + + 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.."); + } + } + 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 diff --git a/src/services/impl/config_compiler_service.hpp b/src/services/impl/config_compiler_service.hpp new file mode 100644 index 0000000..f0e4a79 --- /dev/null +++ b/src/services/impl/config_compiler_service.hpp @@ -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 + +namespace sdl3cpp::services::impl { + +class ConfigCompilerService : public IConfigCompilerService, public di::IInitializable { +public: + ConfigCompilerService(std::shared_ptr configService, + std::shared_ptr renderGraphService, + std::shared_ptr probeService, + std::shared_ptr 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 configService_; + std::shared_ptr renderGraphService_; + std::shared_ptr probeService_; + std::shared_ptr logger_; + ConfigCompilerResult lastResult_{}; +}; + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/json_config_service.cpp b/src/services/impl/json_config_service.cpp index ff10c66..3d33141 100644 --- a/src/services/impl/json_config_service.cpp +++ b/src/services/impl/json_config_service.cpp @@ -1,7 +1,9 @@ #include "json_config_service.hpp" #include "../interfaces/i_logger.hpp" #include +#include #include +#include #include #include #include @@ -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 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 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& 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 ReadVersionField(const rapidjson::Value& document, configPath.string()); } -void ValidateSchemaVersion(const rapidjson::Value& document, - const std::filesystem::path& configPath, - const std::shared_ptr& logger) { +std::optional ValidateSchemaVersion(const rapidjson::Value& document, + const std::filesystem::path& configPath, + const std::shared_ptr& 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& logger, + const std::shared_ptr& 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& logger, + const std::shared_ptr& 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 logger, const char* argv0) - : logger_(std::move(logger)), configJson_(), config_(RuntimeConfig{}) { +JsonConfigService::JsonConfigService(std::shared_ptr logger, + const char* argv0, + std::shared_ptr 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 logger, const char logger_->Info("JsonConfigService initialized with default configuration"); } -JsonConfigService::JsonConfigService(std::shared_ptr logger, const std::filesystem::path& configPath, bool dumpConfig) +JsonConfigService::JsonConfigService(std::shared_ptr logger, + const std::filesystem::path& configPath, + bool dumpConfig, + std::shared_ptr 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 logger, const std: logger_->Info("JsonConfigService initialized from config file: " + configPath.string()); } -JsonConfigService::JsonConfigService(std::shared_ptr logger, const RuntimeConfig& config) - : logger_(std::move(logger)), configJson_(BuildConfigJson(config, {})), config_(config) { +JsonConfigService::JsonConfigService(std::shared_ptr logger, + const RuntimeConfig& config, + std::shared_ptr 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 logger, + std::shared_ptr probeService, const std::filesystem::path& configPath, bool dumpConfig, std::string* configJson) { @@ -307,7 +413,22 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, std::unordered_set 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; diff --git a/src/services/impl/json_config_service.hpp b/src/services/impl/json_config_service.hpp index ffd0e04..66cef28 100644 --- a/src/services/impl/json_config_service.hpp +++ b/src/services/impl/json_config_service.hpp @@ -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 #include @@ -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 logger, const char* argv0); + JsonConfigService(std::shared_ptr logger, + const char* argv0, + std::shared_ptr 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 logger, const std::filesystem::path& configPath, bool dumpConfig); + JsonConfigService(std::shared_ptr logger, + const std::filesystem::path& configPath, + bool dumpConfig, + std::shared_ptr 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 logger, const RuntimeConfig& config); + JsonConfigService(std::shared_ptr logger, + const RuntimeConfig& config, + std::shared_ptr probeService = nullptr); // IConfigService interface implementation uint32_t GetWindowWidth() const override { @@ -144,12 +155,14 @@ public: private: std::shared_ptr logger_; + std::shared_ptr 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 logger, + std::shared_ptr probeService, const std::filesystem::path& configPath, bool dumpConfig, std::string* configJson); diff --git a/src/services/impl/probe_service.cpp b/src/services/impl/probe_service.cpp new file mode 100644 index 0000000..573e5b2 --- /dev/null +++ b/src/services/impl/probe_service.cpp @@ -0,0 +1,63 @@ +#include "probe_service.hpp" + +#include + +namespace sdl3cpp::services::impl { + +ProbeService::ProbeService(std::shared_ptr 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 ProbeService::DrainReports() { + std::vector 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 diff --git a/src/services/impl/probe_service.hpp b/src/services/impl/probe_service.hpp new file mode 100644 index 0000000..589958c --- /dev/null +++ b/src/services/impl/probe_service.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "../interfaces/i_probe_service.hpp" +#include "../interfaces/i_logger.hpp" +#include +#include + +namespace sdl3cpp::services::impl { + +class ProbeService : public IProbeService { +public: + explicit ProbeService(std::shared_ptr logger); + + void Report(const ProbeReport& report) override; + std::vector DrainReports() override; + void ClearReports() override; + +private: + std::string SeverityName(ProbeSeverity severity) const; + void LogReport(const ProbeReport& report) const; + + std::shared_ptr logger_; + std::vector reports_{}; +}; + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/render_graph_service.cpp b/src/services/impl/render_graph_service.cpp new file mode 100644 index 0000000..a689306 --- /dev/null +++ b/src/services/impl/render_graph_service.cpp @@ -0,0 +1,174 @@ +#include "render_graph_service.hpp" + +#include +#include +#include + +namespace sdl3cpp::services::impl { + +namespace { + +bool IsErrorSeverity(ProbeSeverity severity) { + return severity == ProbeSeverity::Error || severity == ProbeSeverity::Fatal; +} + +} // namespace + +RenderGraphService::RenderGraphService(std::shared_ptr logger, + std::shared_ptr 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 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> edges(renderGraph.passes.size()); + std::vector 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 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 diff --git a/src/services/impl/render_graph_service.hpp b/src/services/impl/render_graph_service.hpp new file mode 100644 index 0000000..c2d860a --- /dev/null +++ b/src/services/impl/render_graph_service.hpp @@ -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 + +namespace sdl3cpp::services::impl { + +class RenderGraphService : public IRenderGraphService { +public: + RenderGraphService(std::shared_ptr logger, + std::shared_ptr 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 logger_; + std::shared_ptr probeService_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/src/services/interfaces/config_ir_types.hpp b/src/services/interfaces/config_ir_types.hpp new file mode 100644 index 0000000..99e86d4 --- /dev/null +++ b/src/services/interfaces/config_ir_types.hpp @@ -0,0 +1,107 @@ +#pragma once + +#include "probe_types.hpp" +#include +#include +#include + +namespace sdl3cpp::services { + +struct SceneEntityIR { + std::string id; + std::vector componentTypes; + std::string jsonPath; +}; + +struct SceneIR { + std::vector 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 textures; + std::string jsonPath; +}; + +struct ResourceIR { + std::vector textures; + std::vector meshes; + std::vector shaders; + std::vector 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 inputs; + std::vector outputs; + std::string jsonPath; +}; + +struct RenderGraphIR { + std::vector passes; +}; + +struct RenderGraphBuildResult { + std::vector passOrder; + std::vector diagnostics; + bool success = true; +}; + +struct ConfigCompilerResult { + SceneIR scene; + ResourceIR resources; + RenderGraphIR renderGraph; + RenderGraphBuildResult renderGraphBuild; + std::vector diagnostics; + bool success = true; +}; + +} // namespace sdl3cpp::services diff --git a/src/services/interfaces/i_config_compiler_service.hpp b/src/services/interfaces/i_config_compiler_service.hpp new file mode 100644 index 0000000..1bee5e1 --- /dev/null +++ b/src/services/interfaces/i_config_compiler_service.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include "config_ir_types.hpp" +#include + +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 diff --git a/src/services/interfaces/i_probe_service.hpp b/src/services/interfaces/i_probe_service.hpp new file mode 100644 index 0000000..4fe3b15 --- /dev/null +++ b/src/services/interfaces/i_probe_service.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "probe_types.hpp" +#include + +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 DrainReports() = 0; + + /** + * @brief Clear all queued reports. + */ + virtual void ClearReports() = 0; +}; + +} // namespace sdl3cpp::services diff --git a/src/services/interfaces/i_render_graph_service.hpp b/src/services/interfaces/i_render_graph_service.hpp new file mode 100644 index 0000000..e566da0 --- /dev/null +++ b/src/services/interfaces/i_render_graph_service.hpp @@ -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 diff --git a/src/services/interfaces/probe_types.hpp b/src/services/interfaces/probe_types.hpp new file mode 100644 index 0000000..d8a3337 --- /dev/null +++ b/src/services/interfaces/probe_types.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include + +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