From b77d5123f2dbd1d0fe306a56b51eefd033d872b0 Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sun, 26 Jul 2026 20:47:08 +0800 Subject: [PATCH] Complete G0 AI-native foundation --- CMakeLists.txt | 151 +- CMakePresets.json | 6 +- .../Core/Application/ApplicationClock.cpp | 89 ++ .../Core/Application/ApplicationClock.h | 40 + .../Core/Application/ApplicationConfig.cpp | 680 +++++++++ .../Core/Application/ApplicationConfig.h | 140 ++ .../Core/Application/ApplicationLifecycle.cpp | 195 +++ .../Core/Application/ApplicationLifecycle.h | 71 + .../Core/Application/ApplicationRunner.cpp | 654 +++++++++ .../Core/Application/ApplicationRunner.h | 157 +++ Engine/Source/Core/Core.h | 3 - Engine/Source/Core/Engine.h | 2 + Engine/Source/Core/Paths/AtomicFileStore.cpp | 436 ++++++ Engine/Source/Core/Paths/AtomicFileStore.h | 63 + Engine/Source/Core/Paths/LegacyMigration.cpp | 589 ++++++++ Engine/Source/Core/Paths/LegacyMigration.h | 94 ++ Engine/Source/Core/Paths/PathService.cpp | 712 ++++++++++ Engine/Source/Core/Paths/PathService.h | 198 +++ .../Document/EditorDocumentLedger.cpp | 640 +++++++++ .../Developer/Document/EditorDocumentLedger.h | 159 +++ .../Document/EditorDomainServices.cpp | 275 ++++ .../Developer/Document/EditorDomainServices.h | 91 ++ .../Render/RenderSettingsAuthoringService.cpp | 458 ++++++ .../Render/RenderSettingsAuthoringService.h | 59 + .../Source/Developer/RenderDocIntegration.cpp | 16 +- .../Scene/CameraAuthoringService.cpp | 499 +++++++ .../Developer/Scene/CameraAuthoringService.h | 85 ++ .../Developer/Scene/EditorDomainContracts.cpp | 1224 +++++++++++++++++ .../Developer/Scene/EditorDomainContracts.h | 180 +++ .../Scene/EditorDomainMcpAdapter.cpp | 868 ++++++++++++ .../Developer/Scene/EditorDomainMcpAdapter.h | 46 + .../Scene/LightingAuthoringService.cpp | 354 +++++ .../Scene/LightingAuthoringService.h | 54 + .../Developer/Scene/SceneAuthoringService.cpp | 666 +++++++++ .../Developer/Scene/SceneAuthoringService.h | 154 +++ Engine/Source/Mcp/McpRegistry.cpp | 81 ++ Engine/Source/Mcp/McpRegistry.h | 9 + Engine/Source/RHI/D3D12/D3D12RHI.cpp | 20 +- Engine/Source/Scene/Mesh.cpp | 102 +- Engine/Source/Scene/Scene.cpp | 98 +- Engine/Source/Scene/Scene.h | 1 + Engine/Source/Scene/SceneSerializer.cpp | 174 +-- Engine/Source/Scene/SceneSerializer.h | 6 +- Engine/Source/Scene/WEMeshSerializer.cpp | 84 ++ Engine/Source/Scene/WEMeshSerializer.h | 6 +- Engine/Source/Shader/Shader.h | 7 +- Engine/Source/Shader/ShaderCompiler.cpp | 121 +- Engine/Source/Shader/ShaderCompiler.h | 2 + Engine/Source/Trace/TraceRecorder.cpp | 37 +- Engine/Source/UI/EditorApplication.cpp | 760 ++++++++++ Engine/Source/UI/EditorApplication.h | 3 + Engine/Source/UI/EditorAssetLoader.cpp | 23 +- Engine/Source/UI/EditorFrontend.cpp | 5 +- Engine/Source/UI/EditorFrontend.h | 2 +- Engine/Source/UI/EditorShell.cpp | 80 +- Engine/Source/UI/EditorShell.h | 11 +- Engine/Source/UI/EditorStyle.cpp | 6 +- Engine/Source/UI/ImGuiLayer.cpp | 196 ++- Engine/Source/UI/ImGuiLayer.h | 3 - Engine/Source/UI/Panels/AssetsPanel.cpp | 28 +- .../Source/UI/Panels/EngineSettingsPanel.cpp | 525 ++++--- Engine/Source/UI/Panels/EngineSettingsPanel.h | 40 +- Engine/Source/UI/Panels/HierarchyPanel.cpp | 131 +- Engine/Source/UI/Panels/HierarchyPanel.h | 10 - Engine/Source/UI/Panels/InspectorPanel.cpp | 382 ++--- Engine/Source/UI/Panels/InspectorPanel.h | 35 +- Engine/Source/UI/Panels/ProfilerPanel.cpp | 18 +- Engine/Source/UI/Panels/ViewportPanel.cpp | 202 +-- Engine/Source/UI/Panels/ViewportPanel.h | 12 - Engine/Source/UI/UiConfig.cpp | 55 +- Engine/Source/UI/UiContext.cpp | 140 ++ Engine/Source/UI/UiContext.h | 39 +- Engine/Source/WaveEditor.cpp | 204 +-- Tests/ApplicationContracts.cpp | 692 ++++++++++ Tests/ApplicationContracts.h | 6 + Tests/CoreRuntimeContracts.cpp | 4 + Tests/EditorDomainContractTests.cpp | 517 +++++++ Tests/EditorDomainContractTests.h | 6 + Tests/EditorDomainContractsMain.cpp | 22 + Tests/PathContracts.cpp | 842 ++++++++++++ Tests/PathContracts.h | 6 + Tests/cmake_preset_contracts.py | 8 +- Tests/editor_camera_authoring_contracts.py | 213 +++ Tests/editor_domain_target_contracts.py | 366 +++++ Tests/editor_render_authoring_contracts.py | 335 +++++ Tests/editor_scene_authoring_contracts.py | 225 +++ Tests/editor_window_lifecycle_contracts.py | 35 +- Tests/mcp_asset_input_contracts.py | 96 +- .../mcp_capability_registration_contracts.py | 13 +- Tests/path_target_contracts.py | 88 ++ Tests/runtime_target_contracts.py | 4 + Tests/wemesh_cache_contracts.py | 23 +- Tools/TraceViewer/ViewerMain.cpp | 130 +- ...26-07-25-game-engine-production-roadmap.md | 6 +- ...25-g0-02-engine-loop-application-config.md | 104 +- ...-07-25-g0-03-project-package-user-paths.md | 86 +- .../2026-07-25-m0-01-cpu-contract-baseline.md | 3 + ...-04-existing-editor-domain-capabilities.md | 73 +- docs/specs/README.md | 6 +- 99 files changed, 16373 insertions(+), 1302 deletions(-) create mode 100644 Engine/Source/Core/Application/ApplicationClock.cpp create mode 100644 Engine/Source/Core/Application/ApplicationClock.h create mode 100644 Engine/Source/Core/Application/ApplicationConfig.cpp create mode 100644 Engine/Source/Core/Application/ApplicationConfig.h create mode 100644 Engine/Source/Core/Application/ApplicationLifecycle.cpp create mode 100644 Engine/Source/Core/Application/ApplicationLifecycle.h create mode 100644 Engine/Source/Core/Application/ApplicationRunner.cpp create mode 100644 Engine/Source/Core/Application/ApplicationRunner.h create mode 100644 Engine/Source/Core/Paths/AtomicFileStore.cpp create mode 100644 Engine/Source/Core/Paths/AtomicFileStore.h create mode 100644 Engine/Source/Core/Paths/LegacyMigration.cpp create mode 100644 Engine/Source/Core/Paths/LegacyMigration.h create mode 100644 Engine/Source/Core/Paths/PathService.cpp create mode 100644 Engine/Source/Core/Paths/PathService.h create mode 100644 Engine/Source/Developer/Document/EditorDocumentLedger.cpp create mode 100644 Engine/Source/Developer/Document/EditorDocumentLedger.h create mode 100644 Engine/Source/Developer/Document/EditorDomainServices.cpp create mode 100644 Engine/Source/Developer/Document/EditorDomainServices.h create mode 100644 Engine/Source/Developer/Render/RenderSettingsAuthoringService.cpp create mode 100644 Engine/Source/Developer/Render/RenderSettingsAuthoringService.h create mode 100644 Engine/Source/Developer/Scene/CameraAuthoringService.cpp create mode 100644 Engine/Source/Developer/Scene/CameraAuthoringService.h create mode 100644 Engine/Source/Developer/Scene/EditorDomainContracts.cpp create mode 100644 Engine/Source/Developer/Scene/EditorDomainContracts.h create mode 100644 Engine/Source/Developer/Scene/EditorDomainMcpAdapter.cpp create mode 100644 Engine/Source/Developer/Scene/EditorDomainMcpAdapter.h create mode 100644 Engine/Source/Developer/Scene/LightingAuthoringService.cpp create mode 100644 Engine/Source/Developer/Scene/LightingAuthoringService.h create mode 100644 Engine/Source/Developer/Scene/SceneAuthoringService.cpp create mode 100644 Engine/Source/Developer/Scene/SceneAuthoringService.h create mode 100644 Engine/Source/UI/EditorApplication.cpp create mode 100644 Engine/Source/UI/EditorApplication.h create mode 100644 Tests/ApplicationContracts.cpp create mode 100644 Tests/ApplicationContracts.h create mode 100644 Tests/EditorDomainContractTests.cpp create mode 100644 Tests/EditorDomainContractTests.h create mode 100644 Tests/EditorDomainContractsMain.cpp create mode 100644 Tests/PathContracts.cpp create mode 100644 Tests/PathContracts.h create mode 100644 Tests/editor_camera_authoring_contracts.py create mode 100644 Tests/editor_domain_target_contracts.py create mode 100644 Tests/editor_render_authoring_contracts.py create mode 100644 Tests/editor_scene_authoring_contracts.py create mode 100644 Tests/path_target_contracts.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 8e9590f..31df6b1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,10 +13,8 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) add_compile_options("$<$:/utf-8>") add_compile_options("$<$:/utf-8>") -set(ENGINE_FOLDER "${PROJECT_SOURCE_DIR}/Engine") -set(SHADERS_FOLDER "${ENGINE_FOLDER}/Shaders") -set(ENGINE_TEMP_FOLDER "${ENGINE_FOLDER}/Save") -set(ASSETS_FOLDER "${PROJECT_SOURCE_DIR}/Assets") +set(WAVE_ENGINE_DIR "${PROJECT_SOURCE_DIR}/Engine") +set(WAVE_SHADER_DIR "${WAVE_ENGINE_DIR}/Shaders") file(GLOB_RECURSE WAVE_RUNTIME_HEADER CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/Engine/Source/Core/*.h" @@ -43,9 +41,11 @@ file(GLOB_RECURSE WAVE_RUNTIME_SOURCE CONFIGURE_DEPENDS list(APPEND WAVE_RUNTIME_SOURCE "${PROJECT_SOURCE_DIR}/Engine/Source/Window.cpp") list(REMOVE_ITEM WAVE_RUNTIME_HEADER + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Paths/LegacyMigration.h" "${PROJECT_SOURCE_DIR}/Engine/Source/Renderer/EditorCompositePass.h" "${PROJECT_SOURCE_DIR}/Engine/Source/Renderer/EditorSelectionPass.h") list(REMOVE_ITEM WAVE_RUNTIME_SOURCE + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Paths/LegacyMigration.cpp" "${PROJECT_SOURCE_DIR}/Engine/Source/Renderer/EditorCompositePass.cpp" "${PROJECT_SOURCE_DIR}/Engine/Source/Renderer/EditorSelectionPass.cpp") @@ -123,12 +123,12 @@ if (WIN32) ) endif() file(GLOB_RECURSE WAVE_RUNTIME_SHADERS CONFIGURE_DEPENDS - "${SHADERS_FOLDER}/*.hlsl" - "${SHADERS_FOLDER}/*.hlsli" - "${SHADERS_FOLDER}/*.h") + "${WAVE_SHADER_DIR}/*.hlsl" + "${WAVE_SHADER_DIR}/*.hlsli" + "${WAVE_SHADER_DIR}/*.h") set(WAVE_EDITOR_SHADERS - "${SHADERS_FOLDER}/EditorComposite.hlsl" - "${SHADERS_FOLDER}/EditorSelection.hlsl") + "${WAVE_SHADER_DIR}/EditorComposite.hlsl" + "${WAVE_SHADER_DIR}/EditorSelection.hlsl") list(REMOVE_ITEM WAVE_RUNTIME_SHADERS ${WAVE_EDITOR_SHADERS}) set(ENGINE_HEADER ${WAVE_RUNTIME_HEADER} ${WAVE_EDITOR_SUPPORT_HEADER}) @@ -162,12 +162,30 @@ set_property(TARGET WaveEditor PROPERTY COMPILE_WARNING_AS_ERROR ON) # or third-party source dependencies. set(WAVE_CORE_CONTRACT_SOURCES ${CORE_RUNTIME_CONTRACT_SUITE} + "${PROJECT_SOURCE_DIR}/Tests/ApplicationContracts.h" + "${PROJECT_SOURCE_DIR}/Tests/ApplicationContracts.cpp" "${PROJECT_SOURCE_DIR}/Tests/ContractCoreContracts.h" "${PROJECT_SOURCE_DIR}/Tests/ContractCoreContracts.cpp" + "${PROJECT_SOURCE_DIR}/Tests/PathContracts.h" + "${PROJECT_SOURCE_DIR}/Tests/PathContracts.cpp" "${PROJECT_SOURCE_DIR}/Tests/CoreRuntimeContractsMain.cpp" "${PROJECT_SOURCE_DIR}/Engine/Source/Contract/CapabilityRegistry.h" "${PROJECT_SOURCE_DIR}/Engine/Source/Contract/ContractCore.h" "${PROJECT_SOURCE_DIR}/Engine/Source/Contract/ContractCore.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Application/ApplicationClock.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Application/ApplicationClock.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Application/ApplicationConfig.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Application/ApplicationConfig.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Application/ApplicationLifecycle.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Application/ApplicationLifecycle.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Application/ApplicationRunner.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Application/ApplicationRunner.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Paths/PathService.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Paths/PathService.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Paths/AtomicFileStore.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Paths/AtomicFileStore.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Paths/LegacyMigration.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Paths/LegacyMigration.cpp" "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Engine.cpp" "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Profiler.cpp" "${PROJECT_SOURCE_DIR}/Engine/Source/Misc/Log.cpp" @@ -189,11 +207,34 @@ if(MSVC) target_compile_options(WaveCoreContracts PRIVATE /bigobj /Zc:preprocessor) endif() +# ---------------- WaveEditorDomainContracts ---------------- +# Explicit CPU-only sibling closure for G0-04 pure Editor-domain contracts. +# It compiles no Runtime Scene/Renderer, UI, MCP, RHI, PCH, or third-party source. +set(WAVE_EDITOR_DOMAIN_CONTRACT_SOURCES + "${PROJECT_SOURCE_DIR}/Tests/EditorDomainContractTests.h" + "${PROJECT_SOURCE_DIR}/Tests/EditorDomainContractTests.cpp" + "${PROJECT_SOURCE_DIR}/Tests/EditorDomainContractsMain.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Contract/ContractCore.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Contract/ContractCore.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Developer/Document/EditorDocumentLedger.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Developer/Document/EditorDocumentLedger.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Developer/Scene/EditorDomainContracts.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Developer/Scene/EditorDomainContracts.cpp") +add_executable(WaveEditorDomainContracts ${WAVE_EDITOR_DOMAIN_CONTRACT_SOURCES}) +set_property(TARGET WaveEditorDomainContracts PROPERTY COMPILE_WARNING_AS_ERROR ON) +target_include_directories(WaveEditorDomainContracts PRIVATE + "${PROJECT_SOURCE_DIR}/Engine/Source" + "${PROJECT_SOURCE_DIR}/Tests") +if(MSVC) + target_compile_options(WaveEditorDomainContracts PRIVATE /bigobj /Zc:preprocessor) +endif() + # ---------------- WaveTraceViewer ---------------- file(GLOB TRACE_VIEWER_SOURCES CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/Tools/TraceViewer/*.h" "${PROJECT_SOURCE_DIR}/Tools/TraceViewer/*.cpp") set(TRACE_VIEWER_SHARED + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/Paths/PathService.cpp" "${PROJECT_SOURCE_DIR}/Engine/Source/Trace/TraceCodec.cpp" "${PROJECT_SOURCE_DIR}/Engine/Source/Trace/TraceAnalysis.cpp" "${PROJECT_SOURCE_DIR}/Engine/Source/UI/EditorStyle.cpp" @@ -288,10 +329,6 @@ if (WIN32) endif() add_definitions(-DNOMINMAX) -add_definitions(-DENGINE_FOLDER="${ENGINE_FOLDER}/") -add_definitions(-DSHADERS_FOLDER="${SHADERS_FOLDER}/") -add_definitions(-DASSETS_FOLDER="${ASSETS_FOLDER}/") -add_definitions(-DENGINE_TEMP_FOLDER="${ENGINE_TEMP_FOLDER}/") source_group(TREE "${PROJECT_SOURCE_DIR}/Engine/Source" PREFIX "Runtime Source" FILES ${WAVE_RUNTIME_HEADER} ${WAVE_RUNTIME_SOURCE}) @@ -397,6 +434,34 @@ if(BUILD_TESTING) LABELS "contract;core;cpu" ) + add_test( + NAME WaveEngine.PathTargetContracts + COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/Tests/path_target_contracts.py" + "${PROJECT_SOURCE_DIR}" + ) + set_tests_properties( + WaveEngine.PathTargetContracts + PROPERTIES + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + TIMEOUT 10 + LABELS "contract;path;cpu" + ) + + add_test( + NAME WaveEngine.EditorDomainTargetBoundary + COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/Tests/editor_domain_target_contracts.py" + "${PROJECT_SOURCE_DIR}" + ) + set_tests_properties( + WaveEngine.EditorDomainTargetBoundary + PROPERTIES + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + TIMEOUT 10 + LABELS "contract;editor-domain;cpu" + ) + add_test( NAME WaveEngine.McpCapabilityRegistrationContracts COMMAND "${Python3_EXECUTABLE}" @@ -471,6 +536,54 @@ if(BUILD_TESTING) RUN_SERIAL TRUE ) + add_test( + NAME WaveEngine.EditorSceneAuthoringContracts + COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/Tests/editor_scene_authoring_contracts.py" + "$" + ) + set_tests_properties( + WaveEngine.EditorSceneAuthoringContracts + PROPERTIES + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + TIMEOUT 120 + LABELS "contract;editor-domain;mcp;gpu" + ENVIRONMENT "WAVE_RG_STRICT=1" + RUN_SERIAL TRUE + ) + + add_test( + NAME WaveEngine.EditorCameraAuthoringContracts + COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/Tests/editor_camera_authoring_contracts.py" + "$" + ) + set_tests_properties( + WaveEngine.EditorCameraAuthoringContracts + PROPERTIES + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + TIMEOUT 120 + LABELS "contract;editor-domain;mcp;gpu;camera" + ENVIRONMENT "WAVE_RG_STRICT=1" + RUN_SERIAL TRUE + ) + + add_test( + NAME WaveEngine.EditorRenderAuthoringContracts + COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/Tests/editor_render_authoring_contracts.py" + "$" + ) + set_tests_properties( + WaveEngine.EditorRenderAuthoringContracts + PROPERTIES + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + TIMEOUT 120 + LABELS "contract;editor-domain;mcp;gpu;render" + ENVIRONMENT "WAVE_RG_STRICT=1" + RUN_SERIAL TRUE + ) + add_test( NAME WaveEngine.WEMeshCacheContracts COMMAND "${Python3_EXECUTABLE}" @@ -501,4 +614,16 @@ if(BUILD_TESTING) TIMEOUT 10 LABELS "contract;core;cpu" ) + + add_test( + NAME WaveEngine.EditorDomainContracts + COMMAND "$" + ) + set_tests_properties( + WaveEngine.EditorDomainContracts + PROPERTIES + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + TIMEOUT 10 + LABELS "contract;editor-domain;cpu" + ) endif() diff --git a/CMakePresets.json b/CMakePresets.json index f59fa57..98982c0 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -28,7 +28,8 @@ "configurePreset": "vs2022-x64", "configuration": "Debug", "targets": [ - "WaveCoreContracts" + "WaveCoreContracts", + "WaveEditorDomainContracts" ] }, { @@ -37,7 +38,8 @@ "configurePreset": "vs2022-x64", "configuration": "Release", "targets": [ - "WaveCoreContracts" + "WaveCoreContracts", + "WaveEditorDomainContracts" ] } ], diff --git a/Engine/Source/Core/Application/ApplicationClock.cpp b/Engine/Source/Core/Application/ApplicationClock.cpp new file mode 100644 index 0000000..bcaa023 --- /dev/null +++ b/Engine/Source/Core/Application/ApplicationClock.cpp @@ -0,0 +1,89 @@ +#include "ApplicationClock.h" + +#include +#include +#include + +namespace WaveApplication +{ + FApplicationClock::FApplicationClock(FApplicationTimingConfig InConfig) + : Config(std::move(InConfig)) + { + if (!std::isfinite(Config.MaxDeltaSeconds) + || Config.MaxDeltaSeconds <= 0.0 + || Config.MaxDeltaSeconds > 0.25) + { + throw std::invalid_argument("Application clock maximum delta must be in (0, 0.25]."); + } + if (Config.FixedDeltaSeconds + && (!std::isfinite(*Config.FixedDeltaSeconds) + || *Config.FixedDeltaSeconds <= 0.0 + || *Config.FixedDeltaSeconds > 0.25)) + { + throw std::invalid_argument("Application clock fixed delta must be in (0, 0.25]."); + } + } + + FApplicationClockResult FApplicationClock::Tick(double MonotonicSeconds) + { + FApplicationClockResult Result; + if (!std::isfinite(MonotonicSeconds)) + { + Result.Diagnostics.push_back({ + "application.clock.non_finite", + "clock.monotonic_seconds", + "Monotonic clock sample must be finite.", + EApplicationDiagnosticSeverity::Fatal, + false, + }); + return Result; + } + if (PreviousSeconds && MonotonicSeconds < *PreviousSeconds) + { + Result.Diagnostics.push_back({ + "application.clock.regressed", + "clock.monotonic_seconds", + "Monotonic clock sample moved backwards.", + EApplicationDiagnosticSeverity::Fatal, + false, + }); + return Result; + } + + FApplicationClockSample Sample; + Sample.TickSequence = TickSequence; + Sample.RealTimeSeconds = MonotonicSeconds; + Sample.bFirstTick = !PreviousSeconds.has_value(); + Sample.RealDeltaSeconds = PreviousSeconds + ? MonotonicSeconds - *PreviousSeconds + : 1.0 / 60.0; + Sample.GameDeltaSeconds = std::min(Sample.RealDeltaSeconds, Config.MaxDeltaSeconds); + Sample.bClamped = Sample.RealDeltaSeconds > Config.MaxDeltaSeconds; + if (Config.FixedDeltaSeconds) + { + Sample.GameDeltaSeconds = *Config.FixedDeltaSeconds; + Sample.bFixed = true; + } + + PreviousSeconds = MonotonicSeconds; + ++TickSequence; + Result.Sample = Sample; + if (Sample.bClamped) + { + Result.Diagnostics.push_back({ + "application.clock.delta_clamped", + "clock.game_delta_seconds", + "Variable game delta was clamped to the configured maximum.", + EApplicationDiagnosticSeverity::Warning, + false, + }); + } + return Result; + } + + void FApplicationClock::Reset() + { + PreviousSeconds.reset(); + TickSequence = 0; + } +} diff --git a/Engine/Source/Core/Application/ApplicationClock.h b/Engine/Source/Core/Application/ApplicationClock.h new file mode 100644 index 0000000..f980f85 --- /dev/null +++ b/Engine/Source/Core/Application/ApplicationClock.h @@ -0,0 +1,40 @@ +#pragma once + +#include "ApplicationConfig.h" + +#include +#include + +namespace WaveApplication +{ + struct FApplicationClockSample + { + uint64 TickSequence = 0; + double RealTimeSeconds = 0.0; + double RealDeltaSeconds = 0.0; + double GameDeltaSeconds = 0.0; + bool bFirstTick = false; + bool bClamped = false; + bool bFixed = false; + }; + + struct FApplicationClockResult + { + std::optional Sample; + std::vector Diagnostics; + }; + + class FApplicationClock + { + public: + explicit FApplicationClock(FApplicationTimingConfig InConfig); + + [[nodiscard]] FApplicationClockResult Tick(double MonotonicSeconds); + void Reset(); + + private: + FApplicationTimingConfig Config; + std::optional PreviousSeconds; + uint64 TickSequence = 0; + }; +} diff --git a/Engine/Source/Core/Application/ApplicationConfig.cpp b/Engine/Source/Core/Application/ApplicationConfig.cpp new file mode 100644 index 0000000..a14137b --- /dev/null +++ b/Engine/Source/Core/Application/ApplicationConfig.cpp @@ -0,0 +1,680 @@ +#include "ApplicationConfig.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace WaveApplication +{ + namespace + { + constexpr size_t MaxConfigStringBytes = 256; + constexpr size_t MaxServiceCount = 64; + constexpr size_t MaxServiceIdBytes = 128; + constexpr uint32 MaxWindowExtent = 16384; + constexpr double MaxWallTimeSeconds = 86400.0; + + constexpr std::array ConfigFields = {{ + {"schema_major", EApplicationConfigFieldKind::Integer, "", "1", "must equal 1"}, + {"schema_minor", EApplicationConfigFieldKind::Integer, "", "0", "future minor is accepted with warning"}, + {"mode", EApplicationConfigFieldKind::Enum, "", "editor", "editor | contract_test"}, + {"build_identity", EApplicationConfigFieldKind::String, "", "development", "1..256 UTF-8 bytes"}, + {"path_profile", EApplicationConfigFieldKind::String, "", "editor_source", "1..256 UTF-8 bytes"}, + {"window.enabled", EApplicationConfigFieldKind::Boolean, "", "true", "required for editor"}, + {"window.title", EApplicationConfigFieldKind::String, "", "WaveEngine Editor", "1..256 UTF-8 bytes"}, + {"window.width", EApplicationConfigFieldKind::Integer, "pixels", "1600", "1..16384 when enabled"}, + {"window.height", EApplicationConfigFieldKind::Integer, "pixels", "900", "1..16384 when enabled"}, + {"render.enabled", EApplicationConfigFieldKind::Boolean, "", "true", "requires window in G0"}, + {"render.strict_validation", EApplicationConfigFieldKind::Boolean, "", "false", "boolean"}, + {"timing.fixed_delta_seconds", EApplicationConfigFieldKind::Number, "seconds", "none", "contract_test only; (0, 0.25]"}, + {"timing.max_delta_seconds", EApplicationConfigFieldKind::Number, "seconds", "0.25", "(0, 0.25]"}, + {"limits.max_completed_frames", EApplicationConfigFieldKind::Integer, "frames", "0", "editor only; 0 disables"}, + {"limits.max_ticks", EApplicationConfigFieldKind::Integer, "ticks", "0", "contract_test only; 0 disables"}, + {"limits.max_wall_seconds", EApplicationConfigFieldKind::Number, "seconds", "0", "0 disables; maximum 86400"}, + {"services", EApplicationConfigFieldKind::StringList, "", "", "comma-separated unique IDs; at most 64"}, + }}; + + FApplicationDiagnostic MakeDiagnostic( + std::string Code, + std::string Path, + std::string Message, + EApplicationDiagnosticSeverity Severity = EApplicationDiagnosticSeverity::Error) + { + return { + std::move(Code), + std::move(Path), + std::move(Message), + Severity, + false, + }; + } + + bool ParseBoolean(std::string_view Value, bool& OutValue) + { + if (Value == "true" || Value == "1") + { + OutValue = true; + return true; + } + if (Value == "false" || Value == "0") + { + OutValue = false; + return true; + } + return false; + } + + template + bool ParseUnsigned(std::string_view Value, T& OutValue) + { + if (Value.empty()) + { + return false; + } + T Parsed = 0; + const char* Begin = Value.data(); + const char* End = Begin + Value.size(); + const auto [Position, Error] = std::from_chars(Begin, End, Parsed); + if (Error != std::errc() || Position != End) + { + return false; + } + OutValue = Parsed; + return true; + } + + bool ParseNumber(std::string_view Value, double& OutValue) + { + if (Value.empty()) + { + return false; + } + double Parsed = 0.0; + const char* Begin = Value.data(); + const char* End = Begin + Value.size(); + const auto [Position, Error] = std::from_chars(Begin, End, Parsed); + if (Error != std::errc() || Position != End || !std::isfinite(Parsed)) + { + return false; + } + OutValue = Parsed; + return true; + } + + std::vector ParseStringList(std::string_view Value) + { + std::vector Result; + size_t Offset = 0; + while (Offset <= Value.size()) + { + const size_t Separator = Value.find(',', Offset); + const size_t Count = Separator == std::string_view::npos + ? Value.size() - Offset + : Separator - Offset; + const std::string_view Item = Value.substr(Offset, Count); + if (!Item.empty()) + { + Result.emplace_back(Item); + } + if (Separator == std::string_view::npos) + { + break; + } + Offset = Separator + 1; + } + return Result; + } + + bool IsValidServiceId(std::string_view ServiceId) + { + if (ServiceId.empty() || ServiceId.size() > MaxServiceIdBytes) + { + return false; + } + return std::all_of(ServiceId.begin(), ServiceId.end(), [](char Character) + { + const unsigned char Value = static_cast(Character); + return std::isalnum(Value) != 0 || Character == '.' || Character == '_' || Character == '-'; + }); + } + + void AppendDiagnostics( + std::vector& Target, + std::vector Source) + { + Target.insert( + Target.end(), + std::make_move_iterator(Source.begin()), + std::make_move_iterator(Source.end())); + } + + void HashBytes(uint64& Hash, std::string_view Value) + { + for (const unsigned char Byte : Value) + { + Hash ^= Byte; + Hash *= 1099511628211ull; + } + Hash ^= 0xffu; + Hash *= 1099511628211ull; + } + + template + void HashScalar(uint64& Hash, const T& Value) + { + const auto* Bytes = reinterpret_cast(&Value); + for (size_t Index = 0; Index < sizeof(T); ++Index) + { + Hash ^= Bytes[Index]; + Hash *= 1099511628211ull; + } + } + } + + bool FApplicationConfigResult::HasErrors() const + { + return std::any_of(Diagnostics.begin(), Diagnostics.end(), [](const FApplicationDiagnostic& Diagnostic) + { + return Diagnostic.Severity == EApplicationDiagnosticSeverity::Error + || Diagnostic.Severity == EApplicationDiagnosticSeverity::Fatal; + }); + } + + std::string_view ToString(EApplicationMode Mode) + { + switch (Mode) + { + case EApplicationMode::Editor: + return "editor"; + case EApplicationMode::ContractTest: + return "contract_test"; + } + return "unknown"; + } + + std::optional ParseApplicationMode(std::string_view Value) + { + if (Value == "editor") + { + return EApplicationMode::Editor; + } + if (Value == "contract_test") + { + return EApplicationMode::ContractTest; + } + return std::nullopt; + } + + FApplicationConfig MakeDefaultApplicationConfig(EApplicationMode Mode) + { + FApplicationConfig Config; + Config.Mode = Mode; + Config.Sources = { + {EApplicationConfigSource::CompiledDefault, 0}, + {EApplicationConfigSource::ModeProfile, 1}, + }; + if (Mode == EApplicationMode::ContractTest) + { + Config.PathProfile = "contract_test"; + Config.Window.bEnabled = false; + Config.Window.Width = 0; + Config.Window.Height = 0; + Config.Render.bEnabled = false; + Config.Limits.MaxTicks = 1; + } + return Config; + } + + std::span DescribeApplicationConfig() + { + return ConfigFields; + } + + FApplicationConfigResult ApplyApplicationConfigFields( + FApplicationConfig& Config, + std::span Fields, + EApplicationConfigSource Source) + { + FApplicationConfigResult Result; + if (!Config.Sources.empty() + && static_cast(Source) < static_cast(Config.Sources.back().Source)) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.precedence", + "sources", + "Config source precedence cannot move backwards.")); + return Result; + } + + FApplicationConfig Candidate = Config; + std::unordered_set SeenFields; + for (const FApplicationConfigField& Field : Fields) + { + if (!SeenFields.insert(Field.Name).second) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.duplicate_field", + Field.Name, + "Config layer contains a duplicate field.")); + continue; + } + + const std::string_view Value = Field.Value; + bool ParsedBoolean = false; + uint16 ParsedUint16 = 0; + uint32 ParsedUint32 = 0; + uint64 ParsedUint64 = 0; + double ParsedNumber = 0.0; + + if (Field.Name == "schema_major") + { + if (ParseUnsigned(Value, ParsedUint16)) + { + Candidate.SchemaMajor = ParsedUint16; + } + else + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.wrong_type", Field.Name, "Expected an unsigned integer.")); + } + } + else if (Field.Name == "schema_minor") + { + if (ParseUnsigned(Value, ParsedUint16)) + { + Candidate.SchemaMinor = ParsedUint16; + } + else + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.wrong_type", Field.Name, "Expected an unsigned integer.")); + } + } + else if (Field.Name == "mode") + { + const std::optional ParsedMode = ParseApplicationMode(Value); + if (ParsedMode) + { + Candidate.Mode = *ParsedMode; + } + else + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.unknown_mode", Field.Name, "Application mode is unavailable.")); + } + } + else if (Field.Name == "build_identity") + { + Candidate.BuildIdentity = Field.Value; + } + else if (Field.Name == "path_profile") + { + Candidate.PathProfile = Field.Value; + } + else if (Field.Name == "window.enabled") + { + if (ParseBoolean(Value, ParsedBoolean)) + { + Candidate.Window.bEnabled = ParsedBoolean; + } + else + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.wrong_type", Field.Name, "Expected a boolean.")); + } + } + else if (Field.Name == "window.title") + { + Candidate.Window.Title = Field.Value; + } + else if (Field.Name == "window.width") + { + if (ParseUnsigned(Value, ParsedUint32)) + { + Candidate.Window.Width = ParsedUint32; + } + else + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.wrong_type", Field.Name, "Expected an unsigned integer.")); + } + } + else if (Field.Name == "window.height") + { + if (ParseUnsigned(Value, ParsedUint32)) + { + Candidate.Window.Height = ParsedUint32; + } + else + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.wrong_type", Field.Name, "Expected an unsigned integer.")); + } + } + else if (Field.Name == "render.enabled") + { + if (ParseBoolean(Value, ParsedBoolean)) + { + Candidate.Render.bEnabled = ParsedBoolean; + } + else + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.wrong_type", Field.Name, "Expected a boolean.")); + } + } + else if (Field.Name == "render.strict_validation") + { + if (ParseBoolean(Value, ParsedBoolean)) + { + Candidate.Render.bStrictValidation = ParsedBoolean; + } + else + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.wrong_type", Field.Name, "Expected a boolean.")); + } + } + else if (Field.Name == "timing.fixed_delta_seconds") + { + if (Value == "none") + { + Candidate.Timing.FixedDeltaSeconds.reset(); + } + else if (ParseNumber(Value, ParsedNumber)) + { + Candidate.Timing.FixedDeltaSeconds = ParsedNumber; + } + else + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.wrong_type", Field.Name, "Expected a finite number or 'none'.")); + } + } + else if (Field.Name == "timing.max_delta_seconds") + { + if (ParseNumber(Value, ParsedNumber)) + { + Candidate.Timing.MaxDeltaSeconds = ParsedNumber; + } + else + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.wrong_type", Field.Name, "Expected a finite number.")); + } + } + else if (Field.Name == "limits.max_completed_frames") + { + if (ParseUnsigned(Value, ParsedUint64)) + { + Candidate.Limits.MaxCompletedFrames = ParsedUint64; + } + else + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.wrong_type", Field.Name, "Expected an unsigned integer.")); + } + } + else if (Field.Name == "limits.max_ticks") + { + if (ParseUnsigned(Value, ParsedUint64)) + { + Candidate.Limits.MaxTicks = ParsedUint64; + } + else + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.wrong_type", Field.Name, "Expected an unsigned integer.")); + } + } + else if (Field.Name == "limits.max_wall_seconds") + { + if (ParseNumber(Value, ParsedNumber)) + { + Candidate.Limits.MaxWallSeconds = ParsedNumber; + } + else + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.wrong_type", Field.Name, "Expected a finite number.")); + } + } + else if (Field.Name == "services") + { + Candidate.EnabledServices = ParseStringList(Value); + } + else + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.unknown_key", + Field.Name, + "Unknown application config field.")); + } + } + + if (!Result.HasErrors()) + { + AppendDiagnostics(Result.Diagnostics, ValidateApplicationConfig(Candidate)); + } + if (Result.HasErrors()) + { + return Result; + } + + if (Candidate.SchemaMinor > ApplicationConfigSchemaMinor) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "application.config.future_minor", + "schema_minor", + "Future schema minor accepted; unknown fields remain rejected in G0.", + EApplicationDiagnosticSeverity::Warning)); + } + const uint64 Sequence = Candidate.Sources.empty() ? 0 : Candidate.Sources.back().Sequence + 1; + Candidate.Sources.push_back({Source, Sequence}); + Config = std::move(Candidate); + Result.bApplied = true; + return Result; + } + + std::vector ValidateApplicationConfig(const FApplicationConfig& Config) + { + std::vector Diagnostics; + if (Config.SchemaMajor != ApplicationConfigSchemaMajor) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.major_mismatch", + "schema_major", + "Application config schema major is not supported.")); + } + if (Config.Mode != EApplicationMode::Editor + && Config.Mode != EApplicationMode::ContractTest) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.unknown_mode", + "mode", + "Application mode is unavailable.")); + } + if (Config.BuildIdentity.empty() || Config.BuildIdentity.size() > MaxConfigStringBytes) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.invalid_string", + "build_identity", + "Build identity must contain 1..256 bytes.")); + } + if (Config.PathProfile.empty() || Config.PathProfile.size() > MaxConfigStringBytes) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.invalid_string", + "path_profile", + "Path profile must contain 1..256 bytes.")); + } + if (Config.Window.bEnabled) + { + if (Config.Window.Title.empty() || Config.Window.Title.size() > MaxConfigStringBytes) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.invalid_string", + "window.title", + "Window title must contain 1..256 bytes.")); + } + if (Config.Window.Width == 0 || Config.Window.Width > MaxWindowExtent) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.range", + "window.width", + "Window width must be in [1, 16384].")); + } + if (Config.Window.Height == 0 || Config.Window.Height > MaxWindowExtent) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.range", + "window.height", + "Window height must be in [1, 16384].")); + } + } + if (Config.Render.bEnabled && !Config.Window.bEnabled) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.feature_dependency", + "render.enabled", + "Rendering requires an enabled window in G0.")); + } + if (!std::isfinite(Config.Timing.MaxDeltaSeconds) + || Config.Timing.MaxDeltaSeconds <= 0.0 + || Config.Timing.MaxDeltaSeconds > 0.25) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.range", + "timing.max_delta_seconds", + "Maximum delta must be finite and in (0, 0.25].")); + } + if (Config.Timing.FixedDeltaSeconds) + { + const double FixedDelta = *Config.Timing.FixedDeltaSeconds; + if (Config.Mode != EApplicationMode::ContractTest) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.mode_restriction", + "timing.fixed_delta_seconds", + "Fixed delta is only available in ContractTest mode.")); + } + if (!std::isfinite(FixedDelta) || FixedDelta <= 0.0 || FixedDelta > 0.25) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.range", + "timing.fixed_delta_seconds", + "Fixed delta must be finite and in (0, 0.25].")); + } + } + if (!std::isfinite(Config.Limits.MaxWallSeconds) + || Config.Limits.MaxWallSeconds < 0.0 + || Config.Limits.MaxWallSeconds > MaxWallTimeSeconds) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.range", + "limits.max_wall_seconds", + "Wall-time limit must be finite and in [0, 86400].")); + } + if (Config.Mode == EApplicationMode::Editor) + { + if (!Config.Window.bEnabled || !Config.Render.bEnabled) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.mode_profile", + "mode", + "Editor mode requires window and render services.")); + } + if (Config.Limits.MaxTicks != 0) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.mode_restriction", + "limits.max_ticks", + "Editor mode uses completed-frame limits, not max ticks.")); + } + } + else if (Config.Mode == EApplicationMode::ContractTest) + { + if (Config.Window.bEnabled || Config.Render.bEnabled) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.mode_profile", + "mode", + "ContractTest mode cannot create Window or RHI services.")); + } + if (Config.Limits.MaxCompletedFrames != 0) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.mode_restriction", + "limits.max_completed_frames", + "ContractTest mode uses tick limits, not completed render frames.")); + } + } + + if (Config.EnabledServices.size() > MaxServiceCount) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.limit", + "services", + "Enabled service count exceeds 64.")); + } + std::unordered_set UniqueServices; + for (size_t Index = 0; Index < Config.EnabledServices.size(); ++Index) + { + const std::string& ServiceId = Config.EnabledServices[Index]; + const std::string Path = "services[" + std::to_string(Index) + "]"; + if (!IsValidServiceId(ServiceId)) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.invalid_service", + Path, + "Service ID must contain 1..128 ASCII identifier bytes.")); + } + else if (!UniqueServices.insert(ServiceId).second) + { + Diagnostics.push_back(MakeDiagnostic( + "application.config.duplicate_service", + Path, + "Service IDs must be unique.")); + } + } + return Diagnostics; + } + + uint64 ComputeApplicationConfigHash(const FApplicationConfig& Config) + { + uint64 Hash = 14695981039346656037ull; + HashScalar(Hash, Config.SchemaMajor); + HashScalar(Hash, Config.SchemaMinor); + HashScalar(Hash, Config.Mode); + HashBytes(Hash, Config.BuildIdentity); + HashBytes(Hash, Config.PathProfile); + HashScalar(Hash, Config.Window.bEnabled); + HashBytes(Hash, Config.Window.Title); + HashScalar(Hash, Config.Window.Width); + HashScalar(Hash, Config.Window.Height); + HashScalar(Hash, Config.Render.bEnabled); + HashScalar(Hash, Config.Render.bStrictValidation); + const bool bHasFixedDelta = Config.Timing.FixedDeltaSeconds.has_value(); + HashScalar(Hash, bHasFixedDelta); + if (bHasFixedDelta) + { + HashScalar(Hash, *Config.Timing.FixedDeltaSeconds); + } + HashScalar(Hash, Config.Timing.MaxDeltaSeconds); + HashScalar(Hash, Config.Limits.MaxCompletedFrames); + HashScalar(Hash, Config.Limits.MaxTicks); + HashScalar(Hash, Config.Limits.MaxWallSeconds); + for (const std::string& ServiceId : Config.EnabledServices) + { + HashBytes(Hash, ServiceId); + } + return Hash; + } +} diff --git a/Engine/Source/Core/Application/ApplicationConfig.h b/Engine/Source/Core/Application/ApplicationConfig.h new file mode 100644 index 0000000..823135b --- /dev/null +++ b/Engine/Source/Core/Application/ApplicationConfig.h @@ -0,0 +1,140 @@ +#pragma once + +#include "Core/Core.h" + +#include +#include +#include +#include +#include + +namespace WaveApplication +{ + inline constexpr uint16 ApplicationConfigSchemaMajor = 1; + inline constexpr uint16 ApplicationConfigSchemaMinor = 0; + + enum class EApplicationMode : uint8 + { + Editor, + ContractTest, + }; + + enum class EApplicationConfigSource : uint8 + { + CompiledDefault, + ModeProfile, + ProjectConfig, + UserConfig, + ExplicitOverride, + }; + + enum class EApplicationDiagnosticSeverity : uint8 + { + Info, + Warning, + Error, + Fatal, + }; + + struct FApplicationDiagnostic + { + std::string Code; + std::string Path; + std::string Message; + EApplicationDiagnosticSeverity Severity = EApplicationDiagnosticSeverity::Error; + bool bRetryable = false; + }; + + struct FApplicationConfigSourceRecord + { + EApplicationConfigSource Source = EApplicationConfigSource::CompiledDefault; + uint64 Sequence = 0; + }; + + struct FApplicationWindowConfig + { + bool bEnabled = true; + std::string Title = "WaveEngine Editor"; + uint32 Width = 1600; + uint32 Height = 900; + }; + + struct FApplicationRenderConfig + { + bool bEnabled = true; + bool bStrictValidation = false; + }; + + struct FApplicationTimingConfig + { + std::optional FixedDeltaSeconds; + double MaxDeltaSeconds = 0.25; + }; + + struct FApplicationLimitConfig + { + uint64 MaxCompletedFrames = 0; + uint64 MaxTicks = 0; + double MaxWallSeconds = 0.0; + }; + + struct FApplicationConfig + { + uint16 SchemaMajor = ApplicationConfigSchemaMajor; + uint16 SchemaMinor = ApplicationConfigSchemaMinor; + EApplicationMode Mode = EApplicationMode::Editor; + std::string BuildIdentity = "development"; + std::string PathProfile = "editor_source"; + FApplicationWindowConfig Window; + FApplicationRenderConfig Render; + FApplicationTimingConfig Timing; + FApplicationLimitConfig Limits; + std::vector EnabledServices; + std::vector Sources; + }; + + struct FApplicationConfigField + { + std::string Name; + std::string Value; + }; + + enum class EApplicationConfigFieldKind : uint8 + { + Boolean, + Integer, + Number, + String, + Enum, + StringList, + }; + + struct FApplicationConfigFieldDescriptor + { + std::string_view Name; + EApplicationConfigFieldKind Kind = EApplicationConfigFieldKind::String; + std::string_view Unit; + std::string_view DefaultValue; + std::string_view Constraints; + }; + + struct FApplicationConfigResult + { + bool bApplied = false; + std::vector Diagnostics; + + [[nodiscard]] bool HasErrors() const; + }; + + [[nodiscard]] std::string_view ToString(EApplicationMode Mode); + [[nodiscard]] std::optional ParseApplicationMode(std::string_view Value); + [[nodiscard]] FApplicationConfig MakeDefaultApplicationConfig(EApplicationMode Mode); + [[nodiscard]] std::span DescribeApplicationConfig(); + [[nodiscard]] FApplicationConfigResult ApplyApplicationConfigFields( + FApplicationConfig& Config, + std::span Fields, + EApplicationConfigSource Source); + [[nodiscard]] std::vector ValidateApplicationConfig( + const FApplicationConfig& Config); + [[nodiscard]] uint64 ComputeApplicationConfigHash(const FApplicationConfig& Config); +} diff --git a/Engine/Source/Core/Application/ApplicationLifecycle.cpp b/Engine/Source/Core/Application/ApplicationLifecycle.cpp new file mode 100644 index 0000000..aaf854d --- /dev/null +++ b/Engine/Source/Core/Application/ApplicationLifecycle.cpp @@ -0,0 +1,195 @@ +#include "ApplicationLifecycle.h" + +#include +#include +#include + +namespace WaveApplication +{ + namespace + { + bool IsTransitionAllowed(EApplicationLifecycleState From, EApplicationLifecycleState To) + { + switch (From) + { + case EApplicationLifecycleState::Constructed: + return To == EApplicationLifecycleState::Configured + || To == EApplicationLifecycleState::Failed; + case EApplicationLifecycleState::Configured: + return To == EApplicationLifecycleState::Initializing + || To == EApplicationLifecycleState::Failed; + case EApplicationLifecycleState::Initializing: + return To == EApplicationLifecycleState::Running + || To == EApplicationLifecycleState::Failed; + case EApplicationLifecycleState::Running: + return To == EApplicationLifecycleState::StopRequested + || To == EApplicationLifecycleState::Failed; + case EApplicationLifecycleState::StopRequested: + return To == EApplicationLifecycleState::Failed + || To == EApplicationLifecycleState::Draining; + case EApplicationLifecycleState::Failed: + return To == EApplicationLifecycleState::Draining; + case EApplicationLifecycleState::Draining: + return To == EApplicationLifecycleState::Stopped + || To == EApplicationLifecycleState::Quarantined; + case EApplicationLifecycleState::Stopped: + case EApplicationLifecycleState::Quarantined: + return false; + } + return false; + } + } + + FApplicationLifecycle::FApplicationLifecycle(std::string InstanceId) + { + if (InstanceId.empty() || InstanceId.size() > 128) + { + throw std::invalid_argument("Application instance ID must contain 1..128 bytes."); + } + Status.InstanceId = std::move(InstanceId); + } + + void FApplicationLifecycle::Configure(const FApplicationConfig& Config) + { + const std::vector Diagnostics = ValidateApplicationConfig(Config); + if (!Diagnostics.empty()) + { + throw std::invalid_argument("Application config must be valid before lifecycle Configure."); + } + Status.Mode = Config.Mode; + Transition(EApplicationLifecycleState::Configured); + } + + void FApplicationLifecycle::BeginInitializing() + { + Transition(EApplicationLifecycleState::Initializing); + } + + void FApplicationLifecycle::MarkRunning() + { + Transition(EApplicationLifecycleState::Running); + } + + bool FApplicationLifecycle::RequestStop(FApplicationStopRecord Stop) + { + if (Status.State != EApplicationLifecycleState::Running + && Status.State != EApplicationLifecycleState::StopRequested) + { + return false; + } + if (!Status.FirstStop) + { + if (Stop.Source.empty() || Stop.Reason.empty() + || !std::isfinite(Stop.RealTimeSeconds)) + { + return false; + } + Status.FirstStop = std::move(Stop); + Touch(); + } + if (Status.State == EApplicationLifecycleState::Running) + { + Transition(EApplicationLifecycleState::StopRequested); + } + return true; + } + + void FApplicationLifecycle::MarkFailed(FApplicationDiagnostic Diagnostic, int32 ExitCode) + { + if (Status.State == EApplicationLifecycleState::Stopped + || Status.State == EApplicationLifecycleState::Quarantined) + { + throw std::logic_error("Cannot mark a terminal lifecycle as failed."); + } + Status.Diagnostics.push_back(std::move(Diagnostic)); + Status.ExitCode = ExitCode != 0 ? ExitCode : 1; + if (Status.State == EApplicationLifecycleState::Draining + || Status.State == EApplicationLifecycleState::Failed) + { + Touch(); + } + else + { + Transition(EApplicationLifecycleState::Failed); + } + } + + void FApplicationLifecycle::RecordDiagnostic(FApplicationDiagnostic Diagnostic) + { + if (Status.State == EApplicationLifecycleState::Stopped + || Status.State == EApplicationLifecycleState::Quarantined) + { + throw std::logic_error("Cannot append diagnostics to a terminal lifecycle."); + } + Status.Diagnostics.push_back(std::move(Diagnostic)); + Touch(); + } + + void FApplicationLifecycle::BeginDraining() + { + Transition(EApplicationLifecycleState::Draining); + } + + void FApplicationLifecycle::MarkGpuIdleProven() + { + if (Status.State != EApplicationLifecycleState::Draining) + { + throw std::logic_error("GPU idle proof is only valid while draining."); + } + if (!Status.bGpuIdleProven) + { + Status.bGpuIdleProven = true; + Touch(); + } + } + + void FApplicationLifecycle::MarkStopped() + { + if (!Status.bGpuIdleProven) + { + throw std::logic_error("Application cannot stop before idle or no-GPU proof."); + } + Transition(EApplicationLifecycleState::Stopped); + } + + void FApplicationLifecycle::MarkQuarantined(FApplicationDiagnostic Diagnostic, int32 ExitCode) + { + if (Status.State != EApplicationLifecycleState::Draining) + { + throw std::logic_error("Quarantine is only valid while draining."); + } + Status.Diagnostics.push_back(std::move(Diagnostic)); + Status.ExitCode = ExitCode != 0 ? ExitCode : 1; + Transition(EApplicationLifecycleState::Quarantined); + } + + void FApplicationLifecycle::RecordCompletedTick(bool bCompletedRenderFrame) + { + if (Status.State != EApplicationLifecycleState::Running + && Status.State != EApplicationLifecycleState::StopRequested) + { + throw std::logic_error("Completed ticks can only be recorded while running or stopping."); + } + ++Status.TickSequence; + if (bCompletedRenderFrame) + { + ++Status.CompletedFrameCount; + } + Touch(); + } + + void FApplicationLifecycle::Transition(EApplicationLifecycleState NewState) + { + if (!IsTransitionAllowed(Status.State, NewState)) + { + throw std::logic_error("Invalid application lifecycle transition."); + } + Status.State = NewState; + Touch(); + } + + void FApplicationLifecycle::Touch() + { + ++Status.Revision; + } +} diff --git a/Engine/Source/Core/Application/ApplicationLifecycle.h b/Engine/Source/Core/Application/ApplicationLifecycle.h new file mode 100644 index 0000000..be75c5b --- /dev/null +++ b/Engine/Source/Core/Application/ApplicationLifecycle.h @@ -0,0 +1,71 @@ +#pragma once + +#include "ApplicationConfig.h" + +#include +#include +#include + +namespace WaveApplication +{ + enum class EApplicationLifecycleState : uint8 + { + Constructed, + Configured, + Initializing, + Running, + StopRequested, + Failed, + Draining, + Stopped, + Quarantined, + }; + + struct FApplicationStopRecord + { + std::string Source; + std::string Reason; + double RealTimeSeconds = 0.0; + uint64 TickSequence = 0; + }; + + struct FApplicationStatusSnapshot + { + std::string InstanceId; + EApplicationMode Mode = EApplicationMode::Editor; + EApplicationLifecycleState State = EApplicationLifecycleState::Constructed; + uint64 Revision = 0; + uint64 TickSequence = 0; + uint64 CompletedFrameCount = 0; + int32 ExitCode = 0; + bool bGpuIdleProven = false; + std::optional FirstStop; + std::vector Diagnostics; + }; + + class FApplicationLifecycle + { + public: + explicit FApplicationLifecycle(std::string InstanceId); + + void Configure(const FApplicationConfig& Config); + void BeginInitializing(); + void MarkRunning(); + [[nodiscard]] bool RequestStop(FApplicationStopRecord Stop); + void MarkFailed(FApplicationDiagnostic Diagnostic, int32 ExitCode = 1); + void RecordDiagnostic(FApplicationDiagnostic Diagnostic); + void BeginDraining(); + void MarkGpuIdleProven(); + void MarkStopped(); + void MarkQuarantined(FApplicationDiagnostic Diagnostic, int32 ExitCode = 1); + void RecordCompletedTick(bool bCompletedRenderFrame); + + [[nodiscard]] const FApplicationStatusSnapshot& QueryStatus() const { return Status; } + + private: + void Transition(EApplicationLifecycleState NewState); + void Touch(); + + FApplicationStatusSnapshot Status; + }; +} diff --git a/Engine/Source/Core/Application/ApplicationRunner.cpp b/Engine/Source/Core/Application/ApplicationRunner.cpp new file mode 100644 index 0000000..cd8a967 --- /dev/null +++ b/Engine/Source/Core/Application/ApplicationRunner.cpp @@ -0,0 +1,654 @@ +#include "ApplicationRunner.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace WaveApplication +{ + namespace + { + constexpr std::array TickPhaseOrder = { + EApplicationTickPhase::InputEvents, + EApplicationTickPhase::ExternalRequests, + EApplicationTickPhase::Simulation, + EApplicationTickPhase::RenderPresent, + EApplicationTickPhase::EndFrame, + }; + + constexpr std::array PreIdleShutdownOrder = { + EApplicationShutdownClass::Adapter, + EApplicationShutdownClass::Worker, + }; + + constexpr std::array PostIdleShutdownOrder = { + EApplicationShutdownClass::GpuBacked, + EApplicationShutdownClass::RuntimeState, + EApplicationShutdownClass::Backend, + EApplicationShutdownClass::Platform, + }; + + bool ContainsMode( + const std::vector& Modes, + EApplicationMode Mode) + { + return Modes.empty() + || std::find(Modes.begin(), Modes.end(), Mode) != Modes.end(); + } + + bool ContainsPhase( + const std::vector& Phases, + EApplicationTickPhase Phase) + { + return std::find(Phases.begin(), Phases.end(), Phase) != Phases.end(); + } + + bool HasDuplicatePhases(const std::vector& Phases) + { + std::unordered_set Seen; + for (const EApplicationTickPhase Phase : Phases) + { + if (!Seen.insert(static_cast(Phase)).second) + { + return true; + } + } + return false; + } + + FApplicationDiagnostic MakeRunnerDiagnostic( + std::string Code, + std::string Path, + std::string Message, + EApplicationDiagnosticSeverity Severity = EApplicationDiagnosticSeverity::Fatal) + { + return { + std::move(Code), + std::move(Path), + std::move(Message), + Severity, + false, + }; + } + } + + FApplicationRunner::FApplicationRunner(FApplicationConfig InConfig, std::string InstanceId) + : Config(std::move(InConfig)) + , Clock(Config.Timing) + , Lifecycle(std::move(InstanceId)) + { + } + + void FApplicationRunner::RegisterService(IApplicationService& Service) + { + if (bGraphBuilt || Lifecycle.QueryStatus().State != EApplicationLifecycleState::Constructed) + { + throw std::logic_error("Application services must be registered before initialization."); + } + RegisteredServices.push_back(&Service); + } + + bool FApplicationRunner::Initialize(IApplicationIdleCoordinator& IdleCoordinator) + { + const std::vector ConfigDiagnostics = + ValidateApplicationConfig(Config); + if (!ConfigDiagnostics.empty()) + { + for (const FApplicationDiagnostic& Diagnostic : ConfigDiagnostics) + { + AddFailure(Diagnostic); + } + Shutdown(IdleCoordinator); + return false; + } + if (!BuildServiceGraph()) + { + Shutdown(IdleCoordinator); + return false; + } + + try + { + Lifecycle.Configure(Config); + Lifecycle.BeginInitializing(); + for (IApplicationService* Service : InitializationOrder) + { + EnteredInitialization.push_back(Service); + Service->Initialize(Config); + } + Lifecycle.MarkRunning(); + return true; + } + catch (const std::exception& Error) + { + AddFailure(MakeRunnerDiagnostic( + "application.service.init_failed", + "services", + Error.what())); + } + catch (...) + { + AddFailure(MakeRunnerDiagnostic( + "application.service.init_failed", + "services", + "Unknown service initialization failure.")); + } + Shutdown(IdleCoordinator); + return false; + } + + bool FApplicationRunner::Tick(double MonotonicSeconds) + { + if (Lifecycle.QueryStatus().State != EApplicationLifecycleState::Running) + { + return false; + } + + const FApplicationClockResult ClockResult = Clock.Tick(MonotonicSeconds); + for (const FApplicationDiagnostic& Diagnostic : ClockResult.Diagnostics) + { + if (Diagnostic.Severity == EApplicationDiagnosticSeverity::Fatal + || Diagnostic.Severity == EApplicationDiagnosticSeverity::Error) + { + AddFailure(Diagnostic); + return false; + } + Lifecycle.RecordDiagnostic(Diagnostic); + } + if (!ClockResult.Sample) + { + AddFailure(MakeRunnerDiagnostic( + "application.clock.no_sample", + "clock", + "Application clock produced no sample.")); + return false; + } + + if (!StartRealTimeSeconds) + { + StartRealTimeSeconds = ClockResult.Sample->RealTimeSeconds; + } + LastRealTimeSeconds = ClockResult.Sample->RealTimeSeconds; + + bool bCompletedRenderFrame = false; + try + { + for (const EApplicationTickPhase Phase : TickPhaseOrder) + { + const FApplicationTickContext Context{Phase, *ClockResult.Sample}; + for (IApplicationService* Service : InitializationOrder) + { + if (!ContainsPhase(Service->GetDescriptor().TickPhases, Phase)) + { + continue; + } + FApplicationServiceTickResult Result = Service->Tick(Context); + for (FApplicationDiagnostic& Diagnostic : Result.Diagnostics) + { + if (Diagnostic.Severity == EApplicationDiagnosticSeverity::Fatal + || Diagnostic.Severity == EApplicationDiagnosticSeverity::Error) + { + AddFailure(std::move(Diagnostic)); + return false; + } + Lifecycle.RecordDiagnostic(std::move(Diagnostic)); + } + bCompletedRenderFrame = bCompletedRenderFrame || Result.bCompletedRenderFrame; + if (Result.StopRequest) + { + (void)RequestStop( + std::move(Result.StopRequest->Source), + std::move(Result.StopRequest->Reason)); + } + } + } + Lifecycle.RecordCompletedTick(bCompletedRenderFrame); + } + catch (const std::exception& Error) + { + AddFailure(MakeRunnerDiagnostic( + "application.service.tick_failed", + "services", + Error.what())); + return false; + } + catch (...) + { + AddFailure(MakeRunnerDiagnostic( + "application.service.tick_failed", + "services", + "Unknown service tick failure.")); + return false; + } + + const FApplicationStatusSnapshot& Status = Lifecycle.QueryStatus(); + if (Status.State == EApplicationLifecycleState::Running + && Config.Mode == EApplicationMode::Editor + && Config.Limits.MaxCompletedFrames > 0 + && Status.CompletedFrameCount >= Config.Limits.MaxCompletedFrames) + { + (void)RequestStop("application.limit", "max_completed_frames"); + } + if (Lifecycle.QueryStatus().State == EApplicationLifecycleState::Running + && Config.Mode == EApplicationMode::ContractTest + && Config.Limits.MaxTicks > 0 + && Lifecycle.QueryStatus().TickSequence >= Config.Limits.MaxTicks) + { + (void)RequestStop("application.limit", "max_ticks"); + } + if (Lifecycle.QueryStatus().State == EApplicationLifecycleState::Running + && Config.Limits.MaxWallSeconds > 0.0 + && ClockResult.Sample->RealTimeSeconds - *StartRealTimeSeconds >= Config.Limits.MaxWallSeconds) + { + (void)RequestStop("application.limit", "max_wall_seconds"); + } + return Lifecycle.QueryStatus().State == EApplicationLifecycleState::Running; + } + + bool FApplicationRunner::RequestStop(std::string Source, std::string Reason) + { + const FApplicationStatusSnapshot& Status = Lifecycle.QueryStatus(); + const double RealTimeSeconds = LastRealTimeSeconds.value_or( + StartRealTimeSeconds.value_or(0.0)); + return Lifecycle.RequestStop({ + std::move(Source), + std::move(Reason), + RealTimeSeconds, + Status.TickSequence, + }); + } + + FApplicationRunEvidence FApplicationRunner::QueryEvidence() const + { + FApplicationRunEvidence Evidence; + Evidence.ConfigHash = ComputeApplicationConfigHash(Config); + Evidence.BuildIdentity = Config.BuildIdentity; + Evidence.PathProfile = Config.PathProfile; + Evidence.bWindowEnabled = Config.Window.bEnabled; + Evidence.bRenderEnabled = Config.Render.bEnabled; + Evidence.ConfigSources = Config.Sources; + Evidence.ResolvedServiceIds.reserve(InitializationOrder.size()); + for (const IApplicationService* Service : InitializationOrder) + { + Evidence.ResolvedServiceIds.push_back( + Service->GetDescriptor().ServiceId); + } + Evidence.Status = Lifecycle.QueryStatus(); + return Evidence; + } + + void FApplicationRunner::Shutdown(IApplicationIdleCoordinator& IdleCoordinator) + { + if (bShutdownStarted + || Lifecycle.QueryStatus().State == EApplicationLifecycleState::Stopped + || Lifecycle.QueryStatus().State == EApplicationLifecycleState::Quarantined) + { + return; + } + bShutdownStarted = true; + + if (Lifecycle.QueryStatus().State == EApplicationLifecycleState::Constructed + || Lifecycle.QueryStatus().State == EApplicationLifecycleState::Configured + || Lifecycle.QueryStatus().State == EApplicationLifecycleState::Initializing) + { + AddFailure(MakeRunnerDiagnostic( + "application.runner.shutdown_before_running", + "lifecycle", + "Application shutdown began before reaching Running.")); + } + if (Lifecycle.QueryStatus().State == EApplicationLifecycleState::Running) + { + (void)RequestStop("application.runner", "shutdown"); + } + if (Lifecycle.QueryStatus().State == EApplicationLifecycleState::StopRequested + || Lifecycle.QueryStatus().State == EApplicationLifecycleState::Failed) + { + Lifecycle.BeginDraining(); + } + + RequestStopAllServices(); + RequestStopAndDrainPreIdle(); + + FApplicationIdleResult IdleResult; + try + { + IdleResult = IdleCoordinator.WaitForIdle(); + } + catch (const std::exception& Error) + { + IdleResult.Diagnostics.push_back(MakeRunnerDiagnostic( + "application.gpu.idle_failed", + "gpu_idle", + Error.what())); + } + catch (...) + { + IdleResult.Diagnostics.push_back(MakeRunnerDiagnostic( + "application.gpu.idle_failed", + "gpu_idle", + "Unknown idle coordinator failure.")); + } + + if (!IdleResult.bIdleProven) + { + FApplicationDiagnostic Diagnostic = IdleResult.Diagnostics.empty() + ? MakeRunnerDiagnostic( + "application.gpu.idle_unproven", + "gpu_idle", + "GPU idle was not proven.") + : std::move(IdleResult.Diagnostics.front()); + for (size_t Index = 1; Index < IdleResult.Diagnostics.size(); ++Index) + { + Lifecycle.RecordDiagnostic(std::move(IdleResult.Diagnostics[Index])); + } + Lifecycle.MarkQuarantined(std::move(Diagnostic)); + return; + } + + for (FApplicationDiagnostic& Diagnostic : IdleResult.Diagnostics) + { + Lifecycle.RecordDiagnostic(std::move(Diagnostic)); + } + Lifecycle.MarkGpuIdleProven(); + if (bShutdownFailure) + { + Lifecycle.MarkQuarantined(MakeRunnerDiagnostic( + "application.shutdown.pre_idle_failed", + "services", + "A service failed to stop or drain before GPU idle; unsafe teardown was skipped.")); + return; + } + ShutdownPostIdle(); + if (bShutdownFailure) + { + Lifecycle.MarkQuarantined(MakeRunnerDiagnostic( + "application.shutdown.teardown_failed", + "services", + "Service teardown did not complete; remaining teardown was skipped.")); + return; + } + Lifecycle.MarkStopped(); + } + + bool FApplicationRunner::BuildServiceGraph() + { + bGraphBuilt = true; + std::unordered_map ServicesById; + for (IApplicationService* Service : RegisteredServices) + { + const FApplicationServiceDescriptor& Descriptor = Service->GetDescriptor(); + if (Descriptor.ServiceId.empty() || Descriptor.ServiceId.size() > 128) + { + AddFailure(MakeRunnerDiagnostic( + "application.service.invalid_id", + "services", + "Service ID must contain 1..128 bytes.")); + return false; + } + if (Descriptor.VersionMajor == 0) + { + AddFailure(MakeRunnerDiagnostic( + "application.service.invalid_version", + "services." + Descriptor.ServiceId, + "Service version major must be nonzero.")); + return false; + } + if (!ServicesById.emplace(Descriptor.ServiceId, Service).second) + { + AddFailure(MakeRunnerDiagnostic( + "application.service.duplicate", + "services." + Descriptor.ServiceId, + "Service ID is already registered.")); + return false; + } + if (HasDuplicatePhases(Descriptor.TickPhases)) + { + AddFailure(MakeRunnerDiagnostic( + "application.service.duplicate_phase", + "services." + Descriptor.ServiceId, + "Service descriptor contains a duplicate tick phase.")); + return false; + } + for (const EApplicationTickPhase Phase : Descriptor.TickPhases) + { + if (static_cast(Phase) + > static_cast(EApplicationTickPhase::EndFrame)) + { + AddFailure(MakeRunnerDiagnostic( + "application.service.invalid_phase", + "services." + Descriptor.ServiceId, + "Service descriptor contains an invalid tick phase.")); + return false; + } + } + std::unordered_set UniqueDependencies; + for (const std::string& Dependency : Descriptor.Dependencies) + { + if (!UniqueDependencies.insert(Dependency).second) + { + AddFailure(MakeRunnerDiagnostic( + "application.service.duplicate_dependency", + "services." + Descriptor.ServiceId, + "Service descriptor contains a duplicate dependency.")); + return false; + } + } + } + + std::unordered_map InDegree; + std::unordered_map> Dependents; + for (const auto& [ServiceId, Service] : ServicesById) + { + const FApplicationServiceDescriptor& Descriptor = Service->GetDescriptor(); + if (!IsServiceEnabled(Descriptor)) + { + continue; + } + InDegree.emplace(ServiceId, 0); + } + for (const std::string& RequestedServiceId : Config.EnabledServices) + { + const auto ServiceIt = ServicesById.find(RequestedServiceId); + if (ServiceIt == ServicesById.end() + || !IsServiceEnabled(ServiceIt->second->GetDescriptor())) + { + AddFailure(MakeRunnerDiagnostic( + "application.service.unavailable", + "services." + RequestedServiceId, + "Configured service is not registered or unavailable for this mode.")); + return false; + } + } + + for (const auto& [ServiceId, Service] : ServicesById) + { + const FApplicationServiceDescriptor& Descriptor = Service->GetDescriptor(); + if (!IsServiceEnabled(Descriptor)) + { + continue; + } + for (const std::string& DependencyId : Descriptor.Dependencies) + { + const auto DependencyIt = ServicesById.find(DependencyId); + if (DependencyIt == ServicesById.end() + || !IsServiceEnabled(DependencyIt->second->GetDescriptor())) + { + AddFailure(MakeRunnerDiagnostic( + "application.service.missing_dependency", + "services." + ServiceId, + "Required service dependency is unavailable: " + DependencyId)); + return false; + } + if (static_cast(Descriptor.ShutdownClass) + > static_cast(DependencyIt->second->GetDescriptor().ShutdownClass)) + { + AddFailure(MakeRunnerDiagnostic( + "application.service.shutdown_order", + "services." + ServiceId, + "Dependent service must shut down no later than its dependency.")); + return false; + } + ++InDegree[ServiceId]; + Dependents[DependencyId].push_back(ServiceId); + } + } + + std::set Ready; + for (const auto& [ServiceId, Degree] : InDegree) + { + if (Degree == 0) + { + Ready.insert(ServiceId); + } + } + while (!Ready.empty()) + { + const std::string ServiceId = *Ready.begin(); + Ready.erase(Ready.begin()); + InitializationOrder.push_back(ServicesById.at(ServiceId)); + for (const std::string& DependentId : Dependents[ServiceId]) + { + size_t& Degree = InDegree[DependentId]; + --Degree; + if (Degree == 0) + { + Ready.insert(DependentId); + } + } + } + if (InitializationOrder.size() != InDegree.size()) + { + AddFailure(MakeRunnerDiagnostic( + "application.service.cycle", + "services", + "Service dependency graph contains a cycle.")); + return false; + } + return true; + } + + bool FApplicationRunner::IsServiceEnabled( + const FApplicationServiceDescriptor& Descriptor) const + { + if (!ContainsMode(Descriptor.AllowedModes, Config.Mode)) + { + return false; + } + if (Descriptor.bRequiresWindow && !Config.Window.bEnabled) + { + return false; + } + if (Descriptor.bRequiresRender && !Config.Render.bEnabled) + { + return false; + } + return Config.EnabledServices.empty() + || std::find( + Config.EnabledServices.begin(), + Config.EnabledServices.end(), + Descriptor.ServiceId) != Config.EnabledServices.end(); + } + + void FApplicationRunner::AddFailure(FApplicationDiagnostic Diagnostic) + { + try + { + Lifecycle.MarkFailed(std::move(Diagnostic)); + } + catch (const std::logic_error&) + { + throw; + } + } + + void FApplicationRunner::RequestStopAllServices() + { + for (auto It = EnteredInitialization.rbegin(); It != EnteredInitialization.rend(); ++It) + { + IApplicationService* Service = *It; + try + { + Service->RequestStop(); + } + catch (const std::exception& Error) + { + bShutdownFailure = true; + Lifecycle.MarkFailed(MakeRunnerDiagnostic( + "application.service.stop_failed", + "services." + Service->GetDescriptor().ServiceId, + Error.what())); + } + catch (...) + { + bShutdownFailure = true; + Lifecycle.MarkFailed(MakeRunnerDiagnostic( + "application.service.stop_failed", + "services." + Service->GetDescriptor().ServiceId, + "Unknown service stop failure.")); + } + } + } + + void FApplicationRunner::DrainAndShutdownClass( + EApplicationShutdownClass ShutdownClass) + { + for (auto It = EnteredInitialization.rbegin(); It != EnteredInitialization.rend(); ++It) + { + IApplicationService* Service = *It; + if (Service->GetDescriptor().ShutdownClass != ShutdownClass) + { + continue; + } + try + { + Service->Drain(); + Service->Shutdown(); + } + catch (const std::exception& Error) + { + bShutdownFailure = true; + Lifecycle.MarkFailed(MakeRunnerDiagnostic( + "application.service.shutdown_failed", + "services." + Service->GetDescriptor().ServiceId, + Error.what())); + } + catch (...) + { + bShutdownFailure = true; + Lifecycle.MarkFailed(MakeRunnerDiagnostic( + "application.service.shutdown_failed", + "services." + Service->GetDescriptor().ServiceId, + "Unknown service shutdown failure.")); + } + } + } + + void FApplicationRunner::RequestStopAndDrainPreIdle() + { + for (const EApplicationShutdownClass ShutdownClass : PreIdleShutdownOrder) + { + DrainAndShutdownClass(ShutdownClass); + if (bShutdownFailure) + { + return; + } + } + } + + void FApplicationRunner::ShutdownPostIdle() + { + for (const EApplicationShutdownClass ShutdownClass : PostIdleShutdownOrder) + { + DrainAndShutdownClass(ShutdownClass); + if (bShutdownFailure) + { + return; + } + } + } +} diff --git a/Engine/Source/Core/Application/ApplicationRunner.h b/Engine/Source/Core/Application/ApplicationRunner.h new file mode 100644 index 0000000..d63bb56 --- /dev/null +++ b/Engine/Source/Core/Application/ApplicationRunner.h @@ -0,0 +1,157 @@ +#pragma once + +#include "ApplicationClock.h" +#include "ApplicationLifecycle.h" + +#include +#include +#include +#include + +namespace WaveApplication +{ + enum class EApplicationTickPhase : uint8 + { + InputEvents, + ExternalRequests, + Simulation, + RenderPresent, + EndFrame, + }; + + enum class EApplicationShutdownClass : uint8 + { + Adapter, + Worker, + GpuBacked, + RuntimeState, + Backend, + Platform, + }; + + struct FApplicationServiceDescriptor + { + std::string ServiceId; + uint16 VersionMajor = 1; + uint16 VersionMinor = 0; + std::vector Dependencies; + std::vector AllowedModes; + std::vector TickPhases; + EApplicationShutdownClass ShutdownClass = EApplicationShutdownClass::RuntimeState; + bool bRequiresWindow = false; + bool bRequiresRender = false; + }; + + struct FApplicationStopRequest + { + std::string Source; + std::string Reason; + }; + + struct FApplicationTickContext + { + EApplicationTickPhase Phase = EApplicationTickPhase::InputEvents; + FApplicationClockSample Clock; + }; + + struct FApplicationServiceTickResult + { + bool bCompletedRenderFrame = false; + std::optional StopRequest; + std::vector Diagnostics; + }; + + class IApplicationService + { + public: + virtual ~IApplicationService() = default; + [[nodiscard]] virtual const FApplicationServiceDescriptor& GetDescriptor() const = 0; + virtual void Initialize(const FApplicationConfig& Config) = 0; + virtual FApplicationServiceTickResult Tick(const FApplicationTickContext& Context) = 0; + virtual void RequestStop() = 0; + virtual void Drain() = 0; + virtual void Shutdown() = 0; + }; + + struct FApplicationIdleResult + { + bool bIdleProven = false; + std::vector Diagnostics; + }; + + class IApplicationIdleCoordinator + { + public: + virtual ~IApplicationIdleCoordinator() = default; + [[nodiscard]] virtual FApplicationIdleResult WaitForIdle() = 0; + }; + + class FNoGpuIdleCoordinator final : public IApplicationIdleCoordinator + { + public: + [[nodiscard]] FApplicationIdleResult WaitForIdle() override + { + return {true, {}}; + } + }; + + inline constexpr uint16 ApplicationRunEvidenceSchemaMajor = 1; + inline constexpr uint16 ApplicationRunEvidenceSchemaMinor = 0; + + struct FApplicationRunEvidence + { + uint16 SchemaMajor = ApplicationRunEvidenceSchemaMajor; + uint16 SchemaMinor = ApplicationRunEvidenceSchemaMinor; + uint64 ConfigHash = 0; + std::string BuildIdentity; + std::string PathProfile; + bool bWindowEnabled = false; + bool bRenderEnabled = false; + std::vector ConfigSources; + std::vector ResolvedServiceIds; + FApplicationStatusSnapshot Status; + }; + + class FApplicationRunner + { + public: + FApplicationRunner(FApplicationConfig Config, std::string InstanceId); + + void RegisterService(IApplicationService& Service); + [[nodiscard]] bool Initialize(IApplicationIdleCoordinator& IdleCoordinator); + [[nodiscard]] bool Tick(double MonotonicSeconds); + [[nodiscard]] bool RequestStop(std::string Source, std::string Reason); + void Shutdown(IApplicationIdleCoordinator& IdleCoordinator); + + [[nodiscard]] const FApplicationStatusSnapshot& QueryStatus() const + { + return Lifecycle.QueryStatus(); + } + [[nodiscard]] FApplicationRunEvidence QueryEvidence() const; + [[nodiscard]] std::span GetInitializationOrder() const + { + return InitializationOrder; + } + + private: + [[nodiscard]] bool BuildServiceGraph(); + [[nodiscard]] bool IsServiceEnabled(const FApplicationServiceDescriptor& Descriptor) const; + void AddFailure(FApplicationDiagnostic Diagnostic); + void RequestStopAllServices(); + void DrainAndShutdownClass(EApplicationShutdownClass ShutdownClass); + void RequestStopAndDrainPreIdle(); + void ShutdownPostIdle(); + + FApplicationConfig Config; + FApplicationClock Clock; + FApplicationLifecycle Lifecycle; + std::vector RegisteredServices; + std::vector InitializationOrder; + std::vector EnteredInitialization; + bool bGraphBuilt = false; + bool bShutdownStarted = false; + bool bShutdownFailure = false; + std::optional StartRealTimeSeconds; + std::optional LastRealTimeSeconds; + }; +} diff --git a/Engine/Source/Core/Core.h b/Engine/Source/Core/Core.h index 760e566..2e59c9e 100644 --- a/Engine/Source/Core/Core.h +++ b/Engine/Source/Core/Core.h @@ -61,6 +61,3 @@ constexpr T Align(T Val, uint64 Alignment) { return static_cast(((uint64)Val + Alignment - 1) & ~(Alignment - 1)); } - -inline const std::string GShaderFolder = SHADERS_FOLDER; -inline const std::string GShaderTempFolder = ENGINE_TEMP_FOLDER + std::string("Shaders/"); diff --git a/Engine/Source/Core/Engine.h b/Engine/Source/Core/Engine.h index acae720..5e7c450 100644 --- a/Engine/Source/Core/Engine.h +++ b/Engine/Source/Core/Engine.h @@ -4,6 +4,8 @@ struct FGlobalEngineContext { + // Compatibility telemetry for existing renderer/UI consumers. Application + // config, lifecycle, clock and stop state are owned by FApplicationRunner. uint64 FrameIndex = 0; double CurrentFPS = 0.0; double FrameTimeMs = 0.0; diff --git a/Engine/Source/Core/Paths/AtomicFileStore.cpp b/Engine/Source/Core/Paths/AtomicFileStore.cpp new file mode 100644 index 0000000..804fb1c --- /dev/null +++ b/Engine/Source/Core/Paths/AtomicFileStore.cpp @@ -0,0 +1,436 @@ +#include "AtomicFileStore.h" + +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#endif + +namespace WavePaths +{ + namespace + { + std::atomic GTemporarySequence{0}; + + enum class EWriteAndFlushFailure : uint8 + { + None, + Open, + Write, + Flush, + Close, + InjectedWrite, + InjectedFlush, + }; + + FPathDiagnostic MakeStoreDiagnostic( + std::string Code, + std::string Message, + bool bRetryable = false) + { + return { + std::move(Code), + "target", + std::move(Message), + EPathDiagnosticSeverity::Error, + bRetryable, + }; + } + + std::filesystem::path MakeTemporaryPath(const std::filesystem::path& Target) + { +#if defined(_WIN32) + const uint64 ProcessId = static_cast(GetCurrentProcessId()); +#else + const uint64 ProcessId = 0; +#endif + const uint64 Sequence = GTemporarySequence.fetch_add(1, std::memory_order_relaxed); + return Target.parent_path() + / (Target.filename().string() + + ".wave-tmp-" + + std::to_string(ProcessId) + + "-" + + std::to_string(Sequence)); + } + + void RemoveNoThrow(const std::filesystem::path& Path) + { + if (Path.empty()) + { + return; + } + std::error_code Error; + std::filesystem::remove(Path, Error); + } + + bool WriteAndFlushFile( + const std::filesystem::path& Path, + std::span Data, + EAtomicStoreFailurePoint FailurePoint, + EWriteAndFlushFailure& OutFailure) + { + OutFailure = EWriteAndFlushFailure::None; +#if defined(_WIN32) + HANDLE File = CreateFileW( + Path.c_str(), + GENERIC_WRITE, + 0, + nullptr, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_WRITE_THROUGH, + nullptr); + if (File == INVALID_HANDLE_VALUE) + { + OutFailure = EWriteAndFlushFailure::Open; + return false; + } + bool bSucceeded = true; + size_t Offset = 0; + while (Offset < Data.size()) + { + if (FailurePoint == EAtomicStoreFailurePoint::DuringTemporaryWrite) + { + OutFailure = EWriteAndFlushFailure::InjectedWrite; + bSucceeded = false; + break; + } + const size_t Remaining = Data.size() - Offset; + const DWORD ChunkSize = static_cast( + std::min(Remaining, std::numeric_limits::max())); + DWORD Written = 0; + if (!WriteFile(File, Data.data() + Offset, ChunkSize, &Written, nullptr) + || Written != ChunkSize) + { + OutFailure = EWriteAndFlushFailure::Write; + bSucceeded = false; + break; + } + Offset += Written; + } + if (bSucceeded + && FailurePoint == EAtomicStoreFailurePoint::DuringTemporaryWrite) + { + OutFailure = EWriteAndFlushFailure::InjectedWrite; + bSucceeded = false; + } + if (bSucceeded + && FailurePoint == EAtomicStoreFailurePoint::BeforeTemporaryFlush) + { + OutFailure = EWriteAndFlushFailure::InjectedFlush; + bSucceeded = false; + } + if (bSucceeded && !FlushFileBuffers(File)) + { + OutFailure = EWriteAndFlushFailure::Flush; + bSucceeded = false; + } + if (!CloseHandle(File)) + { + if (OutFailure == EWriteAndFlushFailure::None) + { + OutFailure = EWriteAndFlushFailure::Close; + } + bSucceeded = false; + } + return bSucceeded; +#else + if (FailurePoint == EAtomicStoreFailurePoint::DuringTemporaryWrite) + { + OutFailure = EWriteAndFlushFailure::InjectedWrite; + return false; + } + std::ofstream Out(Path, std::ios::binary | std::ios::trunc); + if (!Out) + { + OutFailure = EWriteAndFlushFailure::Open; + return false; + } + Out.write( + reinterpret_cast(Data.data()), + static_cast(Data.size())); + if (FailurePoint == EAtomicStoreFailurePoint::BeforeTemporaryFlush) + { + OutFailure = EWriteAndFlushFailure::InjectedFlush; + return false; + } + Out.flush(); + if (!Out) + { + OutFailure = EWriteAndFlushFailure::Write; + return false; + } + return true; +#endif + } + + bool ReplaceFile( + const std::filesystem::path& Temporary, + const std::filesystem::path& Target) + { +#if defined(_WIN32) + return MoveFileExW( + Temporary.c_str(), + Target.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != FALSE; +#else + std::error_code Error; + std::filesystem::rename(Temporary, Target, Error); + return !Error; +#endif + } + } + + uint64 HashBytes(std::span Data) + { + uint64 Hash = 1469598103934665603ull; + for (const uint8 Byte : Data) + { + Hash ^= Byte; + Hash *= 1099511628211ull; + } + return Hash; + } + + std::optional HashFile( + const std::filesystem::path& Path, + uint64 MaxBytes, + uint64* OutSize) + { + std::error_code Error; + const uint64 Size = std::filesystem::file_size(Path, Error); + if (Error || Size > MaxBytes) + { + return std::nullopt; + } + std::ifstream In(Path, std::ios::binary); + if (!In) + { + return std::nullopt; + } + uint64 Hash = 1469598103934665603ull; + std::array Buffer{}; + uint64 ReadSize = 0; + while (In) + { + In.read(Buffer.data(), static_cast(Buffer.size())); + const std::streamsize Count = In.gcount(); + for (std::streamsize Index = 0; Index < Count; ++Index) + { + Hash ^= static_cast(Buffer[static_cast(Index)]); + Hash *= 1099511628211ull; + } + ReadSize += static_cast(Count); + } + if (!In.eof() || ReadSize != Size) + { + return std::nullopt; + } + if (OutSize) + { + *OutSize = Size; + } + return Hash; + } + + FAtomicStoreResult AtomicStoreBytes( + const FPathService& Service, + const FAtomicStoreRequest& Request) + { + FAtomicStoreResult Result; + Result.Evidence.Target = Request.Target; + Result.Evidence.RootRevision = Request.Target.RootRevision; + const uint64 ConfiguredMax = Service.QuerySnapshot().Limits.MaxAtomicWriteBytes; + const uint64 EffectiveMax = Request.MaxBytes == 0 + ? ConfiguredMax + : std::min(Request.MaxBytes, ConfiguredMax); + if (Request.Data.size() > EffectiveMax) + { + Result.Diagnostics.push_back(MakeStoreDiagnostic( + "path.atomic.size_limit", + "Atomic store payload exceeds the configured size limit.")); + return Result; + } + + const FPathResolveResult InitialResolve = Service.ResolveWrite( + Request.Target, + EPathOperation::Replace); + if (!InitialResolve.Resolved) + { + Result.Diagnostics = InitialResolve.Diagnostics; + return Result; + } + const std::filesystem::path Target = InitialResolve.Resolved->NativePath; + std::error_code Error; + const bool bTargetExists = std::filesystem::exists(Target, Error); + if (Error) + { + Result.Diagnostics.push_back(MakeStoreDiagnostic( + "path.atomic.inspect_failed", + "Existing target could not be inspected.", + true)); + return Result; + } + Result.Evidence.bPreviousFileExisted = bTargetExists; + if (bTargetExists) + { + const std::optional PreviousHash = HashFile( + Target, + EffectiveMax, + &Result.Evidence.PreviousSize); + if (!PreviousHash) + { + Result.Diagnostics.push_back(MakeStoreDiagnostic( + "path.atomic.existing_hash_failed", + "Existing target could not be hashed.")); + return Result; + } + Result.Evidence.PreviousHash = *PreviousHash; + } + if (Request.ExpectedExistingHash + && (!bTargetExists + || *Request.ExpectedExistingHash != Result.Evidence.PreviousHash)) + { + Result.Diagnostics.push_back(MakeStoreDiagnostic( + "path.atomic.conflict", + "Existing target hash does not match the expected revision.")); + return Result; + } + + std::filesystem::create_directories(Target.parent_path(), Error); + if (Error) + { + Result.Diagnostics.push_back(MakeStoreDiagnostic( + "path.atomic.parent_create_failed", + "Atomic store parent directory could not be created.", + true)); + return Result; + } + const FPathResolveResult RecheckedResolve = Service.ResolveWrite( + Request.Target, + EPathOperation::Replace); + if (!RecheckedResolve.Resolved + || RecheckedResolve.Resolved->NativePath != Target) + { + Result.Diagnostics.push_back(MakeStoreDiagnostic( + "path.atomic.containment_changed", + "Target containment changed before writing.")); + return Result; + } + + const std::filesystem::path Temporary = MakeTemporaryPath(Target); + auto FailWithTemporary = [&](FPathDiagnostic Diagnostic) + { + if (Request.bRetainFailedTemporary) + { + Result.Evidence.bTemporaryRetained = + std::filesystem::exists(Temporary); + } + else + { + RemoveNoThrow(Temporary); + } + Result.Diagnostics.push_back(std::move(Diagnostic)); + }; + + EWriteAndFlushFailure WriteFailure = EWriteAndFlushFailure::None; + if (!WriteAndFlushFile( + Temporary, + Request.Data, + Request.FailurePoint, + WriteFailure)) + { + if (WriteFailure == EWriteAndFlushFailure::InjectedWrite) + { + FailWithTemporary(MakeStoreDiagnostic( + "path.atomic.injected_write", + "Injected failure during temporary write.")); + } + else if (WriteFailure == EWriteAndFlushFailure::InjectedFlush) + { + FailWithTemporary(MakeStoreDiagnostic( + "path.atomic.injected_flush", + "Injected failure before temporary flush.")); + } + else + { + FailWithTemporary(MakeStoreDiagnostic( + "path.atomic.write_failed", + "Temporary file create, write, flush, or close failed.", + true)); + } + return Result; + } + if (Request.FailurePoint == EAtomicStoreFailurePoint::AfterTemporaryWrite) + { + FailWithTemporary(MakeStoreDiagnostic( + "path.atomic.injected_after_write", + "Injected failure after temporary write.")); + return Result; + } + if (Request.Validator) + { + FPathDiagnostic ValidationDiagnostic; + if (!Request.Validator(Temporary, ValidationDiagnostic)) + { + if (ValidationDiagnostic.Code.empty()) + { + ValidationDiagnostic = MakeStoreDiagnostic( + "path.atomic.validation_failed", + "Atomic store validator rejected the temporary file."); + } + FailWithTemporary(std::move(ValidationDiagnostic)); + return Result; + } + } + + Result.Evidence.NewHash = HashBytes(Request.Data); + Result.Evidence.NewSize = static_cast(Request.Data.size()); + uint64 WrittenSize = 0; + const std::optional WrittenHash = HashFile( + Temporary, + EffectiveMax, + &WrittenSize); + if (!WrittenHash + || WrittenSize != Result.Evidence.NewSize + || *WrittenHash != Result.Evidence.NewHash) + { + FailWithTemporary(MakeStoreDiagnostic( + "path.atomic.verify_failed", + "Temporary file size or hash verification failed.")); + return Result; + } + if (Request.FailurePoint == EAtomicStoreFailurePoint::BeforeReplace) + { + FailWithTemporary(MakeStoreDiagnostic( + "path.atomic.injected_before_replace", + "Injected failure before atomic replace.")); + return Result; + } + const FPathResolveResult FinalResolve = Service.ResolveWrite( + Request.Target, + EPathOperation::Replace); + if (!FinalResolve.Resolved || FinalResolve.Resolved->NativePath != Target) + { + FailWithTemporary(MakeStoreDiagnostic( + "path.atomic.containment_changed", + "Target containment changed before replace.")); + return Result; + } + if (!ReplaceFile(Temporary, Target)) + { + FailWithTemporary(MakeStoreDiagnostic( + "path.atomic.replace_failed", + "Atomic replace failed; the previous target was preserved.", + true)); + return Result; + } + Result.Evidence.bCommitted = true; + return Result; + } +} diff --git a/Engine/Source/Core/Paths/AtomicFileStore.h b/Engine/Source/Core/Paths/AtomicFileStore.h new file mode 100644 index 0000000..600f024 --- /dev/null +++ b/Engine/Source/Core/Paths/AtomicFileStore.h @@ -0,0 +1,63 @@ +#pragma once + +#include "PathService.h" + +#include +#include +#include +#include + +namespace WavePaths +{ + enum class EAtomicStoreFailurePoint : uint8 + { + None, + DuringTemporaryWrite, + BeforeTemporaryFlush, + AfterTemporaryWrite, + BeforeReplace, + }; + + struct FAtomicStoreRequest + { + FPathRef Target; + std::span Data; + std::optional ExpectedExistingHash; + uint64 MaxBytes = 0; + bool bRetainFailedTemporary = false; + EAtomicStoreFailurePoint FailurePoint = EAtomicStoreFailurePoint::None; + std::function Validator; + }; + + struct FAtomicStoreEvidence + { + uint16 SchemaMajor = PathSchemaMajor; + uint16 SchemaMinor = PathSchemaMinor; + FPathRef Target; + uint64 RootRevision = 0; + uint64 PreviousSize = 0; + uint64 PreviousHash = 0; + uint64 NewSize = 0; + uint64 NewHash = 0; + bool bPreviousFileExisted = false; + bool bCommitted = false; + bool bTemporaryRetained = false; + }; + + struct FAtomicStoreResult + { + FAtomicStoreEvidence Evidence; + std::vector Diagnostics; + }; + + [[nodiscard]] uint64 HashBytes(std::span Data); + [[nodiscard]] std::optional HashFile( + const std::filesystem::path& Path, + uint64 MaxBytes, + uint64* OutSize = nullptr); + [[nodiscard]] FAtomicStoreResult AtomicStoreBytes( + const FPathService& Service, + const FAtomicStoreRequest& Request); +} diff --git a/Engine/Source/Core/Paths/LegacyMigration.cpp b/Engine/Source/Core/Paths/LegacyMigration.cpp new file mode 100644 index 0000000..a658cea --- /dev/null +++ b/Engine/Source/Core/Paths/LegacyMigration.cpp @@ -0,0 +1,589 @@ +#include "LegacyMigration.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) + #define WIN32_LEAN_AND_MEAN + #include +#endif + +namespace WavePaths +{ + namespace + { + FPathDiagnostic MakeMigrationDiagnostic( + std::string Code, + std::string Field, + std::string Message, + EPathDiagnosticSeverity Severity = EPathDiagnosticSeverity::Error) + { + return { + std::move(Code), + std::move(Field), + std::move(Message), + Severity, + false, + }; + } + + std::string AsciiLower(std::string_view Value) + { + std::string Result(Value); + for (char& Character : Result) + { + Character = static_cast( + std::tolower(static_cast(Character))); + } + return Result; + } + + bool StartsWithPath(std::string_view Path, std::string_view Prefix) + { + return Path == Prefix + || (Path.size() > Prefix.size() + && Path.starts_with(Prefix) + && Path[Prefix.size()] == '/'); + } + + bool IsManagedRoot(std::string_view RelativePath) + { + const std::string Lower = AsciiLower(RelativePath); + return StartsWithPath(Lower, "derived") + || StartsWithPath(Lower, "project") + || StartsWithPath(Lower, "staging"); + } + + bool IsReparsePoint(const std::filesystem::path& Path) + { +#if defined(_WIN32) + const DWORD Attributes = GetFileAttributesW(Path.c_str()); + return Attributes != INVALID_FILE_ATTRIBUTES + && (Attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0; +#else + std::error_code Error; + return std::filesystem::is_symlink( + std::filesystem::symlink_status(Path, Error)); +#endif + } + + std::optional MapLegacyEntry( + const FPathService& Service, + const FLegacyMigrationRequest& Request, + std::string RelativePath) + { + FLegacyMigrationEntry Entry; + const std::string Lower = AsciiLower(RelativePath); + std::string TargetRelativePath; + EPathDomain TargetDomain = EPathDomain::ProjectSaved; + + if (StartsWithPath(Lower, "scenes")) + { + Entry.Kind = ELegacyPathKind::SceneDocument; + TargetDomain = EPathDomain::ProjectSaved; + TargetRelativePath = RelativePath; + Entry.ValidatorId = "wescene"; + } + else if (Lower == "ui_config.cfg") + { + Entry.Kind = ELegacyPathKind::UiConfig; + TargetDomain = EPathDomain::ProjectSaved; + TargetRelativePath = "Config/ui_config.cfg"; + Entry.ValidatorId = "ui_config"; + } + else if (StartsWithPath(Lower, "meshes") + || StartsWithPath(Lower, "shaders")) + { + Entry.Kind = ELegacyPathKind::DerivedArtifact; + TargetDomain = EPathDomain::Derived; + TargetRelativePath = RelativePath; + Entry.ValidatorId = StartsWithPath(Lower, "meshes") + ? "wemesh" + : "shader_cache"; + } + else if (StartsWithPath(Lower, "traces")) + { + Entry.Kind = ELegacyPathKind::UserArtifact; + TargetDomain = EPathDomain::UserSaved; + TargetRelativePath = RelativePath; + Entry.ValidatorId = "wave_trace"; + } + else if (StartsWithPath(Lower, "renderdoc")) + { + Entry.Kind = ELegacyPathKind::UserArtifact; + TargetDomain = EPathDomain::UserSaved; + const size_t PrefixLength = std::string_view("RenderDoc").size(); + const std::string Suffix = RelativePath.size() > PrefixLength + ? RelativePath.substr(PrefixLength + 1) + : std::string{}; + TargetRelativePath = "Captures/RenderDoc"; + if (!Suffix.empty()) + { + TargetRelativePath += "/" + Suffix; + } + Entry.ValidatorId = "renderdoc_capture"; + } + else if (Lower == "imgui.ini") + { + Entry.Kind = ELegacyPathKind::UserArtifact; + TargetDomain = EPathDomain::UserSaved; + TargetRelativePath = "Config/imgui.ini"; + Entry.ValidatorId = "imgui_config"; + } + else if (Lower == "wavetraceviewer.ini") + { + Entry.Kind = ELegacyPathKind::UserArtifact; + TargetDomain = EPathDomain::UserSaved; + TargetRelativePath = "Config/WaveTraceViewer.ini"; + Entry.ValidatorId = "trace_viewer_config"; + } + else + { + Entry.Source = Service.MakeRef( + Request.LegacyDomain, + Request.LegacyRelativeRoot + "/" + RelativePath); + return Entry; + } + + Entry.bSupported = true; + Entry.Source = Service.MakeRef( + Request.LegacyDomain, + Request.LegacyRelativeRoot + "/" + RelativePath); + Entry.Target = Service.MakeRef( + TargetDomain, + std::move(TargetRelativePath)); + Entry.Staging = Service.MakeRef( + EPathDomain::Staging, + "Legacy/" + + Request.StagingWorkspace + + "/" + + std::string(ToString(TargetDomain)) + + "/" + + Entry.Target.RelativePath); + return Entry; + } + + std::optional> ReadFileBounded( + const std::filesystem::path& Path, + uint64 ExpectedSize, + uint64 MaxBytes) + { + if (ExpectedSize > MaxBytes + || ExpectedSize > static_cast( + std::numeric_limits::max())) + { + return std::nullopt; + } + std::ifstream In(Path, std::ios::binary); + if (!In) + { + return std::nullopt; + } + std::vector Data(static_cast(ExpectedSize)); + if (!Data.empty()) + { + In.read( + reinterpret_cast(Data.data()), + static_cast(Data.size())); + } + if (!In || In.peek() != std::ifstream::traits_type::eof()) + { + return std::nullopt; + } + return Data; + } + } + + FLegacyMigrationPlan BuildLegacyMigrationPlan( + const FPathService& Service, + const FLegacyMigrationRequest& Request) + { + FLegacyMigrationPlan Plan; + Plan.RootRevision = Service.QuerySnapshot().Revision; + Plan.LegacyRoot = Service.MakeRef( + Request.LegacyDomain, + Request.LegacyRelativeRoot); + + const std::vector RootSchema = + ValidatePathRefSchema(Plan.LegacyRoot); + const FPathRef WorkspaceProbe = Service.MakeRef( + EPathDomain::Staging, + "Legacy/" + Request.StagingWorkspace + "/workspace.marker"); + const std::vector WorkspaceSchema = + ValidatePathRefSchema(WorkspaceProbe); + if (!RootSchema.empty() || !WorkspaceSchema.empty()) + { + Plan.Diagnostics.insert( + Plan.Diagnostics.end(), + RootSchema.begin(), + RootSchema.end()); + Plan.Diagnostics.insert( + Plan.Diagnostics.end(), + WorkspaceSchema.begin(), + WorkspaceSchema.end()); + return Plan; + } + + const FPathResolveResult Root = Service.ResolveRead(Plan.LegacyRoot); + if (!Root.Resolved) + { + const bool bMissingRoot = std::any_of( + Root.Diagnostics.begin(), + Root.Diagnostics.end(), + [](const FPathDiagnostic& Diagnostic) + { + return Diagnostic.Code == "path.resolve.not_found"; + }); + if (bMissingRoot) + { + Plan.bComplete = true; + return Plan; + } + Plan.Diagnostics = Root.Diagnostics; + return Plan; + } + std::error_code Error; + if (!std::filesystem::is_directory(Root.Resolved->NativePath, Error) + || Error) + { + Plan.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.legacy_root_not_directory", + "legacy_root", + "Legacy migration root is not a readable directory.")); + return Plan; + } + + const FPathServiceLimits Limits = Service.QuerySnapshot().Limits; + const FPathResolveResult WorkspaceProbeResolve = Service.ResolveWrite( + WorkspaceProbe, + EPathOperation::Replace); + if (!WorkspaceProbeResolve.Resolved) + { + Plan.Diagnostics = WorkspaceProbeResolve.Diagnostics; + return Plan; + } + const std::filesystem::path WorkspaceRoot = + WorkspaceProbeResolve.Resolved->NativePath.parent_path(); + const std::filesystem::path StagingLegacyRoot = WorkspaceRoot.parent_path(); + uint32 WorkspaceCount = 0; + if (std::filesystem::exists(StagingLegacyRoot, Error)) + { + for (std::filesystem::directory_iterator WorkspaceIterator( + StagingLegacyRoot, + std::filesystem::directory_options::none, + Error), + WorkspaceEnd; + WorkspaceIterator != WorkspaceEnd && !Error; + WorkspaceIterator.increment(Error)) + { + if (WorkspaceIterator->is_directory(Error) && !Error) + { + ++WorkspaceCount; + } + } + if (Error) + { + Plan.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.workspace_inspect_failed", + "staging", + "Existing staging workspaces could not be inspected.")); + return Plan; + } + } + else if (Error) + { + Plan.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.workspace_inspect_failed", + "staging", + "Staging workspace root could not be inspected.")); + return Plan; + } + const bool bWorkspaceExists = + std::filesystem::is_directory(WorkspaceRoot, Error); + if (Error) + { + Error.clear(); + } + if (!bWorkspaceExists && WorkspaceCount >= Limits.MaxStagingWorkspaces) + { + Plan.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.workspace_limit", + "staging", + "Staging workspace limit was exceeded.")); + return Plan; + } + + std::filesystem::recursive_directory_iterator Iterator( + Root.Resolved->NativePath, + std::filesystem::directory_options::none, + Error); + const std::filesystem::recursive_directory_iterator End; + if (Error) + { + Plan.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.discovery_failed", + "legacy_root", + "Legacy migration discovery could not start.")); + return Plan; + } + + while (Iterator != End) + { + const std::filesystem::directory_entry Entry = *Iterator; + const std::filesystem::path NativePath = Entry.path(); + const std::filesystem::path Relative = + std::filesystem::relative(NativePath, Root.Resolved->NativePath, Error); + if (Error) + { + Plan.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.relative_failed", + "source", + "Legacy entry could not be expressed relative to its root.")); + return Plan; + } + const std::string RelativeText = Relative.generic_string(); + if (Entry.is_directory(Error) && !Error && IsManagedRoot(RelativeText)) + { + Iterator.disable_recursion_pending(); + Iterator.increment(Error); + if (Error) + { + break; + } + continue; + } + if (IsReparsePoint(NativePath)) + { + if (Entry.is_directory(Error) && !Error) + { + Iterator.disable_recursion_pending(); + } + Plan.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.reparse_rejected", + "source", + "Legacy migration does not traverse reparse points.")); + Iterator.increment(Error); + if (Error) + { + break; + } + continue; + } + if (Entry.is_regular_file(Error) && !Error) + { + ++Plan.InspectedEntries; + if (Plan.InspectedEntries > Limits.MaxLegacyEntries) + { + Plan.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.entry_limit", + "legacy_root", + "Legacy migration entry limit was exceeded.")); + return Plan; + } + + std::optional Mapped = + MapLegacyEntry(Service, Request, RelativeText); + if (!Mapped) + { + Plan.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.mapping_failed", + "source", + "Legacy migration entry could not be mapped.")); + return Plan; + } + FLegacyMigrationEntry& MigrationEntry = *Mapped; + const std::optional SourceHash = HashFile( + NativePath, + Limits.MaxAtomicWriteBytes, + &MigrationEntry.SourceSize); + if (!SourceHash) + { + MigrationEntry.bSupported = false; + Plan.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.source_limit_or_hash", + "source", + "Legacy entry exceeds the file limit or could not be hashed.")); + } + else + { + MigrationEntry.SourceHash = *SourceHash; + } + + if (MigrationEntry.bSupported) + { + if (MigrationEntry.SourceSize > Limits.MaxStagingBytes + || Plan.TotalSupportedBytes + > Limits.MaxStagingBytes - MigrationEntry.SourceSize) + { + Plan.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.staging_quota", + "legacy_root", + "Legacy migration exceeds the staging byte quota.")); + return Plan; + } + Plan.TotalSupportedBytes += MigrationEntry.SourceSize; + + const FPathResolveResult Target = Service.ResolveWrite( + MigrationEntry.Target, + EPathOperation::Replace); + if (!Target.Resolved) + { + Plan.Diagnostics.insert( + Plan.Diagnostics.end(), + Target.Diagnostics.begin(), + Target.Diagnostics.end()); + MigrationEntry.bSupported = false; + } + else + { + const bool bTargetExists = + std::filesystem::exists(Target.Resolved->NativePath, Error); + if (Error) + { + Plan.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.target_inspect_failed", + "target", + "Migration target could not be inspected.")); + return Plan; + } + MigrationEntry.bTargetExists = bTargetExists; + if (bTargetExists) + { + const std::optional TargetHash = HashFile( + Target.Resolved->NativePath, + Limits.MaxAtomicWriteBytes); + if (!TargetHash) + { + Plan.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.target_hash_failed", + "target", + "Migration target could not be hashed.")); + return Plan; + } + MigrationEntry.ExistingTargetHash = *TargetHash; + MigrationEntry.bTargetMatches = + *TargetHash == MigrationEntry.SourceHash; + MigrationEntry.bConflict = + !MigrationEntry.bTargetMatches; + } + } + } + Plan.Entries.push_back(std::move(MigrationEntry)); + } + else if (Error) + { + Plan.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.inspect_failed", + "source", + "Legacy entry type could not be inspected.")); + return Plan; + } + + Iterator.increment(Error); + if (Error) + { + break; + } + } + if (Error) + { + Plan.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.discovery_failed", + "legacy_root", + "Legacy migration discovery did not complete.")); + return Plan; + } + Plan.bComplete = true; + return Plan; + } + + FLegacyStageResult StageLegacyMigrationEntry( + const FPathService& Service, + const FLegacyStageRequest& Request) + { + FLegacyStageResult Result; + Result.Evidence.Source = Request.Entry.Source; + Result.Evidence.Staging = Request.Entry.Staging; + Result.Evidence.SourceSize = Request.Entry.SourceSize; + Result.Evidence.SourceHash = Request.Entry.SourceHash; + Result.Evidence.bApproved = Request.bApproved; + if (!Request.bApproved) + { + Result.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.approval_required", + "approval", + "Legacy staging requires explicit approval.")); + return Result; + } + if (!Request.Entry.bSupported) + { + Result.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.unsupported", + "source", + "Unsupported legacy entries cannot be staged.")); + return Result; + } + + const FPathResolveResult Source = Service.ResolveRead(Request.Entry.Source); + if (!Source.Resolved) + { + Result.Diagnostics = Source.Diagnostics; + return Result; + } + const FPathServiceLimits Limits = Service.QuerySnapshot().Limits; + const std::optional> Data = ReadFileBounded( + Source.Resolved->NativePath, + Request.Entry.SourceSize, + Limits.MaxAtomicWriteBytes); + if (!Data || HashBytes(*Data) != Request.Entry.SourceHash) + { + Result.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.source_changed", + "source", + "Legacy source changed after the migration preview.")); + return Result; + } + + FAtomicStoreRequest StoreRequest{ + Request.Entry.Staging, + std::span(*Data), + }; + StoreRequest.FailurePoint = Request.FailurePoint; + StoreRequest.Validator = Request.Validator; + const FAtomicStoreResult Store = + AtomicStoreBytes(Service, StoreRequest); + Result.Evidence.Store = Store.Evidence; + Result.Diagnostics = Store.Diagnostics; + if (!Store.Evidence.bCommitted) + { + return Result; + } + Result.Evidence.bValidated = true; + Result.Evidence.bStaged = true; + + uint64 SourceSize = 0; + const std::optional SourceHash = HashFile( + Source.Resolved->NativePath, + Limits.MaxAtomicWriteBytes, + &SourceSize); + Result.Evidence.bSourcePreserved = + SourceHash + && *SourceHash == Request.Entry.SourceHash + && SourceSize == Request.Entry.SourceSize; + if (!Result.Evidence.bSourcePreserved) + { + Result.Evidence.bStaged = false; + Result.Diagnostics.push_back(MakeMigrationDiagnostic( + "path.migration.source_postcheck_failed", + "source", + "Legacy source preservation could not be proven after staging.")); + } + return Result; + } +} diff --git a/Engine/Source/Core/Paths/LegacyMigration.h b/Engine/Source/Core/Paths/LegacyMigration.h new file mode 100644 index 0000000..c16425d --- /dev/null +++ b/Engine/Source/Core/Paths/LegacyMigration.h @@ -0,0 +1,94 @@ +#pragma once + +#include "AtomicFileStore.h" + +#include +#include +#include + +namespace WavePaths +{ + enum class ELegacyPathKind : uint8 + { + SceneDocument, + UiConfig, + DerivedArtifact, + UserArtifact, + Unsupported, + }; + + struct FLegacyMigrationRequest + { + EPathDomain LegacyDomain = EPathDomain::EngineContent; + std::string LegacyRelativeRoot = "Save"; + std::string StagingWorkspace = "legacy-path-migration"; + }; + + struct FLegacyMigrationEntry + { + ELegacyPathKind Kind = ELegacyPathKind::Unsupported; + FPathRef Source; + FPathRef Target; + FPathRef Staging; + uint64 SourceSize = 0; + uint64 SourceHash = 0; + uint64 ExistingTargetHash = 0; + bool bSupported = false; + bool bTargetExists = false; + bool bTargetMatches = false; + bool bConflict = false; + std::string ValidatorId; + }; + + struct FLegacyMigrationPlan + { + uint16 SchemaMajor = PathSchemaMajor; + uint16 SchemaMinor = PathSchemaMinor; + uint64 RootRevision = 0; + FPathRef LegacyRoot; + uint32 InspectedEntries = 0; + uint64 TotalSupportedBytes = 0; + bool bComplete = false; + std::vector Entries; + std::vector Diagnostics; + }; + + struct FLegacyStageRequest + { + FLegacyMigrationEntry Entry; + bool bApproved = false; + EAtomicStoreFailurePoint FailurePoint = EAtomicStoreFailurePoint::None; + std::function Validator; + }; + + struct FLegacyStageEvidence + { + uint16 SchemaMajor = PathSchemaMajor; + uint16 SchemaMinor = PathSchemaMinor; + FPathRef Source; + FPathRef Staging; + uint64 SourceSize = 0; + uint64 SourceHash = 0; + FAtomicStoreEvidence Store; + bool bApproved = false; + bool bValidated = false; + bool bStaged = false; + bool bSourcePreserved = false; + bool bPromoted = false; + }; + + struct FLegacyStageResult + { + FLegacyStageEvidence Evidence; + std::vector Diagnostics; + }; + + [[nodiscard]] FLegacyMigrationPlan BuildLegacyMigrationPlan( + const FPathService& Service, + const FLegacyMigrationRequest& Request); + [[nodiscard]] FLegacyStageResult StageLegacyMigrationEntry( + const FPathService& Service, + const FLegacyStageRequest& Request); +} diff --git a/Engine/Source/Core/Paths/PathService.cpp b/Engine/Source/Core/Paths/PathService.cpp new file mode 100644 index 0000000..0e56273 --- /dev/null +++ b/Engine/Source/Core/Paths/PathService.cpp @@ -0,0 +1,712 @@ +#include "PathService.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace WavePaths +{ + namespace + { + const FPathService* GPathService = nullptr; + + FPathDiagnostic MakeDiagnostic( + std::string Code, + std::string Field, + std::string Message, + EPathDiagnosticSeverity Severity = EPathDiagnosticSeverity::Error) + { + return { + std::move(Code), + std::move(Field), + std::move(Message), + Severity, + false, + }; + } + + std::string AsciiLower(std::string_view Value) + { + std::string Result(Value); + for (char& Character : Result) + { + Character = static_cast( + std::tolower(static_cast(Character))); + } + return Result; + } + + bool IsReservedWindowsSegment(std::string_view Segment) + { + const size_t Dot = Segment.find('.'); + const std::string Base = AsciiLower(Segment.substr(0, Dot)); + static const std::array Fixed = { + "con", + "prn", + "aux", + "nul", + }; + if (std::find(Fixed.begin(), Fixed.end(), Base) != Fixed.end()) + { + return true; + } + return Base.size() == 4 + && (Base.starts_with("com") || Base.starts_with("lpt")) + && Base[3] >= '1' + && Base[3] <= '9'; + } + + bool IsDomainAllowedInPackage(EPathDomain Domain) + { + return Domain == EPathDomain::Executable + || Domain == EPathDomain::EngineContent + || Domain == EPathDomain::UserSaved + || Domain == EPathDomain::PackageContent + || Domain == EPathDomain::Temp; + } + + bool EqualPathComponent( + const std::filesystem::path& Left, + const std::filesystem::path& Right) + { +#if defined(_WIN32) + const std::wstring LeftText = Left.native(); + const std::wstring RightText = Right.native(); + if (LeftText.size() != RightText.size()) + { + return false; + } + for (size_t Index = 0; Index < LeftText.size(); ++Index) + { + if (std::towlower(LeftText[Index]) != std::towlower(RightText[Index])) + { + return false; + } + } + return true; +#else + return Left == Right; +#endif + } + + bool IsContainedPath( + const std::filesystem::path& Root, + const std::filesystem::path& Candidate) + { + auto RootIt = Root.begin(); + auto CandidateIt = Candidate.begin(); + for (; RootIt != Root.end(); ++RootIt, ++CandidateIt) + { + if (CandidateIt == Candidate.end() + || !EqualPathComponent(*RootIt, *CandidateIt)) + { + return false; + } + } + return true; + } + + std::optional Canonicalize( + const std::filesystem::path& Path, + std::error_code& Error) + { + std::filesystem::path Result = std::filesystem::weakly_canonical(Path, Error); + if (Error) + { + return std::nullopt; + } + return Result.lexically_normal(); + } + } + + std::string_view ToString(EPathDomain Domain) + { + switch (Domain) + { + case EPathDomain::Executable: + return "executable"; + case EPathDomain::EngineContent: + return "engine_content"; + case EPathDomain::ProjectSource: + return "project_source"; + case EPathDomain::Derived: + return "derived"; + case EPathDomain::ProjectSaved: + return "project_saved"; + case EPathDomain::UserSaved: + return "user_saved"; + case EPathDomain::PackageContent: + return "package_content"; + case EPathDomain::Staging: + return "staging"; + case EPathDomain::Temp: + return "temp"; + case EPathDomain::External: + return "external"; + } + return "unknown"; + } + + bool ValidateUtf8(std::string_view Value) + { + size_t Index = 0; + while (Index < Value.size()) + { + const uint8 Lead = static_cast(Value[Index]); + size_t Length = 0; + uint32 CodePoint = 0; + if (Lead <= 0x7f) + { + Length = 1; + CodePoint = Lead; + } + else if ((Lead & 0xe0) == 0xc0) + { + Length = 2; + CodePoint = Lead & 0x1f; + } + else if ((Lead & 0xf0) == 0xe0) + { + Length = 3; + CodePoint = Lead & 0x0f; + } + else if ((Lead & 0xf8) == 0xf0) + { + Length = 4; + CodePoint = Lead & 0x07; + } + else + { + return false; + } + if (Index + Length > Value.size()) + { + return false; + } + for (size_t Offset = 1; Offset < Length; ++Offset) + { + const uint8 Continuation = static_cast(Value[Index + Offset]); + if ((Continuation & 0xc0) != 0x80) + { + return false; + } + CodePoint = (CodePoint << 6) | (Continuation & 0x3f); + } + const bool bOverlong = (Length == 2 && CodePoint < 0x80) + || (Length == 3 && CodePoint < 0x800) + || (Length == 4 && CodePoint < 0x10000); + if (bOverlong + || CodePoint > 0x10ffff + || (CodePoint >= 0xd800 && CodePoint <= 0xdfff)) + { + return false; + } + Index += Length; + } + return true; + } + + std::vector ValidatePathRefSchema(const FPathRef& Ref) + { + std::vector Diagnostics; + if (Ref.SchemaMajor != PathSchemaMajor) + { + Diagnostics.push_back(MakeDiagnostic( + "path.schema.major_mismatch", + "schema_major", + "PathRef schema major is unsupported.")); + } + if (Ref.SchemaMinor > PathSchemaMinor) + { + Diagnostics.push_back(MakeDiagnostic( + "path.schema.minor_mismatch", + "schema_minor", + "PathRef schema minor is newer than this resolver.")); + } + if (static_cast(Ref.Domain) > static_cast(EPathDomain::External)) + { + Diagnostics.push_back(MakeDiagnostic( + "path.schema.unknown_domain", + "domain", + "PathRef domain is unsupported.")); + } + if (Ref.ProjectScope.Value.empty() || Ref.ProjectScope.Value.size() > 128) + { + Diagnostics.push_back(MakeDiagnostic( + "path.schema.invalid_scope", + "project_scope", + "Project scope must contain 1..128 bytes.")); + } + + const std::string_view Path = Ref.RelativePath; + if (Path.empty() || Path.size() > MaxPathRefBytes) + { + Diagnostics.push_back(MakeDiagnostic( + "path.schema.invalid_length", + "relative_path", + "Relative path must contain 1..1024 UTF-8 bytes.")); + return Diagnostics; + } + if (!ValidateUtf8(Path)) + { + Diagnostics.push_back(MakeDiagnostic( + "path.schema.invalid_utf8", + "relative_path", + "Relative path is not valid UTF-8.")); + return Diagnostics; + } + if (Path.starts_with('/') + || Path.starts_with('\\') + || (Path.size() >= 2 + && std::isalpha(static_cast(Path[0])) + && Path[1] == ':')) + { + Diagnostics.push_back(MakeDiagnostic( + "path.schema.absolute", + "relative_path", + "PathRef must not contain an absolute or rooted path.")); + } + if (Path.find('\\') != std::string_view::npos) + { + Diagnostics.push_back(MakeDiagnostic( + "path.schema.non_generic_separator", + "relative_path", + "PathRef must use generic '/' separators.")); + } + + size_t SegmentCount = 0; + size_t Begin = 0; + while (Begin <= Path.size()) + { + const size_t End = Path.find('/', Begin); + const size_t SegmentEnd = End == std::string_view::npos ? Path.size() : End; + const std::string_view Segment = Path.substr(Begin, SegmentEnd - Begin); + ++SegmentCount; + if (Segment.empty()) + { + Diagnostics.push_back(MakeDiagnostic( + "path.schema.empty_segment", + "relative_path", + "PathRef contains an empty segment.")); + break; + } + if (Segment == "." || Segment == "..") + { + Diagnostics.push_back(MakeDiagnostic( + "path.schema.traversal", + "relative_path", + "PathRef contains a traversal segment.")); + } + if (Segment.size() > MaxPathSegmentBytes) + { + Diagnostics.push_back(MakeDiagnostic( + "path.schema.segment_too_long", + "relative_path", + "PathRef segment exceeds 255 UTF-8 bytes.")); + } + if (Segment.find(':') != std::string_view::npos) + { + Diagnostics.push_back(MakeDiagnostic( + "path.schema.alternate_stream", + "relative_path", + "PathRef segment contains ':' or an alternate stream.")); + } + if (Segment.find_first_of("<>\"|?*") != std::string_view::npos + || std::any_of( + Segment.begin(), + Segment.end(), + [](char Character) + { + return static_cast(Character) < 32; + }) + || Segment.back() == '.' + || Segment.back() == ' ') + { + Diagnostics.push_back(MakeDiagnostic( + "path.schema.invalid_windows_name", + "relative_path", + "PathRef segment is invalid on Windows.")); + } + if (IsReservedWindowsSegment(Segment)) + { + Diagnostics.push_back(MakeDiagnostic( + "path.schema.reserved_device", + "relative_path", + "PathRef segment is a reserved Windows device name.")); + } + if (End == std::string_view::npos) + { + break; + } + Begin = End + 1; + } + if (SegmentCount > MaxPathRefSegments) + { + Diagnostics.push_back(MakeDiagnostic( + "path.schema.too_many_segments", + "relative_path", + "PathRef exceeds 64 segments.")); + } + return Diagnostics; + } + + FPathConfigureResult FPathService::Configure(FPathServiceConfig InConfig) + { + FPathConfigureResult Result; + if (bConfigured) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.config.immutable", + "config", + "Path service is immutable after configuration.")); + return Result; + } + if (InConfig.SchemaMajor != PathSchemaMajor + || InConfig.SchemaMinor > PathSchemaMinor) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.config.schema_mismatch", + "schema", + "Path service config schema is unsupported.")); + } + if (InConfig.Revision == 0) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.config.invalid_revision", + "revision", + "Path root revision must be nonzero.")); + } + if (InConfig.ProjectScope.Value.empty() + || InConfig.ProjectScope.Value.size() > 128) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.config.invalid_scope", + "project_scope", + "Project scope must contain 1..128 bytes.")); + } + if (InConfig.Limits.MaxAtomicWriteBytes == 0 + || InConfig.Limits.MaxLegacyEntries == 0 + || InConfig.Limits.MaxStagingWorkspaces == 0 + || InConfig.Limits.MaxStagingBytes == 0) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.config.invalid_limits", + "limits", + "Path service budgets must be nonzero.")); + } + + std::unordered_set SeenDomains; + std::vector CandidateRoots; + for (const FPathRootConfig& Root : InConfig.Roots) + { + if (!SeenDomains.insert(static_cast(Root.Domain)).second) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.config.duplicate_domain", + std::string(ToString(Root.Domain)), + "Path domain is configured more than once.")); + continue; + } + if (InConfig.Policy == EPathPolicyProfile::PackageRuntime + && Root.bAvailable + && !IsDomainAllowedInPackage(Root.Domain)) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.policy.package_domain", + std::string(ToString(Root.Domain)), + "Package policy must not register this path domain.")); + continue; + } + if (!Root.bAvailable) + { + CandidateRoots.push_back({Root, {}}); + continue; + } + if (Root.AbsoluteRoot.empty() || !Root.AbsoluteRoot.is_absolute()) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.config.root_not_absolute", + std::string(ToString(Root.Domain)), + "Available path root must be absolute.")); + continue; + } + std::error_code Error; + const std::optional CanonicalRoot = + Canonicalize(Root.AbsoluteRoot, Error); + if (!CanonicalRoot) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.config.root_unresolved", + std::string(ToString(Root.Domain)), + "Path root could not be canonicalized.")); + continue; + } + const bool bExists = std::filesystem::exists(*CanonicalRoot, Error); + if (Error || (bExists && !std::filesystem::is_directory(*CanonicalRoot, Error))) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.config.root_not_directory", + std::string(ToString(Root.Domain)), + "Path root must be a directory.")); + continue; + } + CandidateRoots.push_back({Root, *CanonicalRoot}); + } + + if (!Result.Diagnostics.empty()) + { + return Result; + } + Config = std::move(InConfig); + Roots = std::move(CandidateRoots); + bConfigured = true; + Result.bConfigured = true; + return Result; + } + + FPathServiceSnapshot FPathService::QuerySnapshot() const + { + if (!bConfigured) + { + throw std::logic_error("Path service is not configured."); + } + FPathServiceSnapshot Snapshot; + Snapshot.Revision = Config.Revision; + Snapshot.Policy = Config.Policy; + Snapshot.ProjectScope = Config.ProjectScope; + Snapshot.Limits = Config.Limits; + Snapshot.Roots.reserve(Roots.size()); + for (const FConfiguredRoot& Root : Roots) + { + Snapshot.Roots.push_back({ + Root.Descriptor.Domain, + Root.Descriptor.Access, + Root.Descriptor.Source, + Root.Descriptor.bSensitive, + Root.Descriptor.bAvailable, + }); + } + return Snapshot; + } + + FPathRef FPathService::MakeRef(EPathDomain Domain, std::string RelativePath) const + { + if (!bConfigured) + { + throw std::logic_error("Path service is not configured."); + } + return { + PathSchemaMajor, + PathSchemaMinor, + Domain, + Config.ProjectScope, + std::move(RelativePath), + Config.Revision, + }; + } + + FPathResolveResult FPathService::ResolveRead(const FPathRef& Ref) const + { + return Resolve(Ref, EPathOperation::Read); + } + + FPathResolveResult FPathService::ResolveWrite( + const FPathRef& Ref, + EPathOperation Operation) const + { + if (Operation == EPathOperation::Read) + { + FPathResolveResult Result; + Result.Diagnostics.push_back(MakeDiagnostic( + "path.operation.invalid_write", + "operation", + "ResolveWrite requires Create or Replace.")); + return Result; + } + return Resolve(Ref, Operation); + } + + FPathResolveResult FPathService::Resolve( + const FPathRef& Ref, + EPathOperation Operation) const + { + FPathResolveResult Result; + if (!bConfigured) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.service.not_configured", + "service", + "Path service is not configured.")); + return Result; + } + Result.Diagnostics = ValidatePathRefSchema(Ref); + if (!Result.Diagnostics.empty()) + { + return Result; + } + if (Ref.ProjectScope != Config.ProjectScope) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.ref.stale_scope", + "project_scope", + "PathRef belongs to a different project session.")); + return Result; + } + if (Ref.RootRevision != Config.Revision) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.ref.stale_revision", + "root_revision", + "PathRef root revision is stale.")); + return Result; + } + const FConfiguredRoot* Root = FindRoot(Ref.Domain); + if (!Root || !Root->Descriptor.bAvailable) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.domain.unavailable", + std::string(ToString(Ref.Domain)), + "Path domain is unavailable in this policy.")); + return Result; + } + const bool bWrite = Operation != EPathOperation::Read; + if (Root->Descriptor.Access == EPathAccess::Denied + || (bWrite && Root->Descriptor.Access != EPathAccess::ReadWrite)) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.domain.access_denied", + std::string(ToString(Ref.Domain)), + "Path operation is denied by domain policy.")); + return Result; + } + if (Ref.Domain == EPathDomain::External) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.external.approval_required", + "external", + "External paths require an explicit adapter policy and approval.")); + return Result; + } + + const std::filesystem::path Candidate = + Root->CanonicalRoot / std::filesystem::u8path(Ref.RelativePath); + std::error_code Error; + const std::optional CanonicalCandidate = + Canonicalize(Candidate, Error); + if (!CanonicalCandidate + || !IsContainedPath(Root->CanonicalRoot, *CanonicalCandidate)) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.resolve.escape", + "relative_path", + "Resolved path escaped its configured root.")); + return Result; + } + if (Operation == EPathOperation::Read + && (!std::filesystem::exists(*CanonicalCandidate, Error) || Error)) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.resolve.not_found", + "relative_path", + "Resolved read target does not exist.")); + return Result; + } + if (Operation == EPathOperation::Create + && std::filesystem::exists(*CanonicalCandidate, Error) + && !Error) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "path.resolve.already_exists", + "relative_path", + "Create target already exists.")); + return Result; + } + + Result.Resolved = FResolvedPath{ + Ref, + *CanonicalCandidate, + Operation, + }; + return Result; + } + + const FPathService::FConfiguredRoot* FPathService::FindRoot(EPathDomain Domain) const + { + const auto It = std::find_if( + Roots.begin(), + Roots.end(), + [Domain](const FConfiguredRoot& Root) + { + return Root.Descriptor.Domain == Domain; + }); + return It == Roots.end() ? nullptr : &*It; + } + + void InstallPathService(const FPathService& Service) + { + if (!Service.IsConfigured()) + { + throw std::logic_error("Cannot install an unconfigured path service."); + } + if (GPathService && GPathService != &Service) + { + throw std::logic_error("A different path service is already installed."); + } + GPathService = &Service; + } + + void UninstallPathService(const FPathService& Service) + { + if (GPathService == &Service) + { + GPathService = nullptr; + } + } + + bool HasPathService() + { + return GPathService != nullptr; + } + + const FPathService& GetPathService() + { + if (!GPathService) + { + throw std::logic_error("Path service is not installed."); + } + return *GPathService; + } + + std::filesystem::path ResolveRequiredReadPath( + EPathDomain Domain, + std::string RelativePath) + { + const FPathService& Service = GetPathService(); + const FPathResolveResult Result = + Service.ResolveRead(Service.MakeRef(Domain, std::move(RelativePath))); + if (!Result.Resolved) + { + throw std::runtime_error("Required read path could not be resolved."); + } + return Result.Resolved->NativePath; + } + + std::filesystem::path ResolveRequiredWritePath( + EPathDomain Domain, + std::string RelativePath) + { + const FPathService& Service = GetPathService(); + const FPathResolveResult Result = Service.ResolveWrite( + Service.MakeRef(Domain, std::move(RelativePath)), + EPathOperation::Replace); + if (!Result.Resolved) + { + throw std::runtime_error("Required write path could not be resolved."); + } + return Result.Resolved->NativePath; + } +} diff --git a/Engine/Source/Core/Paths/PathService.h b/Engine/Source/Core/Paths/PathService.h new file mode 100644 index 0000000..21849a6 --- /dev/null +++ b/Engine/Source/Core/Paths/PathService.h @@ -0,0 +1,198 @@ +#pragma once + +#include "Core/Core.h" + +#include +#include +#include +#include +#include + +namespace WavePaths +{ + inline constexpr uint16 PathSchemaMajor = 1; + inline constexpr uint16 PathSchemaMinor = 0; + inline constexpr size_t MaxPathRefBytes = 1024; + inline constexpr size_t MaxPathRefSegments = 64; + inline constexpr size_t MaxPathSegmentBytes = 255; + + enum class EPathDomain : uint8 + { + Executable, + EngineContent, + ProjectSource, + Derived, + ProjectSaved, + UserSaved, + PackageContent, + Staging, + Temp, + External, + }; + + enum class EPathAccess : uint8 + { + Denied, + ReadOnly, + ReadWrite, + }; + + enum class EPathPolicyProfile : uint8 + { + EditorSource, + PackageRuntime, + }; + + enum class EPathOperation : uint8 + { + Read, + Create, + Replace, + }; + + enum class EPathDiagnosticSeverity : uint8 + { + Info, + Warning, + Error, + Fatal, + }; + + struct FPathDiagnostic + { + std::string Code; + std::string Field; + std::string Message; + EPathDiagnosticSeverity Severity = EPathDiagnosticSeverity::Error; + bool bRetryable = false; + }; + + struct FProjectScopeId + { + std::string Value; + + bool operator==(const FProjectScopeId&) const = default; + }; + + struct FPathRef + { + uint16 SchemaMajor = PathSchemaMajor; + uint16 SchemaMinor = PathSchemaMinor; + EPathDomain Domain = EPathDomain::ProjectSource; + FProjectScopeId ProjectScope; + std::string RelativePath; + uint64 RootRevision = 0; + }; + + struct FPathRootConfig + { + EPathDomain Domain = EPathDomain::ProjectSource; + std::filesystem::path AbsoluteRoot; + EPathAccess Access = EPathAccess::Denied; + std::string Source; + bool bSensitive = false; + bool bAvailable = true; + }; + + struct FPathServiceLimits + { + uint64 MaxAtomicWriteBytes = 64ull * 1024ull * 1024ull; + uint32 MaxLegacyEntries = 4096; + uint32 MaxStagingWorkspaces = 64; + uint64 MaxStagingBytes = 4ull * 1024ull * 1024ull * 1024ull; + }; + + struct FPathServiceConfig + { + uint16 SchemaMajor = PathSchemaMajor; + uint16 SchemaMinor = PathSchemaMinor; + uint64 Revision = 1; + EPathPolicyProfile Policy = EPathPolicyProfile::EditorSource; + FProjectScopeId ProjectScope; + std::vector Roots; + FPathServiceLimits Limits; + }; + + struct FPathRootSnapshot + { + EPathDomain Domain = EPathDomain::ProjectSource; + EPathAccess Access = EPathAccess::Denied; + std::string Source; + bool bSensitive = false; + bool bAvailable = false; + }; + + struct FPathServiceSnapshot + { + uint16 SchemaMajor = PathSchemaMajor; + uint16 SchemaMinor = PathSchemaMinor; + uint64 Revision = 0; + EPathPolicyProfile Policy = EPathPolicyProfile::EditorSource; + FProjectScopeId ProjectScope; + std::vector Roots; + FPathServiceLimits Limits; + }; + + struct FPathConfigureResult + { + bool bConfigured = false; + std::vector Diagnostics; + }; + + struct FResolvedPath + { + FPathRef Ref; + std::filesystem::path NativePath; + EPathOperation Operation = EPathOperation::Read; + }; + + struct FPathResolveResult + { + std::optional Resolved; + std::vector Diagnostics; + }; + + [[nodiscard]] std::string_view ToString(EPathDomain Domain); + [[nodiscard]] bool ValidateUtf8(std::string_view Value); + [[nodiscard]] std::vector ValidatePathRefSchema(const FPathRef& Ref); + + class FPathService + { + public: + [[nodiscard]] FPathConfigureResult Configure(FPathServiceConfig Config); + [[nodiscard]] bool IsConfigured() const { return bConfigured; } + [[nodiscard]] FPathServiceSnapshot QuerySnapshot() const; + [[nodiscard]] FPathRef MakeRef(EPathDomain Domain, std::string RelativePath) const; + [[nodiscard]] FPathResolveResult ResolveRead(const FPathRef& Ref) const; + [[nodiscard]] FPathResolveResult ResolveWrite( + const FPathRef& Ref, + EPathOperation Operation) const; + + private: + struct FConfiguredRoot + { + FPathRootConfig Descriptor; + std::filesystem::path CanonicalRoot; + }; + + [[nodiscard]] FPathResolveResult Resolve( + const FPathRef& Ref, + EPathOperation Operation) const; + [[nodiscard]] const FConfiguredRoot* FindRoot(EPathDomain Domain) const; + + FPathServiceConfig Config; + std::vector Roots; + bool bConfigured = false; + }; + + void InstallPathService(const FPathService& Service); + void UninstallPathService(const FPathService& Service); + [[nodiscard]] bool HasPathService(); + [[nodiscard]] const FPathService& GetPathService(); + [[nodiscard]] std::filesystem::path ResolveRequiredReadPath( + EPathDomain Domain, + std::string RelativePath); + [[nodiscard]] std::filesystem::path ResolveRequiredWritePath( + EPathDomain Domain, + std::string RelativePath); +} diff --git a/Engine/Source/Developer/Document/EditorDocumentLedger.cpp b/Engine/Source/Developer/Document/EditorDocumentLedger.cpp new file mode 100644 index 0000000..9abd16b --- /dev/null +++ b/Engine/Source/Developer/Document/EditorDocumentLedger.cpp @@ -0,0 +1,640 @@ +#include "Developer/Document/EditorDocumentLedger.h" + +#include +#include +#include + +namespace WaveEditorDomain +{ + namespace + { + using namespace WaveContract; + + FStructuredDiagnostic MakeDiagnostic( + std::string Code, + std::string Path, + std::string Message, + std::string CorrelationId = {}, + bool bRetryable = false, + EDiagnosticSeverity Severity = EDiagnosticSeverity::Error) + { + FStructuredDiagnostic Diagnostic; + Diagnostic.Code = std::move(Code); + Diagnostic.Path = std::move(Path); + Diagnostic.Message = std::move(Message); + Diagnostic.CorrelationId = std::move(CorrelationId); + Diagnostic.bRetryable = bRetryable; + Diagnostic.Severity = Severity; + return Diagnostic; + } + + FRevision MakeRevision(std::string_view Domain, uint64_t Value) + { + return { std::string(Domain), Value, { 1, 0 } }; + } + + bool HasError(const std::vector& Diagnostics) + { + return std::ranges::any_of( + Diagnostics, + [](const FStructuredDiagnostic& Diagnostic) + { + return Diagnostic.Severity == EDiagnosticSeverity::Error; + }); + } + + std::optional ValidateMutation( + const FEditorMutation& Mutation, + std::string_view Domain, + uint64_t CurrentRevision) + { + if (Mutation.Capability.empty() + || Mutation.Capability.size() > MaxContractNameBytes) + { + return MakeDiagnostic( + "editor.mutation.capability_invalid", + "$.capability", + "mutation capability must be non-empty and bounded"); + } + if (Mutation.ValidatorResults.empty()) + { + return MakeDiagnostic( + "editor.mutation.validator_result_required", + "$.validatorResults", + "a validated mutation must retain at least one validator result"); + } + if (Mutation.ValidatorResults.size() > MaxChangeItems) + { + return MakeDiagnostic( + "editor.mutation.validator_results_exceeded", + "$.validatorResults", + "validator result count exceeds the supported limit"); + } + for (size_t DiagnosticIndex = 0; + DiagnosticIndex < Mutation.ValidatorResults.size(); + ++DiagnosticIndex) + { + const FStructuredDiagnostic& Diagnostic = + Mutation.ValidatorResults[DiagnosticIndex]; + if (Diagnostic.Code.empty() + || Diagnostic.Code.size() > MaxContractNameBytes + || Diagnostic.Path.size() > MaxContractTextBytes + || Diagnostic.Message.size() > MaxContractTextBytes + || Diagnostic.Details.size() > MaxContractTextBytes + || Diagnostic.CorrelationId.size() > MaxContractNameBytes + || ToString(Diagnostic.Severity) == "unknown") + { + return MakeDiagnostic( + "editor.mutation.validator_result_invalid", + "$.validatorResults[" + + std::to_string(DiagnosticIndex) + + "]", + "validator results must use bounded structured diagnostics"); + } + } + if (HasError(Mutation.ValidatorResults)) + { + return MakeDiagnostic( + "editor.mutation.validator_rejected", + "$.validatorResults", + "a mutation with validator errors cannot be committed"); + } + for (size_t TargetIndex = 0; + TargetIndex < Mutation.Targets.size(); + ++TargetIndex) + { + if (Mutation.Targets[TargetIndex].Scope + != EReferenceScope::Session) + { + return MakeDiagnostic( + "editor.mutation.target_scope_invalid", + "$.targets[" + std::to_string(TargetIndex) + "].scope", + "current Editor object references must use session scope"); + } + } + if (CurrentRevision == std::numeric_limits::max()) + { + return MakeDiagnostic( + "editor.revision.exhausted", + "$.revision", + "document revision cannot be incremented"); + } + + FChangeSet Candidate; + Candidate.BeforeRevision = MakeRevision(Domain, CurrentRevision); + Candidate.AfterRevision = MakeRevision(Domain, CurrentRevision + 1); + Candidate.Targets = Mutation.Targets; + Candidate.ChangedFields = Mutation.ChangedFields; + Candidate.ReferenceChanges = Mutation.ReferenceChanges; + Candidate.Warnings = Mutation.Warnings; + return ValidateChangeSet(Candidate); + } + + FEditorCommandResult MakeConflictResult( + const FExpectedRevisionCheck& Check) + { + FEditorCommandResult Result; + Result.Status = EEditorCommandStatus::Conflict; + Result.BeforeRevision = Check.CurrentRevision; + Result.AfterRevision = Check.CurrentRevision; + Result.Diagnostics = Check.Diagnostics; + return Result; + } + + FChangeCursor MakeCursor( + const auto& State, + uint64_t NextSequence) + { + FChangeCursor Cursor; + Cursor.Provider = State.Provider; + Cursor.SchemaVersion = { 1, 0 }; + Cursor.NextSequence = NextSequence; + Cursor.SourceRevision = MakeRevision(State.Domain, State.Revision); + return Cursor; + } + } + + FEditorDocumentLedger::FEditorDocumentLedger() + : Domains{ + FDomainState{ + std::string(SceneDomain), + std::string(SceneDomain) + ".changes" + }, + FDomainState{ + std::string(RenderSettingsDomain), + std::string(RenderSettingsDomain) + ".changes" + } + } + { + } + + void FEditorDocumentLedger::EstablishHydratedBaseline( + uint64_t SceneRevision, + uint64_t RenderSettingsRevision) + { + for (FDomainState& State : Domains) + { + State.Revision = + State.Domain == SceneDomain + ? SceneRevision + : RenderSettingsRevision; + State.NextEventSequence = 1; + State.TotalEvidenceProduced = 0; + State.Events.clear(); + State.Evidence.clear(); + } + bHasHydratedBaseline = true; + } + + std::optional FEditorDocumentLedger::GetRevision( + std::string_view Domain) const + { + const FDomainState* State = FindState(Domain); + if (!bHasHydratedBaseline || State == nullptr) + { + return std::nullopt; + } + return MakeRevision(State->Domain, State->Revision); + } + + FExpectedRevisionCheck FEditorDocumentLedger::CheckExpected( + std::string_view Domain, + const FEditorCommandContext& Context) const + { + FExpectedRevisionCheck Check; + const FDomainState* State = FindState(Domain); + if (State == nullptr) + { + Check.Diagnostics.push_back(MakeDiagnostic( + "editor.domain.unknown", + "$.domain", + "editor document domain is not registered", + Context.CorrelationId)); + return Check; + } + + Check.CurrentRevision = MakeRevision(State->Domain, State->Revision); + if (!bHasHydratedBaseline) + { + Check.Diagnostics.push_back(MakeDiagnostic( + "editor.ledger.baseline_required", + "$.domain", + "editor document baseline must be established after hydration", + Context.CorrelationId)); + return Check; + } + if (Context.RequestId.empty() + || Context.RequestId.size() > MaxContractNameBytes) + { + Check.Diagnostics.push_back(MakeDiagnostic( + "editor.command.request_id_invalid", + "$.requestId", + "editor commands require a non-empty bounded request id", + Context.CorrelationId)); + return Check; + } + if (Context.CorrelationId.size() > MaxContractNameBytes) + { + Check.Diagnostics.push_back(MakeDiagnostic( + "editor.command.correlation_id_invalid", + "$.correlationId", + "editor command correlation id exceeds the supported limit")); + return Check; + } + + if (Context.InvocationMode == EEditorInvocationMode::Typed) + { + if (!Context.ExpectedRevision.has_value()) + { + Check.Diagnostics.push_back(MakeDiagnostic( + "editor.revision.expected_required", + "$.expectedRevision", + "typed editor commands require an expected revision", + Context.CorrelationId)); + return Check; + } + if (auto Diagnostic = ValidateRevision(*Context.ExpectedRevision)) + { + Diagnostic->Path = "$.expectedRevision" + Diagnostic->Path.substr(1); + Diagnostic->CorrelationId = Context.CorrelationId; + Check.Diagnostics.push_back(std::move(*Diagnostic)); + return Check; + } + + const FRevision& Expected = *Context.ExpectedRevision; + if (Expected.Domain != State->Domain + || Expected.SchemaVersion.Major != 1 + || Expected.Value != State->Revision) + { + FStructuredDiagnostic Diagnostic = MakeDiagnostic( + "editor.revision.conflict", + "$.expectedRevision", + "expected revision does not match the current document revision", + Context.CorrelationId, + true); + Diagnostic.Details = + "current=" + std::to_string(State->Revision); + Check.Diagnostics.push_back(std::move(Diagnostic)); + Check.bConflict = true; + return Check; + } + } + else + { + const bool bLegacy = + Context.InvocationMode == EEditorInvocationMode::LegacyCompatibility; + Check.Diagnostics.push_back(MakeDiagnostic( + bLegacy + ? "editor.revision.legacy_base_captured" + : "editor.revision.external_observation", + "$.expectedRevision", + bLegacy + ? "legacy compatibility adapter captured the current revision" + : "external runtime change was observed without document undo", + Context.CorrelationId, + false, + EDiagnosticSeverity::Info)); + } + + Check.bAllowed = true; + return Check; + } + + FEditorLedgerOutcome FEditorDocumentLedger::CommitValidated( + std::string_view Domain, + const FEditorCommandContext& Context, + const FEditorMutation& Mutation) + { + FEditorLedgerOutcome Outcome; + const FExpectedRevisionCheck Check = CheckExpected(Domain, Context); + if (!Check.bAllowed) + { + if (Check.bConflict) + { + Outcome.Result = MakeConflictResult(Check); + } + else + { + Outcome.Diagnostics = Check.Diagnostics; + } + return Outcome; + } + + FDomainState* State = FindState(Domain); + if (State == nullptr) + { + Outcome.Diagnostics.push_back(MakeDiagnostic( + "editor.domain.unknown", + "$.domain", + "editor document domain is not registered", + Context.CorrelationId)); + return Outcome; + } + if (auto Diagnostic = ValidateMutation( + Mutation, + State->Domain, + State->Revision)) + { + Diagnostic->CorrelationId = Context.CorrelationId; + Outcome.Diagnostics.push_back(std::move(*Diagnostic)); + return Outcome; + } + if (Mutation.Targets.size() + > std::numeric_limits::max() - State->NextEventSequence) + { + Outcome.Diagnostics.push_back(MakeDiagnostic( + "editor.change_feed.sequence_exhausted", + "$.targets", + "change feed sequence cannot represent every target", + Context.CorrelationId)); + return Outcome; + } + + const FRevision BeforeRevision = + MakeRevision(State->Domain, State->Revision); + const FRevision AfterRevision = + MakeRevision(State->Domain, State->Revision + 1); + FChangeSet ChangeSet; + ChangeSet.BeforeRevision = BeforeRevision; + ChangeSet.AfterRevision = AfterRevision; + ChangeSet.Targets = Mutation.Targets; + ChangeSet.ChangedFields = Mutation.ChangedFields; + ChangeSet.ReferenceChanges = Mutation.ReferenceChanges; + ChangeSet.Warnings = Mutation.Warnings; + + std::vector NewEvents; + NewEvents.reserve(Mutation.Targets.size()); + uint64_t Sequence = State->NextEventSequence; + for (const FObjectRef& Target : Mutation.Targets) + { + FDomainEvent Event; + Event.Provider = State->Provider; + Event.SchemaVersion = { 1, 0 }; + Event.Sequence = Sequence++; + Event.SourceRevision = AfterRevision; + Event.Object = Target; + Event.Kind = Mutation.Kind; + Event.ChangedFields = Mutation.ChangedFields; + if (auto Diagnostic = ValidateDomainEvent(Event)) + { + Diagnostic->CorrelationId = Context.CorrelationId; + Outcome.Diagnostics.push_back(std::move(*Diagnostic)); + return Outcome; + } + NewEvents.push_back(std::move(Event)); + } + + FEvidenceBundle Evidence; + Evidence.RequestId = Context.RequestId; + Evidence.Capability = Mutation.Capability; + Evidence.BeforeRevision = BeforeRevision; + Evidence.AfterRevision = AfterRevision; + Evidence.ChangeSet = ChangeSet; + Evidence.ValidatorResults = Mutation.ValidatorResults; + Evidence.ValidatorResults.insert( + Evidence.ValidatorResults.end(), + Check.Diagnostics.begin(), + Check.Diagnostics.end()); + + State->Revision = AfterRevision.Value; + State->NextEventSequence = Sequence; + for (FDomainEvent& Event : NewEvents) + { + State->Events.push_back(std::move(Event)); + if (State->Events.size() > MaxEditorDomainEvents) + { + State->Events.pop_front(); + } + } + State->Evidence.push_back(Evidence); + if (State->TotalEvidenceProduced + < std::numeric_limits::max()) + { + ++State->TotalEvidenceProduced; + } + if (State->Evidence.size() > MaxEditorDomainEvidence) + { + State->Evidence.pop_front(); + } + + FEditorCommandResult Result; + Result.Status = EEditorCommandStatus::Applied; + Result.BeforeRevision = BeforeRevision; + Result.AfterRevision = AfterRevision; + Result.ChangeSet = std::move(ChangeSet); + Result.Diagnostics = Check.Diagnostics; + Result.Evidence = std::move(Evidence); + Outcome.Result = std::move(Result); + return Outcome; + } + + FEditorLedgerOutcome FEditorDocumentLedger::ResolveNoOp( + std::string_view Domain, + const FEditorCommandContext& Context) const + { + FEditorLedgerOutcome Outcome; + const FExpectedRevisionCheck Check = CheckExpected(Domain, Context); + if (!Check.bAllowed) + { + if (Check.bConflict) + { + Outcome.Result = MakeConflictResult(Check); + } + else + { + Outcome.Diagnostics = Check.Diagnostics; + } + return Outcome; + } + + FEditorCommandResult Result; + Result.Status = EEditorCommandStatus::NoOp; + Result.BeforeRevision = Check.CurrentRevision; + Result.AfterRevision = Check.CurrentRevision; + Result.Diagnostics = Check.Diagnostics; + Result.Diagnostics.push_back(MakeDiagnostic( + "editor.command.no_op", + "$", + "command payload is equivalent to the current state", + Context.CorrelationId, + false, + EDiagnosticSeverity::Info)); + Outcome.Result = std::move(Result); + return Outcome; + } + + FEditorLedgerOutcome FEditorDocumentLedger::ObserveExternal( + std::string_view Domain, + const FEditorCommandContext& Context, + const FEditorMutation& Mutation) + { + if (Context.InvocationMode != EEditorInvocationMode::ExternalObservation) + { + FEditorLedgerOutcome Outcome; + Outcome.Diagnostics.push_back(MakeDiagnostic( + "editor.observation.mode_required", + "$.invocationMode", + "external observation requires ExternalObservation invocation mode", + Context.CorrelationId)); + return Outcome; + } + return CommitValidated(Domain, Context, Mutation); + } + + FEditorEventPage FEditorDocumentLedger::ReadEvents( + std::string_view Domain, + const std::optional& Cursor, + size_t Limit) const + { + FEditorEventPage Page; + const FDomainState* State = FindState(Domain); + if (State == nullptr || !bHasHydratedBaseline) + { + Page.Status = EChangeCursorStatus::ResyncRequired; + Page.Diagnostics.push_back(MakeDiagnostic( + State == nullptr + ? "editor.domain.unknown" + : "editor.ledger.baseline_required", + "$.domain", + State == nullptr + ? "editor document domain is not registered" + : "editor document baseline must be established after hydration")); + return Page; + } + + Page.SourceRevision = MakeRevision(State->Domain, State->Revision); + if (Limit == 0 || Limit > MaxEditorDomainEvents) + { + Page.NextCursor = MakeCursor(*State, State->NextEventSequence); + Page.Diagnostics.push_back(MakeDiagnostic( + "editor.change_feed.limit_invalid", + "$.limit", + "change feed limit must be in [1, 1024]")); + return Page; + } + + const uint64_t FirstAvailableSequence = + State->Events.empty() + ? State->NextEventSequence + : State->Events.front().Sequence; + uint64_t RequestedSequence = FirstAvailableSequence; + if (Cursor.has_value()) + { + const bool bRevisionMismatch = + Cursor->SourceRevision.Domain != State->Domain + || Cursor->SourceRevision.SchemaVersion.Major != 1 + || Cursor->SourceRevision.Value > State->Revision; + const bool bFutureSequence = + Cursor->NextSequence > State->NextEventSequence; + if (EvaluateChangeCursor( + *Cursor, + State->Provider, + 1, + FirstAvailableSequence) + == EChangeCursorStatus::ResyncRequired + || bRevisionMismatch + || bFutureSequence) + { + Page.Status = EChangeCursorStatus::ResyncRequired; + Page.NextCursor = MakeCursor(*State, State->NextEventSequence); + Page.Diagnostics.push_back(MakeDiagnostic( + "editor.change_feed.resync_required", + "$.cursor", + "change cursor is expired, incompatible, or contains a gap", + {}, + true)); + return Page; + } + RequestedSequence = Cursor->NextSequence; + } + + for (const FDomainEvent& Event : State->Events) + { + if (Event.Sequence < RequestedSequence) + { + continue; + } + if (Page.Events.size() == Limit) + { + Page.bTruncated = true; + break; + } + Page.Events.push_back(Event); + } + const uint64_t NextSequence = + Page.Events.empty() + ? RequestedSequence + : Page.Events.back().Sequence + 1; + Page.NextCursor = MakeCursor(*State, NextSequence); + return Page; + } + + FEditorEvidencePage FEditorDocumentLedger::ReadEvidence( + std::string_view Domain, + size_t Limit) const + { + FEditorEvidencePage Page; + const FDomainState* State = FindState(Domain); + if (State == nullptr || !bHasHydratedBaseline) + { + Page.Diagnostics.push_back(MakeDiagnostic( + State == nullptr + ? "editor.domain.unknown" + : "editor.ledger.baseline_required", + "$.domain", + State == nullptr + ? "editor document domain is not registered" + : "editor document baseline must be established after hydration")); + return Page; + } + Page.SourceRevision = MakeRevision(State->Domain, State->Revision); + if (Limit == 0 || Limit > MaxEditorDomainEvidence) + { + Page.Diagnostics.push_back(MakeDiagnostic( + "editor.evidence.limit_invalid", + "$.limit", + "evidence limit must be in [1, 256]")); + return Page; + } + + const size_t Start = + State->Evidence.size() > Limit + ? State->Evidence.size() - Limit + : 0; + Page.bTruncated = + Start != 0 + || State->TotalEvidenceProduced > State->Evidence.size(); + Page.Evidence.reserve(State->Evidence.size() - Start); + for (size_t Index = Start; Index < State->Evidence.size(); ++Index) + { + Page.Evidence.push_back(State->Evidence[Index]); + } + return Page; + } + + FEditorDocumentLedger::FDomainState* FEditorDocumentLedger::FindState( + std::string_view Domain) + { + for (FDomainState& State : Domains) + { + if (State.Domain == Domain) + { + return &State; + } + } + return nullptr; + } + + const FEditorDocumentLedger::FDomainState* + FEditorDocumentLedger::FindState(std::string_view Domain) const + { + for (const FDomainState& State : Domains) + { + if (State.Domain == Domain) + { + return &State; + } + } + return nullptr; + } +} diff --git a/Engine/Source/Developer/Document/EditorDocumentLedger.h b/Engine/Source/Developer/Document/EditorDocumentLedger.h new file mode 100644 index 0000000..b0695e9 --- /dev/null +++ b/Engine/Source/Developer/Document/EditorDocumentLedger.h @@ -0,0 +1,159 @@ +#pragma once + +#include "Contract/ContractCore.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace WaveEditorDomain +{ + inline constexpr std::string_view SceneDomain = "editor.scene"; + inline constexpr std::string_view RenderSettingsDomain = "editor.render_settings"; + inline constexpr size_t MaxEditorDomainEvents = 1024; + inline constexpr size_t MaxEditorDomainEvidence = 256; + + enum class EEditorInvocationMode : uint8_t + { + Typed, + LegacyCompatibility, + ExternalObservation + }; + + struct FEditorCommandContext + { + std::optional ExpectedRevision; + std::string RequestId; + std::string CorrelationId; + EEditorInvocationMode InvocationMode = EEditorInvocationMode::Typed; + }; + + enum class EEditorCommandStatus : uint8_t + { + Applied, + NoOp, + Conflict + }; + + struct FEditorMutation + { + std::string Capability; + std::vector Targets; + WaveContract::EDomainChangeKind Kind = + WaveContract::EDomainChangeKind::Updated; + std::vector ChangedFields; + std::vector ReferenceChanges; + std::vector Warnings; + std::vector ValidatorResults; + }; + + struct FEditorCommandResult + { + EEditorCommandStatus Status = EEditorCommandStatus::NoOp; + WaveContract::FRevision BeforeRevision; + WaveContract::FRevision AfterRevision; + std::optional ChangeSet; + std::vector Diagnostics; + std::optional Evidence; + }; + + struct FEditorLedgerOutcome + { + std::optional Result; + std::vector Diagnostics; + + [[nodiscard]] bool HasResult() const + { + return Result.has_value(); + } + }; + + struct FExpectedRevisionCheck + { + bool bAllowed = false; + bool bConflict = false; + WaveContract::FRevision CurrentRevision; + std::vector Diagnostics; + }; + + struct FEditorEventPage + { + WaveContract::EChangeCursorStatus Status = + WaveContract::EChangeCursorStatus::Current; + WaveContract::FRevision SourceRevision; + std::vector Events; + WaveContract::FChangeCursor NextCursor; + bool bTruncated = false; + std::vector Diagnostics; + }; + + struct FEditorEvidencePage + { + WaveContract::FRevision SourceRevision; + std::vector Evidence; + bool bTruncated = false; + std::vector Diagnostics; + }; + + class FEditorDocumentLedger + { + public: + FEditorDocumentLedger(); + + void EstablishHydratedBaseline( + uint64_t SceneRevision, + uint64_t RenderSettingsRevision); + + [[nodiscard]] bool HasHydratedBaseline() const + { + return bHasHydratedBaseline; + } + + [[nodiscard]] std::optional GetRevision( + std::string_view Domain) const; + [[nodiscard]] FExpectedRevisionCheck CheckExpected( + std::string_view Domain, + const FEditorCommandContext& Context) const; + [[nodiscard]] FEditorLedgerOutcome CommitValidated( + std::string_view Domain, + const FEditorCommandContext& Context, + const FEditorMutation& Mutation); + [[nodiscard]] FEditorLedgerOutcome ResolveNoOp( + std::string_view Domain, + const FEditorCommandContext& Context) const; + [[nodiscard]] FEditorLedgerOutcome ObserveExternal( + std::string_view Domain, + const FEditorCommandContext& Context, + const FEditorMutation& Mutation); + [[nodiscard]] FEditorEventPage ReadEvents( + std::string_view Domain, + const std::optional& Cursor, + size_t Limit) const; + [[nodiscard]] FEditorEvidencePage ReadEvidence( + std::string_view Domain, + size_t Limit) const; + + private: + struct FDomainState + { + std::string Domain; + std::string Provider; + uint64_t Revision = 0; + uint64_t NextEventSequence = 1; + uint64_t TotalEvidenceProduced = 0; + std::deque Events; + std::deque Evidence; + }; + + [[nodiscard]] FDomainState* FindState(std::string_view Domain); + [[nodiscard]] const FDomainState* FindState(std::string_view Domain) const; + + std::array Domains; + bool bHasHydratedBaseline = false; + }; +} diff --git a/Engine/Source/Developer/Document/EditorDomainServices.cpp b/Engine/Source/Developer/Document/EditorDomainServices.cpp new file mode 100644 index 0000000..a7a9743 --- /dev/null +++ b/Engine/Source/Developer/Document/EditorDomainServices.cpp @@ -0,0 +1,275 @@ +#include "Developer/Document/EditorDomainServices.h" + +#include "Scene/Camera.h" +#include "Scene/Scene.h" + +#include +#include +#include +#include +#include +#include + +namespace WaveEditorDomain +{ + namespace + { + using namespace WaveContract; + + struct FDocumentSnapshot + { + std::map Instances; + FEditorCameraState Camera; + }; + + FEditorCameraState CaptureCamera(const FScene* Scene) + { + if (!Scene) + { + return {}; + } + return { + { + Scene->Camera.Position.x, + Scene->Camera.Position.y, + Scene->Camera.Position.z + }, + { + Scene->Camera.Rotation.Pitch, + Scene->Camera.Rotation.Yaw, + Scene->Camera.Rotation.Roll + } + }; + } + + FDocumentSnapshot CaptureDocument( + const FSceneAuthoringService& SceneService, + const FScene* Scene) + { + FDocumentSnapshot Snapshot; + for (const FSceneInstanceState& Instance : + SceneService.ListInstances().Value) + { + uint32 Id = 0; + const auto [End, Error] = std::from_chars( + Instance.Ref.Id.data(), + Instance.Ref.Id.data() + Instance.Ref.Id.size(), + Id); + if (Error == std::errc{} + && End == Instance.Ref.Id.data() + + Instance.Ref.Id.size()) + { + Snapshot.Instances.emplace(Id, Instance); + } + } + Snapshot.Camera = CaptureCamera(Scene); + return Snapshot; + } + + bool NearlyEqual(double Left, double Right) + { + return std::abs(Left - Right) <= 1.0e-6; + } + + bool CameraEquivalent( + const FEditorCameraState& Left, + const FEditorCameraState& Right) + { + return NearlyEqual(Left.Position.X, Right.Position.X) + && NearlyEqual(Left.Position.Y, Right.Position.Y) + && NearlyEqual(Left.Position.Z, Right.Position.Z) + && NearlyEqual(Left.Rotation.Pitch, Right.Rotation.Pitch) + && NearlyEqual(Left.Rotation.Yaw, Right.Rotation.Yaw) + && NearlyEqual(Left.Rotation.Roll, Right.Rotation.Roll); + } + + void AddUniqueField( + std::vector& Fields, + std::string Field) + { + if (std::find(Fields.begin(), Fields.end(), Field) + == Fields.end()) + { + Fields.push_back(std::move(Field)); + } + } + } + + FEditorDomainServices::FEditorDomainServices( + IEditorAuthoringHost& InHost) + : Host(InHost) + , SceneService(Ledger, Host) + , CameraService(Ledger, Host) + , LightingService(Ledger, Host) + , RenderSettingsService(Ledger, Host) + { + } + + void FEditorDomainServices::EstablishHydratedBaseline( + uint64 SceneRevision, + uint64 RenderSettingsRevision) + { + Ledger.EstablishHydratedBaseline( + SceneRevision, + RenderSettingsRevision); + CameraService.SynchronizeObserverBaseline(); + } + + FEditorCommandContext FEditorDomainServices::MakeCommandContext( + std::string_view Domain, + std::string_view Capability, + EEditorInvocationMode InvocationMode) + { + FEditorCommandContext Context; + Context.InvocationMode = InvocationMode; + Context.ExpectedRevision = Ledger.GetRevision(Domain); + Context.RequestId = + std::string( + InvocationMode == EEditorInvocationMode::LegacyCompatibility + ? "legacy:" + : InvocationMode == EEditorInvocationMode::ExternalObservation + ? "external:" + : "ui:") + + std::to_string(NextRequestId) + + ":" + + std::string(Capability); + Context.CorrelationId = + "editor:" + std::to_string(NextRequestId); + if (NextRequestId < std::numeric_limits::max()) + { + ++NextRequestId; + } + return Context; + } + + FEditorCommandContext FEditorDomainServices::MakeUiCommandContext( + std::string_view Domain, + std::string_view Capability) + { + return MakeCommandContext( + Domain, + Capability, + EEditorInvocationMode::Typed); + } + + FEditorCommandContext FEditorDomainServices::MakeLegacyCommandContext( + std::string_view Domain, + std::string_view Capability) + { + return MakeCommandContext( + Domain, + Capability, + EEditorInvocationMode::LegacyCompatibility); + } + + bool FEditorDomainServices::Undo() + { + return ApplyHistory(true); + } + + bool FEditorDomainServices::Redo() + { + return ApplyHistory(false); + } + + bool FEditorDomainServices::ApplyHistory(bool bUndo) + { + if ((bUndo && !Host.CanUndoEditorCommand()) + || (!bUndo && !Host.CanRedoEditorCommand())) + { + return false; + } + const char* Capability = + bUndo ? "editor.history.undo" : "editor.history.redo"; + const FEditorCommandContext Context = MakeUiCommandContext( + SceneDomain, + Capability); + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(SceneDomain, Context); + if (!Check.bAllowed) + { + return false; + } + + const FDocumentSnapshot Before = + CaptureDocument(SceneService, Host.GetEditorScene()); + if (bUndo) + { + Host.UndoEditorCommand(); + } + else + { + Host.RedoEditorCommand(); + } + const FDocumentSnapshot After = + CaptureDocument(SceneService, Host.GetEditorScene()); + + FEditorMutation Mutation; + Mutation.Capability = Capability; + Mutation.Kind = EDomainChangeKind::Updated; + for (const auto& [Id, BeforeInstance] : Before.Instances) + { + const auto AfterIt = After.Instances.find(Id); + if (AfterIt == After.Instances.end()) + { + Mutation.Targets.push_back(BeforeInstance.Ref); + Mutation.Kind = EDomainChangeKind::Removed; + AddUniqueField(Mutation.ChangedFields, "instance"); + continue; + } + const FSceneInstanceState& AfterInstance = AfterIt->second; + if (BeforeInstance.DisplayName != AfterInstance.DisplayName) + { + Mutation.Targets.push_back(AfterInstance.Ref); + AddUniqueField( + Mutation.ChangedFields, + "display_name"); + } + if (!EditorTransformsEquivalent( + BeforeInstance.Transform, + AfterInstance.Transform)) + { + if (std::find( + Mutation.Targets.begin(), + Mutation.Targets.end(), + AfterInstance.Ref) + == Mutation.Targets.end()) + { + Mutation.Targets.push_back(AfterInstance.Ref); + } + AddUniqueField( + Mutation.ChangedFields, + "transform"); + } + } + for (const auto& [Id, AfterInstance] : After.Instances) + { + if (!Before.Instances.contains(Id)) + { + Mutation.Targets.push_back(AfterInstance.Ref); + Mutation.Kind = EDomainChangeKind::Created; + AddUniqueField(Mutation.ChangedFields, "instance"); + } + } + if (!CameraEquivalent(Before.Camera, After.Camera)) + { + Mutation.Targets.push_back(MakeCameraRef()); + AddUniqueField(Mutation.ChangedFields, "camera.transform"); + CameraService.SynchronizeObserverBaseline(); + } + if (Mutation.Targets.empty()) + { + (void)Ledger.ResolveNoOp(SceneDomain, Context); + return true; + } + Mutation.ValidatorResults = { + MakeValidationPassed("wave.editor.document.history") + }; + const FEditorLedgerOutcome Outcome = + Ledger.CommitValidated(SceneDomain, Context, Mutation); + check( + Outcome.HasResult(), + "Editor history effect could not be recorded in the document ledger"); + return true; + } +} diff --git a/Engine/Source/Developer/Document/EditorDomainServices.h b/Engine/Source/Developer/Document/EditorDomainServices.h new file mode 100644 index 0000000..6b6e328 --- /dev/null +++ b/Engine/Source/Developer/Document/EditorDomainServices.h @@ -0,0 +1,91 @@ +#pragma once + +#include "Developer/Document/EditorDocumentLedger.h" +#include "Developer/Render/RenderSettingsAuthoringService.h" +#include "Developer/Scene/CameraAuthoringService.h" +#include "Developer/Scene/LightingAuthoringService.h" +#include "Developer/Scene/SceneAuthoringService.h" + +#include +#include + +namespace WaveEditorDomain +{ + class FEditorDomainServices + { + public: + explicit FEditorDomainServices(IEditorAuthoringHost& InHost); + + void EstablishHydratedBaseline( + uint64 SceneRevision = 0, + uint64 RenderSettingsRevision = 0); + + [[nodiscard]] FEditorCommandContext MakeCommandContext( + std::string_view Domain, + std::string_view Capability, + EEditorInvocationMode InvocationMode); + [[nodiscard]] FEditorCommandContext MakeUiCommandContext( + std::string_view Domain, + std::string_view Capability); + [[nodiscard]] FEditorCommandContext MakeLegacyCommandContext( + std::string_view Domain, + std::string_view Capability); + + [[nodiscard]] bool Undo(); + [[nodiscard]] bool Redo(); + + [[nodiscard]] FEditorDocumentLedger& GetLedger() + { + return Ledger; + } + [[nodiscard]] const FEditorDocumentLedger& GetLedger() const + { + return Ledger; + } + [[nodiscard]] FSceneAuthoringService& SceneAuthoring() + { + return SceneService; + } + [[nodiscard]] const FSceneAuthoringService& SceneAuthoring() const + { + return SceneService; + } + [[nodiscard]] FCameraAuthoringService& CameraAuthoring() + { + return CameraService; + } + [[nodiscard]] const FCameraAuthoringService& CameraAuthoring() const + { + return CameraService; + } + [[nodiscard]] FLightingAuthoringService& LightingAuthoring() + { + return LightingService; + } + [[nodiscard]] const FLightingAuthoringService& LightingAuthoring() const + { + return LightingService; + } + [[nodiscard]] FRenderSettingsAuthoringService& + RenderSettingsAuthoring() + { + return RenderSettingsService; + } + [[nodiscard]] const FRenderSettingsAuthoringService& + RenderSettingsAuthoring() const + { + return RenderSettingsService; + } + + private: + [[nodiscard]] bool ApplyHistory(bool bUndo); + + IEditorAuthoringHost& Host; + FEditorDocumentLedger Ledger; + FSceneAuthoringService SceneService; + FCameraAuthoringService CameraService; + FLightingAuthoringService LightingService; + FRenderSettingsAuthoringService RenderSettingsService; + uint64 NextRequestId = 1; + }; +} diff --git a/Engine/Source/Developer/Render/RenderSettingsAuthoringService.cpp b/Engine/Source/Developer/Render/RenderSettingsAuthoringService.cpp new file mode 100644 index 0000000..f9bbf9c --- /dev/null +++ b/Engine/Source/Developer/Render/RenderSettingsAuthoringService.cpp @@ -0,0 +1,458 @@ +#include "Developer/Render/RenderSettingsAuthoringService.h" + +#include "Scene/Scene.h" + +#include +#include + +namespace WaveEditorDomain +{ + namespace + { + using namespace WaveContract; + + FStructuredDiagnostic MakeDiagnostic( + std::string Code, + std::string Path, + std::string Message) + { + FStructuredDiagnostic Diagnostic; + Diagnostic.Code = std::move(Code); + Diagnostic.Path = std::move(Path); + Diagnostic.Message = std::move(Message); + return Diagnostic; + } + + FEditorLedgerOutcome MakeInvalidOutcome( + std::vector Diagnostics, + std::string_view CorrelationId) + { + for (FStructuredDiagnostic& Diagnostic : Diagnostics) + { + Diagnostic.CorrelationId = CorrelationId; + } + FEditorLedgerOutcome Outcome; + Outcome.Diagnostics = std::move(Diagnostics); + return Outcome; + } + + void RetainValidationPassed( + FEditorValidationResult& Validation, + std::string_view Validator) + { + if (Validation.Succeeded() && Validation.Diagnostics.empty()) + { + Validation.Diagnostics.push_back( + MakeValidationPassed(Validator)); + } + } + + bool NearlyEqual(double Left, double Right) + { + return std::abs(Left - Right) <= 1.0e-6; + } + + bool SettingsEquivalent( + const FEditorRenderSettingsState& Left, + const FEditorRenderSettingsState& Right) + { + return Left.DebugView == Right.DebugView + && Left.RenderMode == Right.RenderMode + && NearlyEqual( + Left.LodErrorThreshold, + Right.LodErrorThreshold) + && Left.bAutoExposureEnabled + == Right.bAutoExposureEnabled + && NearlyEqual( + Left.AutoExposureSpeed, + Right.AutoExposureSpeed) + && NearlyEqual( + Left.AutoExposureMin, + Right.AutoExposureMin) + && NearlyEqual( + Left.AutoExposureMax, + Right.AutoExposureMax) + && NearlyEqual( + Left.AutoExposureMiddleGrey, + Right.AutoExposureMiddleGrey) + && NearlyEqual( + Left.ManualExposure, + Right.ManualExposure) + && Left.PathTracingMaxBounces + == Right.PathTracingMaxBounces + && Left.PathTracingSamplesPerPixel + == Right.PathTracingSamplesPerPixel + && Left.PathTracingRussianRouletteStart + == Right.PathTracingRussianRouletteStart + && Left.bPathTracingAccumulationEnabled + == Right.bPathTracingAccumulationEnabled; + } + + FEditorRenderSettingsState CaptureSettings(const FScene& Scene) + { + return { + static_cast(Scene.DebugViewMode), + static_cast(Scene.RenderMode), + Scene.LODErrorThreshold, + Scene.bAutoExposureEnabled, + Scene.AutoExposureSpeed, + Scene.AutoExposureMin, + Scene.AutoExposureMax, + Scene.AutoExposureMiddleGrey, + Scene.ManualExposure, + Scene.PTMaxBounces, + Scene.PTSamplesPerPixel, + Scene.PTRussianRouletteStartBounce, + Scene.bPTAccumulationEnabled + }; + } + + void ApplySettings( + FScene& Scene, + const FEditorRenderSettingsState& Settings) + { + Scene.DebugViewMode = + static_cast(Settings.DebugView); + Scene.RenderMode = + static_cast(Settings.RenderMode); + Scene.LODErrorThreshold = + static_cast(Settings.LodErrorThreshold); + Scene.bAutoExposureEnabled = + Settings.bAutoExposureEnabled; + Scene.AutoExposureSpeed = + static_cast(Settings.AutoExposureSpeed); + Scene.AutoExposureMin = + static_cast(Settings.AutoExposureMin); + Scene.AutoExposureMax = + static_cast(Settings.AutoExposureMax); + Scene.AutoExposureMiddleGrey = + static_cast(Settings.AutoExposureMiddleGrey); + Scene.ManualExposure = + static_cast(Settings.ManualExposure); + Scene.PTMaxBounces = + Settings.PathTracingMaxBounces; + Scene.PTSamplesPerPixel = + Settings.PathTracingSamplesPerPixel; + Scene.PTRussianRouletteStartBounce = + Settings.PathTracingRussianRouletteStart; + Scene.bPTAccumulationEnabled = + Settings.bPathTracingAccumulationEnabled; + } + + std::vector GetChangedFields( + const FEditorRenderSettingsState& Before, + const FEditorRenderSettingsState& After) + { + std::vector Fields; + const auto AddIf = [&Fields]( + bool bChanged, + const char* Field) + { + if (bChanged) + { + Fields.emplace_back(Field); + } + }; + AddIf( + Before.DebugView != After.DebugView, + "debug_view"); + AddIf( + Before.RenderMode != After.RenderMode, + "render_mode"); + AddIf( + !NearlyEqual( + Before.LodErrorThreshold, + After.LodErrorThreshold), + "lod_error_threshold"); + AddIf( + Before.bAutoExposureEnabled + != After.bAutoExposureEnabled, + "auto_exposure_enabled"); + AddIf( + !NearlyEqual( + Before.AutoExposureSpeed, + After.AutoExposureSpeed), + "auto_exposure_speed"); + AddIf( + !NearlyEqual( + Before.AutoExposureMin, + After.AutoExposureMin), + "auto_exposure_min"); + AddIf( + !NearlyEqual( + Before.AutoExposureMax, + After.AutoExposureMax), + "auto_exposure_max"); + AddIf( + !NearlyEqual( + Before.AutoExposureMiddleGrey, + After.AutoExposureMiddleGrey), + "auto_exposure_middle_grey"); + AddIf( + !NearlyEqual( + Before.ManualExposure, + After.ManualExposure), + "manual_exposure"); + AddIf( + Before.PathTracingMaxBounces + != After.PathTracingMaxBounces, + "pt_max_bounces"); + AddIf( + Before.PathTracingSamplesPerPixel + != After.PathTracingSamplesPerPixel, + "pt_samples_per_pixel"); + AddIf( + Before.PathTracingRussianRouletteStart + != After.PathTracingRussianRouletteStart, + "pt_russian_roulette_start"); + AddIf( + Before.bPathTracingAccumulationEnabled + != After.bPathTracingAccumulationEnabled, + "pt_accumulation_enabled"); + return Fields; + } + } + + FObjectRef MakeRenderSettingsRef() + { + return MakeSessionObjectRef( + "editor.render_settings", + "main"); + } + + FRenderSettingsAuthoringService::FRenderSettingsAuthoringService( + FEditorDocumentLedger& InLedger, + IEditorAuthoringHost& InHost) + : Ledger(InLedger) + , Host(InHost) + { + } + + bool FRenderSettingsAuthoringService::IsAvailable() const + { + return Host.GetEditorScene() != nullptr; + } + + TEditorTypedSnapshot + FRenderSettingsAuthoringService::GetSettings() const + { + TEditorTypedSnapshot Snapshot; + if (const std::optional Revision = + Ledger.GetRevision(RenderSettingsDomain)) + { + Snapshot.Meta.SourceRevision = *Revision; + } + if (const FScene* Scene = Host.GetEditorScene()) + { + Snapshot.Value = CaptureSettings(*Scene); + } + return Snapshot; + } + + uint32 + FRenderSettingsAuthoringService::GetAccumulatedSamplesDisplay() const + { + const FScene* Scene = Host.GetEditorScene(); + return Scene ? Scene->PTAccumulatedSamplesDisplay : 0; + } + + FEditorValidationResult + FRenderSettingsAuthoringService::HydrateSettings( + const FEditorRenderSettingsState& Settings) + { + FEditorValidationResult Validation = + ValidateRenderSettings(Settings); + if (Validation.Succeeded()) + { + if (FScene* Scene = Host.GetEditorScene()) + { + ApplySettings(*Scene, Settings); + } + } + return Validation; + } + + FEditorLedgerOutcome FRenderSettingsAuthoringService::SetSettings( + const FEditorRenderSettingsState& Settings, + const FEditorCommandContext& Context, + std::string_view Capability) + { + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(RenderSettingsDomain, Context); + if (!Check.bAllowed) + { + return Ledger.ResolveNoOp(RenderSettingsDomain, Context); + } + if (!IsAvailable()) + { + return MakeInvalidOutcome( + { MakeDiagnostic( + "editor.renderer.not_initialized", + "$.target", + "Scene not initialized") }, + Context.CorrelationId); + } + FEditorValidationResult Validation = + ValidateRenderSettings(Settings); + if (!Validation.Succeeded()) + { + return MakeInvalidOutcome( + std::move(Validation.Diagnostics), + Context.CorrelationId); + } + RetainValidationPassed( + Validation, + "wave.editor.render.set_settings"); + return CommitSettings( + GetSettings().Value, + Settings, + Context, + Capability, + false, + std::move(Validation.Diagnostics)); + } + + FRenderSettingsPreviewResult + FRenderSettingsAuthoringService::PreviewSettings( + const FEditorRenderSettingsState& Settings, + const FEditorCommandContext& Context) + { + FRenderSettingsPreviewResult Result; + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(RenderSettingsDomain, Context); + if (!Check.bAllowed) + { + Result.Diagnostics = Check.Diagnostics; + return Result; + } + FScene* Scene = Host.GetEditorScene(); + if (!Scene) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "editor.renderer.not_initialized", + "$.target", + "Scene not initialized")); + return Result; + } + FEditorValidationResult Validation = + ValidateRenderSettings(Settings); + Result.Diagnostics = Validation.Diagnostics; + if (!Validation.Succeeded()) + { + return Result; + } + const FEditorRenderSettingsState Before = + CaptureSettings(*Scene); + if (SettingsEquivalent(Before, Settings)) + { + Result.bNoOp = true; + return Result; + } + ApplySettings(*Scene, Settings); + if (RequiresPathTracingReset(Before, Settings)) + { + Scene->bPTResetAccumulation = true; + } + Result.bApplied = true; + return Result; + } + + FEditorLedgerOutcome + FRenderSettingsAuthoringService::CommitSettingsPreview( + const FEditorRenderSettingsState& Before, + const FEditorCommandContext& Context, + std::string_view Capability) + { + FScene* Scene = Host.GetEditorScene(); + if (!Scene) + { + return MakeInvalidOutcome( + { MakeDiagnostic( + "editor.renderer.not_initialized", + "$.target", + "Scene not initialized") }, + Context.CorrelationId); + } + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(RenderSettingsDomain, Context); + if (!Check.bAllowed) + { + const FEditorRenderSettingsState Current = + CaptureSettings(*Scene); + ApplySettings(*Scene, Before); + if (RequiresPathTracingReset( + Current, + Before)) + { + Scene->bPTResetAccumulation = true; + } + return Ledger.ResolveNoOp(RenderSettingsDomain, Context); + } + const FEditorRenderSettingsState After = + CaptureSettings(*Scene); + FEditorValidationResult Validation = + ValidateRenderSettings(After); + if (!Validation.Succeeded()) + { + ApplySettings(*Scene, Before); + return MakeInvalidOutcome( + std::move(Validation.Diagnostics), + Context.CorrelationId); + } + RetainValidationPassed( + Validation, + "wave.editor.render.commit_settings_preview"); + return CommitSettings( + Before, + After, + Context, + Capability, + true, + std::move(Validation.Diagnostics)); + } + + void FRenderSettingsAuthoringService::RequestPathTracingReset() + { + if (FScene* Scene = Host.GetEditorScene()) + { + Scene->bPTResetAccumulation = true; + } + } + + FEditorLedgerOutcome + FRenderSettingsAuthoringService::CommitSettings( + const FEditorRenderSettingsState& Before, + const FEditorRenderSettingsState& After, + const FEditorCommandContext& Context, + std::string_view Capability, + bool bAlreadyApplied, + std::vector ValidatorResults) + { + if (SettingsEquivalent(Before, After)) + { + return Ledger.ResolveNoOp(RenderSettingsDomain, Context); + } + FScene* Scene = Host.GetEditorScene(); + check(Scene != nullptr, "Validated render settings lost their scene"); + if (!bAlreadyApplied) + { + ApplySettings(*Scene, After); + } + if (RequiresPathTracingReset(Before, After)) + { + Scene->bPTResetAccumulation = true; + } + Host.MarkEditorSettingsDirty(); + + FEditorMutation Mutation; + Mutation.Capability = std::string(Capability); + Mutation.Targets = { MakeRenderSettingsRef() }; + Mutation.Kind = EDomainChangeKind::Updated; + Mutation.ChangedFields = GetChangedFields(Before, After); + Mutation.ValidatorResults = std::move(ValidatorResults); + return Ledger.CommitValidated( + RenderSettingsDomain, + Context, + Mutation); + } +} diff --git a/Engine/Source/Developer/Render/RenderSettingsAuthoringService.h b/Engine/Source/Developer/Render/RenderSettingsAuthoringService.h new file mode 100644 index 0000000..a0c105c --- /dev/null +++ b/Engine/Source/Developer/Render/RenderSettingsAuthoringService.h @@ -0,0 +1,59 @@ +#pragma once + +#include "Developer/Scene/SceneAuthoringService.h" + +#include +#include + +namespace WaveEditorDomain +{ + struct FRenderSettingsPreviewResult + { + bool bApplied = false; + bool bNoOp = false; + std::vector Diagnostics; + }; + + [[nodiscard]] WaveContract::FObjectRef MakeRenderSettingsRef(); + + class FRenderSettingsAuthoringService + { + public: + FRenderSettingsAuthoringService( + FEditorDocumentLedger& InLedger, + IEditorAuthoringHost& InHost); + + [[nodiscard]] bool IsAvailable() const; + [[nodiscard]] TEditorTypedSnapshot + GetSettings() const; + [[nodiscard]] uint32 GetAccumulatedSamplesDisplay() const; + [[nodiscard]] FEditorValidationResult HydrateSettings( + const FEditorRenderSettingsState& Settings); + [[nodiscard]] FEditorLedgerOutcome SetSettings( + const FEditorRenderSettingsState& Settings, + const FEditorCommandContext& Context, + std::string_view Capability = + "editor.renderer.set_setting"); + [[nodiscard]] FRenderSettingsPreviewResult PreviewSettings( + const FEditorRenderSettingsState& Settings, + const FEditorCommandContext& Context); + [[nodiscard]] FEditorLedgerOutcome CommitSettingsPreview( + const FEditorRenderSettingsState& Before, + const FEditorCommandContext& Context, + std::string_view Capability = + "editor.renderer.set_setting"); + void RequestPathTracingReset(); + + private: + [[nodiscard]] FEditorLedgerOutcome CommitSettings( + const FEditorRenderSettingsState& Before, + const FEditorRenderSettingsState& After, + const FEditorCommandContext& Context, + std::string_view Capability, + bool bAlreadyApplied, + std::vector ValidatorResults); + + FEditorDocumentLedger& Ledger; + IEditorAuthoringHost& Host; + }; +} diff --git a/Engine/Source/Developer/RenderDocIntegration.cpp b/Engine/Source/Developer/RenderDocIntegration.cpp index 48fbc34..e43b365 100644 --- a/Engine/Source/Developer/RenderDocIntegration.cpp +++ b/Engine/Source/Developer/RenderDocIntegration.cpp @@ -1,5 +1,6 @@ #include "RenderDocIntegration.h" +#include "Core/Paths/PathService.h" #include "Misc/Log.h" #include @@ -204,7 +205,20 @@ void FRenderDocIntegration::Initialize() Module = RenderDocModule; Api = RenderDocApi; RenderDocApi->SetCaptureKeys(nullptr, 0); - const std::string CaptureTemplate = std::string(ENGINE_TEMP_FOLDER) + "RenderDoc/WaveEngine"; + const std::filesystem::path CaptureTemplatePath = + WavePaths::ResolveRequiredWritePath( + WavePaths::EPathDomain::UserSaved, + "Captures/RenderDoc/WaveEngine"); + std::error_code CaptureDirectoryError; + std::filesystem::create_directories( + CaptureTemplatePath.parent_path(), + CaptureDirectoryError); + if (CaptureDirectoryError) + { + StatusText = "RenderDoc capture directory is unavailable"; + return; + } + const std::string CaptureTemplate = CaptureTemplatePath.string(); RenderDocApi->SetCaptureFilePathTemplate(CaptureTemplate.c_str()); int Major = 0; diff --git a/Engine/Source/Developer/Scene/CameraAuthoringService.cpp b/Engine/Source/Developer/Scene/CameraAuthoringService.cpp new file mode 100644 index 0000000..3bf8340 --- /dev/null +++ b/Engine/Source/Developer/Scene/CameraAuthoringService.cpp @@ -0,0 +1,499 @@ +#include "Developer/Scene/CameraAuthoringService.h" + +#include "Scene/Camera.h" +#include "Scene/Scene.h" + +#include +#include +#include + +namespace WaveEditorDomain +{ + namespace + { + using namespace WaveContract; + + FStructuredDiagnostic MakeDiagnostic( + std::string Code, + std::string Path, + std::string Message) + { + FStructuredDiagnostic Diagnostic; + Diagnostic.Code = std::move(Code); + Diagnostic.Path = std::move(Path); + Diagnostic.Message = std::move(Message); + return Diagnostic; + } + + FEditorLedgerOutcome MakeInvalidOutcome( + std::vector Diagnostics, + std::string_view CorrelationId) + { + for (FStructuredDiagnostic& Diagnostic : Diagnostics) + { + Diagnostic.CorrelationId = CorrelationId; + } + FEditorLedgerOutcome Outcome; + Outcome.Diagnostics = std::move(Diagnostics); + return Outcome; + } + + void RetainValidationPassed( + FEditorValidationResult& Validation, + std::string_view Validator) + { + if (Validation.Succeeded() && Validation.Diagnostics.empty()) + { + Validation.Diagnostics.push_back( + MakeValidationPassed(Validator)); + } + } + + bool NearlyEqual(double Left, double Right) + { + return std::abs(Left - Right) <= 1.0e-6; + } + + void ApplyCamera( + FCamera& Camera, + const FEditorCameraState& State) + { + Camera.Position = FVector3( + static_cast(State.Position.X), + static_cast(State.Position.Y), + static_cast(State.Position.Z)); + Camera.SetRotation(FRotator( + static_cast(State.Rotation.Pitch), + static_cast(State.Rotation.Yaw), + static_cast(State.Rotation.Roll))); + } + + std::vector GetChangedFields( + const FEditorCameraState& Before, + const FEditorCameraState& After) + { + std::vector Fields; + if (!NearlyEqual(Before.Position.X, After.Position.X) + || !NearlyEqual(Before.Position.Y, After.Position.Y) + || !NearlyEqual(Before.Position.Z, After.Position.Z)) + { + Fields.push_back("camera.position"); + } + if (!NearlyEqual(Before.Rotation.Pitch, After.Rotation.Pitch) + || !NearlyEqual(Before.Rotation.Yaw, After.Rotation.Yaw) + || !NearlyEqual(Before.Rotation.Roll, After.Rotation.Roll)) + { + Fields.push_back("camera.rotation"); + } + return Fields; + } + } + + FEditorCameraState CaptureEditorCameraState(const FCamera& Camera) + { + return { + { + static_cast(Camera.Position.x), + static_cast(Camera.Position.y), + static_cast(Camera.Position.z) + }, + { + static_cast(Camera.Rotation.Pitch), + static_cast(Camera.Rotation.Yaw), + static_cast(Camera.Rotation.Roll) + } + }; + } + + bool EditorCameraStatesEquivalent( + const FEditorCameraState& Left, + const FEditorCameraState& Right) + { + return NearlyEqual(Left.Position.X, Right.Position.X) + && NearlyEqual(Left.Position.Y, Right.Position.Y) + && NearlyEqual(Left.Position.Z, Right.Position.Z) + && NearlyEqual(Left.Rotation.Pitch, Right.Rotation.Pitch) + && NearlyEqual(Left.Rotation.Yaw, Right.Rotation.Yaw) + && NearlyEqual(Left.Rotation.Roll, Right.Rotation.Roll); + } + + FCameraAuthoringService::FCameraAuthoringService( + FEditorDocumentLedger& InLedger, + IEditorAuthoringHost& InHost) + : Ledger(InLedger) + , Host(InHost) + { + } + + TEditorTypedSnapshot + FCameraAuthoringService::GetCamera() const + { + TEditorTypedSnapshot Snapshot; + if (const std::optional Revision = + Ledger.GetRevision(SceneDomain)) + { + Snapshot.Meta.SourceRevision = *Revision; + } + if (FScene* Scene = Host.GetEditorScene()) + { + Snapshot.Value.Transform = + CaptureEditorCameraState(Scene->Camera); + Snapshot.Value.FieldOfViewDegrees = + Scene->Camera.GetFov(); + Snapshot.Value.NearPlane = + Scene->Camera.GetNearPlane(); + Snapshot.Value.FarPlane = + Scene->Camera.GetFarPlane(); + } + return Snapshot; + } + + bool FCameraAuthoringService::IsAvailable() const + { + return Host.GetEditorScene() != nullptr; + } + + FEditorLedgerOutcome FCameraAuthoringService::SetCamera( + FEditorCameraState Camera, + const FEditorCommandContext& Context, + std::string_view Label, + std::string_view Capability) + { + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(SceneDomain, Context); + if (!Check.bAllowed) + { + return Ledger.ResolveNoOp(SceneDomain, Context); + } + FScene* Scene = Host.GetEditorScene(); + if (!Scene) + { + return MakeInvalidOutcome( + { MakeDiagnostic( + "editor.camera.not_initialized", + "$.target", + "camera is not initialized") }, + Context.CorrelationId); + } + FEditorValidationResult Validation = ValidateCamera(Camera); + if (!Validation.Succeeded()) + { + return MakeInvalidOutcome( + std::move(Validation.Diagnostics), + Context.CorrelationId); + } + RetainValidationPassed( + Validation, + "wave.editor.scene.set_camera"); + return CommitCamera( + CaptureEditorCameraState(Scene->Camera), + Camera, + Context, + Label, + Capability, + false, + std::move(Validation.Diagnostics)); + } + + FEditorLedgerOutcome FCameraAuthoringService::MoveCamera( + const FEditorVector3& DeltaWorld, + const FEditorCommandContext& Context) + { + FScene* Scene = Host.GetEditorScene(); + if (!Scene) + { + return MakeInvalidOutcome( + { MakeDiagnostic( + "editor.camera.not_initialized", + "$.target", + "camera is not initialized") }, + Context.CorrelationId); + } + FEditorValidationResult DeltaValidation = + ValidatePosition(DeltaWorld, "$.delta_world"); + if (!DeltaValidation.Succeeded()) + { + return MakeInvalidOutcome( + std::move(DeltaValidation.Diagnostics), + Context.CorrelationId); + } + FEditorCameraState Camera = + CaptureEditorCameraState(Scene->Camera); + Camera.Position.X += DeltaWorld.X; + Camera.Position.Y += DeltaWorld.Y; + Camera.Position.Z += DeltaWorld.Z; + return SetCamera( + Camera, + Context, + "Move Camera", + "editor.viewport.camera.move"); + } + + FEditorLedgerOutcome FCameraAuthoringService::LookAt( + const FEditorVector3& Target, + const FEditorCommandContext& Context) + { + FScene* Scene = Host.GetEditorScene(); + if (!Scene) + { + return MakeInvalidOutcome( + { MakeDiagnostic( + "editor.camera.not_initialized", + "$.target", + "camera is not initialized") }, + Context.CorrelationId); + } + FEditorValidationResult TargetValidation = + ValidatePosition(Target, "$.target"); + if (!TargetValidation.Succeeded()) + { + return MakeInvalidOutcome( + std::move(TargetValidation.Diagnostics), + Context.CorrelationId); + } + + FEditorCameraState Camera = + CaptureEditorCameraState(Scene->Camera); + const double DeltaX = Target.X - Camera.Position.X; + const double DeltaY = Target.Y - Camera.Position.Y; + const double DeltaZ = Target.Z - Camera.Position.Z; + const double Length = std::hypot(DeltaX, DeltaY, DeltaZ); + if (!std::isfinite(Length) || Length < 1.0e-6) + { + return MakeInvalidOutcome( + { MakeDiagnostic( + "editor.camera.look_at_target_equal", + "$.target", + "Target equals camera position") }, + Context.CorrelationId); + } + + const double ForwardX = DeltaX / Length; + const double ForwardY = DeltaY / Length; + const double ForwardZ = DeltaZ / Length; + constexpr double RadiansToDegrees = + 180.0 / static_cast(PI); + Camera.Rotation = { + std::asin(std::clamp(ForwardZ, -1.0, 1.0)) + * RadiansToDegrees, + std::atan2(ForwardY, ForwardX) + * RadiansToDegrees, + 0.0 + }; + return SetCamera( + Camera, + Context, + "Aim Camera", + "editor.viewport.camera.look_at"); + } + + FCameraPreviewResult FCameraAuthoringService::PreviewCamera( + FEditorCameraState Camera, + const FEditorCommandContext& Context) + { + FCameraPreviewResult Result; + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(SceneDomain, Context); + if (!Check.bAllowed) + { + Result.Diagnostics = Check.Diagnostics; + return Result; + } + FScene* Scene = Host.GetEditorScene(); + if (!Scene) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "editor.camera.not_initialized", + "$.target", + "camera is not initialized")); + return Result; + } + FEditorValidationResult Validation = ValidateCamera(Camera); + Result.Diagnostics = Validation.Diagnostics; + if (!Validation.Succeeded()) + { + return Result; + } + if (EditorCameraStatesEquivalent( + CaptureEditorCameraState(Scene->Camera), + Camera)) + { + Result.bNoOp = true; + return Result; + } + ApplyCamera(Scene->Camera, Camera); + SynchronizeObserverBaseline(); + Result.bApplied = true; + return Result; + } + + FEditorLedgerOutcome FCameraAuthoringService::CommitCameraPreview( + const FEditorCameraState& Before, + const FEditorCommandContext& Context, + std::string_view Label) + { + FScene* Scene = Host.GetEditorScene(); + if (!Scene) + { + return MakeInvalidOutcome( + { MakeDiagnostic( + "editor.camera.not_initialized", + "$.target", + "camera is not initialized") }, + Context.CorrelationId); + } + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(SceneDomain, Context); + if (!Check.bAllowed) + { + ApplyCamera(Scene->Camera, Before); + SynchronizeObserverBaseline(); + return Ledger.ResolveNoOp(SceneDomain, Context); + } + FEditorCameraState After = + CaptureEditorCameraState(Scene->Camera); + FEditorValidationResult Validation = ValidateCamera(After); + if (!Validation.Succeeded()) + { + ApplyCamera(Scene->Camera, Before); + SynchronizeObserverBaseline(); + return MakeInvalidOutcome( + std::move(Validation.Diagnostics), + Context.CorrelationId); + } + RetainValidationPassed( + Validation, + "wave.editor.scene.commit_camera_preview"); + return CommitCamera( + Before, + After, + Context, + Label, + "editor.viewport.camera.set", + true, + std::move(Validation.Diagnostics)); + } + + void FCameraAuthoringService::SynchronizeObserverBaseline() + { + if (const FScene* Scene = Host.GetEditorScene()) + { + LastObservedCamera = + CaptureEditorCameraState(Scene->Camera); + } + else + { + LastObservedCamera.reset(); + } + } + + FEditorLedgerOutcome + FCameraAuthoringService::ObserveExternalCameraChange( + const FEditorCommandContext& Context) + { + FScene* Scene = Host.GetEditorScene(); + if (!Scene) + { + LastObservedCamera.reset(); + return MakeInvalidOutcome( + { MakeDiagnostic( + "editor.camera.not_initialized", + "$.target", + "camera is not initialized") }, + Context.CorrelationId); + } + const FEditorCameraState Current = + CaptureEditorCameraState(Scene->Camera); + if (!LastObservedCamera.has_value()) + { + LastObservedCamera = Current; + return Ledger.ResolveNoOp(SceneDomain, Context); + } + if (EditorCameraStatesEquivalent( + *LastObservedCamera, + Current)) + { + return Ledger.ResolveNoOp(SceneDomain, Context); + } + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(SceneDomain, Context); + if (!Check.bAllowed) + { + return Ledger.ResolveNoOp(SceneDomain, Context); + } + FEditorCameraState Validated = Current; + FEditorValidationResult Validation = + ValidateCamera(Validated); + if (!Validation.Succeeded()) + { + return MakeInvalidOutcome( + std::move(Validation.Diagnostics), + Context.CorrelationId); + } + RetainValidationPassed( + Validation, + "wave.editor.scene.observe_camera_navigation"); + + const FEditorCameraState Before = *LastObservedCamera; + Host.MarkEditorSceneDirty(); + FEditorMutation Mutation; + Mutation.Capability = + "editor.camera.navigation.observe"; + Mutation.Targets = { MakeCameraRef() }; + Mutation.Kind = EDomainChangeKind::Updated; + Mutation.ChangedFields = + GetChangedFields(Before, Validated); + Mutation.ValidatorResults = + std::move(Validation.Diagnostics); + FEditorLedgerOutcome Outcome = + Ledger.ObserveExternal( + SceneDomain, + Context, + Mutation); + if (Outcome.Result.has_value() + && Outcome.Result->Status + != EEditorCommandStatus::Conflict) + { + LastObservedCamera = Current; + } + return Outcome; + } + + FEditorValidationResult FCameraAuthoringService::ValidateCamera( + FEditorCameraState& Camera) const + { + FEditorValidationResult Validation = + ValidatePosition(Camera.Position); + Validation.Append(ValidateAndNormalizeCameraRotation( + Camera.Rotation)); + return Validation; + } + + FEditorLedgerOutcome FCameraAuthoringService::CommitCamera( + const FEditorCameraState& Before, + const FEditorCameraState& After, + const FEditorCommandContext& Context, + std::string_view Label, + std::string_view Capability, + bool bAlreadyExecuted, + std::vector ValidatorResults) + { + if (EditorCameraStatesEquivalent(Before, After)) + { + return Ledger.ResolveNoOp(SceneDomain, Context); + } + Host.ApplyCameraTransform( + Before, + After, + Label, + bAlreadyExecuted); + SynchronizeObserverBaseline(); + FEditorMutation Mutation; + Mutation.Capability = std::string(Capability); + Mutation.Targets = { MakeCameraRef() }; + Mutation.Kind = EDomainChangeKind::Updated; + Mutation.ChangedFields = GetChangedFields(Before, After); + Mutation.ValidatorResults = std::move(ValidatorResults); + return Ledger.CommitValidated(SceneDomain, Context, Mutation); + } +} diff --git a/Engine/Source/Developer/Scene/CameraAuthoringService.h b/Engine/Source/Developer/Scene/CameraAuthoringService.h new file mode 100644 index 0000000..9d0e89e --- /dev/null +++ b/Engine/Source/Developer/Scene/CameraAuthoringService.h @@ -0,0 +1,85 @@ +#pragma once + +#include "Developer/Scene/SceneAuthoringService.h" + +#include +#include +#include + +class FCamera; + +namespace WaveEditorDomain +{ + struct FEditorCameraSnapshotState + { + FEditorCameraState Transform; + double FieldOfViewDegrees = 60.0; + double NearPlane = 0.01; + double FarPlane = 1000000.0; + + bool operator==(const FEditorCameraSnapshotState&) const = default; + }; + + struct FCameraPreviewResult + { + bool bApplied = false; + bool bNoOp = false; + std::vector Diagnostics; + }; + + [[nodiscard]] FEditorCameraState CaptureEditorCameraState( + const FCamera& Camera); + [[nodiscard]] bool EditorCameraStatesEquivalent( + const FEditorCameraState& Left, + const FEditorCameraState& Right); + + class FCameraAuthoringService + { + public: + FCameraAuthoringService( + FEditorDocumentLedger& InLedger, + IEditorAuthoringHost& InHost); + + [[nodiscard]] TEditorTypedSnapshot + GetCamera() const; + [[nodiscard]] bool IsAvailable() const; + [[nodiscard]] FEditorLedgerOutcome SetCamera( + FEditorCameraState Camera, + const FEditorCommandContext& Context, + std::string_view Label, + std::string_view Capability = + "editor.viewport.camera.set"); + [[nodiscard]] FEditorLedgerOutcome MoveCamera( + const FEditorVector3& DeltaWorld, + const FEditorCommandContext& Context); + [[nodiscard]] FEditorLedgerOutcome LookAt( + const FEditorVector3& Target, + const FEditorCommandContext& Context); + [[nodiscard]] FCameraPreviewResult PreviewCamera( + FEditorCameraState Camera, + const FEditorCommandContext& Context); + [[nodiscard]] FEditorLedgerOutcome CommitCameraPreview( + const FEditorCameraState& Before, + const FEditorCommandContext& Context, + std::string_view Label); + void SynchronizeObserverBaseline(); + [[nodiscard]] FEditorLedgerOutcome ObserveExternalCameraChange( + const FEditorCommandContext& Context); + + private: + [[nodiscard]] FEditorValidationResult ValidateCamera( + FEditorCameraState& Camera) const; + [[nodiscard]] FEditorLedgerOutcome CommitCamera( + const FEditorCameraState& Before, + const FEditorCameraState& After, + const FEditorCommandContext& Context, + std::string_view Label, + std::string_view Capability, + bool bAlreadyExecuted, + std::vector ValidatorResults); + + FEditorDocumentLedger& Ledger; + IEditorAuthoringHost& Host; + std::optional LastObservedCamera; + }; +} diff --git a/Engine/Source/Developer/Scene/EditorDomainContracts.cpp b/Engine/Source/Developer/Scene/EditorDomainContracts.cpp new file mode 100644 index 0000000..e07c15b --- /dev/null +++ b/Engine/Source/Developer/Scene/EditorDomainContracts.cpp @@ -0,0 +1,1224 @@ +#include "Developer/Scene/EditorDomainContracts.h" + +#include +#include +#include +#include +#include + +namespace WaveEditorDomain +{ + namespace + { + using namespace WaveContract; + + FStructuredDiagnostic MakeError( + std::string Code, + std::string Path, + std::string Message) + { + FStructuredDiagnostic Diagnostic; + Diagnostic.Code = std::move(Code); + Diagnostic.Path = std::move(Path); + Diagnostic.Message = std::move(Message); + return Diagnostic; + } + + void AddError( + FEditorValidationResult& Result, + std::string Code, + std::string Path, + std::string Message) + { + Result.Diagnostics.push_back(MakeError( + std::move(Code), + std::move(Path), + std::move(Message))); + } + + bool IsFinite(const FEditorVector3& Value) + { + return std::isfinite(Value.X) + && std::isfinite(Value.Y) + && std::isfinite(Value.Z); + } + + bool IsValidUtf8(std::string_view Value) + { + size_t Index = 0; + while (Index < Value.size()) + { + const uint8_t First = static_cast(Value[Index]); + if (First <= 0x7f) + { + ++Index; + continue; + } + + size_t ContinuationCount = 0; + uint32_t CodePoint = 0; + uint32_t MinimumCodePoint = 0; + if ((First & 0xe0) == 0xc0) + { + ContinuationCount = 1; + CodePoint = First & 0x1f; + MinimumCodePoint = 0x80; + } + else if ((First & 0xf0) == 0xe0) + { + ContinuationCount = 2; + CodePoint = First & 0x0f; + MinimumCodePoint = 0x800; + } + else if ((First & 0xf8) == 0xf0) + { + ContinuationCount = 3; + CodePoint = First & 0x07; + MinimumCodePoint = 0x10000; + } + else + { + return false; + } + + if (Index + ContinuationCount >= Value.size()) + { + return false; + } + for (size_t Offset = 1; Offset <= ContinuationCount; ++Offset) + { + const uint8_t Continuation = + static_cast(Value[Index + Offset]); + if ((Continuation & 0xc0) != 0x80) + { + return false; + } + CodePoint = (CodePoint << 6) | (Continuation & 0x3f); + } + if (CodePoint < MinimumCodePoint + || CodePoint > 0x10ffff + || (CodePoint >= 0xd800 && CodePoint <= 0xdfff)) + { + return false; + } + Index += ContinuationCount + 1; + } + return true; + } + + double NormalizeAxis(double AngleDegrees) + { + return std::remainder(AngleDegrees, 360.0); + } + + std::shared_ptr MakeNumber( + std::string Description, + std::optional Minimum = std::nullopt, + std::optional Maximum = std::nullopt, + std::string Unit = {}) + { + auto Schema = std::make_shared(); + Schema->Kind = EValueKind::Number; + Schema->Description = std::move(Description); + Schema->Minimum = Minimum; + Schema->Maximum = Maximum; + Schema->Unit = std::move(Unit); + return Schema; + } + + std::shared_ptr MakeInteger( + std::string Description, + std::optional Minimum = std::nullopt, + std::optional Maximum = std::nullopt) + { + auto Schema = std::make_shared(); + Schema->Kind = EValueKind::Integer; + Schema->Description = std::move(Description); + Schema->Minimum = Minimum; + Schema->Maximum = Maximum; + return Schema; + } + + std::shared_ptr MakeBoolean(std::string Description) + { + auto Schema = std::make_shared(); + Schema->Kind = EValueKind::Boolean; + Schema->Description = std::move(Description); + return Schema; + } + + std::shared_ptr MakeEnum( + std::string Description, + std::vector Values) + { + auto Schema = std::make_shared(); + Schema->Kind = EValueKind::Enum; + Schema->Description = std::move(Description); + Schema->EnumValues = std::move(Values); + return Schema; + } + + std::shared_ptr MakeVector3( + std::string Description, + std::string Unit = "engine_units", + std::optional Minimum = std::nullopt, + std::optional Maximum = std::nullopt) + { + auto Schema = std::make_shared(); + Schema->Kind = EValueKind::Object; + Schema->Description = std::move(Description); + Schema->Unit = Unit; + Schema->CoordinateSystem = std::string(EditorCoordinateSystem); + Schema->Fields = { + { "x", true, MakeNumber("X component", Minimum, Maximum, Unit) }, + { "y", true, MakeNumber("Y component", Minimum, Maximum, Unit) }, + { "z", true, MakeNumber("Z component", Minimum, Maximum, Unit) } + }; + return Schema; + } + + std::shared_ptr MakeQuaternion() + { + auto Schema = std::make_shared(); + Schema->Kind = EValueKind::Object; + Schema->Description = "Normalized instance orientation quaternion"; + Schema->CoordinateSystem = std::string(EditorCoordinateSystem); + Schema->Fields = { + { "x", true, MakeNumber("Quaternion X") }, + { "y", true, MakeNumber("Quaternion Y") }, + { "z", true, MakeNumber("Quaternion Z") }, + { "w", true, MakeNumber("Quaternion W") } + }; + return Schema; + } + + std::shared_ptr MakeRotator() + { + auto Schema = std::make_shared(); + Schema->Kind = EValueKind::Object; + Schema->Description = "Pitch/Yaw/Roll camera orientation"; + Schema->Unit = "degrees"; + Schema->CoordinateSystem = std::string(EditorCoordinateSystem); + Schema->Fields = { + { "pitch", true, MakeNumber("Rotation around +Y", -89.0, 89.0, "degrees") }, + { "yaw", true, MakeNumber("Rotation around +Z", -180.0, 180.0, "degrees") }, + { "roll", true, MakeNumber("Rotation around +X", -180.0, 180.0, "degrees") } + }; + return Schema; + } + + FValueSchema MakeRootSchema( + std::string Id, + std::string Description) + { + FValueSchema Schema; + Schema.Id = { std::move(Id), { 1, 0 } }; + Schema.Kind = EValueKind::Object; + Schema.Description = std::move(Description); + return Schema; + } + + std::shared_ptr MakeRevisionSchema() + { + auto Schema = std::make_shared(); + Schema->Kind = EValueKind::Object; + Schema->Description = "Process-local editor document revision"; + auto Domain = std::make_shared(); + Domain->Kind = EValueKind::String; + Domain->Description = "Revision domain"; + Domain->MaxLength = MaxContractNameBytes; + Schema->Fields = { + { "domain", true, std::move(Domain) }, + { "value", true, MakeInteger("Monotonic revision", 0.0) }, + { "schema_major", true, MakeInteger("Revision schema major", 1.0, 1.0) }, + { "schema_minor", true, MakeInteger("Revision schema minor", 0.0) } + }; + return Schema; + } + } + + bool FEditorValidationResult::Succeeded() const + { + return std::ranges::none_of( + Diagnostics, + [](const FStructuredDiagnostic& Diagnostic) + { + return Diagnostic.Severity == EDiagnosticSeverity::Error; + }); + } + + void FEditorValidationResult::Append(FEditorValidationResult Other) + { + Diagnostics.insert( + Diagnostics.end(), + std::make_move_iterator(Other.Diagnostics.begin()), + std::make_move_iterator(Other.Diagnostics.end())); + } + + FObjectRef MakeSessionObjectRef(std::string Kind, std::string Id) + { + return { + std::move(Kind), + std::move(Id), + EReferenceScope::Session, + {} + }; + } + + FStructuredDiagnostic MakeValidationPassed(std::string_view ValidatorName) + { + FStructuredDiagnostic Diagnostic; + Diagnostic.Code = "editor.validation.passed"; + Diagnostic.Path = "$"; + Diagnostic.Message = "editor domain validator passed"; + Diagnostic.Severity = EDiagnosticSeverity::Info; + Diagnostic.Details = std::string(ValidatorName); + return Diagnostic; + } + + FEditorValidationResult ValidateInstanceName( + std::string_view Name, + std::string_view Path) + { + FEditorValidationResult Result; + if (Name.empty() || Name.size() > 256) + { + AddError( + Result, + "editor.instance.name_size_invalid", + std::string(Path), + "instance name must contain 1 to 256 UTF-8 bytes"); + } + else if (!IsValidUtf8(Name)) + { + AddError( + Result, + "editor.instance.name_utf8_invalid", + std::string(Path), + "instance name must be valid UTF-8"); + } + return Result; + } + + FEditorValidationResult ValidatePosition( + const FEditorVector3& Position, + std::string_view Path) + { + FEditorValidationResult Result; + if (!IsFinite(Position)) + { + AddError( + Result, + "editor.position.non_finite", + std::string(Path), + "position components must be finite"); + } + return Result; + } + + FEditorValidationResult ValidateAndNormalizeQuaternion( + FEditorQuaternion& Rotation, + std::string_view Path) + { + FEditorValidationResult Result; + if (!std::isfinite(Rotation.X) + || !std::isfinite(Rotation.Y) + || !std::isfinite(Rotation.Z) + || !std::isfinite(Rotation.W)) + { + AddError( + Result, + "editor.rotation.non_finite", + std::string(Path), + "quaternion components must be finite"); + return Result; + } + + const double LengthSquared = + Rotation.X * Rotation.X + + Rotation.Y * Rotation.Y + + Rotation.Z * Rotation.Z + + Rotation.W * Rotation.W; + if (!(LengthSquared > std::numeric_limits::min()) + || !std::isfinite(LengthSquared)) + { + AddError( + Result, + "editor.rotation.zero_length", + std::string(Path), + "quaternion must have a finite non-zero length"); + return Result; + } + + const double InverseLength = 1.0 / std::sqrt(LengthSquared); + Rotation.X *= InverseLength; + Rotation.Y *= InverseLength; + Rotation.Z *= InverseLength; + Rotation.W *= InverseLength; + return Result; + } + + FEditorValidationResult ValidateScale( + const FEditorVector3& Scale, + EEditorScaleInputPolicy Policy, + std::string_view Path) + { + FEditorValidationResult Result; + if (!IsFinite(Scale)) + { + AddError( + Result, + "editor.scale.non_finite", + std::string(Path), + "scale components must be finite"); + return Result; + } + if (Scale.X < MinimumInstanceScale + || Scale.Y < MinimumInstanceScale + || Scale.Z < MinimumInstanceScale) + { + AddError( + Result, + "editor.scale.below_minimum", + std::string(Path), + "scale components must be at least 0.001"); + } + if (Policy == EEditorScaleInputPolicy::LegacyMcpOrGizmo + && (Scale.X > MaximumLegacyScale + || Scale.Y > MaximumLegacyScale + || Scale.Z > MaximumLegacyScale)) + { + AddError( + Result, + "editor.scale.legacy_maximum_exceeded", + std::string(Path), + "legacy MCP and gizmo scale components must not exceed 100"); + } + return Result; + } + + FEditorValidationResult ValidateAndNormalizeCameraRotation( + FEditorRotator& Rotation, + std::string_view Path) + { + FEditorValidationResult Result; + if (!std::isfinite(Rotation.Pitch) + || !std::isfinite(Rotation.Yaw) + || !std::isfinite(Rotation.Roll)) + { + AddError( + Result, + "editor.camera.rotation_non_finite", + std::string(Path), + "camera Pitch/Yaw/Roll must be finite"); + return Result; + } + Rotation.Pitch = std::clamp(Rotation.Pitch, -89.0, 89.0); + Rotation.Yaw = NormalizeAxis(Rotation.Yaw); + Rotation.Roll = NormalizeAxis(Rotation.Roll); + return Result; + } + + FEditorValidationResult ValidateAndNormalizeMainLight( + FEditorMainLightState& Light, + double MinimumDirectionLength, + std::string_view Path) + { + FEditorValidationResult Result; + if (!IsFinite(Light.Color) + || Light.Color.X < 0.0 || Light.Color.X > 1.0 + || Light.Color.Y < 0.0 || Light.Color.Y > 1.0 + || Light.Color.Z < 0.0 || Light.Color.Z > 1.0) + { + AddError( + Result, + "editor.light.color_out_of_range", + std::string(Path) + ".color", + "light color components must be finite and in [0, 1]"); + } + if (!std::isfinite(Light.Intensity) + || Light.Intensity < 0.0 + || Light.Intensity > 20.0) + { + AddError( + Result, + "editor.light.intensity_out_of_range", + std::string(Path) + ".intensity", + "light intensity must be finite and in [0, 20]"); + } + if (!IsFinite(Light.Direction)) + { + AddError( + Result, + "editor.light.direction_non_finite", + std::string(Path) + ".direction", + "light direction components must be finite"); + return Result; + } + + const double LengthSquared = + Light.Direction.X * Light.Direction.X + + Light.Direction.Y * Light.Direction.Y + + Light.Direction.Z * Light.Direction.Z; + const double MinimumLengthSquared = + MinimumDirectionLength * MinimumDirectionLength; + if (!(LengthSquared > MinimumLengthSquared) + || !std::isfinite(LengthSquared)) + { + AddError( + Result, + "editor.light.direction_too_small", + std::string(Path) + ".direction", + "light direction must exceed the caller policy threshold"); + return Result; + } + + const double InverseLength = 1.0 / std::sqrt(LengthSquared); + const FEditorVector3 NormalizedDirection{ + Light.Direction.X * InverseLength, + Light.Direction.Y * InverseLength, + Light.Direction.Z * InverseLength + }; + if (Result.Succeeded()) + { + Light.Direction = NormalizedDirection; + } + return Result; + } + + FEditorValidationResult ValidateRenderSettings( + const FEditorRenderSettingsState& Settings, + std::string_view Path) + { + FEditorValidationResult Result; + if (Settings.DebugView != EEditorDebugView::Lit + && Settings.DebugView != EEditorDebugView::MeshletIndex + && Settings.DebugView != EEditorDebugView::GroupId) + { + AddError( + Result, + "editor.renderer.debug_view_invalid", + std::string(Path) + ".debug_view", + "debug view must be Lit, MeshletIndex, or GroupId"); + } + if (Settings.RenderMode != EEditorRenderMode::Raster + && Settings.RenderMode != EEditorRenderMode::PathTracing) + { + AddError( + Result, + "editor.renderer.render_mode_invalid", + std::string(Path) + ".render_mode", + "render mode must be Raster or PathTracing"); + } + + const auto ValidateRange = [&Result, Path]( + double Value, + double Minimum, + double Maximum, + std::string_view Field, + std::string_view Code) + { + if (!std::isfinite(Value) + || Value < Minimum + || Value > Maximum) + { + AddError( + Result, + std::string(Code), + std::string(Path) + "." + std::string(Field), + "renderer setting is non-finite or outside its supported range"); + } + }; + ValidateRange( + Settings.LodErrorThreshold, + 0.0001, + 1.0, + "lod_error_threshold", + "editor.renderer.lod_error_out_of_range"); + ValidateRange( + Settings.AutoExposureSpeed, + 0.1, + 10.0, + "auto_exposure_speed", + "editor.renderer.exposure_speed_out_of_range"); + ValidateRange( + Settings.AutoExposureMin, + 0.01, + 2.0, + "auto_exposure_min", + "editor.renderer.exposure_min_out_of_range"); + ValidateRange( + Settings.AutoExposureMax, + 1.0, + 20.0, + "auto_exposure_max", + "editor.renderer.exposure_max_out_of_range"); + ValidateRange( + Settings.AutoExposureMiddleGrey, + 0.05, + 0.5, + "auto_exposure_middle_grey", + "editor.renderer.middle_grey_out_of_range"); + ValidateRange( + Settings.ManualExposure, + 0.1, + 10.0, + "manual_exposure", + "editor.renderer.manual_exposure_out_of_range"); + if (std::isfinite(Settings.AutoExposureMin) + && std::isfinite(Settings.AutoExposureMax) + && Settings.AutoExposureMin > Settings.AutoExposureMax) + { + AddError( + Result, + "editor.renderer.exposure_pair_invalid", + std::string(Path) + ".auto_exposure", + "auto exposure minimum must not exceed maximum"); + } + if (Settings.PathTracingMaxBounces < 1 + || Settings.PathTracingMaxBounces > 16) + { + AddError( + Result, + "editor.renderer.pt_bounces_out_of_range", + std::string(Path) + ".pt_max_bounces", + "path tracing max bounces must be in [1, 16]"); + } + if (Settings.PathTracingSamplesPerPixel < 1 + || Settings.PathTracingSamplesPerPixel > 8) + { + AddError( + Result, + "editor.renderer.pt_samples_out_of_range", + std::string(Path) + ".pt_samples_per_pixel", + "path tracing samples per frame must be in [1, 8]"); + } + if (Settings.PathTracingRussianRouletteStart > 8) + { + AddError( + Result, + "editor.renderer.pt_rr_start_out_of_range", + std::string(Path) + ".pt_russian_roulette_start", + "path tracing Russian roulette start must be in [0, 8]"); + } + return Result; + } + + bool RequiresPathTracingReset( + const FEditorMainLightState& Before, + const FEditorMainLightState& After) + { + return Before != After; + } + + bool RequiresPathTracingReset( + const FEditorRenderSettingsState& Before, + const FEditorRenderSettingsState& After) + { + return Before.RenderMode != After.RenderMode + || Before.PathTracingMaxBounces != After.PathTracingMaxBounces + || Before.PathTracingSamplesPerPixel + != After.PathTracingSamplesPerPixel + || Before.PathTracingRussianRouletteStart + != After.PathTracingRussianRouletteStart + || Before.bPathTracingAccumulationEnabled + != After.bPathTracingAccumulationEnabled; + } + + FValueSchema MakeInstanceTransformSchema() + { + FValueSchema Schema = MakeRootSchema( + "wave.editor.scene.instance_transform", + "Editable instance transform"); + Schema.CoordinateSystem = std::string(EditorCoordinateSystem); + Schema.Fields = { + { "position", true, MakeVector3("Instance position") }, + { "rotation", true, MakeQuaternion() }, + { + "scale", + true, + MakeVector3( + "Positive instance scale", + "ratio", + MinimumInstanceScale) + } + }; + return Schema; + } + + FValueSchema MakeCameraStateSchema() + { + FValueSchema Schema = MakeRootSchema( + "wave.editor.scene.camera", + "Editor camera transform"); + Schema.CoordinateSystem = std::string(EditorCoordinateSystem); + Schema.Fields = { + { "position", true, MakeVector3("Camera position") }, + { "rotation", true, MakeRotator() } + }; + return Schema; + } + + FValueSchema MakeMainLightStateSchema() + { + FValueSchema Schema = MakeRootSchema( + "wave.editor.render.main_light", + "Main directional light settings"); + Schema.CoordinateSystem = std::string(EditorCoordinateSystem); + Schema.Fields = { + { "color", true, MakeVector3("Linear light color", "ratio", 0.0, 1.0) }, + { "intensity", true, MakeNumber("Light intensity", 0.0, 20.0) }, + { "direction", true, MakeVector3("Normalized light direction", "ratio") } + }; + return Schema; + } + + FValueSchema MakeRenderSettingsStateSchema() + { + FValueSchema Schema = MakeRootSchema( + "wave.editor.render.settings", + "Renderer settings currently exposed by the Editor"); + Schema.Fields = { + { "debug_view", true, MakeEnum( + "Viewport debug visualization", + { "lit", "meshlet_index", "group_id" }) }, + { "render_mode", true, MakeEnum( + "Renderer mode", + { "raster", "path_tracing" }) }, + { "lod_error_threshold", true, MakeNumber( + "Cluster LOD error threshold", + 0.0001, + 1.0) }, + { "auto_exposure_enabled", true, MakeBoolean( + "Whether automatic exposure is enabled") }, + { "auto_exposure_speed", true, MakeNumber( + "Automatic exposure adaptation speed", + 0.1, + 10.0) }, + { "auto_exposure_min", true, MakeNumber( + "Minimum automatic exposure", + 0.01, + 2.0) }, + { "auto_exposure_max", true, MakeNumber( + "Maximum automatic exposure", + 1.0, + 20.0) }, + { "auto_exposure_middle_grey", true, MakeNumber( + "Automatic exposure middle grey", + 0.05, + 0.5) }, + { "manual_exposure", true, MakeNumber( + "Manual exposure", + 0.1, + 10.0) }, + { "pt_max_bounces", true, MakeInteger( + "Path tracing maximum bounces", + 1.0, + 16.0) }, + { "pt_samples_per_pixel", true, MakeInteger( + "Path tracing samples per frame", + 1.0, + 8.0) }, + { "pt_russian_roulette_start", true, MakeInteger( + "Path tracing Russian roulette start bounce", + 0.0, + 8.0) }, + { "pt_accumulation_enabled", true, MakeBoolean( + "Whether path tracing accumulates samples") } + }; + return Schema; + } + + FValueSchema MakeEditorCommandResultSchema() + { + FValueSchema Schema = MakeRootSchema( + "wave.editor.command.result", + "Revisioned editor command result"); + auto ChangedFields = std::make_shared(); + ChangedFields->Kind = EValueKind::Array; + ChangedFields->Description = "Stable field paths changed by the command"; + ChangedFields->MinItems = 0; + ChangedFields->MaxItems = MaxChangeItems; + ChangedFields->Items = std::make_shared(); + ChangedFields->Items->Kind = EValueKind::String; + ChangedFields->Items->Description = "Changed field path"; + ChangedFields->Items->MaxLength = MaxContractTextBytes; + Schema.Fields = { + { "status", true, MakeEnum( + "Command application status", + { "applied", "no_op", "conflict" }) }, + { "before_revision", true, MakeRevisionSchema() }, + { "after_revision", true, MakeRevisionSchema() }, + { "changed_fields", true, std::move(ChangedFields) } + }; + return Schema; + } + + std::vector + MakeEditorDomainCapabilityDescriptors() + { + const auto MakeString = []( + std::string Description, + uint64_t MaxLength = MaxContractTextBytes) + { + auto Schema = std::make_shared(); + Schema->Kind = EValueKind::String; + Schema->Description = std::move(Description); + Schema->MaxLength = MaxLength; + return Schema; + }; + const auto MakeLegacyVector = []( + std::string Description, + uint64_t ComponentCount, + std::string Unit = "engine_units", + std::optional Minimum = std::nullopt, + std::optional Maximum = std::nullopt) + { + auto Schema = std::make_shared(); + Schema->Kind = EValueKind::Array; + Schema->Description = std::move(Description); + Schema->MinItems = ComponentCount; + Schema->MaxItems = ComponentCount; + Schema->Unit = Unit; + Schema->CoordinateSystem = + std::string(EditorCoordinateSystem); + Schema->Items = MakeNumber( + "Finite vector component", + Minimum, + Maximum, + Unit); + return Schema; + }; + const auto MakeInput = []( + std::string_view Name, + std::vector Fields) + { + FValueSchema Schema; + Schema.Id = { + std::string(Name) + ".typed_input", + { 1, 0 } + }; + Schema.Kind = EValueKind::Object; + Schema.Description = + "Named typed input for the Editor domain capability"; + Schema.Fields = std::move(Fields); + return Schema; + }; + const auto MakeObjectOutput = []( + std::string_view Name, + std::string Description, + std::vector Fields) + { + FValueSchema Schema; + Schema.Id = { + std::string(Name) + ".typed_output", + { 1, 0 } + }; + Schema.Kind = EValueKind::Object; + Schema.Description = std::move(Description); + Schema.Fields = std::move(Fields); + return Schema; + }; + const auto MakeNullOutput = [&MakeObjectOutput]( + std::string_view Name) + { + FValueSchema Schema = MakeObjectOutput( + Name, + "Legacy compatibility command returns null", + {}); + Schema.bNullable = true; + return Schema; + }; + const auto MakeArrayOutput = []( + std::string_view Name, + std::string Description, + std::shared_ptr Item, + uint64_t MaximumItems) + { + FValueSchema Schema; + Schema.Id = { + std::string(Name) + ".typed_output", + { 1, 0 } + }; + Schema.Kind = EValueKind::Array; + Schema.Description = std::move(Description); + Schema.MinItems = 0; + Schema.MaxItems = MaximumItems; + Schema.Items = std::move(Item); + return Schema; + }; + const auto MakeDescriptor = []( + std::string Name, + std::string Summary, + ECapabilityEffect Effect, + FValueSchema Input, + FValueSchema Output, + bool bSupportsUndo = false) + { + FCapabilityDescriptor Descriptor; + Descriptor.Name = std::move(Name); + Descriptor.Version = { 2, 0 }; + Descriptor.Stability = "stable"; + Descriptor.MinimumReaderVersion = { 1, 0 }; + Descriptor.MinimumWriterVersion = { 1, 0 }; + Descriptor.Summary = std::move(Summary); + Descriptor.Effect = Effect; + Descriptor.InputSchema = std::move(Input); + Descriptor.OutputSchema = std::move(Output); + Descriptor.Permission = + Effect == ECapabilityEffect::Query + ? "editor.read" + : "editor.write"; + Descriptor.Risk = + Effect == ECapabilityEffect::Query + ? "low" + : "medium"; + Descriptor.bSupportsUndo = bSupportsUndo; + Descriptor.bIdempotent = + Effect == ECapabilityEffect::Query; + Descriptor.bSupportsRetry = + Effect == ECapabilityEffect::Query; + Descriptor.Budget = { + 1024 * 1024, + 1024 * 1024, + 30000, + 1 + }; + Descriptor.CoordinateSystem = + std::string(EditorCoordinateSystem); + Descriptor.ReferenceScope = + bSupportsUndo + ? EReferenceScope::Document + : EReferenceScope::Session; + return Descriptor; + }; + const auto MakeId = []() + { + return MakeInteger( + "Session-scoped instance MCP id", + 0.0, + static_cast( + std::numeric_limits::max())); + }; + + auto HierarchyItem = std::make_shared(); + HierarchyItem->Kind = EValueKind::Object; + HierarchyItem->Description = "Scene hierarchy entry"; + HierarchyItem->Fields = { + { "id", true, MakeId() }, + { "name", true, MakeString("Instance display name", 256) }, + { "position", true, MakeLegacyVector("Instance position", 3) } + }; + auto InspectorState = std::make_shared(); + InspectorState->Kind = EValueKind::Object; + InspectorState->Description = "Inspectable instance state"; + InspectorState->Fields = { + { "name", true, MakeString("Instance display name", 256) }, + { "position", true, MakeLegacyVector("Instance position", 3) }, + { "rotation", true, MakeLegacyVector( + "Instance quaternion", + 4, + "ratio") }, + { "rotation_euler_deg", true, MakeLegacyVector( + "Instance Pitch/Yaw/Roll", + 3, + "degrees") }, + { "coordinate_system", true, MakeEnum( + "Coordinate system", + { std::string(EditorCoordinateSystem) }) }, + { "scale", true, MakeLegacyVector( + "Instance scale", + 3, + "ratio", + MinimumInstanceScale, + 100.0) } + }; + auto FieldMetadata = std::make_shared(); + FieldMetadata->Kind = EValueKind::Object; + FieldMetadata->Description = "Inspectable field metadata"; + FieldMetadata->Fields = { + { "path", true, MakeEnum( + "Editable field path", + { + "transform.position", + "transform.rotation", + "transform.rotation_euler_deg", + "transform.scale" + }) }, + { "type", true, MakeEnum( + "Legacy field type", + { "vec3", "vec4", "vec3_deg" }) } + }; + + std::vector Descriptors; + Descriptors.reserve(16); + const auto AddCommand = [&]( + std::string Name, + std::string Summary, + std::vector Fields, + bool bSupportsUndo = false) + { + FValueSchema Input = MakeInput(Name, std::move(Fields)); + FValueSchema Output = MakeNullOutput(Name); + Descriptors.push_back(MakeDescriptor( + std::move(Name), + std::move(Summary), + ECapabilityEffect::Command, + std::move(Input), + std::move(Output), + bSupportsUndo)); + }; + const auto AddQuery = [&]( + std::string Name, + std::string Summary, + std::vector Fields, + FValueSchema Output) + { + Descriptors.push_back(MakeDescriptor( + Name, + std::move(Summary), + ECapabilityEffect::Query, + MakeInput(Name, std::move(Fields)), + std::move(Output))); + }; + + AddQuery( + "editor.hierarchy.list", + "List scene hierarchy entries and current selection", + {}, + MakeArrayOutput( + "editor.hierarchy.list", + "Stable session instance list", + HierarchyItem, + 4096)); + AddCommand( + "editor.hierarchy.select", + "Select one session-scoped scene instance", + { { "id", true, MakeId() } }); + AddCommand( + "editor.hierarchy.delete", + "Delete one session-scoped scene instance", + { { "id", true, MakeId() } }, + true); + AddCommand( + "editor.hierarchy.rename", + "Rename one session-scoped scene instance display name", + { + { "id", true, MakeId() }, + { "new_name", true, MakeString( + "Instance display name", + 256) } + }, + true); + AddQuery( + "editor.inspector.get", + "Read inspectable fields for one session-scoped scene instance", + { { "id", true, MakeId() } }, + [&]() + { + FValueSchema Output = *InspectorState; + Output.Id = { + "editor.inspector.get.typed_output", + { 1, 0 } + }; + return Output; + }()); + AddCommand( + "editor.inspector.set_field", + "Set one inspectable field on a session-scoped scene instance", + { + { "id", true, MakeId() }, + { "position", false, MakeLegacyVector( + "New position", + 3) }, + { "rotation", false, MakeLegacyVector( + "New quaternion", + 4, + "ratio") }, + { "rotation_euler_deg", false, MakeLegacyVector( + "New Pitch/Yaw/Roll", + 3, + "degrees") }, + { "scale", false, MakeLegacyVector( + "New scale", + 3, + "ratio", + MinimumInstanceScale, + 100.0) } + }, + true); + AddQuery( + "editor.inspector.list_fields", + "List editable field metadata for one session-scoped scene instance", + { { "id", true, MakeId() } }, + MakeArrayOutput( + "editor.inspector.list_fields", + "Editable field metadata", + FieldMetadata, + 4)); + + AddQuery( + "editor.viewport.camera.get", + "Read the editor camera transform and projection", + {}, + MakeObjectOutput( + "editor.viewport.camera.get", + "Editor camera state", + { + { "position", true, MakeLegacyVector( + "Camera position", + 3) }, + { "rotation_deg", true, MakeLegacyVector( + "Camera Pitch/Yaw/Roll", + 3, + "degrees") }, + { "coordinate_system", true, MakeEnum( + "Coordinate system", + { std::string(EditorCoordinateSystem) }) }, + { "fov", true, MakeNumber( + "Vertical field of view", + std::nullopt, + std::nullopt, + "degrees") }, + { "near", true, MakeNumber("Near plane") }, + { "far", true, MakeNumber("Far plane") } + })); + AddCommand( + "editor.viewport.camera.set", + "Set the editor camera position and Pitch/Yaw/Roll rotation", + { + { "position", true, MakeLegacyVector( + "Camera position", + 3) }, + { "rotation_deg", true, MakeLegacyVector( + "Camera Pitch/Yaw/Roll", + 3, + "degrees") } + }, + true); + AddCommand( + "editor.viewport.camera.move", + "Move the editor camera by a world-space delta", + { { "delta_world", true, MakeLegacyVector( + "World-space camera delta", + 3) } }, + true); + AddCommand( + "editor.viewport.camera.look_at", + "Rotate the editor camera to face a world-space target", + { { "target", true, MakeLegacyVector( + "World-space look-at target", + 3) } }, + true); + + AddQuery( + "editor.lighting.get_main", + "Read the main directional light settings", + {}, + MakeObjectOutput( + "editor.lighting.get_main", + "Main directional light state", + { + { "color", true, MakeLegacyVector( + "Linear light color", + 3, + "ratio", + 0.0, + 1.0) }, + { "intensity", true, MakeNumber( + "Light intensity", + 0.0, + 20.0) }, + { "direction", true, MakeLegacyVector( + "Normalized light direction", + 3, + "ratio") } + })); + AddCommand( + "editor.lighting.set_main", + "Set main directional light color, intensity, and direction", + { + { "color", true, MakeLegacyVector( + "Linear light color", + 3, + "ratio", + 0.0, + 1.0) }, + { "intensity", true, MakeNumber( + "Light intensity", + 0.0, + 20.0) }, + { "direction", true, MakeLegacyVector( + "Light direction", + 3, + "ratio") } + }); + AddQuery( + "editor.renderer.get_settings", + "Read renderer settings exposed by the editor", + {}, + MakeObjectOutput( + "editor.renderer.get_settings", + "Legacy renderer settings view", + { + { "render_mode", true, MakeEnum( + "Renderer mode", + { "forward", "path_tracing" }) }, + { "auto_exposure_enabled", true, MakeBoolean( + "Whether automatic exposure is enabled") }, + { "manual_exposure", true, MakeNumber( + "Manual exposure", + 0.1, + 10.0) }, + { "pt_max_bounces", true, MakeInteger( + "Path tracing maximum bounces", + 1.0, + 16.0) }, + { "pt_samples_per_pixel", true, MakeInteger( + "Path tracing samples per frame", + 1.0, + 8.0) }, + { "pt_accumulation_enabled", true, MakeBoolean( + "Whether path tracing accumulates samples") }, + { "lod_error_threshold", true, MakeNumber( + "Cluster LOD error threshold", + 0.0001, + 1.0) } + })); + AddCommand( + "editor.renderer.set_setting", + "Set one renderer setting through its typed field", + { + { "render_mode", false, MakeEnum( + "Renderer mode", + { "forward", "path_tracing" }) }, + { "auto_exposure_enabled", false, MakeBoolean( + "Whether automatic exposure is enabled") }, + { "manual_exposure", false, MakeNumber( + "Manual exposure", + 0.1, + 10.0) }, + { "pt_max_bounces", false, MakeInteger( + "Path tracing maximum bounces", + 1.0, + 16.0) }, + { "pt_samples_per_pixel", false, MakeInteger( + "Path tracing samples per frame", + 1.0, + 8.0) }, + { "pt_accumulation_enabled", false, MakeBoolean( + "Whether path tracing accumulates samples") }, + { "lod_error_threshold", false, MakeNumber( + "Cluster LOD error threshold", + 0.0001, + 1.0) } + }); + AddCommand( + "editor.viewport.set_debug_view", + "Set the renderer debug visualization mode", + { { "mode", true, MakeEnum( + "Debug visualization mode", + { "none", "meshlet_index", "group_id" }) } }); + return Descriptors; + } +} diff --git a/Engine/Source/Developer/Scene/EditorDomainContracts.h b/Engine/Source/Developer/Scene/EditorDomainContracts.h new file mode 100644 index 0000000..9ce1224 --- /dev/null +++ b/Engine/Source/Developer/Scene/EditorDomainContracts.h @@ -0,0 +1,180 @@ +#pragma once + +#include "Contract/ContractCore.h" +#include "Developer/Document/EditorDocumentLedger.h" + +#include +#include +#include +#include + +namespace WaveEditorDomain +{ + inline constexpr std::string_view EditorCoordinateSystem = + "LH_XForward_YRight_ZUp"; + inline constexpr double MinimumInstanceScale = 0.001; + inline constexpr double MaximumLegacyScale = 100.0; + inline constexpr double MinimumUiLightDirectionLength = 1.0e-4; + inline constexpr double MinimumLegacyMcpDirectionLength = 1.0e-6; + + struct FEditorVector3 + { + double X = 0.0; + double Y = 0.0; + double Z = 0.0; + + bool operator==(const FEditorVector3&) const = default; + }; + + struct FEditorQuaternion + { + double X = 0.0; + double Y = 0.0; + double Z = 0.0; + double W = 1.0; + + bool operator==(const FEditorQuaternion&) const = default; + }; + + struct FEditorRotator + { + double Pitch = 0.0; + double Yaw = 0.0; + double Roll = 0.0; + + bool operator==(const FEditorRotator&) const = default; + }; + + struct FEditorTransform + { + FEditorVector3 Position; + FEditorQuaternion Rotation; + FEditorVector3 Scale{ 1.0, 1.0, 1.0 }; + + bool operator==(const FEditorTransform&) const = default; + }; + + struct FEditorCameraState + { + FEditorVector3 Position; + FEditorRotator Rotation; + + bool operator==(const FEditorCameraState&) const = default; + }; + + struct FEditorMainLightState + { + FEditorVector3 Color{ 1.0, 1.0, 1.0 }; + double Intensity = 1.0; + FEditorVector3 Direction{ -0.3, 0.2, -1.0 }; + + bool operator==(const FEditorMainLightState&) const = default; + }; + + enum class EEditorDebugView : uint8_t + { + Lit = 0, + MeshletIndex = 1, + GroupId = 2 + }; + + enum class EEditorRenderMode : uint8_t + { + Raster = 0, + PathTracing = 1 + }; + + struct FEditorRenderSettingsState + { + EEditorDebugView DebugView = EEditorDebugView::Lit; + EEditorRenderMode RenderMode = EEditorRenderMode::Raster; + double LodErrorThreshold = 1.0 / 1440.0; + bool bAutoExposureEnabled = true; + double AutoExposureSpeed = 2.5; + double AutoExposureMin = 0.2; + double AutoExposureMax = 6.0; + double AutoExposureMiddleGrey = 0.18; + double ManualExposure = 1.5; + uint32_t PathTracingMaxBounces = 4; + uint32_t PathTracingSamplesPerPixel = 1; + uint32_t PathTracingRussianRouletteStart = 2; + bool bPathTracingAccumulationEnabled = true; + + bool operator==(const FEditorRenderSettingsState&) const = default; + }; + + struct FEditorValidationResult + { + std::vector Diagnostics; + + [[nodiscard]] bool Succeeded() const; + void Append(FEditorValidationResult Other); + }; + + enum class EEditorScaleInputPolicy : uint8_t + { + Domain, + LegacyMcpOrGizmo + }; + + template + struct TEditorTypedSnapshot + { + WaveContract::FSnapshotMeta Meta; + TValue Value; + }; + + template + struct TEditorTypedCommandRequest + { + FEditorCommandContext Context; + WaveContract::FObjectRef Target; + TPayload Payload; + std::string Label; + }; + + [[nodiscard]] WaveContract::FObjectRef MakeSessionObjectRef( + std::string Kind, + std::string Id); + [[nodiscard]] WaveContract::FStructuredDiagnostic MakeValidationPassed( + std::string_view ValidatorName); + + [[nodiscard]] FEditorValidationResult ValidateInstanceName( + std::string_view Name, + std::string_view Path = "$.name"); + [[nodiscard]] FEditorValidationResult ValidatePosition( + const FEditorVector3& Position, + std::string_view Path = "$.position"); + [[nodiscard]] FEditorValidationResult ValidateAndNormalizeQuaternion( + FEditorQuaternion& Rotation, + std::string_view Path = "$.rotation"); + [[nodiscard]] FEditorValidationResult ValidateScale( + const FEditorVector3& Scale, + EEditorScaleInputPolicy Policy = EEditorScaleInputPolicy::Domain, + std::string_view Path = "$.scale"); + [[nodiscard]] FEditorValidationResult ValidateAndNormalizeCameraRotation( + FEditorRotator& Rotation, + std::string_view Path = "$.rotation"); + [[nodiscard]] FEditorValidationResult ValidateAndNormalizeMainLight( + FEditorMainLightState& Light, + double MinimumDirectionLength = 0.0, + std::string_view Path = "$"); + [[nodiscard]] FEditorValidationResult ValidateRenderSettings( + const FEditorRenderSettingsState& Settings, + std::string_view Path = "$"); + + [[nodiscard]] bool RequiresPathTracingReset( + const FEditorMainLightState& Before, + const FEditorMainLightState& After); + [[nodiscard]] bool RequiresPathTracingReset( + const FEditorRenderSettingsState& Before, + const FEditorRenderSettingsState& After); + + [[nodiscard]] WaveContract::FValueSchema MakeInstanceTransformSchema(); + [[nodiscard]] WaveContract::FValueSchema MakeCameraStateSchema(); + [[nodiscard]] WaveContract::FValueSchema MakeMainLightStateSchema(); + [[nodiscard]] WaveContract::FValueSchema MakeRenderSettingsStateSchema(); + [[nodiscard]] WaveContract::FValueSchema MakeEditorCommandResultSchema(); + [[nodiscard]] std::vector + MakeEditorDomainCapabilityDescriptors(); +} diff --git a/Engine/Source/Developer/Scene/EditorDomainMcpAdapter.cpp b/Engine/Source/Developer/Scene/EditorDomainMcpAdapter.cpp new file mode 100644 index 0000000..633db7c --- /dev/null +++ b/Engine/Source/Developer/Scene/EditorDomainMcpAdapter.cpp @@ -0,0 +1,868 @@ +#include "Developer/Scene/EditorDomainMcpAdapter.h" + +#include "Developer/Document/EditorDomainServices.h" +#include "Developer/Render/RenderSettingsAuthoringService.h" +#include "Developer/Scene/CameraAuthoringService.h" +#include "Developer/Scene/LightingAuthoringService.h" +#include "Developer/Scene/SceneAuthoringService.h" +#include "Mcp/McpRegistry.h" +#include "Mcp/McpValidation.h" +#include "Scene/Instance.h" + +#include +#include +#include +#include +#include + +namespace WaveEditorDomain +{ + namespace + { + FVector3 ToRuntimeVector(const FEditorVector3& Value) + { + return FVector3( + static_cast(Value.X), + static_cast(Value.Y), + static_cast(Value.Z)); + } + + FQuat ToRuntimeQuaternion(const FEditorQuaternion& Value) + { + return FQuat( + static_cast(Value.X), + static_cast(Value.Y), + static_cast(Value.Z), + static_cast(Value.W)); + } + + FEditorVector3 ToEditorVector(const FVector3& Value) + { + return { + static_cast(Value.x), + static_cast(Value.y), + static_cast(Value.z) + }; + } + + FEditorQuaternion ToEditorQuaternion(const FQuat& Value) + { + return { + static_cast(Value.x), + static_cast(Value.y), + static_cast(Value.z), + static_cast(Value.w) + }; + } + + void RequireCommandOutcome(const FEditorLedgerOutcome& Outcome) + { + if (Outcome.Result.has_value()) + { + if (Outcome.Result->Status == EEditorCommandStatus::Conflict) + { + throw std::runtime_error( + "Editor document revision conflict"); + } + return; + } + if (!Outcome.Diagnostics.empty()) + { + throw std::invalid_argument( + Outcome.Diagnostics.front().Message); + } + throw std::runtime_error("Editor domain command failed"); + } + + bool ConvertTypedFieldSelection( + const FJson& Input, + bool bRequiresId, + const std::vector>& Fields, + FJson& OutLegacyParams, + WaveContract::FStructuredDiagnostic& OutDiagnostic) + { + if (!Input.is_object()) + { + OutDiagnostic.Code = + "contract.input.object_required"; + OutDiagnostic.Path = "$"; + OutDiagnostic.Message = + "typed capability input must be an object"; + return false; + } + const auto Id = Input.find("id"); + if (bRequiresId && Id == Input.end()) + { + OutDiagnostic.Code = + "contract.input.field_required"; + OutDiagnostic.Path = "$.id"; + OutDiagnostic.Message = + "typed instance field input requires id"; + return false; + } + + const std::pair* Selected = nullptr; + for (auto Property = Input.begin(); + Property != Input.end(); + ++Property) + { + if (bRequiresId && Property.key() == "id") + { + continue; + } + const auto Match = std::find_if( + Fields.begin(), + Fields.end(), + [&Property](const auto& Candidate) + { + return Candidate.first == Property.key(); + }); + if (Match == Fields.end()) + { + OutDiagnostic.Code = + "contract.input.field_unknown"; + OutDiagnostic.Path = "$." + Property.key(); + OutDiagnostic.Message = + "typed capability input contains an unknown field"; + return false; + } + if (Selected != nullptr) + { + OutDiagnostic.Code = + "editor.input.field_selection"; + OutDiagnostic.Path = "$"; + OutDiagnostic.Message = + "exactly one typed field must be supplied"; + return false; + } + Selected = &*Match; + } + if (Selected == nullptr) + { + OutDiagnostic.Code = + "editor.input.field_selection"; + OutDiagnostic.Path = "$"; + OutDiagnostic.Message = + "exactly one typed field must be supplied"; + return false; + } + + OutLegacyParams = FJson::array(); + if (bRequiresId) + { + OutLegacyParams.push_back(*Id); + } + OutLegacyParams.push_back(Selected->second); + OutLegacyParams.push_back(Input.at(Selected->first)); + return true; + } + + bool ConvertTypedInspectorField( + const FJson& Input, + FJson& OutLegacyParams, + WaveContract::FStructuredDiagnostic& OutDiagnostic) + { + return ConvertTypedFieldSelection( + Input, + true, + { + { "position", "transform.position" }, + { "rotation", "transform.rotation" }, + { + "rotation_euler_deg", + "transform.rotation_euler_deg" + }, + { "scale", "transform.scale" } + }, + OutLegacyParams, + OutDiagnostic); + } + + bool ConvertTypedRendererSetting( + const FJson& Input, + FJson& OutLegacyParams, + WaveContract::FStructuredDiagnostic& OutDiagnostic) + { + return ConvertTypedFieldSelection( + Input, + false, + { + { "render_mode", "render_mode" }, + { + "auto_exposure_enabled", + "auto_exposure_enabled" + }, + { "manual_exposure", "manual_exposure" }, + { "pt_max_bounces", "pt_max_bounces" }, + { + "pt_samples_per_pixel", + "pt_samples_per_pixel" + }, + { + "pt_accumulation_enabled", + "pt_accumulation_enabled" + }, + { + "lod_error_threshold", + "lod_error_threshold" + } + }, + OutLegacyParams, + OutDiagnostic); + } + } + + FEditorDomainMcpAdapter::FEditorDomainMcpAdapter( + FEditorDomainServices& InServices) + : Services(InServices) + { + } + + void FEditorDomainMcpAdapter::Register(FMcpRegistry& Registry) + { + if (bRegistered) + { + return; + } + try + { + Registry.AddReadOnly( + "editor.hierarchy.list", + "List scene hierarchy entries and current selection", + {}, + this, + &FEditorDomainMcpAdapter::HierarchyList); + Registry.Add( + "editor.hierarchy.select", + "Select one session-scoped scene instance", + { "id" }, + this, + &FEditorDomainMcpAdapter::HierarchySelect); + Registry.Add( + "editor.hierarchy.delete", + "Delete one session-scoped scene instance", + { "id" }, + this, + &FEditorDomainMcpAdapter::HierarchyDelete); + Registry.Add( + "editor.hierarchy.rename", + "Rename one session-scoped scene instance display name", + { "id", "new_name" }, + this, + &FEditorDomainMcpAdapter::HierarchyRename); + Registry.AddReadOnly( + "editor.inspector.get", + "Read inspectable fields for one session-scoped scene instance", + { "id" }, + this, + &FEditorDomainMcpAdapter::InspectorGet); + Registry.Add( + "editor.inspector.set_field", + "Set one inspectable field on a session-scoped scene instance", + { "id", "path", "value" }, + this, + &FEditorDomainMcpAdapter::InspectorSetField); + Registry.AddReadOnly( + "editor.inspector.list_fields", + "List editable field metadata for one session-scoped scene instance", + { "id" }, + this, + &FEditorDomainMcpAdapter::InspectorListFields); + Registry.AddReadOnly( + "editor.viewport.camera.get", + "Read the editor camera transform and projection", + {}, + this, + &FEditorDomainMcpAdapter::CameraGet); + Registry.Add( + "editor.viewport.camera.set", + "Set the editor camera position and Pitch/Yaw/Roll rotation", + { "position", "rotation_deg" }, + this, + &FEditorDomainMcpAdapter::CameraSet); + Registry.Add( + "editor.viewport.camera.move", + "Move the editor camera by a world-space delta", + { "delta_world" }, + this, + &FEditorDomainMcpAdapter::CameraMove); + Registry.Add( + "editor.viewport.camera.look_at", + "Rotate the editor camera to face a world-space target", + { "target" }, + this, + &FEditorDomainMcpAdapter::CameraLookAt); + Registry.AddReadOnly( + "editor.lighting.get_main", + "Read the main directional light settings", + {}, + this, + &FEditorDomainMcpAdapter::LightingGetMain); + Registry.Add( + "editor.lighting.set_main", + "Set main directional light color, intensity, and direction", + { "color", "intensity", "direction" }, + this, + &FEditorDomainMcpAdapter::LightingSetMain); + Registry.AddReadOnly( + "editor.renderer.get_settings", + "Read renderer settings exposed by the editor", + {}, + this, + &FEditorDomainMcpAdapter::RendererGetSettings); + Registry.Add( + "editor.renderer.set_setting", + "Set one renderer setting through its legacy path", + { "path", "value" }, + this, + &FEditorDomainMcpAdapter::RendererSetSetting); + Registry.Add( + "editor.viewport.set_debug_view", + "Set the renderer debug visualization mode", + { "mode" }, + this, + &FEditorDomainMcpAdapter::ViewportSetDebugView); + for (WaveContract::FCapabilityDescriptor Descriptor : + MakeEditorDomainCapabilityDescriptors()) + { + if (Descriptor.Name + == "editor.inspector.set_field") + { + Registry.AddCapabilityVersion( + std::move(Descriptor), + this, + ConvertTypedInspectorField); + } + else if (Descriptor.Name + == "editor.renderer.set_setting") + { + Registry.AddCapabilityVersion( + std::move(Descriptor), + this, + ConvertTypedRendererSetting); + } + else + { + Registry.AddCapabilityVersion( + std::move(Descriptor), + this); + } + } + bRegistered = true; + } + catch (...) + { + Registry.RemoveByOwner(this); + throw; + } + } + + void FEditorDomainMcpAdapter::Unregister(FMcpRegistry& Registry) + { + Registry.RemoveByOwner(this); + bRegistered = false; + } + + FJson FEditorDomainMcpAdapter::HierarchyList() + { + FJson Result = FJson::array(); + for (const FSceneInstanceState& Instance : + Services.SceneAuthoring().ListInstances().Value) + { + FJson Item; + Item["id"] = std::stoul(Instance.Ref.Id); + Item["name"] = Instance.DisplayName; + Item["position"] = + TJsonSerializer::ToJson( + ToRuntimeVector(Instance.Transform.Position)); + Result.push_back(std::move(Item)); + } + return Result; + } + + void FEditorDomainMcpAdapter::HierarchySelect(uint32 Id) + { + const TRefCountPtr Instance = + Services.SceneAuthoring().ResolveInstance(Id); + if (!Instance) + { + throw std::runtime_error("Instance id not found"); + } + Services.SceneAuthoring().SelectInstance(MakeInstanceRef(Id)); + } + + void FEditorDomainMcpAdapter::HierarchyDelete(uint32 Id) + { + if (!Services.SceneAuthoring().ResolveInstance(Id)) + { + throw std::runtime_error("Instance id not found"); + } + const FEditorCommandContext Context = + Services.MakeLegacyCommandContext( + SceneDomain, + "editor.hierarchy.delete"); + RequireCommandOutcome( + Services.SceneAuthoring().RemoveInstance( + MakeInstanceRef(Id), + Context)); + } + + void FEditorDomainMcpAdapter::HierarchyRename( + uint32 Id, + std::string NewName) + { + if (!Services.SceneAuthoring().ResolveInstance(Id)) + { + throw std::runtime_error("Instance id not found"); + } + if (NewName.empty() || NewName.size() > 256) + { + throw std::invalid_argument( + "Instance name must contain 1 to 256 bytes"); + } + const FEditorCommandContext Context = + Services.MakeLegacyCommandContext( + SceneDomain, + "editor.hierarchy.rename"); + RequireCommandOutcome( + Services.SceneAuthoring().RenameInstance( + MakeInstanceRef(Id), + std::move(NewName), + Context)); + } + + FJson FEditorDomainMcpAdapter::InspectorGet(uint32 Id) + { + const FSceneInstanceQueryResult Query = + Services.SceneAuthoring().GetInstance(MakeInstanceRef(Id)); + if (!Query.Snapshot.has_value()) + { + throw std::runtime_error("Instance id not found"); + } + const FSceneInstanceState& Instance = Query.Snapshot->Value; + FJson Result; + Result["name"] = Instance.DisplayName; + Result["position"] = TJsonSerializer::ToJson( + ToRuntimeVector(Instance.Transform.Position)); + Result["rotation"] = TJsonSerializer::ToJson( + ToRuntimeQuaternion(Instance.Transform.Rotation)); + const FRotator Rotator = FRotator::FromQuaternion( + ToRuntimeQuaternion(Instance.Transform.Rotation)); + Result["rotation_euler_deg"] = + FJson::array({ Rotator.Pitch, Rotator.Yaw, Rotator.Roll }); + Result["coordinate_system"] = "LH_XForward_YRight_ZUp"; + Result["scale"] = TJsonSerializer::ToJson( + ToRuntimeVector(Instance.Transform.Scale)); + return Result; + } + + void FEditorDomainMcpAdapter::InspectorSetField( + uint32 Id, + std::string Path, + FJson Value) + { + const FSceneInstanceQueryResult Query = + Services.SceneAuthoring().GetInstance(MakeInstanceRef(Id)); + if (!Query.Snapshot.has_value()) + { + throw std::runtime_error("Instance id not found"); + } + + FEditorTransform After = Query.Snapshot->Value.Transform; + const char* CommandLabel = "Edit Transform"; + if (Path == "transform.position") + { + const FVector3 Position = + TJsonSerializer::FromJson(Value); + McpValidation::RequireFinite(Position, "position"); + After.Position = ToEditorVector(Position); + CommandLabel = "Move Object"; + } + else if (Path == "transform.scale") + { + After.Scale = ToEditorVector( + McpValidation::ValidateScale( + TJsonSerializer::FromJson(Value))); + CommandLabel = "Scale Object"; + } + else if (Path == "transform.rotation") + { + After.Rotation = ToEditorQuaternion( + McpValidation::NormalizeQuaternion( + TJsonSerializer::FromJson(Value), + "rotation")); + CommandLabel = "Rotate Object"; + } + else if (Path == "transform.rotation_euler_deg") + { + const FVector3 EulerDegrees = + McpValidation::NormalizeEulerDegrees( + TJsonSerializer::FromJson(Value), + "rotation_euler_deg"); + After.Rotation = ToEditorQuaternion( + FRotator( + EulerDegrees.x, + EulerDegrees.y, + EulerDegrees.z).Quaternion()); + CommandLabel = "Rotate Object"; + } + else + { + throw std::runtime_error("Unknown field path: " + Path); + } + + const FEditorCommandContext Context = + Services.MakeLegacyCommandContext( + SceneDomain, + "editor.inspector.set_field"); + RequireCommandOutcome( + Services.SceneAuthoring().SetInstanceTransform( + MakeInstanceRef(Id), + After, + Context, + CommandLabel, + EEditorScaleInputPolicy::LegacyMcpOrGizmo)); + } + + FJson FEditorDomainMcpAdapter::InspectorListFields(uint32 Id) + { + (void)Id; + FJson Result = FJson::array(); + const auto Add = [&Result](const char* Path, const char* Type) + { + FJson Entry; + Entry["path"] = Path; + Entry["type"] = Type; + Result.push_back(std::move(Entry)); + }; + Add("transform.position", "vec3"); + Add("transform.rotation", "vec4"); + Add("transform.rotation_euler_deg", "vec3_deg"); + Add("transform.scale", "vec3"); + return Result; + } + + FJson FEditorDomainMcpAdapter::CameraGet() + { + const FCameraAuthoringService& CameraService = + Services.CameraAuthoring(); + if (!CameraService.IsAvailable()) + { + throw std::runtime_error("Camera not initialized"); + } + const FEditorCameraSnapshotState& Camera = + CameraService.GetCamera().Value; + FJson Result; + Result["position"] = TJsonSerializer::ToJson( + ToRuntimeVector(Camera.Transform.Position)); + Result["rotation_deg"] = FJson::array({ + Camera.Transform.Rotation.Pitch, + Camera.Transform.Rotation.Yaw, + Camera.Transform.Rotation.Roll + }); + Result["coordinate_system"] = "LH_XForward_YRight_ZUp"; + Result["fov"] = Camera.FieldOfViewDegrees; + Result["near"] = Camera.NearPlane; + Result["far"] = Camera.FarPlane; + return Result; + } + + void FEditorDomainMcpAdapter::CameraSet( + FVector3 Position, + FVector3 RotationDeg) + { + if (!Services.CameraAuthoring().IsAvailable()) + { + throw std::runtime_error("Camera not initialized"); + } + McpValidation::RequireFinite(Position, "position"); + const FVector3 SafeRotation = + McpValidation::NormalizeEulerDegrees( + RotationDeg, + "rotation_deg"); + FEditorCameraState Camera = + Services.CameraAuthoring().GetCamera().Value.Transform; + Camera.Position = ToEditorVector(Position); + Camera.Rotation = { + SafeRotation.x, + SafeRotation.y, + SafeRotation.z + }; + const FEditorCommandContext Context = + Services.MakeLegacyCommandContext( + SceneDomain, + "editor.viewport.camera.set"); + RequireCommandOutcome( + Services.CameraAuthoring().SetCamera( + Camera, + Context, + "Set Camera")); + } + + void FEditorDomainMcpAdapter::CameraMove(FVector3 DeltaWorld) + { + if (!Services.CameraAuthoring().IsAvailable()) + { + throw std::runtime_error("Camera not initialized"); + } + McpValidation::RequireFinite(DeltaWorld, "delta_world"); + const FEditorCameraState Current = + Services.CameraAuthoring().GetCamera().Value.Transform; + const FVector3 NewPosition( + static_cast(Current.Position.X) + DeltaWorld.x, + static_cast(Current.Position.Y) + DeltaWorld.y, + static_cast(Current.Position.Z) + DeltaWorld.z); + McpValidation::RequireFinite(NewPosition, "camera position"); + const FEditorCommandContext Context = + Services.MakeLegacyCommandContext( + SceneDomain, + "editor.viewport.camera.move"); + FEditorCameraState Camera = Current; + Camera.Position = ToEditorVector(NewPosition); + RequireCommandOutcome( + Services.CameraAuthoring().SetCamera( + Camera, + Context, + "Move Camera", + "editor.viewport.camera.move")); + } + + void FEditorDomainMcpAdapter::CameraLookAt(FVector3 Target) + { + if (!Services.CameraAuthoring().IsAvailable()) + { + throw std::runtime_error("Camera not initialized"); + } + McpValidation::RequireFinite(Target, "target"); + const FEditorCameraState Current = + Services.CameraAuthoring().GetCamera().Value.Transform; + const double DeltaX = + static_cast(Target.x) - Current.Position.X; + const double DeltaY = + static_cast(Target.y) - Current.Position.Y; + const double DeltaZ = + static_cast(Target.z) - Current.Position.Z; + const double Length = std::hypot(DeltaX, DeltaY, DeltaZ); + if (!std::isfinite(Length) || Length < 1.0e-6) + { + throw std::runtime_error("Target equals camera position"); + } + const FEditorCommandContext Context = + Services.MakeLegacyCommandContext( + SceneDomain, + "editor.viewport.camera.look_at"); + RequireCommandOutcome( + Services.CameraAuthoring().LookAt( + ToEditorVector(Target), + Context)); + } + + FJson FEditorDomainMcpAdapter::LightingGetMain() + { + if (!Services.LightingAuthoring().IsAvailable()) + { + throw std::runtime_error("MainLight not initialized"); + } + const FEditorMainLightState& Light = + Services.LightingAuthoring().GetMainLight().Value; + FJson Result; + Result["color"] = TJsonSerializer::ToJson( + ToRuntimeVector(Light.Color)); + Result["intensity"] = static_cast(Light.Intensity); + Result["direction"] = TJsonSerializer::ToJson( + ToRuntimeVector(Light.Direction)); + return Result; + } + + void FEditorDomainMcpAdapter::LightingSetMain( + FVector3 Color, + float Intensity, + FVector3 Direction) + { + if (!Services.LightingAuthoring().IsAvailable()) + { + throw std::runtime_error("MainLight not initialized"); + } + McpValidation::RequireRange(Color, 0.0f, 1.0f, "color"); + McpValidation::RequireRange( + Intensity, + 0.0f, + 20.0f, + "intensity"); + const FVector3 NormalizedDirection = + McpValidation::NormalizeDirection(Direction, "direction"); + const FEditorCommandContext Context = + Services.MakeLegacyCommandContext( + RenderSettingsDomain, + "editor.lighting.set_main"); + RequireCommandOutcome( + Services.LightingAuthoring().SetMainLight( + { + ToEditorVector(Color), + static_cast(Intensity), + ToEditorVector(NormalizedDirection) + }, + Context)); + } + + FJson FEditorDomainMcpAdapter::RendererGetSettings() + { + if (!Services.RenderSettingsAuthoring().IsAvailable()) + { + throw std::runtime_error("Scene not initialized"); + } + const FEditorRenderSettingsState& Settings = + Services.RenderSettingsAuthoring().GetSettings().Value; + FJson Result; + Result["render_mode"] = + Settings.RenderMode == EEditorRenderMode::Raster + ? "forward" + : "path_tracing"; + Result["auto_exposure_enabled"] = + Settings.bAutoExposureEnabled; + Result["manual_exposure"] = + static_cast(Settings.ManualExposure); + Result["pt_max_bounces"] = + Settings.PathTracingMaxBounces; + Result["pt_samples_per_pixel"] = + Settings.PathTracingSamplesPerPixel; + Result["pt_accumulation_enabled"] = + Settings.bPathTracingAccumulationEnabled; + Result["lod_error_threshold"] = + static_cast(Settings.LodErrorThreshold); + return Result; + } + + void FEditorDomainMcpAdapter::RendererSetSetting( + std::string Path, + FJson Value) + { + if (!Services.RenderSettingsAuthoring().IsAvailable()) + { + throw std::runtime_error("Scene not initialized"); + } + FEditorRenderSettingsState Settings = + Services.RenderSettingsAuthoring().GetSettings().Value; + if (Path == "render_mode") + { + const std::string Mode = Value.get(); + if (Mode == "forward") + { + Settings.RenderMode = EEditorRenderMode::Raster; + } + else if (Mode == "path_tracing") + { + Settings.RenderMode = EEditorRenderMode::PathTracing; + } + else + { + throw std::runtime_error( + "Unknown render_mode: " + Mode); + } + } + else if (Path == "auto_exposure_enabled") + { + Settings.bAutoExposureEnabled = + TJsonSerializer::FromJson(Value); + } + else if (Path == "manual_exposure") + { + const float Exposure = + TJsonSerializer::FromJson(Value); + if (Exposure < 0.1f || Exposure > 10.0f) + { + throw std::invalid_argument( + "manual_exposure must be in [0.1, 10.0]"); + } + Settings.ManualExposure = Exposure; + } + else if (Path == "pt_max_bounces") + { + const uint32 Bounces = + TJsonSerializer::FromJson(Value); + if (Bounces < 1 || Bounces > 16) + { + throw std::invalid_argument( + "pt_max_bounces must be in [1, 16]"); + } + Settings.PathTracingMaxBounces = Bounces; + } + else if (Path == "pt_samples_per_pixel") + { + const uint32 Samples = + TJsonSerializer::FromJson(Value); + if (Samples < 1 || Samples > 8) + { + throw std::invalid_argument( + "pt_samples_per_pixel must be in [1, 8]"); + } + Settings.PathTracingSamplesPerPixel = Samples; + } + else if (Path == "pt_accumulation_enabled") + { + Settings.bPathTracingAccumulationEnabled = + TJsonSerializer::FromJson(Value); + } + else if (Path == "lod_error_threshold") + { + const float Threshold = + TJsonSerializer::FromJson(Value); + if (Threshold < 0.0001f || Threshold > 1.0f) + { + throw std::invalid_argument( + "lod_error_threshold must be in [0.0001, 1.0]"); + } + Settings.LodErrorThreshold = Threshold; + } + else + { + throw std::runtime_error( + "Unknown setting path: " + Path); + } + const FEditorCommandContext Context = + Services.MakeLegacyCommandContext( + RenderSettingsDomain, + "editor.renderer.set_setting"); + RequireCommandOutcome( + Services.RenderSettingsAuthoring().SetSettings( + Settings, + Context)); + } + + void FEditorDomainMcpAdapter::ViewportSetDebugView( + std::string Mode) + { + if (!Services.RenderSettingsAuthoring().IsAvailable()) + { + throw std::runtime_error("Scene not initialized"); + } + FEditorRenderSettingsState Settings = + Services.RenderSettingsAuthoring().GetSettings().Value; + if (Mode == "none") + { + Settings.DebugView = EEditorDebugView::Lit; + } + else if (Mode == "meshlet_index") + { + Settings.DebugView = EEditorDebugView::MeshletIndex; + } + else if (Mode == "group_id") + { + Settings.DebugView = EEditorDebugView::GroupId; + } + else + { + throw std::runtime_error( + "Unknown debug view mode: " + Mode); + } + const FEditorCommandContext Context = + Services.MakeLegacyCommandContext( + RenderSettingsDomain, + "editor.viewport.set_debug_view"); + RequireCommandOutcome( + Services.RenderSettingsAuthoring().SetSettings( + Settings, + Context, + "editor.viewport.set_debug_view")); + } +} diff --git a/Engine/Source/Developer/Scene/EditorDomainMcpAdapter.h b/Engine/Source/Developer/Scene/EditorDomainMcpAdapter.h new file mode 100644 index 0000000..61231e5 --- /dev/null +++ b/Engine/Source/Developer/Scene/EditorDomainMcpAdapter.h @@ -0,0 +1,46 @@ +#pragma once + +#include "Mcp/JsonSerializer.h" + +#include +#include + +class FMcpRegistry; + +namespace WaveEditorDomain +{ + class FEditorDomainServices; + + class FEditorDomainMcpAdapter + { + public: + explicit FEditorDomainMcpAdapter(FEditorDomainServices& InServices); + + void Register(FMcpRegistry& Registry); + void Unregister(FMcpRegistry& Registry); + + FJson HierarchyList(); + void HierarchySelect(uint32 Id); + void HierarchyDelete(uint32 Id); + void HierarchyRename(uint32 Id, std::string NewName); + FJson InspectorGet(uint32 Id); + void InspectorSetField(uint32 Id, std::string Path, FJson Value); + FJson InspectorListFields(uint32 Id); + FJson CameraGet(); + void CameraSet(FVector3 Position, FVector3 RotationDeg); + void CameraMove(FVector3 DeltaWorld); + void CameraLookAt(FVector3 Target); + FJson LightingGetMain(); + void LightingSetMain( + FVector3 Color, + float Intensity, + FVector3 Direction); + FJson RendererGetSettings(); + void RendererSetSetting(std::string Path, FJson Value); + void ViewportSetDebugView(std::string Mode); + + private: + FEditorDomainServices& Services; + bool bRegistered = false; + }; +} diff --git a/Engine/Source/Developer/Scene/LightingAuthoringService.cpp b/Engine/Source/Developer/Scene/LightingAuthoringService.cpp new file mode 100644 index 0000000..6027ec2 --- /dev/null +++ b/Engine/Source/Developer/Scene/LightingAuthoringService.cpp @@ -0,0 +1,354 @@ +#include "Developer/Scene/LightingAuthoringService.h" + +#include "Scene/Light.h" +#include "Scene/Scene.h" + +#include +#include + +namespace WaveEditorDomain +{ + namespace + { + using namespace WaveContract; + + FStructuredDiagnostic MakeDiagnostic( + std::string Code, + std::string Path, + std::string Message) + { + FStructuredDiagnostic Diagnostic; + Diagnostic.Code = std::move(Code); + Diagnostic.Path = std::move(Path); + Diagnostic.Message = std::move(Message); + return Diagnostic; + } + + FEditorLedgerOutcome MakeInvalidOutcome( + std::vector Diagnostics, + std::string_view CorrelationId) + { + for (FStructuredDiagnostic& Diagnostic : Diagnostics) + { + Diagnostic.CorrelationId = CorrelationId; + } + FEditorLedgerOutcome Outcome; + Outcome.Diagnostics = std::move(Diagnostics); + return Outcome; + } + + void RetainValidationPassed( + FEditorValidationResult& Validation, + std::string_view Validator) + { + if (Validation.Succeeded() && Validation.Diagnostics.empty()) + { + Validation.Diagnostics.push_back( + MakeValidationPassed(Validator)); + } + } + + bool NearlyEqual(double Left, double Right) + { + return std::abs(Left - Right) <= 1.0e-6; + } + + bool LightsEquivalent( + const FEditorMainLightState& Left, + const FEditorMainLightState& Right) + { + return NearlyEqual(Left.Color.X, Right.Color.X) + && NearlyEqual(Left.Color.Y, Right.Color.Y) + && NearlyEqual(Left.Color.Z, Right.Color.Z) + && NearlyEqual(Left.Intensity, Right.Intensity) + && NearlyEqual(Left.Direction.X, Right.Direction.X) + && NearlyEqual(Left.Direction.Y, Right.Direction.Y) + && NearlyEqual(Left.Direction.Z, Right.Direction.Z); + } + + FEditorMainLightState CaptureLight(const FLight& Light) + { + return { + { + Light.Color.x, + Light.Color.y, + Light.Color.z + }, + Light.Intensity, + { + Light.Direction.x, + Light.Direction.y, + Light.Direction.z + } + }; + } + + void ApplyLight( + FLight& Target, + const FEditorMainLightState& Light) + { + Target.Color = FVector3( + static_cast(Light.Color.X), + static_cast(Light.Color.Y), + static_cast(Light.Color.Z)); + Target.Intensity = static_cast(Light.Intensity); + Target.Direction = FVector3( + static_cast(Light.Direction.X), + static_cast(Light.Direction.Y), + static_cast(Light.Direction.Z)); + } + + std::vector GetChangedFields( + const FEditorMainLightState& Before, + const FEditorMainLightState& After) + { + std::vector Fields; + if (!NearlyEqual(Before.Color.X, After.Color.X) + || !NearlyEqual(Before.Color.Y, After.Color.Y) + || !NearlyEqual(Before.Color.Z, After.Color.Z)) + { + Fields.push_back("main_light.color"); + } + if (!NearlyEqual(Before.Intensity, After.Intensity)) + { + Fields.push_back("main_light.intensity"); + } + if (!NearlyEqual(Before.Direction.X, After.Direction.X) + || !NearlyEqual(Before.Direction.Y, After.Direction.Y) + || !NearlyEqual(Before.Direction.Z, After.Direction.Z)) + { + Fields.push_back("main_light.direction"); + } + return Fields; + } + } + + FObjectRef MakeMainLightRef() + { + return MakeSessionObjectRef("editor.main_light", "main"); + } + + FLightingAuthoringService::FLightingAuthoringService( + FEditorDocumentLedger& InLedger, + IEditorAuthoringHost& InHost) + : Ledger(InLedger) + , Host(InHost) + { + } + + bool FLightingAuthoringService::IsAvailable() const + { + const FScene* Scene = Host.GetEditorScene(); + return Scene && Scene->MainLight; + } + + TEditorTypedSnapshot + FLightingAuthoringService::GetMainLight() const + { + TEditorTypedSnapshot Snapshot; + if (const std::optional Revision = + Ledger.GetRevision(RenderSettingsDomain)) + { + Snapshot.Meta.SourceRevision = *Revision; + } + if (const FScene* Scene = Host.GetEditorScene(); + Scene && Scene->MainLight) + { + Snapshot.Value = CaptureLight(*Scene->MainLight); + } + return Snapshot; + } + + FEditorValidationResult + FLightingAuthoringService::HydrateMainLight( + FEditorMainLightState Light) + { + FEditorValidationResult Validation = + ValidateAndNormalizeMainLight(Light); + if (Validation.Succeeded()) + { + if (FScene* Scene = Host.GetEditorScene(); + Scene && Scene->MainLight) + { + ApplyLight(*Scene->MainLight, Light); + } + } + return Validation; + } + + FEditorLedgerOutcome FLightingAuthoringService::SetMainLight( + FEditorMainLightState Light, + const FEditorCommandContext& Context, + double MinimumDirectionLength) + { + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(RenderSettingsDomain, Context); + if (!Check.bAllowed) + { + return Ledger.ResolveNoOp(RenderSettingsDomain, Context); + } + if (!IsAvailable()) + { + return MakeInvalidOutcome( + { MakeDiagnostic( + "editor.light.not_initialized", + "$.target", + "MainLight not initialized") }, + Context.CorrelationId); + } + FEditorValidationResult Validation = + ValidateAndNormalizeMainLight( + Light, + MinimumDirectionLength); + if (!Validation.Succeeded()) + { + return MakeInvalidOutcome( + std::move(Validation.Diagnostics), + Context.CorrelationId); + } + RetainValidationPassed( + Validation, + "wave.editor.render.set_main_light"); + return CommitMainLight( + GetMainLight().Value, + Light, + Context, + false, + std::move(Validation.Diagnostics)); + } + + FLightingPreviewResult + FLightingAuthoringService::PreviewMainLight( + FEditorMainLightState Light, + const FEditorCommandContext& Context, + double MinimumDirectionLength) + { + FLightingPreviewResult Result; + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(RenderSettingsDomain, Context); + if (!Check.bAllowed) + { + Result.Diagnostics = Check.Diagnostics; + return Result; + } + FScene* Scene = Host.GetEditorScene(); + if (!Scene || !Scene->MainLight) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "editor.light.not_initialized", + "$.target", + "MainLight not initialized")); + return Result; + } + FEditorValidationResult Validation = + ValidateAndNormalizeMainLight( + Light, + MinimumDirectionLength); + Result.Diagnostics = Validation.Diagnostics; + if (!Validation.Succeeded()) + { + return Result; + } + const FEditorMainLightState Before = + CaptureLight(*Scene->MainLight); + if (LightsEquivalent(Before, Light)) + { + Result.bNoOp = true; + return Result; + } + ApplyLight(*Scene->MainLight, Light); + if (RequiresPathTracingReset(Before, Light)) + { + Scene->bPTResetAccumulation = true; + } + Result.bApplied = true; + return Result; + } + + FEditorLedgerOutcome + FLightingAuthoringService::CommitMainLightPreview( + const FEditorMainLightState& Before, + const FEditorCommandContext& Context, + double MinimumDirectionLength) + { + FScene* Scene = Host.GetEditorScene(); + if (!Scene || !Scene->MainLight) + { + return MakeInvalidOutcome( + { MakeDiagnostic( + "editor.light.not_initialized", + "$.target", + "MainLight not initialized") }, + Context.CorrelationId); + } + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(RenderSettingsDomain, Context); + if (!Check.bAllowed) + { + ApplyLight(*Scene->MainLight, Before); + Scene->bPTResetAccumulation = true; + return Ledger.ResolveNoOp(RenderSettingsDomain, Context); + } + FEditorMainLightState After = + CaptureLight(*Scene->MainLight); + FEditorValidationResult Validation = + ValidateAndNormalizeMainLight( + After, + MinimumDirectionLength); + if (!Validation.Succeeded()) + { + ApplyLight(*Scene->MainLight, Before); + Scene->bPTResetAccumulation = true; + return MakeInvalidOutcome( + std::move(Validation.Diagnostics), + Context.CorrelationId); + } + RetainValidationPassed( + Validation, + "wave.editor.render.commit_main_light_preview"); + return CommitMainLight( + Before, + After, + Context, + true, + std::move(Validation.Diagnostics)); + } + + FEditorLedgerOutcome + FLightingAuthoringService::CommitMainLight( + const FEditorMainLightState& Before, + const FEditorMainLightState& After, + const FEditorCommandContext& Context, + bool bAlreadyApplied, + std::vector ValidatorResults) + { + if (LightsEquivalent(Before, After)) + { + return Ledger.ResolveNoOp(RenderSettingsDomain, Context); + } + FScene* Scene = Host.GetEditorScene(); + check( + Scene && Scene->MainLight, + "Validated MainLight command lost its target"); + if (!bAlreadyApplied) + { + ApplyLight(*Scene->MainLight, After); + } + if (RequiresPathTracingReset(Before, After)) + { + Scene->bPTResetAccumulation = true; + } + Host.MarkEditorSettingsDirty(); + + FEditorMutation Mutation; + Mutation.Capability = "editor.lighting.set_main"; + Mutation.Targets = { MakeMainLightRef() }; + Mutation.Kind = EDomainChangeKind::Updated; + Mutation.ChangedFields = GetChangedFields(Before, After); + Mutation.ValidatorResults = std::move(ValidatorResults); + return Ledger.CommitValidated( + RenderSettingsDomain, + Context, + Mutation); + } +} diff --git a/Engine/Source/Developer/Scene/LightingAuthoringService.h b/Engine/Source/Developer/Scene/LightingAuthoringService.h new file mode 100644 index 0000000..58d574e --- /dev/null +++ b/Engine/Source/Developer/Scene/LightingAuthoringService.h @@ -0,0 +1,54 @@ +#pragma once + +#include "Developer/Scene/SceneAuthoringService.h" + +#include + +namespace WaveEditorDomain +{ + struct FLightingPreviewResult + { + bool bApplied = false; + bool bNoOp = false; + std::vector Diagnostics; + }; + + [[nodiscard]] WaveContract::FObjectRef MakeMainLightRef(); + + class FLightingAuthoringService + { + public: + FLightingAuthoringService( + FEditorDocumentLedger& InLedger, + IEditorAuthoringHost& InHost); + + [[nodiscard]] bool IsAvailable() const; + [[nodiscard]] TEditorTypedSnapshot + GetMainLight() const; + [[nodiscard]] FEditorValidationResult HydrateMainLight( + FEditorMainLightState Light); + [[nodiscard]] FEditorLedgerOutcome SetMainLight( + FEditorMainLightState Light, + const FEditorCommandContext& Context, + double MinimumDirectionLength = 0.0); + [[nodiscard]] FLightingPreviewResult PreviewMainLight( + FEditorMainLightState Light, + const FEditorCommandContext& Context, + double MinimumDirectionLength); + [[nodiscard]] FEditorLedgerOutcome CommitMainLightPreview( + const FEditorMainLightState& Before, + const FEditorCommandContext& Context, + double MinimumDirectionLength); + + private: + [[nodiscard]] FEditorLedgerOutcome CommitMainLight( + const FEditorMainLightState& Before, + const FEditorMainLightState& After, + const FEditorCommandContext& Context, + bool bAlreadyApplied, + std::vector ValidatorResults); + + FEditorDocumentLedger& Ledger; + IEditorAuthoringHost& Host; + }; +} diff --git a/Engine/Source/Developer/Scene/SceneAuthoringService.cpp b/Engine/Source/Developer/Scene/SceneAuthoringService.cpp new file mode 100644 index 0000000..b764acd --- /dev/null +++ b/Engine/Source/Developer/Scene/SceneAuthoringService.cpp @@ -0,0 +1,666 @@ +#include "Developer/Scene/SceneAuthoringService.h" + +#include "Scene/Instance.h" +#include "Scene/Scene.h" + +#include +#include +#include +#include +#include + +namespace WaveEditorDomain +{ + namespace + { + using namespace WaveContract; + + FStructuredDiagnostic MakeDiagnostic( + std::string Code, + std::string Path, + std::string Message) + { + FStructuredDiagnostic Diagnostic; + Diagnostic.Code = std::move(Code); + Diagnostic.Path = std::move(Path); + Diagnostic.Message = std::move(Message); + return Diagnostic; + } + + FEditorLedgerOutcome MakeInvalidOutcome( + std::vector Diagnostics, + std::string_view CorrelationId) + { + for (FStructuredDiagnostic& Diagnostic : Diagnostics) + { + Diagnostic.CorrelationId = CorrelationId; + } + FEditorLedgerOutcome Outcome; + Outcome.Diagnostics = std::move(Diagnostics); + return Outcome; + } + + FEditorLedgerOutcome MakeInvalidOutcome( + FStructuredDiagnostic Diagnostic, + std::string_view CorrelationId) + { + return MakeInvalidOutcome( + std::vector{ + std::move(Diagnostic) + }, + CorrelationId); + } + + void RetainValidationPassed( + FEditorValidationResult& Validation, + std::string_view Validator) + { + if (Validation.Succeeded() && Validation.Diagnostics.empty()) + { + Validation.Diagnostics.push_back( + MakeValidationPassed(Validator)); + } + } + + bool NearlyEqual(double Left, double Right) + { + return std::abs(Left - Right) <= 1.0e-6; + } + + bool PositionEquivalent( + const FEditorVector3& Left, + const FEditorVector3& Right) + { + return NearlyEqual(Left.X, Right.X) + && NearlyEqual(Left.Y, Right.Y) + && NearlyEqual(Left.Z, Right.Z); + } + + bool RotationEquivalent( + const FEditorQuaternion& Left, + const FEditorQuaternion& Right) + { + const double LeftLength = std::sqrt( + Left.X * Left.X + + Left.Y * Left.Y + + Left.Z * Left.Z + + Left.W * Left.W); + const double RightLength = std::sqrt( + Right.X * Right.X + + Right.Y * Right.Y + + Right.Z * Right.Z + + Right.W * Right.W); + if (!(LeftLength > 0.0) || !(RightLength > 0.0)) + { + return false; + } + const double DotValue = + (Left.X * Right.X + + Left.Y * Right.Y + + Left.Z * Right.Z + + Left.W * Right.W) + / (LeftLength * RightLength); + return std::abs(DotValue) >= 1.0 - 1.0e-6; + } + + bool ScaleEquivalent( + const FEditorVector3& Left, + const FEditorVector3& Right) + { + return NearlyEqual(Left.X, Right.X) + && NearlyEqual(Left.Y, Right.Y) + && NearlyEqual(Left.Z, Right.Z); + } + + void ApplyTransform( + FInstance& Instance, + const FEditorTransform& Transform) + { + Instance.Position = FVector3( + static_cast(Transform.Position.X), + static_cast(Transform.Position.Y), + static_cast(Transform.Position.Z)); + Instance.Rotation = FQuat( + static_cast(Transform.Rotation.X), + static_cast(Transform.Rotation.Y), + static_cast(Transform.Rotation.Z), + static_cast(Transform.Rotation.W)); + Instance.Scale = FVector3( + static_cast(Transform.Scale.X), + static_cast(Transform.Scale.Y), + static_cast(Transform.Scale.Z)); + Instance.SetDirty(); + } + + std::vector GetChangedTransformFields( + const FEditorTransform& Before, + const FEditorTransform& After) + { + std::vector Fields; + if (!PositionEquivalent(Before.Position, After.Position)) + { + Fields.push_back("transform.position"); + } + if (!RotationEquivalent(Before.Rotation, After.Rotation)) + { + Fields.push_back("transform.rotation"); + } + if (!ScaleEquivalent(Before.Scale, After.Scale)) + { + Fields.push_back("transform.scale"); + } + return Fields; + } + + bool IsVisibleInstance( + const FScene& Scene, + const TRefCountPtr& Instance) + { + const std::vector> Visible = + Scene.GetVisibleInstances(); + return std::find(Visible.begin(), Visible.end(), Instance) + != Visible.end(); + } + + std::optional ParseInstanceRef( + const FObjectRef& Ref) + { + if (Ref.Kind != InstanceObjectKind + || Ref.Scope != EReferenceScope::Session + || !Ref.Project.empty() + || Ref.Id.empty()) + { + return std::nullopt; + } + uint32 Id = 0; + const char* Begin = Ref.Id.data(); + const char* End = Begin + Ref.Id.size(); + const auto [ParsedEnd, Error] = + std::from_chars(Begin, End, Id); + if (Error != std::errc{} || ParsedEnd != End || Id == 0) + { + return std::nullopt; + } + return Id; + } + } + + FObjectRef MakeInstanceRef(uint32 McpId) + { + return MakeSessionObjectRef( + std::string(InstanceObjectKind), + std::to_string(McpId)); + } + + FObjectRef MakeCameraRef() + { + return MakeSessionObjectRef( + std::string(CameraObjectKind), + "main"); + } + + FEditorTransform CaptureEditorTransform(const FInstance& Instance) + { + return { + { + static_cast(Instance.Position.x), + static_cast(Instance.Position.y), + static_cast(Instance.Position.z) + }, + { + static_cast(Instance.Rotation.x), + static_cast(Instance.Rotation.y), + static_cast(Instance.Rotation.z), + static_cast(Instance.Rotation.w) + }, + { + static_cast(Instance.Scale.x), + static_cast(Instance.Scale.y), + static_cast(Instance.Scale.z) + } + }; + } + + bool EditorTransformsEquivalent( + const FEditorTransform& Left, + const FEditorTransform& Right) + { + return PositionEquivalent(Left.Position, Right.Position) + && RotationEquivalent(Left.Rotation, Right.Rotation) + && ScaleEquivalent(Left.Scale, Right.Scale); + } + + FSceneAuthoringService::FSceneAuthoringService( + FEditorDocumentLedger& InLedger, + IEditorAuthoringHost& InHost) + : Ledger(InLedger) + , Host(InHost) + { + } + + TEditorTypedSnapshot> + FSceneAuthoringService::ListInstances() const + { + TEditorTypedSnapshot> Snapshot; + if (const std::optional Revision = + Ledger.GetRevision(SceneDomain)) + { + Snapshot.Meta.SourceRevision = *Revision; + } + const std::vector> Instances = + GetVisibleRuntimeInstances(); + Snapshot.Value.reserve(Instances.size()); + for (const TRefCountPtr& Instance : Instances) + { + if (!Instance) + { + continue; + } + Snapshot.Value.push_back({ + MakeInstanceRef(Instance->McpId), + Instance->DisplayName, + CaptureEditorTransform(*Instance) + }); + } + return Snapshot; + } + + FSceneInstanceQueryResult FSceneAuthoringService::GetInstance( + const FObjectRef& Ref) const + { + FSceneInstanceQueryResult Result; + const TRefCountPtr Instance = ResolveInstance(Ref); + if (!Instance) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "editor.instance.not_found", + "$.target", + "session instance reference was not found")); + return Result; + } + + TEditorTypedSnapshot Snapshot; + if (const std::optional Revision = + Ledger.GetRevision(SceneDomain)) + { + Snapshot.Meta.SourceRevision = *Revision; + } + Snapshot.Value = { + MakeInstanceRef(Instance->McpId), + Instance->DisplayName, + CaptureEditorTransform(*Instance) + }; + Result.Snapshot = std::move(Snapshot); + return Result; + } + + TRefCountPtr FSceneAuthoringService::ResolveInstance( + const FObjectRef& Ref) const + { + const std::optional Id = ParseInstanceRef(Ref); + return Id.has_value() ? ResolveInstance(*Id) : nullptr; + } + + TRefCountPtr FSceneAuthoringService::ResolveInstance( + uint32 McpId) const + { + const std::vector> Instances = + GetVisibleRuntimeInstances(); + const auto It = std::find_if( + Instances.begin(), + Instances.end(), + [McpId](const TRefCountPtr& Instance) + { + return Instance && Instance->McpId == McpId; + }); + return It == Instances.end() ? nullptr : *It; + } + + std::vector> + FSceneAuthoringService::GetVisibleRuntimeInstances() const + { + FScene* Scene = Host.GetEditorScene(); + if (!Scene) + { + return {}; + } + std::vector> Instances = + Scene->GetVisibleInstances(); + std::sort( + Instances.begin(), + Instances.end(), + [](const TRefCountPtr& Left, + const TRefCountPtr& Right) + { + const uint32 LeftId = + Left ? Left->McpId : std::numeric_limits::max(); + const uint32 RightId = + Right ? Right->McpId : std::numeric_limits::max(); + return LeftId < RightId; + }); + return Instances; + } + + void FSceneAuthoringService::SelectInstance(const FObjectRef& Ref) + { + if (const TRefCountPtr Instance = ResolveInstance(Ref)) + { + Host.SelectEditorInstance(Instance); + } + } + + void FSceneAuthoringService::ClearSelection() + { + Host.ClearEditorSelection(); + } + + FEditorLedgerOutcome FSceneAuthoringService::AddInstance( + const TRefCountPtr& Instance, + const FEditorCommandContext& Context, + std::string_view Label) + { + if (!Instance || !Host.GetEditorScene()) + { + return MakeInvalidOutcome( + MakeDiagnostic( + "editor.instance.invalid", + "$.target", + "instance and editor scene are required"), + Context.CorrelationId); + } + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(SceneDomain, Context); + if (!Check.bAllowed) + { + return Ledger.ResolveNoOp(SceneDomain, Context); + } + if (IsVisibleInstance(*Host.GetEditorScene(), Instance)) + { + return Ledger.ResolveNoOp(SceneDomain, Context); + } + + FEditorTransform Transform = CaptureEditorTransform(*Instance); + FEditorValidationResult Validation = + ValidateInstanceName(Instance->DisplayName); + Validation.Append(ValidateTransform( + Transform, + EEditorScaleInputPolicy::Domain)); + if (!Validation.Succeeded()) + { + return MakeInvalidOutcome( + std::move(Validation.Diagnostics), + Context.CorrelationId); + } + RetainValidationPassed( + Validation, + "wave.editor.scene.add_instance"); + + ApplyTransform(*Instance, Transform); + Host.ApplyAddInstance(Instance, Label); + FEditorMutation Mutation; + Mutation.Capability = "editor.scene.add_instance"; + Mutation.Targets = { MakeInstanceRef(Instance->McpId) }; + Mutation.Kind = EDomainChangeKind::Created; + Mutation.ChangedFields = { "instance" }; + Mutation.ValidatorResults = std::move(Validation.Diagnostics); + return Ledger.CommitValidated(SceneDomain, Context, Mutation); + } + + FEditorLedgerOutcome FSceneAuthoringService::RemoveInstance( + const FObjectRef& Ref, + const FEditorCommandContext& Context) + { + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(SceneDomain, Context); + if (!Check.bAllowed) + { + return Ledger.ResolveNoOp(SceneDomain, Context); + } + const TRefCountPtr Instance = ResolveInstance(Ref); + if (!Instance) + { + return MakeInvalidOutcome( + MakeDiagnostic( + "editor.instance.not_found", + "$.target", + "session instance reference was not found"), + Context.CorrelationId); + } + + Host.ApplyRemoveInstance(Instance); + FEditorMutation Mutation; + Mutation.Capability = "editor.hierarchy.delete"; + Mutation.Targets = { MakeInstanceRef(Instance->McpId) }; + Mutation.Kind = EDomainChangeKind::Removed; + Mutation.ChangedFields = { "instance" }; + Mutation.ValidatorResults = { + MakeValidationPassed("wave.editor.scene.remove_instance") + }; + return Ledger.CommitValidated(SceneDomain, Context, Mutation); + } + + FEditorLedgerOutcome FSceneAuthoringService::RenameInstance( + const FObjectRef& Ref, + std::string Name, + const FEditorCommandContext& Context) + { + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(SceneDomain, Context); + if (!Check.bAllowed) + { + return Ledger.ResolveNoOp(SceneDomain, Context); + } + const TRefCountPtr Instance = ResolveInstance(Ref); + if (!Instance) + { + return MakeInvalidOutcome( + MakeDiagnostic( + "editor.instance.not_found", + "$.target", + "session instance reference was not found"), + Context.CorrelationId); + } + FEditorValidationResult Validation = ValidateInstanceName(Name); + if (!Validation.Succeeded()) + { + return MakeInvalidOutcome( + std::move(Validation.Diagnostics), + Context.CorrelationId); + } + if (Instance->DisplayName == Name) + { + return Ledger.ResolveNoOp(SceneDomain, Context); + } + RetainValidationPassed( + Validation, + "wave.editor.scene.rename_instance"); + + Host.ApplyRenameInstance(Instance, std::move(Name)); + FEditorMutation Mutation; + Mutation.Capability = "editor.hierarchy.rename"; + Mutation.Targets = { MakeInstanceRef(Instance->McpId) }; + Mutation.Kind = EDomainChangeKind::Updated; + Mutation.ChangedFields = { "display_name" }; + Mutation.ValidatorResults = std::move(Validation.Diagnostics); + return Ledger.CommitValidated(SceneDomain, Context, Mutation); + } + + FEditorLedgerOutcome FSceneAuthoringService::SetInstanceTransform( + const FObjectRef& Ref, + FEditorTransform Transform, + const FEditorCommandContext& Context, + std::string_view Label, + EEditorScaleInputPolicy ScalePolicy) + { + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(SceneDomain, Context); + if (!Check.bAllowed) + { + return Ledger.ResolveNoOp(SceneDomain, Context); + } + const TRefCountPtr Instance = ResolveInstance(Ref); + if (!Instance) + { + return MakeInvalidOutcome( + MakeDiagnostic( + "editor.instance.not_found", + "$.target", + "session instance reference was not found"), + Context.CorrelationId); + } + FEditorValidationResult Validation = + ValidateTransform(Transform, ScalePolicy); + if (!Validation.Succeeded()) + { + return MakeInvalidOutcome( + std::move(Validation.Diagnostics), + Context.CorrelationId); + } + RetainValidationPassed( + Validation, + "wave.editor.scene.set_instance_transform"); + return CommitTransform( + Instance, + CaptureEditorTransform(*Instance), + Transform, + Context, + Label, + false, + std::move(Validation.Diagnostics)); + } + + FSceneTransformPreviewResult + FSceneAuthoringService::PreviewInstanceTransform( + const FObjectRef& Ref, + FEditorTransform Transform, + const FEditorCommandContext& Context, + EEditorScaleInputPolicy ScalePolicy) + { + FSceneTransformPreviewResult Result; + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(SceneDomain, Context); + if (!Check.bAllowed) + { + Result.Diagnostics = Check.Diagnostics; + return Result; + } + const TRefCountPtr Instance = ResolveInstance(Ref); + if (!Instance) + { + Result.Diagnostics.push_back(MakeDiagnostic( + "editor.instance.not_found", + "$.target", + "session instance reference was not found")); + return Result; + } + FEditorValidationResult Validation = + ValidateTransform(Transform, ScalePolicy); + Result.Diagnostics = Validation.Diagnostics; + if (!Validation.Succeeded()) + { + return Result; + } + if (EditorTransformsEquivalent( + CaptureEditorTransform(*Instance), + Transform)) + { + Result.bNoOp = true; + return Result; + } + ApplyTransform(*Instance, Transform); + Result.bApplied = true; + return Result; + } + + FEditorLedgerOutcome + FSceneAuthoringService::CommitInstanceTransformPreview( + const FObjectRef& Ref, + const FEditorTransform& Before, + const FEditorCommandContext& Context, + std::string_view Label, + EEditorScaleInputPolicy ScalePolicy) + { + const TRefCountPtr Instance = ResolveInstance(Ref); + if (!Instance) + { + return MakeInvalidOutcome( + MakeDiagnostic( + "editor.instance.not_found", + "$.target", + "session instance reference was not found"), + Context.CorrelationId); + } + const FExpectedRevisionCheck Check = + Ledger.CheckExpected(SceneDomain, Context); + if (!Check.bAllowed) + { + ApplyTransform(*Instance, Before); + return Ledger.ResolveNoOp(SceneDomain, Context); + } + + FEditorTransform After = CaptureEditorTransform(*Instance); + FEditorValidationResult Validation = + ValidateTransform(After, ScalePolicy); + if (!Validation.Succeeded()) + { + ApplyTransform(*Instance, Before); + return MakeInvalidOutcome( + std::move(Validation.Diagnostics), + Context.CorrelationId); + } + RetainValidationPassed( + Validation, + "wave.editor.scene.commit_instance_transform_preview"); + return CommitTransform( + Instance, + Before, + After, + Context, + Label, + true, + std::move(Validation.Diagnostics)); + } + + FEditorValidationResult FSceneAuthoringService::ValidateTransform( + FEditorTransform& Transform, + EEditorScaleInputPolicy ScalePolicy) const + { + FEditorValidationResult Validation = + ValidatePosition(Transform.Position); + Validation.Append(ValidateAndNormalizeQuaternion( + Transform.Rotation)); + Validation.Append(ValidateScale( + Transform.Scale, + ScalePolicy)); + return Validation; + } + + FEditorLedgerOutcome FSceneAuthoringService::CommitTransform( + const TRefCountPtr& Instance, + const FEditorTransform& Before, + const FEditorTransform& After, + const FEditorCommandContext& Context, + std::string_view Label, + bool bAlreadyExecuted, + std::vector ValidatorResults) + { + if (EditorTransformsEquivalent(Before, After)) + { + return Ledger.ResolveNoOp(SceneDomain, Context); + } + Host.ApplyInstanceTransform( + Instance, + Before, + After, + Label, + bAlreadyExecuted); + FEditorMutation Mutation; + Mutation.Capability = "editor.inspector.set_field"; + Mutation.Targets = { MakeInstanceRef(Instance->McpId) }; + Mutation.Kind = EDomainChangeKind::Updated; + Mutation.ChangedFields = + GetChangedTransformFields(Before, After); + Mutation.ValidatorResults = std::move(ValidatorResults); + return Ledger.CommitValidated(SceneDomain, Context, Mutation); + } +} diff --git a/Engine/Source/Developer/Scene/SceneAuthoringService.h b/Engine/Source/Developer/Scene/SceneAuthoringService.h new file mode 100644 index 0000000..b5c1113 --- /dev/null +++ b/Engine/Source/Developer/Scene/SceneAuthoringService.h @@ -0,0 +1,154 @@ +#pragma once + +#include "Core/Core.h" +#include "Developer/Document/EditorDocumentLedger.h" +#include "Developer/Scene/EditorDomainContracts.h" + +#include +#include +#include +#include + +class FInstance; +class FScene; + +namespace WaveEditorDomain +{ + inline constexpr std::string_view InstanceObjectKind = "editor.instance"; + inline constexpr std::string_view CameraObjectKind = "editor.camera"; + + struct FSceneInstanceState + { + WaveContract::FObjectRef Ref; + std::string DisplayName; + FEditorTransform Transform; + + bool operator==(const FSceneInstanceState&) const = default; + }; + + struct FSceneInstanceQueryResult + { + std::optional> Snapshot; + std::vector Diagnostics; + }; + + struct FSceneTransformPreviewResult + { + bool bApplied = false; + bool bNoOp = false; + std::vector Diagnostics; + }; + + class IEditorAuthoringHost + { + public: + virtual ~IEditorAuthoringHost() = default; + + [[nodiscard]] virtual FScene* GetEditorScene() const = 0; + virtual void SelectEditorInstance( + const TRefCountPtr& Instance) = 0; + virtual void ClearEditorSelection() = 0; + virtual void ApplyAddInstance( + const TRefCountPtr& Instance, + std::string_view Label) = 0; + virtual void ApplyRemoveInstance( + const TRefCountPtr& Instance) = 0; + virtual void ApplyRenameInstance( + const TRefCountPtr& Instance, + std::string Name) = 0; + virtual void ApplyInstanceTransform( + const TRefCountPtr& Instance, + const FEditorTransform& Before, + const FEditorTransform& After, + std::string_view Label, + bool bAlreadyExecuted) = 0; + virtual void ApplyCameraTransform( + const FEditorCameraState& Before, + const FEditorCameraState& After, + std::string_view Label, + bool bAlreadyExecuted) = 0; + virtual void MarkEditorSceneDirty() = 0; + virtual void MarkEditorSettingsDirty() = 0; + [[nodiscard]] virtual bool CanUndoEditorCommand() const = 0; + [[nodiscard]] virtual bool CanRedoEditorCommand() const = 0; + virtual void UndoEditorCommand() = 0; + virtual void RedoEditorCommand() = 0; + }; + + [[nodiscard]] WaveContract::FObjectRef MakeInstanceRef(uint32 McpId); + [[nodiscard]] WaveContract::FObjectRef MakeCameraRef(); + [[nodiscard]] FEditorTransform CaptureEditorTransform( + const FInstance& Instance); + [[nodiscard]] bool EditorTransformsEquivalent( + const FEditorTransform& Left, + const FEditorTransform& Right); + + class FSceneAuthoringService + { + public: + FSceneAuthoringService( + FEditorDocumentLedger& InLedger, + IEditorAuthoringHost& InHost); + + [[nodiscard]] TEditorTypedSnapshot> + ListInstances() const; + [[nodiscard]] FSceneInstanceQueryResult GetInstance( + const WaveContract::FObjectRef& Ref) const; + [[nodiscard]] TRefCountPtr ResolveInstance( + const WaveContract::FObjectRef& Ref) const; + [[nodiscard]] TRefCountPtr ResolveInstance(uint32 McpId) const; + [[nodiscard]] std::vector> + GetVisibleRuntimeInstances() const; + + void SelectInstance(const WaveContract::FObjectRef& Ref); + void ClearSelection(); + + [[nodiscard]] FEditorLedgerOutcome AddInstance( + const TRefCountPtr& Instance, + const FEditorCommandContext& Context, + std::string_view Label); + [[nodiscard]] FEditorLedgerOutcome RemoveInstance( + const WaveContract::FObjectRef& Ref, + const FEditorCommandContext& Context); + [[nodiscard]] FEditorLedgerOutcome RenameInstance( + const WaveContract::FObjectRef& Ref, + std::string Name, + const FEditorCommandContext& Context); + [[nodiscard]] FEditorLedgerOutcome SetInstanceTransform( + const WaveContract::FObjectRef& Ref, + FEditorTransform Transform, + const FEditorCommandContext& Context, + std::string_view Label, + EEditorScaleInputPolicy ScalePolicy = + EEditorScaleInputPolicy::Domain); + [[nodiscard]] FSceneTransformPreviewResult PreviewInstanceTransform( + const WaveContract::FObjectRef& Ref, + FEditorTransform Transform, + const FEditorCommandContext& Context, + EEditorScaleInputPolicy ScalePolicy = + EEditorScaleInputPolicy::Domain); + [[nodiscard]] FEditorLedgerOutcome CommitInstanceTransformPreview( + const WaveContract::FObjectRef& Ref, + const FEditorTransform& Before, + const FEditorCommandContext& Context, + std::string_view Label, + EEditorScaleInputPolicy ScalePolicy = + EEditorScaleInputPolicy::Domain); + + private: + [[nodiscard]] FEditorValidationResult ValidateTransform( + FEditorTransform& Transform, + EEditorScaleInputPolicy ScalePolicy) const; + [[nodiscard]] FEditorLedgerOutcome CommitTransform( + const TRefCountPtr& Instance, + const FEditorTransform& Before, + const FEditorTransform& After, + const FEditorCommandContext& Context, + std::string_view Label, + bool bAlreadyExecuted, + std::vector ValidatorResults); + + FEditorDocumentLedger& Ledger; + IEditorAuthoringHost& Host; + }; +} diff --git a/Engine/Source/Mcp/McpRegistry.cpp b/Engine/Source/Mcp/McpRegistry.cpp index c47bff3..9a08f60 100644 --- a/Engine/Source/Mcp/McpRegistry.cpp +++ b/Engine/Source/Mcp/McpRegistry.cpp @@ -129,6 +129,87 @@ void FMcpRegistry::Insert(const std::string& Name, FMcpTool Tool) Tools.emplace(Name, std::move(Tool)); } +void FMcpRegistry::AddCapabilityVersion( + WaveContract::FCapabilityDescriptor Descriptor, + void* Owner, + FCapabilityInputAdapter InputAdapter) +{ + std::lock_guard Lock(Mutex); + check(Owner != nullptr, "MCP capability owner is required"); + const auto Tool = Tools.find(Descriptor.Name); + check( + Tool != Tools.end(), + "Versioned capability requires an existing legacy adapter: ", + Descriptor.Name); + check( + Tool->second.Owner == Owner, + "Versioned capability owner does not match its legacy adapter: ", + Descriptor.Name); + check( + Descriptor.Version.Major > 1, + "Versioned capability must not replace legacy v1: ", + Descriptor.Name); + + auto Provider = OwnerProviders.find(Owner); + check( + Provider != OwnerProviders.end(), + "Versioned capability provider is not initialized: ", + Descriptor.Name); + WaveContract::TCapabilityRegistration Registration; + Registration.Provider = Provider->second; + Registration.Descriptor = Descriptor; + Registration.Handler = [ + Descriptor, + InputAdapter, + Trampoline = Tool->second.Trampoline](const FJson& Input) + { + FJson LegacyParams; + WaveContract::FStructuredDiagnostic Diagnostic; + const bool bConverted = InputAdapter + ? InputAdapter(Input, LegacyParams, Diagnostic) + : ConvertNamedCapabilityInput( + Descriptor, + Input, + LegacyParams, + Diagnostic); + if (!bConverted) + { + throw std::invalid_argument(Diagnostic.Message); + } + return Trampoline(LegacyParams); + }; + Registration.Validator = [ + Descriptor, + InputAdapter, + LegacyValidator = Tool->second.Validator](const FJson& Input) + { + FJson LegacyParams; + WaveContract::FStructuredDiagnostic Diagnostic; + const bool bConverted = InputAdapter + ? InputAdapter(Input, LegacyParams, Diagnostic) + : ConvertNamedCapabilityInput( + Descriptor, + Input, + LegacyParams, + Diagnostic); + if (!bConverted) + { + return std::vector{ + std::move(Diagnostic) + }; + } + return LegacyValidator(LegacyParams); + }; + const std::optional Diagnostic = + Capabilities.Register(std::move(Registration)); + check( + !Diagnostic.has_value(), + "MCP capability version registration failed: ", + Diagnostic.has_value() ? Diagnostic->Code : std::string(), + " ", + Diagnostic.has_value() ? Diagnostic->Message : std::string()); +} + void FMcpRegistry::RemoveByOwner(void* Owner) { std::lock_guard Lock(Mutex); diff --git a/Engine/Source/Mcp/McpRegistry.h b/Engine/Source/Mcp/McpRegistry.h index 4d9f46e..99e1218 100644 --- a/Engine/Source/Mcp/McpRegistry.h +++ b/Engine/Source/Mcp/McpRegistry.h @@ -67,6 +67,11 @@ struct TMcpLegacyJsonTraits> class FMcpRegistry { public: + using FCapabilityInputAdapter = std::function; + static FMcpRegistry& Get(); template @@ -86,6 +91,10 @@ class FMcpRegistry TOwner* Self, TRet (TOwner::*Method)(TArgs...)); + void AddCapabilityVersion( + WaveContract::FCapabilityDescriptor Descriptor, + void* Owner, + FCapabilityInputAdapter InputAdapter = {}); void RemoveByOwner(void* Owner); void Clear(); diff --git a/Engine/Source/RHI/D3D12/D3D12RHI.cpp b/Engine/Source/RHI/D3D12/D3D12RHI.cpp index 7673b9e..456b252 100644 --- a/Engine/Source/RHI/D3D12/D3D12RHI.cpp +++ b/Engine/Source/RHI/D3D12/D3D12RHI.cpp @@ -1,4 +1,5 @@ #include "D3D12RHI.h" +#include "Core/Paths/PathService.h" #include "D3D12Utils.h" #include "D3D12Shader.h" #include "Window.h" @@ -229,9 +230,22 @@ FD3D12DynamicRHI::FD3D12DynamicRHI() // Bindless root signature { + const WavePaths::FPathService& Paths = WavePaths::GetPathService(); + const WavePaths::FPathResolveResult BindlessFolder = Paths.ResolveRead( + Paths.MakeRef( + WavePaths::EPathDomain::EngineContent, + "Shaders/Bindless")); + const WavePaths::FPathResolveResult BindlessShader = Paths.ResolveRead( + Paths.MakeRef( + WavePaths::EPathDomain::EngineContent, + "Shaders/Bindless/Bindless.hlsl")); + if (!BindlessFolder.Resolved || !BindlessShader.Resolved) + { + throw std::runtime_error("Bindless shader paths could not be resolved."); + } FShaderCompileArguments Args; Args.push_back("-I"); - Args.push_back(GShaderFolder + "Bindless/"); + Args.push_back(BindlessFolder.Resolved->NativePath.string()); Args.push_back("-E"); Args.push_back("BindlessMain"); Args.push_back("-T"); @@ -242,7 +256,9 @@ FD3D12DynamicRHI::FD3D12DynamicRHI() Args.push_back("-Od"); Args.push_back("-Qembed_debug"); #endif - FShaderCompileResult Result = FShaderCompiler::Get().CompileShader(GShaderFolder + "Bindless/Bindless.hlsl", Args); + FShaderCompileResult Result = FShaderCompiler::Get().CompileShader( + BindlessShader.Resolved->NativePath.string(), + Args); ThrowIfFailed(RootDevice->CreateRootSignature(0, Result.RootSignature.data(), Result.RootSignature.size(), IID_PPV_ARGS(&BindlessRootSignature))); } diff --git a/Engine/Source/Scene/Mesh.cpp b/Engine/Source/Scene/Mesh.cpp index 0de13bf..550a080 100644 --- a/Engine/Source/Scene/Mesh.cpp +++ b/Engine/Source/Scene/Mesh.cpp @@ -1,4 +1,6 @@ #include "Mesh.h" +#include "Core/Paths/AtomicFileStore.h" +#include "Core/Paths/PathService.h" #include "Core/ParallelFor.h" #include "MeshImportSettings.h" #include "ModelLoader.h" @@ -11,6 +13,12 @@ #include "RHI/RHIResource.h" #include "Resource.h" +#include +#include +#include +#include +#include + namespace { bool IsWEMeshPath(const std::filesystem::path& Path) @@ -22,6 +30,62 @@ namespace } return Extension == ".wemesh"; } + + struct FDerivedCacheLocation + { + WavePaths::FPathRef Ref; + std::filesystem::path NativePath; + }; + + FDerivedCacheLocation MakeDerivedCacheLocation( + const std::filesystem::path& SourcePath) + { + std::error_code Error; + std::filesystem::path Identity = + std::filesystem::weakly_canonical(SourcePath, Error); + if (Error) + { + Error.clear(); + Identity = std::filesystem::absolute(SourcePath, Error); + } + if (Error) + { + Identity = SourcePath; + } + + std::string IdentityText = Identity.lexically_normal().generic_string(); +#if defined(_WIN32) + for (char& Character : IdentityText) + { + Character = static_cast( + std::tolower(static_cast(Character))); + } +#endif + const std::span IdentityBytes( + reinterpret_cast(IdentityText.data()), + IdentityText.size()); + std::ostringstream FileName; + FileName + << std::hex + << std::setfill('0') + << std::setw(16) + << WavePaths::HashBytes(IdentityBytes) + << ".wemesh"; + const WavePaths::FPathService& Paths = WavePaths::GetPathService(); + FDerivedCacheLocation Result; + Result.Ref = Paths.MakeRef( + WavePaths::EPathDomain::Derived, + "MeshCache/" + FileName.str()); + const WavePaths::FPathResolveResult Resolved = Paths.ResolveWrite( + Result.Ref, + WavePaths::EPathOperation::Replace); + if (!Resolved.Resolved) + { + throw std::runtime_error("Derived mesh cache path could not be resolved."); + } + Result.NativePath = Resolved.Resolved->NativePath; + return Result; + } } TRefCountPtr FMesh::LoadModel(const char* FileName, bool bCreateRHIResources) @@ -36,7 +100,14 @@ TRefCountPtr FMesh::LoadModel(const char* FileName, bool bCreateRHIResour std::error_code PathError; const std::filesystem::path AbsolutePath = std::filesystem::absolute(SourcePath, PathError); SourcePath = (PathError ? SourcePath : AbsolutePath).lexically_normal(); - const std::filesystem::path CachePath = SourcePath.parent_path() / (SourcePath.stem().string() + ".wemesh"); + std::optional CacheRef; + std::filesystem::path CachePath = SourcePath; + if (!IsWEMeshPath(SourcePath)) + { + FDerivedCacheLocation Cache = MakeDerivedCacheLocation(SourcePath); + CacheRef = std::move(Cache.Ref); + CachePath = std::move(Cache.NativePath); + } FModelLoadResult LoadResult; TRefCountPtr Mesh = MakeShared(); @@ -74,11 +145,17 @@ TRefCountPtr FMesh::LoadModel(const char* FileName, bool bCreateRHIResour if (!std::filesystem::exists(ResolvedSource)) { const std::filesystem::path Stem = SourcePath.stem(); - static const std::array AssetDirs = { + const std::array AssetDirs = { SourcePath.parent_path(), - std::filesystem::path(ASSETS_FOLDER), - std::filesystem::path(ASSETS_FOLDER) / "Meshes", - std::filesystem::path(ENGINE_TEMP_FOLDER) / "Meshes", + WavePaths::ResolveRequiredReadPath( + WavePaths::EPathDomain::ProjectSource, + "Assets"), + WavePaths::ResolveRequiredReadPath( + WavePaths::EPathDomain::ProjectSource, + "Assets") / "Meshes", + WavePaths::ResolveRequiredWritePath( + WavePaths::EPathDomain::Derived, + "Meshes/root.marker").parent_path(), }; static const std::array Extensions = { ".obj", ".fbx", ".gltf", ".glb" }; bool bFound = false; @@ -97,6 +174,13 @@ TRefCountPtr FMesh::LoadModel(const char* FileName, bool bCreateRHIResour } } } + if (IsWEMeshPath(SourcePath) && ResolvedSource != SourcePath) + { + FDerivedCacheLocation Cache = + MakeDerivedCacheLocation(ResolvedSource); + CacheRef = std::move(Cache.Ref); + CachePath = std::move(Cache.NativePath); + } if (!FModelLoader::LoadFromFile(ResolvedSource.string(), LoadResult)) { @@ -139,7 +223,13 @@ TRefCountPtr FMesh::LoadModel(const char* FileName, bool bCreateRHIResour Log::Warning("Could not fingerprint every model source dependency; cache will be direct-load only: ", ResolvedSource.string()); } - if (WEMeshSerializer::Save(CachePath, OutData)) + const bool bSaved = CacheRef + ? WEMeshSerializer::Save( + WavePaths::GetPathService(), + *CacheRef, + OutData).Evidence.bCommitted + : WEMeshSerializer::Save(CachePath, OutData); + if (bSaved) { Mesh->AssetPath = CachePath.lexically_normal().string(); } diff --git a/Engine/Source/Scene/Scene.cpp b/Engine/Source/Scene/Scene.cpp index d839f4e..0409661 100644 --- a/Engine/Source/Scene/Scene.cpp +++ b/Engine/Source/Scene/Scene.cpp @@ -7,8 +7,10 @@ #include "SkyboxLoader.h" #include "WEMeshSerializer.h" #include "ModelLoader.h" +#include "Core/Paths/PathService.h" #include "Core/Profiler.h" +#include TRefCountPtr FScene::Instance = nullptr; @@ -124,7 +126,10 @@ FScene::FScene() ) ); - SkyboxTexture = FSkyboxLoader::Load(std::string(ASSETS_FOLDER) + "Skybox"); + SkyboxTexture = FSkyboxLoader::Load( + WavePaths::ResolveRequiredReadPath( + WavePaths::EPathDomain::ProjectSource, + "Assets/Skybox").string()); } FScene::~FScene() @@ -205,19 +210,55 @@ void FScene::RetainGPUResources(const FCommandListRef& CmdList) const namespace { - std::filesystem::path GetSceneRootDir() + std::filesystem::path GetProjectSavedRoot() { - return std::filesystem::path(ENGINE_TEMP_FOLDER); + return WavePaths::ResolveRequiredWritePath( + WavePaths::EPathDomain::ProjectSaved, + "root.marker").parent_path(); } - std::filesystem::path GetSceneFilePath() + struct FSceneReadLocation { - return GetSceneRootDir() / "Scenes" / "default.wescn"; + std::filesystem::path ScenePath; + std::filesystem::path ReferenceRoot; + bool bLegacy = false; + }; + + std::optional GetSceneReadLocation() + { + const WavePaths::FPathService& Paths = WavePaths::GetPathService(); + const WavePaths::FPathResolveResult Current = Paths.ResolveRead( + Paths.MakeRef( + WavePaths::EPathDomain::ProjectSaved, + "Scenes/default.wescn")); + if (Current.Resolved) + { + return FSceneReadLocation{ + Current.Resolved->NativePath, + GetProjectSavedRoot(), + false, + }; + } + const WavePaths::FPathResolveResult Legacy = Paths.ResolveRead( + Paths.MakeRef( + WavePaths::EPathDomain::EngineContent, + "Save/Scenes/default.wescn")); + if (!Legacy.Resolved) + { + return std::nullopt; + } + return FSceneReadLocation{ + Legacy.Resolved->NativePath, + Legacy.Resolved->NativePath.parent_path().parent_path(), + true, + }; } std::filesystem::path GetMeshCacheDir() { - return GetSceneRootDir() / "Meshes"; + return WavePaths::ResolveRequiredWritePath( + WavePaths::EPathDomain::Derived, + "Meshes/root.marker").parent_path(); } bool IsWEMeshPath(const std::filesystem::path& Path) @@ -262,14 +303,20 @@ namespace bool FScene::LoadDefaultScene() { - const std::filesystem::path ScenePath = GetSceneFilePath(); - if (!std::filesystem::exists(ScenePath)) + const std::optional Location = GetSceneReadLocation(); + if (!Location) { - Log::Info("Scene file not found: ", ScenePath.string()); + Log::Info("Scene file not found in ProjectSaved or the explicit legacy root."); return false; } + const std::filesystem::path& ScenePath = Location->ScenePath; Log::Info("Loading scene: ", ScenePath.string()); + if (Location->bLegacy) + { + Log::Warning( + "Loading the legacy Engine/Save scene read-only; future saves use ProjectSaved."); + } WESceneSerializer::FSceneData Data; if (!WESceneSerializer::Load(ScenePath, Data)) @@ -285,7 +332,7 @@ bool FScene::LoadDefaultScene() std::filesystem::path MeshPath = Instance.MeshPath; if (!MeshPath.is_absolute()) { - MeshPath = GetSceneRootDir() / MeshPath; + MeshPath = Location->ReferenceRoot / MeshPath; } TRefCountPtr Mesh; auto It = MeshCache.find(MeshPath.string()); @@ -350,7 +397,7 @@ bool FScene::SaveDefaultScene() const { std::error_code RelativeError; const std::filesystem::path RelativePath = - std::filesystem::relative(MeshPath, GetSceneRootDir(), RelativeError); + std::filesystem::relative(MeshPath, GetProjectSavedRoot(), RelativeError); // Different Windows drive roots have no relative representation. Keep // the absolute path in that case; the scene loader already supports it. if (!RelativeError && !RelativePath.empty()) @@ -366,11 +413,15 @@ bool FScene::SaveDefaultScene() const Data.Instances.push_back(std::move(Item)); } - const std::filesystem::path ScenePath = GetSceneFilePath(); - std::filesystem::create_directories(ScenePath.parent_path()); - if (!WESceneSerializer::Save(ScenePath, Data)) + const WavePaths::FPathService& Paths = WavePaths::GetPathService(); + const WavePaths::FPathRef SceneRef = Paths.MakeRef( + WavePaths::EPathDomain::ProjectSaved, + "Scenes/default.wescn"); + const WavePaths::FAtomicStoreResult Store = + WESceneSerializer::Save(Paths, SceneRef, Data); + if (!Store.Evidence.bCommitted) { - Log::Error("Failed to save scene: ", ScenePath.string()); + Log::Error("Failed to save scene through ProjectSaved atomic storage."); return false; } return true; @@ -399,10 +450,14 @@ void FScene::ImportDroppedModel(const std::string& Path) Log::Info("Import model: ", SourcePath.string()); Log::Info("Serialize mesh to: ", DestPath.string()); - const std::string RelativeName = std::filesystem::relative(DestPath, GetSceneRootDir()).generic_string(); + const std::string RelativeName = + std::filesystem::relative(DestPath, GetProjectSavedRoot()).generic_string(); FImportRequest Request; Request.SourcePath = SourcePath.string(); Request.DestPath = DestPath.string(); + Request.DestRelativePath = + "Meshes/" + + std::filesystem::relative(DestPath, MeshDir).generic_string(); Request.RelativeName = RelativeName; PendingImports.push_back(std::move(Request)); if (!bImportRunning.load() && !ImportThread.joinable()) @@ -547,7 +602,16 @@ void FScene::StartImportJob(const FImportRequest& Request) { MeshData.Name = Request.RelativeName; UpdateImportStatus(EImportStage::WritingCache, 0.85f); - if (!WEMeshSerializer::Save(DestPath, MeshData)) + const WavePaths::FPathService& Paths = + WavePaths::GetPathService(); + const WavePaths::FAtomicStoreResult Store = + WEMeshSerializer::Save( + Paths, + Paths.MakeRef( + WavePaths::EPathDomain::Derived, + Request.DestRelativePath), + MeshData); + if (!Store.Evidence.bCommitted) { Result.Error = "Failed to write imported mesh cache: " + DestPath.string(); } diff --git a/Engine/Source/Scene/Scene.h b/Engine/Source/Scene/Scene.h index b17714d..32d3fdb 100644 --- a/Engine/Source/Scene/Scene.h +++ b/Engine/Source/Scene/Scene.h @@ -138,6 +138,7 @@ class FScene { std::string SourcePath; std::string DestPath; + std::string DestRelativePath; std::string RelativeName; }; diff --git a/Engine/Source/Scene/SceneSerializer.cpp b/Engine/Source/Scene/SceneSerializer.cpp index 2bc5728..a5e34e6 100644 --- a/Engine/Source/Scene/SceneSerializer.cpp +++ b/Engine/Source/Scene/SceneSerializer.cpp @@ -1,17 +1,12 @@ #include "SceneSerializer.h" +#include "Misc/Utils.h" -#include -#include #include #include +#include #include -#include #include -#if defined(_WIN32) -#include -#endif - namespace { constexpr uint32 MakeFourCC(char A, char B, char C, char D) @@ -107,17 +102,17 @@ namespace return true; } - void WriteU32(std::ofstream& Out, uint32 Value) + void WriteU32(std::ostream& Out, uint32 Value) { Out.write(reinterpret_cast(&Value), sizeof(Value)); } - void WriteU16(std::ofstream& Out, uint16 Value) + void WriteU16(std::ostream& Out, uint16 Value) { Out.write(reinterpret_cast(&Value), sizeof(Value)); } - void WriteBytes(std::ofstream& Out, const void* Data, size_t Size) + void WriteBytes(std::ostream& Out, const void* Data, size_t Size) { if (Size == 0) { @@ -130,7 +125,7 @@ namespace Out.write(reinterpret_cast(Data), static_cast(Size)); } - void WriteString(std::ofstream& Out, const std::string& Value) + void WriteString(std::ostream& Out, const std::string& Value) { if (Value.size() > MAX_SCENE_STRING_BYTES) { @@ -141,7 +136,7 @@ namespace WriteBytes(Out, Value.data(), Length); } - uint64 GetWritePosition(std::ofstream& Out) + uint64 GetWritePosition(std::ostream& Out) { const std::streampos Position = Out.tellp(); if (Position == std::streampos(-1)) @@ -223,98 +218,26 @@ namespace uint64 RemainingBytes = 0; }; - std::filesystem::path MakeTemporaryPath(const std::filesystem::path& TargetPath) - { - static std::atomic Counter{0}; - const uint64 Timestamp = static_cast( - std::chrono::steady_clock::now().time_since_epoch().count()); - - for (uint32 Attempt = 0; Attempt < 128; ++Attempt) - { - std::filesystem::path Candidate = TargetPath; - Candidate += ".tmp." - + std::to_string(Timestamp) - + "." - + std::to_string(Counter.fetch_add(1, std::memory_order_relaxed)); - - std::error_code Error; - const bool bExists = std::filesystem::exists(Candidate, Error); - if (Error) - { - throw std::system_error(Error); - } - if (!bExists) - { - return Candidate; - } - } - throw std::runtime_error("Failed to reserve a temporary WEScene path"); - } - - void RemoveFileNoThrow(const std::filesystem::path& Path) - { - if (Path.empty()) - { - return; - } - std::error_code Error; - std::filesystem::remove(Path, Error); - } - - bool CommitTemporaryFile( - const std::filesystem::path& TemporaryPath, - const std::filesystem::path& TargetPath) - { -#if defined(_WIN32) - return MoveFileExW( - TemporaryPath.c_str(), - TargetPath.c_str(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != FALSE; -#else - std::error_code Error; - std::filesystem::rename(TemporaryPath, TargetPath, Error); - return !Error; -#endif - } -} - -namespace WESceneSerializer -{ - bool Save(const std::filesystem::path& Path, const FSceneData& Data) + bool SerializeCurrentScene( + const WESceneSerializer::FSceneData& Data, + std::vector& OutData) { uint64 ExpectedFileSize = 0; - if (Path.empty() || !CalculateCurrentFileSize(Data, ExpectedFileSize)) + if (!CalculateCurrentFileSize(Data, ExpectedFileSize) + || ExpectedFileSize > static_cast( + std::numeric_limits::max())) { return false; } - - std::filesystem::path TemporaryPath; try { - if (const std::filesystem::path Parent = Path.parent_path(); !Parent.empty()) - { - std::error_code Error; - std::filesystem::create_directories(Parent, Error); - if (Error) - { - return false; - } - } - - TemporaryPath = MakeTemporaryPath(Path); - std::ofstream Out(TemporaryPath, std::ios::binary | std::ios::trunc); - if (!Out) - { - return false; - } + std::ostringstream Out(std::ios::binary | std::ios::out); Out.exceptions(std::ios::badbit | std::ios::failbit); - WriteU32(Out, SCENE_MAGIC); WriteU16(Out, SCENE_VERSION_V3); WriteU16(Out, SCENE_VERSION_MINOR); WriteU32(Out, static_cast(Data.Instances.size())); - - for (const FSceneInstance& Instance : Data.Instances) + for (const WESceneSerializer::FSceneInstance& Instance : Data.Instances) { WriteString(Out, Instance.MeshPath); WriteString(Out, Instance.DisplayName); @@ -322,36 +245,61 @@ namespace WESceneSerializer WriteBytes(Out, &Instance.Rotation, sizeof(Instance.Rotation)); WriteBytes(Out, &Instance.Scale, sizeof(Instance.Scale)); } - - const uint64 WrittenStreamSize = GetWritePosition(Out); - if (WrittenStreamSize != ExpectedFileSize) + if (GetWritePosition(Out) != ExpectedFileSize) { - throw std::runtime_error("Unexpected WEScene serialized size"); - } - Out.flush(); - Out.close(); - - std::error_code SizeError; - const uint64 WrittenFileSize = std::filesystem::file_size(TemporaryPath, SizeError); - if (SizeError || WrittenFileSize != ExpectedFileSize) - { - RemoveFileNoThrow(TemporaryPath); return false; } - - if (!CommitTemporaryFile(TemporaryPath, Path)) - { - RemoveFileNoThrow(TemporaryPath); - return false; - } - return true; + const std::string Bytes = std::move(Out).str(); + OutData.assign(Bytes.begin(), Bytes.end()); + return OutData.size() == ExpectedFileSize; } catch (...) { - RemoveFileNoThrow(TemporaryPath); + OutData.clear(); return false; } } +} + +namespace WESceneSerializer +{ + bool Save(const std::filesystem::path& Path, const FSceneData& Data) + { + std::vector Bytes; + if (Path.empty() || !SerializeCurrentScene(Data, Bytes)) + { + return false; + } + return Utils::StoreFile( + Path, + Bytes.data(), + Bytes.size(), + "wb"); + } + + WavePaths::FAtomicStoreResult Save( + const WavePaths::FPathService& Service, + const WavePaths::FPathRef& Target, + const FSceneData& Data) + { + std::vector Bytes; + if (!SerializeCurrentScene(Data, Bytes)) + { + WavePaths::FAtomicStoreResult Result; + Result.Evidence.Target = Target; + Result.Diagnostics.push_back({ + "path.atomic.serialization_failed", + "target", + "WEScene serialization failed before atomic storage.", + WavePaths::EPathDiagnosticSeverity::Error, + false, + }); + return Result; + } + return WavePaths::AtomicStoreBytes( + Service, + {Target, Bytes}); + } bool Load(const std::filesystem::path& Path, FSceneData& OutData) { diff --git a/Engine/Source/Scene/SceneSerializer.h b/Engine/Source/Scene/SceneSerializer.h index 47475ce..22c60a6 100644 --- a/Engine/Source/Scene/SceneSerializer.h +++ b/Engine/Source/Scene/SceneSerializer.h @@ -1,7 +1,7 @@ #pragma once - #include "Core/Math.h" +#include "Core/Paths/AtomicFileStore.h" namespace WESceneSerializer { @@ -20,5 +20,9 @@ namespace WESceneSerializer }; bool Save(const std::filesystem::path& Path, const FSceneData& Data); + [[nodiscard]] WavePaths::FAtomicStoreResult Save( + const WavePaths::FPathService& Service, + const WavePaths::FPathRef& Target, + const FSceneData& Data); bool Load(const std::filesystem::path& Path, FSceneData& OutData); } diff --git a/Engine/Source/Scene/WEMeshSerializer.cpp b/Engine/Source/Scene/WEMeshSerializer.cpp index 1b7a61c..f46977b 100644 --- a/Engine/Source/Scene/WEMeshSerializer.cpp +++ b/Engine/Source/Scene/WEMeshSerializer.cpp @@ -1454,6 +1454,90 @@ namespace WEMeshSerializer } } + WavePaths::FAtomicStoreResult Save( + const WavePaths::FPathService& Service, + const WavePaths::FPathRef& Target, + const FMeshData& Data) + { + WavePaths::FAtomicStoreResult Result; + Result.Evidence.Target = Target; + Result.Evidence.RootRevision = Target.RootRevision; + const WavePaths::FPathResolveResult Resolved = Service.ResolveWrite( + Target, + WavePaths::EPathOperation::Replace); + if (!Resolved.Resolved) + { + Result.Diagnostics = Resolved.Diagnostics; + return Result; + } + + const uint64 MaxArtifactBytes = + Service.QuerySnapshot().Limits.MaxStagingBytes; + std::error_code Error; + Result.Evidence.bPreviousFileExisted = + std::filesystem::exists(Resolved.Resolved->NativePath, Error); + if (Error) + { + Result.Diagnostics.push_back({ + "path.atomic.inspect_failed", + "target", + "Existing WEMesh target could not be inspected.", + WavePaths::EPathDiagnosticSeverity::Error, + true, + }); + return Result; + } + if (Result.Evidence.bPreviousFileExisted) + { + const std::optional PreviousHash = WavePaths::HashFile( + Resolved.Resolved->NativePath, + MaxArtifactBytes, + &Result.Evidence.PreviousSize); + if (!PreviousHash) + { + Result.Diagnostics.push_back({ + "path.atomic.existing_hash_failed", + "target", + "Existing WEMesh target could not be hashed within budget.", + WavePaths::EPathDiagnosticSeverity::Error, + false, + }); + return Result; + } + Result.Evidence.PreviousHash = *PreviousHash; + } + + if (!Save(Resolved.Resolved->NativePath, Data)) + { + Result.Diagnostics.push_back({ + "path.atomic.format_store_failed", + "target", + "WEMesh validation or format-owned atomic storage failed.", + WavePaths::EPathDiagnosticSeverity::Error, + false, + }); + return Result; + } + const std::optional NewHash = WavePaths::HashFile( + Resolved.Resolved->NativePath, + MaxArtifactBytes, + &Result.Evidence.NewSize); + if (!NewHash) + { + Result.Diagnostics.push_back({ + "path.atomic.verify_failed", + "target", + "Stored WEMesh could not be verified within budget.", + WavePaths::EPathDiagnosticSeverity::Error, + false, + }); + return Result; + } + Result.Evidence.NewHash = *NewHash; + Result.Evidence.bCommitted = true; + return Result; + } + bool LoadInternal( const std::filesystem::path& Path, const std::filesystem::path* ExpectedSourcePath, diff --git a/Engine/Source/Scene/WEMeshSerializer.h b/Engine/Source/Scene/WEMeshSerializer.h index 90746f1..d0cc057 100644 --- a/Engine/Source/Scene/WEMeshSerializer.h +++ b/Engine/Source/Scene/WEMeshSerializer.h @@ -1,6 +1,6 @@ #pragma once - +#include "Core/Paths/AtomicFileStore.h" #include "Material.h" #include "SceneDefinitions.h" @@ -47,6 +47,10 @@ namespace WEMeshSerializer const std::vector& Dependencies = {}); bool Save(const std::filesystem::path& Path, const FMeshData& Data); + [[nodiscard]] WavePaths::FAtomicStoreResult Save( + const WavePaths::FPathService& Service, + const WavePaths::FPathRef& Target, + const FMeshData& Data); // Direct artifact load. Accepts legacy WEMesh v2 files without provenance. bool Load(const std::filesystem::path& Path, FMeshData& OutData); // Cache load. Requires current v3 provenance matching source contents, diff --git a/Engine/Source/Shader/Shader.h b/Engine/Source/Shader/Shader.h index 8df80e2..94a5e21 100644 --- a/Engine/Source/Shader/Shader.h +++ b/Engine/Source/Shader/Shader.h @@ -71,8 +71,11 @@ class FShaderMap : FNonCopyable const FHash& ShaderTypeHash = GetShaderTypeHash(); check(GetRegisterTable()[ShaderTypeHash]== nullptr); - std::string ShaderFilePath = GShaderFolder + File; - GetRegisterTable()[ShaderTypeHash] = std::make_unique(Name, ShaderFilePath.c_str(), Entry, ShaderFrequency); + GetRegisterTable()[ShaderTypeHash] = std::make_unique( + Name, + File, + Entry, + ShaderFrequency); AddToCompileBatch(); } diff --git a/Engine/Source/Shader/ShaderCompiler.cpp b/Engine/Source/Shader/ShaderCompiler.cpp index 2a58948..d6f36ff 100644 --- a/Engine/Source/Shader/ShaderCompiler.cpp +++ b/Engine/Source/Shader/ShaderCompiler.cpp @@ -1,5 +1,7 @@ #include "ShaderCompiler.h" #include "Shader.h" +#include "Core/Paths/AtomicFileStore.h" +#include "Core/Paths/PathService.h" #include "RHI/DynamicRHI.h" #include "RHI/RHIDefinitions.h" #include @@ -11,6 +13,35 @@ namespace constexpr FHash FnvOffsetBasis = 1469598103934665603ull; constexpr FHash FnvPrime = 1099511628211ull; + std::filesystem::path ResolveRequiredShaderReadPath( + WavePaths::EPathDomain Domain, + std::string RelativePath) + { + const WavePaths::FPathService& Paths = WavePaths::GetPathService(); + const WavePaths::FPathResolveResult Resolved = + Paths.ResolveRead(Paths.MakeRef(Domain, std::move(RelativePath))); + if (!Resolved.Resolved) + { + throw std::runtime_error("Required shader path could not be resolved."); + } + return Resolved.Resolved->NativePath; + } + + std::filesystem::path ResolveRequiredShaderWritePath( + WavePaths::EPathDomain Domain, + std::string RelativePath) + { + const WavePaths::FPathService& Paths = WavePaths::GetPathService(); + const WavePaths::FPathResolveResult Resolved = Paths.ResolveWrite( + Paths.MakeRef(Domain, std::move(RelativePath)), + WavePaths::EPathOperation::Replace); + if (!Resolved.Resolved) + { + throw std::runtime_error("Required shader cache path could not be resolved."); + } + return Resolved.Resolved->NativePath; + } + void HashCombine(FHash& Seed, FHash Value) { Seed ^= Value + 0x9e3779b97f4a7c15ull + (Seed << 6) + (Seed >> 2); @@ -99,7 +130,9 @@ namespace std::filesystem::path ResolvedPath = ParentDir / IncludePath; if (!std::filesystem::exists(ResolvedPath)) { - ResolvedPath = std::filesystem::path(GShaderFolder) / IncludePath; + ResolvedPath = ResolveRequiredShaderReadPath( + WavePaths::EPathDomain::EngineContent, + "Shaders") / IncludePath; } if (std::filesystem::exists(ResolvedPath)) @@ -359,14 +392,16 @@ static std::string GetShaderProfileName(EShaderFrequency Frequency) return nullptr; } -FShaderCompileArguments BuildArgs(const FShaderRegisterInfo& Info, const FShaderCompilerEnvironment& Env) +FShaderCompileArguments BuildArgs( + const FShaderRegisterInfo& Info, + const FShaderCompilerEnvironment& Env, + const std::filesystem::path& FilePath) { FShaderCompileArguments OutArgs; // Include path - std::string ShaderPath(Info.ShaderFilePath.substr(0, Info.ShaderFilePath.find_last_of('/'))); OutArgs.push_back("-I"); - OutArgs.push_back(ShaderPath); + OutArgs.push_back(FilePath.parent_path().string()); // Entry OutArgs.push_back("-E"); @@ -444,7 +479,9 @@ void FShaderCompiler::CompileShader(const FShaderCompileBatch& Batch, bool bForc { const std::vector& PermutationInfos = Batch.GetPermutationCompileInfos(); const FShaderRegisterInfo& ShaderInfo = Batch.GetShaderInfo(); - const auto& FilePath = ShaderInfo.ShaderFilePath; + const std::filesystem::path FilePath = ResolveRequiredShaderReadPath( + WavePaths::EPathDomain::EngineContent, + "Shaders/" + ShaderInfo.ShaderFilePath); const FHash SourceDependencyHash = ComputeShaderDependencyHash(FilePath); FShaderCompileTasks CompileTasks; @@ -452,7 +489,10 @@ void FShaderCompiler::CompileShader(const FShaderCompileBatch& Batch, bool bForc { const FShaderCompilerEnvironment& Env = PermutationInfo.Environment; const uint64& Hash = PermutationInfo.Hash; - FShaderCompileArguments CompileArgs = BuildArgs(PermutationInfo.ShaderInfo, Env); + FShaderCompileArguments CompileArgs = BuildArgs( + PermutationInfo.ShaderInfo, + Env, + FilePath); const FHash ArgsHash = ComputeCompileArgumentsHash(CompileArgs); const std::string CacheKey = std::format( "{0}_{1}_{2}_{3}", @@ -461,19 +501,27 @@ void FShaderCompiler::CompileShader(const FShaderCompileBatch& Batch, bool bForc std::to_string(SourceDependencyHash), std::to_string(ArgsHash)); - const std::filesystem::path CachedShaderPath = - std::filesystem::path(GShaderTempFolder) / - std::filesystem::path(ShaderInfo.ShaderFilePath).stem() / - (CacheKey + ".shaderbytecode"); - - const std::filesystem::path CachedShaderPdbPath = - std::filesystem::path(GShaderTempFolder) / - std::filesystem::path(ShaderInfo.ShaderFilePath).stem() / - (CacheKey + ".pdb"); + const std::string CacheDirectory = + "Shaders/" + + std::filesystem::path(ShaderInfo.ShaderFilePath).stem().string() + + "/"; + const std::string CachedShaderRelativePath = + CacheDirectory + CacheKey + ".shaderbytecode"; + const std::string CachedShaderPdbRelativePath = + CacheDirectory + CacheKey + ".pdb"; + const std::filesystem::path CachedShaderPath = ResolveRequiredShaderWritePath( + WavePaths::EPathDomain::Derived, + CachedShaderRelativePath); + + const std::filesystem::path CachedShaderPdbPath = ResolveRequiredShaderWritePath( + WavePaths::EPathDomain::Derived, + CachedShaderPdbRelativePath); // Create from shader compiler then store it. FShaderCompileTasks::FBatch CompileBatch{ .Name = PermutationInfo.ShaderInfo.ShaderName + std::string(" PermutationId: ") + std::to_string(PermutationInfo.PermutationParameters.PermutationId), + .CachedShaderRelativePath = CachedShaderRelativePath, + .CachedShaderPdbRelativePath = CachedShaderPdbRelativePath, .CachedShaderPath = CachedShaderPath, .CachedShaderPdbPath = CachedShaderPdbPath, .Args = std::move(CompileArgs), @@ -489,11 +537,7 @@ void FShaderCompiler::CompileShader(const FShaderCompileBatch& Batch, bool bForc // Get shader from cache. if (exists(TaskBatch.CachedShaderPath)) { - if (bForceRecompile) - { - std::filesystem::remove(TaskBatch.CachedShaderPath); - } - else + if (!bForceRecompile) { std::vector BlobData; if (Utils::LoadFile(TaskBatch.CachedShaderPath, BlobData, "rb") && !BlobData.empty()) @@ -506,26 +550,41 @@ void FShaderCompiler::CompileShader(const FShaderCompileBatch& Batch, bool bForc } // Compile shader - FShaderCompileResult CompileResult = CompileShader(FilePath, TaskBatch.Args); + FShaderCompileResult CompileResult = CompileShader(FilePath.string(), TaskBatch.Args); Log::Debug("Compiling shader: ", TaskBatch.Name, " in file: ", FilePath);//, " with args: ", ShaderCompileArgsToString(TaskBatch.Args)); // Cache shader if (CompileResult.bSuccess) { - const auto SaveFolder = TaskBatch.CachedShaderPath.parent_path(); - if (!exists(SaveFolder)) - { - create_directories(SaveFolder); - } - - if (!Utils::StoreFile(TaskBatch.CachedShaderPath, CompileResult.ShaderCode.data(), CompileResult.ShaderCode.size(), "wb")) + const WavePaths::FPathService& Paths = WavePaths::GetPathService(); + const WavePaths::FAtomicStoreResult ShaderStore = + WavePaths::AtomicStoreBytes( + Paths, + { + Paths.MakeRef( + WavePaths::EPathDomain::Derived, + TaskBatch.CachedShaderRelativePath), + CompileResult.ShaderCode, + }); + if (!ShaderStore.Evidence.bCommitted) { Log::Warning("Failed to update shader cache: ", TaskBatch.CachedShaderPath.string()); } - if (!CompileResult.Pdb.empty() && - !Utils::StoreFile(TaskBatch.CachedShaderPdbPath, CompileResult.Pdb.data(), CompileResult.Pdb.size(), "wb")) + if (!CompileResult.Pdb.empty()) { - Log::Warning("Failed to update shader debug cache: ", TaskBatch.CachedShaderPdbPath.string()); + const WavePaths::FAtomicStoreResult PdbStore = + WavePaths::AtomicStoreBytes( + Paths, + { + Paths.MakeRef( + WavePaths::EPathDomain::Derived, + TaskBatch.CachedShaderPdbRelativePath), + CompileResult.Pdb, + }); + if (!PdbStore.Evidence.bCommitted) + { + Log::Warning("Failed to update shader debug cache: ", TaskBatch.CachedShaderPdbPath.string()); + } } TaskBatch.Shader = FShaderMap::CreateShader(CompileResult.ShaderCode, ShaderInfo, TaskBatch.PermutationHash); } diff --git a/Engine/Source/Shader/ShaderCompiler.h b/Engine/Source/Shader/ShaderCompiler.h index 0dc016c..33b6c93 100644 --- a/Engine/Source/Shader/ShaderCompiler.h +++ b/Engine/Source/Shader/ShaderCompiler.h @@ -120,6 +120,8 @@ class FShaderCompiler struct FBatch { std::string Name; + std::string CachedShaderRelativePath; + std::string CachedShaderPdbRelativePath; std::filesystem::path CachedShaderPath; std::filesystem::path CachedShaderPdbPath; FShaderCompileArguments Args; diff --git a/Engine/Source/Trace/TraceRecorder.cpp b/Engine/Source/Trace/TraceRecorder.cpp index abf036c..059060a 100644 --- a/Engine/Source/Trace/TraceRecorder.cpp +++ b/Engine/Source/Trace/TraceRecorder.cpp @@ -2,6 +2,7 @@ #include "Core/Core.h" #include "Core/Engine.h" +#include "Core/Paths/PathService.h" #include "Core/Profiler.h" #include "Misc/Log.h" #include "Trace/TraceCodec.h" @@ -322,27 +323,29 @@ namespace WaveTrace return false; } - // 输出目录固定创建(宏拼接方式与 GShaderTempFolder 一致)。 - const std::filesystem::path DefaultFolder(ENGINE_TEMP_FOLDER + std::string("Traces/")); - std::error_code DirError; - std::filesystem::create_directories(DefaultFolder, DirError); - std::filesystem::path TracePath; - if (Options.FilePath.empty()) - { - TracePath = DefaultFolder / MakeDefaultTraceFileName(); - } - else + if (Options.bWriteFile) { - TracePath = std::filesystem::path(Options.FilePath); - if (TracePath.has_parent_path()) + std::error_code DirError; + if (Options.FilePath.empty()) { - std::filesystem::create_directories(TracePath.parent_path(), DirError); + const std::filesystem::path DefaultFolder = + WavePaths::ResolveRequiredWritePath( + WavePaths::EPathDomain::UserSaved, + "Traces/root.marker").parent_path(); + std::filesystem::create_directories(DefaultFolder, DirError); + TracePath = DefaultFolder / MakeDefaultTraceFileName(); + } + else + { + TracePath = std::filesystem::path(Options.FilePath); + if (TracePath.has_parent_path()) + { + std::filesystem::create_directories( + TracePath.parent_path(), + DirError); + } } - } - - if (Options.bWriteFile) - { I.File.open(TracePath, std::ios::binary | std::ios::trunc); if (!I.File.is_open()) { diff --git a/Engine/Source/UI/EditorApplication.cpp b/Engine/Source/UI/EditorApplication.cpp new file mode 100644 index 0000000..a0334f9 --- /dev/null +++ b/Engine/Source/UI/EditorApplication.cpp @@ -0,0 +1,760 @@ +#include "EditorApplication.h" + +#include "Core/Application/ApplicationRunner.h" +#include "Core/Engine.h" +#include "Core/Paths/PathService.h" +#include "Core/Profiler.h" +#include "Developer/RenderDocIntegration.h" +#include "Input/InputSystem.h" +#include "Mcp/MainThreadDispatcher.h" +#include "Mcp/McpServer.h" +#include "Misc/Log.h" +#include "Misc/Utils.h" +#include "RenderGraph/RenderGraphResourcePool.h" +#include "Renderer/Renderer.h" +#include "RHI/DynamicRHI.h" +#include "RHI/RHIThread.h" +#include "Scene/Scene.h" +#include "Shader/Shader.h" +#include "Trace/TraceRecorder.h" +#include "UI/EditorFrontend.h" +#include "Window.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + using namespace WaveApplication; + using namespace WavePaths; + + FApplicationServiceTickResult NoApplicationTickResult() + { + return {}; + } + + std::filesystem::path GetExecutablePath() + { +#if defined(_WIN32) + std::vector Buffer(512); + for (;;) + { + const DWORD Length = GetModuleFileNameW( + nullptr, + Buffer.data(), + static_cast(Buffer.size())); + if (Length == 0) + { + throw std::runtime_error("Could not resolve the WaveEditor executable path."); + } + if (Length < Buffer.size() - 1) + { + return std::filesystem::path( + std::wstring_view(Buffer.data(), Length)).lexically_normal(); + } + Buffer.resize(Buffer.size() * 2); + } +#else + throw std::runtime_error("WaveEditor path configuration requires Windows."); +#endif + } + + std::filesystem::path GetLocalAppDataPath() + { +#if defined(_WIN32) + std::vector Buffer(512); + for (;;) + { + const DWORD Length = GetEnvironmentVariableW( + L"LOCALAPPDATA", + Buffer.data(), + static_cast(Buffer.size())); + if (Length == 0) + { + throw std::runtime_error("LOCALAPPDATA is unavailable."); + } + if (Length < Buffer.size()) + { + const std::filesystem::path Result( + std::wstring_view(Buffer.data(), Length)); + if (!Result.is_absolute()) + { + throw std::runtime_error("LOCALAPPDATA must be an absolute path."); + } + return Result.lexically_normal(); + } + Buffer.resize(static_cast(Length) + 1); + } +#else + throw std::runtime_error("WaveEditor user path configuration requires Windows."); +#endif + } + + class FEditorPathService final : public IApplicationService + { + public: + const FApplicationServiceDescriptor& GetDescriptor() const override + { + return Descriptor; + } + + void Initialize(const FApplicationConfig& ApplicationConfig) override + { + if (ApplicationConfig.PathProfile != "editor_source") + { + throw std::runtime_error("WaveEditor requires the editor_source path profile."); + } + + const std::filesystem::path ExecutablePath = GetExecutablePath(); + const std::filesystem::path ExecutableRoot = ExecutablePath.parent_path(); + const std::filesystem::path ProjectRoot = + ExecutableRoot.parent_path().parent_path(); + const std::filesystem::path EngineRoot = ProjectRoot / "Engine"; + const std::filesystem::path LegacySaveRoot = EngineRoot / "Save"; + const std::filesystem::path UserRoot = + GetLocalAppDataPath() / "WaveEngine"; + if (!std::filesystem::is_directory(EngineRoot / "Shaders") + || !std::filesystem::is_directory(ProjectRoot / "Assets")) + { + throw std::runtime_error( + "WaveEditor source layout is unavailable relative to the executable."); + } + + FPathServiceConfig Config; + Config.Revision = 1; + Config.Policy = EPathPolicyProfile::EditorSource; + Config.ProjectScope = {"wave-editor-source-session"}; + Config.Roots = { + {EPathDomain::Executable, ExecutableRoot, EPathAccess::ReadOnly, "executable", false, true}, + {EPathDomain::EngineContent, EngineRoot, EPathAccess::ReadOnly, "editor_source", false, true}, + {EPathDomain::ProjectSource, ProjectRoot, EPathAccess::ReadOnly, "editor_source", false, true}, + {EPathDomain::Derived, LegacySaveRoot / "Derived", EPathAccess::ReadWrite, "editor_source", false, true}, + {EPathDomain::ProjectSaved, LegacySaveRoot / "Project", EPathAccess::ReadWrite, "editor_source", false, true}, + {EPathDomain::UserSaved, UserRoot, EPathAccess::ReadWrite, "local_app_data", true, true}, + {EPathDomain::PackageContent, {}, EPathAccess::Denied, "unavailable", false, false}, + {EPathDomain::Staging, LegacySaveRoot / "Staging", EPathAccess::ReadWrite, "editor_source", true, true}, + {EPathDomain::Temp, UserRoot / "Temp", EPathAccess::ReadWrite, "local_app_data", true, true}, + {EPathDomain::External, {}, EPathAccess::Denied, "approval_required", true, false}, + }; + for (const FPathRootConfig& Root : Config.Roots) + { + if (!Root.bAvailable || Root.Access != EPathAccess::ReadWrite) + { + continue; + } + std::error_code Error; + std::filesystem::create_directories(Root.AbsoluteRoot, Error); + if (Error) + { + throw std::runtime_error( + "Could not create path root for " + + std::string(ToString(Root.Domain)) + + "."); + } + } + + const FPathConfigureResult Configured = Paths.Configure(std::move(Config)); + if (!Configured.bConfigured) + { + const std::string Message = Configured.Diagnostics.empty() + ? "Unknown path configuration failure." + : Configured.Diagnostics.front().Message; + throw std::runtime_error(Message); + } + InstallPathService(Paths); + bInstalled = true; + } + + FApplicationServiceTickResult Tick(const FApplicationTickContext&) override + { + return NoApplicationTickResult(); + } + + void RequestStop() override {} + void Drain() override {} + + void Shutdown() override + { + if (bInstalled) + { + UninstallPathService(Paths); + bInstalled = false; + } + } + + private: + FApplicationServiceDescriptor Descriptor{ + "editor.paths", + 1, + 0, + {}, + {EApplicationMode::Editor}, + {}, + EApplicationShutdownClass::Platform, + false, + false, + }; + FPathService Paths; + bool bInstalled = false; + }; + + class FEditorLogService final : public IApplicationService + { + public: + const FApplicationServiceDescriptor& GetDescriptor() const override + { + return Descriptor; + } + + void Initialize(const FApplicationConfig&) override + { + const FPathService& Paths = GetPathService(); + const FPathResolveResult LogPath = Paths.ResolveWrite( + Paths.MakeRef(EPathDomain::UserSaved, "Logs/WaveEngine.log"), + EPathOperation::Replace); + if (!LogPath.Resolved) + { + throw std::runtime_error("WaveEngine log path could not be resolved."); + } + std::error_code Error; + std::filesystem::create_directories( + LogPath.Resolved->NativePath.parent_path(), + Error); + if (Error || !Log::OpenLog(LogPath.Resolved->NativePath)) + { + std::cerr << "Warning: failed to open WaveEngine.log; continuing with console logging.\n"; + } + bInitialized = true; + } + + FApplicationServiceTickResult Tick(const FApplicationTickContext&) override + { + return NoApplicationTickResult(); + } + + void RequestStop() override {} + void Drain() override {} + + void Shutdown() override + { + if (bInitialized) + { + Log::CloseLog(); + bInitialized = false; + } + } + + private: + FApplicationServiceDescriptor Descriptor{ + "editor.log", + 1, + 0, + {"editor.paths"}, + {EApplicationMode::Editor}, + {}, + EApplicationShutdownClass::Platform, + false, + false, + }; + bool bInitialized = false; + }; + + class FEditorWindowService final : public IApplicationService + { + public: + const FApplicationServiceDescriptor& GetDescriptor() const override + { + return Descriptor; + } + + void Initialize(const FApplicationConfig& Config) override + { + FWindow::Init(Config.Window.Width, Config.Window.Height); + Window = FWindow::GetInstance(); + Window->SetTitle(Config.Window.Title); + FInputSystem::Init(Window->GetGLFWWindow()); + bInputInitialized = true; + FRenderDocIntegration::Get().Initialize(); + } + + FApplicationServiceTickResult Tick(const FApplicationTickContext&) override + { + FRHIThread::RethrowIfFailed(); + SCOPED_CPU_EVENT("Input"); + FInputSystem::Get().BeginFrame(); + Window->PollEvents(); + return NoApplicationTickResult(); + } + + void RequestStop() override {} + void Drain() override {} + + void Shutdown() override + { + if (bInputInitialized) + { + FInputSystem::Shutdown(); + bInputInitialized = false; + } + Window.reset(); + FWindow::Shutdown(); + } + + [[nodiscard]] const TRefCountPtr& GetWindow() const + { + return Window; + } + + private: + FApplicationServiceDescriptor Descriptor{ + "editor.window_input", + 1, + 0, + {"editor.log"}, + {EApplicationMode::Editor}, + {EApplicationTickPhase::InputEvents}, + EApplicationShutdownClass::Platform, + true, + false, + }; + TRefCountPtr Window; + bool bInputInitialized = false; + }; + + class FEditorBackendService final : public IApplicationService + { + public: + const FApplicationServiceDescriptor& GetDescriptor() const override + { + return Descriptor; + } + + void Initialize(const FApplicationConfig&) override + { + FDynamicRHI::Init(); + } + + FApplicationServiceTickResult Tick(const FApplicationTickContext&) override + { + return NoApplicationTickResult(); + } + + void RequestStop() override {} + void Drain() override {} + + void Shutdown() override + { + if (GDynamicRHI) + { + FDynamicRHI::Destroy(); + } + } + + private: + FApplicationServiceDescriptor Descriptor{ + "editor.rhi", + 1, + 0, + {"editor.window_input"}, + {EApplicationMode::Editor}, + {}, + EApplicationShutdownClass::Backend, + true, + true, + }; + }; + + class FEditorRuntimeStateService final : public IApplicationService + { + public: + const FApplicationServiceDescriptor& GetDescriptor() const override + { + return Descriptor; + } + + void Initialize(const FApplicationConfig&) override + { + FShaderMap::Init(); + bShaderMapInitialized = true; + Scene = FScene::Init(); + } + + FApplicationServiceTickResult Tick(const FApplicationTickContext&) override + { + return NoApplicationTickResult(); + } + + void RequestStop() override {} + void Drain() override {} + + void Shutdown() override + { + Scene.reset(); + FScene::Shutdown(); + if (bShaderMapInitialized) + { + FShaderMap::Shutdown(); + bShaderMapInitialized = false; + } + } + + [[nodiscard]] const TRefCountPtr& GetScene() const + { + return Scene; + } + + private: + FApplicationServiceDescriptor Descriptor{ + "editor.runtime_state", + 1, + 0, + {"editor.rhi"}, + {EApplicationMode::Editor}, + {}, + EApplicationShutdownClass::RuntimeState, + true, + true, + }; + TRefCountPtr Scene; + bool bShaderMapInitialized = false; + }; + + class FEditorRenderService final : public IApplicationService + { + public: + const FApplicationServiceDescriptor& GetDescriptor() const override + { + return Descriptor; + } + + void Initialize(const FApplicationConfig&) override + { + Renderer = std::make_unique(); + Frontend = std::make_unique(); + Frontend->Init(); + } + + FApplicationServiceTickResult Tick(const FApplicationTickContext&) override + { + SCOPED_CPU_EVENT("Renderer"); + TRefCountPtr Window = FWindow::GetInstance(); + FRenderDocIntegration& RenderDoc = FRenderDocIntegration::Get(); + void* NativeWindow = glfwGetWin32Window(Window->GetGLFWWindow()); + const bool bRenderDocCaptureStarted = !Window->IsMinimized() + && RenderDoc.BeginFrameCapture(NativeWindow); + try + { + const bool bPresented = Frontend->Render(*Renderer); + if (bRenderDocCaptureStarted) + { + FRHIThread::WaitForLastPresent(); + RenderDoc.EndFrameCapture(NativeWindow); + } + FApplicationServiceTickResult Result; + Result.bCompletedRenderFrame = bPresented; + return Result; + } + catch (...) + { + if (bRenderDocCaptureStarted) + { + RenderDoc.CancelFrameCapture(NativeWindow); + } + throw; + } + } + + void RequestStop() override {} + void Drain() override {} + + void Shutdown() override + { + if (Frontend) + { + Frontend->Shutdown(); + Frontend.reset(); + } + if (Renderer) + { + Renderer->Shutdown(); + Renderer.reset(); + } + FRenderGraphResourcePool::Get()->Reset(); + } + + [[nodiscard]] FEditorFrontend& GetFrontend() const + { + return *Frontend; + } + + private: + FApplicationServiceDescriptor Descriptor{ + "editor.render", + 1, + 0, + {"editor.runtime_state"}, + {EApplicationMode::Editor}, + {EApplicationTickPhase::RenderPresent}, + EApplicationShutdownClass::GpuBacked, + true, + true, + }; + std::unique_ptr Renderer; + std::unique_ptr Frontend; + }; + + class FEditorSimulationService final : public IApplicationService + { + public: + FEditorSimulationService( + FEditorRuntimeStateService& InRuntimeState, + FEditorRenderService& InRender) + : RuntimeState(InRuntimeState) + , Render(InRender) + { + } + + const FApplicationServiceDescriptor& GetDescriptor() const override + { + return Descriptor; + } + + void Initialize(const FApplicationConfig&) override {} + + FApplicationServiceTickResult Tick(const FApplicationTickContext& Context) override + { + SCOPED_CPU_EVENT("Scene Tick"); + RuntimeState.GetScene()->Tick( + static_cast(Context.Clock.GameDeltaSeconds), + Render.GetFrontend().GetViewInput()); + return NoApplicationTickResult(); + } + + void RequestStop() override {} + void Drain() override {} + void Shutdown() override {} + + private: + FApplicationServiceDescriptor Descriptor{ + "editor.simulation", + 1, + 0, + {"editor.render"}, + {EApplicationMode::Editor}, + {EApplicationTickPhase::Simulation}, + EApplicationShutdownClass::GpuBacked, + true, + true, + }; + FEditorRuntimeStateService& RuntimeState; + FEditorRenderService& Render; + }; + + class FEditorAdapterService final : public IApplicationService + { + public: + const FApplicationServiceDescriptor& GetDescriptor() const override + { + return Descriptor; + } + + void Initialize(const FApplicationConfig&) override + { + FMcpServer::Get().StartStdio(); + } + + FApplicationServiceTickResult Tick(const FApplicationTickContext& Context) override + { + if (Context.Phase == EApplicationTickPhase::ExternalRequests) + { + SCOPED_CPU_EVENT("MCP"); + FMainThreadDispatcher::Get().Drain(5.0); + } + else if (Context.Phase == EApplicationTickPhase::EndFrame) + { + FPSCounter.Tick(); + } + return NoApplicationTickResult(); + } + + void RequestStop() override + { + if (!bStopRequested) + { + FMainThreadDispatcher::Get().Shutdown(); + FMcpServer::Get().Shutdown(); + WaveTrace::FTraceRecorder::Get().Stop(); + bStopRequested = true; + } + } + + void Drain() override {} + void Shutdown() override {} + + private: + FApplicationServiceDescriptor Descriptor{ + "editor.adapters", + 1, + 0, + {"editor.simulation"}, + {EApplicationMode::Editor}, + { + EApplicationTickPhase::ExternalRequests, + EApplicationTickPhase::EndFrame, + }, + EApplicationShutdownClass::Adapter, + true, + true, + }; + FFPSCounter FPSCounter; + bool bStopRequested = false; + }; + + class FEditorIdleCoordinator final : public IApplicationIdleCoordinator + { + public: + FApplicationIdleResult WaitForIdle() override + { + if (!GDynamicRHI) + { + return {true, {}}; + } + try + { + GDynamicRHI->WaitForGpuIdle(); + return {true, {}}; + } + catch (const std::exception& Error) + { + FRHIThread::Stop(); + return { + false, + {{ + "application.gpu.idle_failed", + "gpu_idle", + Error.what(), + EApplicationDiagnosticSeverity::Fatal, + false, + }}, + }; + } + catch (...) + { + FRHIThread::Stop(); + return { + false, + {{ + "application.gpu.idle_failed", + "gpu_idle", + "Unknown GPU idle failure.", + EApplicationDiagnosticSeverity::Fatal, + false, + }}, + }; + } + } + }; + + double GetMonotonicSeconds() + { + return std::chrono::duration( + std::chrono::steady_clock::now().time_since_epoch()).count(); + } + + std::string_view DiagnosticSeverityName(EApplicationDiagnosticSeverity Severity) + { + switch (Severity) + { + case EApplicationDiagnosticSeverity::Info: + return "info"; + case EApplicationDiagnosticSeverity::Warning: + return "warning"; + case EApplicationDiagnosticSeverity::Error: + return "error"; + case EApplicationDiagnosticSeverity::Fatal: + return "fatal"; + } + return "unknown"; + } + + void LogApplicationDiagnostics(const FApplicationStatusSnapshot& Status) + { + for (const FApplicationDiagnostic& Diagnostic : Status.Diagnostics) + { + Log::Error( + "Application diagnostic [", + DiagnosticSeverityName(Diagnostic.Severity), + "] ", + Diagnostic.Code, + " at ", + Diagnostic.Path, + ": ", + Diagnostic.Message); + } + } +} + +int RunEditorApplication() +{ + using namespace WaveApplication; + + FEditorLogService LogService; + FEditorPathService PathService; + FEditorWindowService WindowService; + FEditorBackendService BackendService; + FEditorRuntimeStateService RuntimeStateService; + FEditorRenderService RenderService; + FEditorSimulationService SimulationService(RuntimeStateService, RenderService); + FEditorAdapterService AdapterService; + FEditorIdleCoordinator IdleCoordinator; + + FApplicationConfig Config = MakeDefaultApplicationConfig(EApplicationMode::Editor); + FApplicationRunner Runner(std::move(Config), "wave-editor"); + Runner.RegisterService(PathService); + Runner.RegisterService(LogService); + Runner.RegisterService(WindowService); + Runner.RegisterService(BackendService); + Runner.RegisterService(RuntimeStateService); + Runner.RegisterService(RenderService); + Runner.RegisterService(SimulationService); + Runner.RegisterService(AdapterService); + + SET_THREAD_NAME("Main Thread"); + if (Runner.Initialize(IdleCoordinator)) + { + while (Runner.QueryStatus().State == EApplicationLifecycleState::Running) + { + const TRefCountPtr& Window = WindowService.GetWindow(); + if (Window->ShouldClose()) + { + (void)Runner.RequestStop("editor.window", "close_requested"); + break; + } + + MARK_FRAME(); + { + SCOPED_CPU_EVENT("Frame"); + (void)Runner.Tick(GetMonotonicSeconds()); + } + GlobalContext.FrameIndex = Runner.QueryStatus().TickSequence; + } + Runner.Shutdown(IdleCoordinator); + } + + const FApplicationStatusSnapshot& Status = Runner.QueryStatus(); + LogApplicationDiagnostics(Status); + if (Status.State == EApplicationLifecycleState::Quarantined) + { + Log::Error( + "Application shutdown was quarantined; terminating without C++ object teardown."); + Log::CloseLog(); + std::_Exit(Status.ExitCode != 0 ? Status.ExitCode : 1); + } + return Status.ExitCode; +} diff --git a/Engine/Source/UI/EditorApplication.h b/Engine/Source/UI/EditorApplication.h new file mode 100644 index 0000000..7b63821 --- /dev/null +++ b/Engine/Source/UI/EditorApplication.h @@ -0,0 +1,3 @@ +#pragma once + +int RunEditorApplication(); diff --git a/Engine/Source/UI/EditorAssetLoader.cpp b/Engine/Source/UI/EditorAssetLoader.cpp index 61ee276..f18e490 100644 --- a/Engine/Source/UI/EditorAssetLoader.cpp +++ b/Engine/Source/UI/EditorAssetLoader.cpp @@ -1,6 +1,7 @@ #include "EditorAssetLoader.h" -#include "UI/EditorCommands.h" +#include "Developer/Document/EditorDomainServices.h" +#include "Developer/Scene/SceneAuthoringService.h" #include "UI/UiContext.h" #include "Scene/Instance.h" #include "Scene/Mesh.h" @@ -77,7 +78,25 @@ void FEditorAssetLoader::Tick(FUiContext& Context) Instance->Position = *CompletedRequest.SpawnPosition; } Instance->SetDirty(); - EditorCommands::AddInstance(Context, Instance, CompletedRequest.CommandLabel.c_str()); + check( + Context.DomainServices != nullptr, + "Asset promotion requires initialized SceneAuthoring"); + const WaveEditorDomain::FEditorCommandContext CommandContext = + Context.DomainServices->MakeUiCommandContext( + WaveEditorDomain::SceneDomain, + "editor.scene.add_instance"); + const WaveEditorDomain::FEditorLedgerOutcome Outcome = + Context.DomainServices->SceneAuthoring().AddInstance( + Instance, + CommandContext, + CompletedRequest.CommandLabel); + if (!Outcome.Result.has_value()) + { + throw std::runtime_error( + Outcome.Diagnostics.empty() + ? "SceneAuthoring rejected asset promotion" + : Outcome.Diagnostics.front().Message); + } LastError.clear(); } catch (const std::exception& Exception) diff --git a/Engine/Source/UI/EditorFrontend.cpp b/Engine/Source/UI/EditorFrontend.cpp index d1a0811..c8de048 100644 --- a/Engine/Source/UI/EditorFrontend.cpp +++ b/Engine/Source/UI/EditorFrontend.cpp @@ -72,14 +72,14 @@ FRenderViewInput FEditorFrontend::GetViewInput() const return ViewInput; } -void FEditorFrontend::Render(FRenderer& Renderer) +bool FEditorFrontend::Render(FRenderer& Renderer) { SCOPED_CPU_EVENT("EditorFrontend::Render"); TRefCountPtr Window = FWindow::GetInstance(); if (!Window || Window->IsMinimized()) { - return; + return false; } GDynamicRHI->ResizeSwapchain(Window->Width(), Window->Height()); @@ -94,6 +94,7 @@ void FEditorFrontend::Render(FRenderer& Renderer) SetSceneViewTexture(FrameOutput.ColorTexture); ImGuiLayer.Render(); GDynamicRHI->Present(); + return true; } FRenderFrameExtensionOutput FEditorFrontend::AddPasses( diff --git a/Engine/Source/UI/EditorFrontend.h b/Engine/Source/UI/EditorFrontend.h index 7166366..79db8ba 100644 --- a/Engine/Source/UI/EditorFrontend.h +++ b/Engine/Source/UI/EditorFrontend.h @@ -14,7 +14,7 @@ class FEditorFrontend final : public IRenderFrameExtension void Init(); void Shutdown(); FRenderViewInput GetViewInput() const; - void Render(FRenderer& Renderer); + [[nodiscard]] bool Render(FRenderer& Renderer); FRenderFrameExtensionOutput AddPasses( const FRenderFrameExtensionContext& Context) override; diff --git a/Engine/Source/UI/EditorShell.cpp b/Engine/Source/UI/EditorShell.cpp index 18b226c..e834e27 100644 --- a/Engine/Source/UI/EditorShell.cpp +++ b/Engine/Source/UI/EditorShell.cpp @@ -2,6 +2,8 @@ #include "Core/Engine.h" #include "Core/Profiler.h" +#include "Developer/Document/EditorDomainServices.h" +#include "Developer/Scene/EditorDomainMcpAdapter.h" #include "Mcp/McpRegistry.h" #include "Scene/Instance.h" #include "Scene/Scene.h" @@ -108,9 +110,21 @@ FEditorShell::FEditorShell(FUiContext& InContext, FEditorShellCallbacks InCallba : Context(InContext) , Callbacks(std::move(InCallbacks)) { + DomainServices = + std::make_unique( + Context); + Context.DomainServices = DomainServices.get(); RegisterDefaultPanels(); } +FEditorShell::~FEditorShell() +{ + if (Context.DomainServices == DomainServices.get()) + { + Context.DomainServices = nullptr; + } +} + void FEditorShell::RegisterDefaultPanels() { Panels.Add({ @@ -137,6 +151,14 @@ void FEditorShell::Initialize(FMcpRegistry& Registry) } try { + check( + DomainServices != nullptr, + "Editor domain services must exist before hydration"); + DomainServices->EstablishHydratedBaseline(); + DomainMcpAdapter = + std::make_unique( + *DomainServices); + DomainMcpAdapter->Register(Registry); Panels.RegisterMcpTools(Registry); Registry.Add( "editor.app.request_exit", @@ -192,7 +214,12 @@ void FEditorShell::Initialize(FMcpRegistry& Registry) catch (...) { Panels.UnregisterMcpTools(Registry); + if (DomainMcpAdapter) + { + DomainMcpAdapter->Unregister(Registry); + } Registry.RemoveByOwner(this); + DomainMcpAdapter.reset(); throw; } } @@ -204,8 +231,13 @@ void FEditorShell::Shutdown(FMcpRegistry& Registry) return; } Panels.UnregisterMcpTools(Registry); + if (DomainMcpAdapter) + { + DomainMcpAdapter->Unregister(Registry); + } Registry.RemoveByOwner(this); FlushPendingEdits(); + DomainMcpAdapter.reset(); bInitialized = false; } @@ -255,8 +287,9 @@ bool FEditorShell::Undo() { return false; } - Context.CommandStack.Undo(Context); - return true; + return DomainServices + ? DomainServices->Undo() + : (Context.CommandStack.Undo(Context), true); } bool FEditorShell::Redo() @@ -266,8 +299,9 @@ bool FEditorShell::Redo() { return false; } - Context.CommandStack.Redo(Context); - return true; + return DomainServices + ? DomainServices->Redo() + : (Context.CommandStack.Redo(Context), true); } bool FEditorShell::CanUndo() @@ -554,7 +588,13 @@ void FEditorShell::DrawPrimaryToolbar() if (Context.Scene) { - FScene* Scene = Context.Scene.get(); + check( + DomainServices != nullptr, + "Primary toolbar requires initialized RenderSettingsAuthoring"); + WaveEditorDomain::FRenderSettingsAuthoringService& Authoring = + DomainServices->RenderSettingsAuthoring(); + WaveEditorDomain::FEditorRenderSettingsState Settings = + Authoring.GetSettings().Value; ImGui::SameLine(0.0f, 10.0f * Scale); ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); ImGui::SameLine(0.0f, 10.0f * Scale); @@ -563,14 +603,21 @@ void FEditorShell::DrawPrimaryToolbar() EditorWidgets::Icon(EEditorIcon::Eye, EEditorColor::TextMuted, 15.0f); ImGui::SameLine(0.0f, 6.0f * Scale); } - int RenderMode = static_cast(Scene->RenderMode); + int RenderMode = static_cast(Settings.RenderMode); const char* RenderModes[] = {"Raster", "Path Trace"}; ImGui::SetNextItemWidth((bVeryCompact ? 92.0f : 112.0f) * Scale); if (ImGui::Combo("##PrimaryRenderMode", &RenderMode, RenderModes, IM_ARRAYSIZE(RenderModes))) { - Scene->RenderMode = static_cast(RenderMode); - Scene->bPTResetAccumulation = true; - Context.MarkSettingsDirty(); + Settings.RenderMode = + static_cast( + RenderMode); + const WaveEditorDomain::FEditorCommandContext CommandContext = + DomainServices->MakeUiCommandContext( + WaveEditorDomain::RenderSettingsDomain, + "editor.renderer.set_setting"); + (void)Authoring.SetSettings( + Settings, + CommandContext); } if (!bCompact) { @@ -578,11 +625,20 @@ void FEditorShell::DrawPrimaryToolbar() if (EditorWidgets::FilterChip( "##AutoExposure", "Auto Exposure", - Scene->bAutoExposureEnabled, + Settings.bAutoExposureEnabled, EEditorIcon::Exposure)) { - Scene->bAutoExposureEnabled = !Scene->bAutoExposureEnabled; - Context.MarkSettingsDirty(); + Settings = + Authoring.GetSettings().Value; + Settings.bAutoExposureEnabled = + !Settings.bAutoExposureEnabled; + const WaveEditorDomain::FEditorCommandContext CommandContext = + DomainServices->MakeUiCommandContext( + WaveEditorDomain::RenderSettingsDomain, + "editor.renderer.set_setting"); + (void)Authoring.SetSettings( + Settings, + CommandContext); } } } diff --git a/Engine/Source/UI/EditorShell.h b/Engine/Source/UI/EditorShell.h index ec78f8a..a85486d 100644 --- a/Engine/Source/UI/EditorShell.h +++ b/Engine/Source/UI/EditorShell.h @@ -6,11 +6,18 @@ #include #include +#include #include struct FUiContext; class FMcpRegistry; +namespace WaveEditorDomain +{ + class FEditorDomainMcpAdapter; + class FEditorDomainServices; +} + enum class EEditorAction : uint8 { SaveAll, @@ -41,7 +48,7 @@ class FEditorShell { public: FEditorShell(FUiContext& InContext, FEditorShellCallbacks InCallbacks); - ~FEditorShell() = default; + ~FEditorShell(); FEditorShell(const FEditorShell&) = delete; FEditorShell& operator=(const FEditorShell&) = delete; @@ -79,6 +86,8 @@ class FEditorShell FUiContext& Context; FEditorShellCallbacks Callbacks; + std::unique_ptr DomainServices; + std::unique_ptr DomainMcpAdapter; FEditorPanelRegistry Panels; bool bInitialized = false; bool bLayoutBuilt = false; diff --git a/Engine/Source/UI/EditorStyle.cpp b/Engine/Source/UI/EditorStyle.cpp index fb7629b..d8ebc0c 100644 --- a/Engine/Source/UI/EditorStyle.cpp +++ b/Engine/Source/UI/EditorStyle.cpp @@ -1,5 +1,7 @@ #include "EditorStyle.h" +#include "Core/Paths/PathService.h" + #include #include #include @@ -124,7 +126,9 @@ bool FEditorStyle::RebuildForDpi(float DpiScale) const float FontSize = BaseFontSize * SafeScale; const std::filesystem::path FallbackFontPath = - std::filesystem::path(ENGINE_FOLDER) / "ThirdParty" / "imgui" / "misc" / "fonts" / "Roboto-Medium.ttf"; + WavePaths::ResolveRequiredReadPath( + WavePaths::EPathDomain::EngineContent, + "ThirdParty/imgui/misc/fonts/Roboto-Medium.ttf"); // WaveEngine is Windows-only. Prefer the native UI family so the editor // feels at home beside Visual Studio and PIX, while keeping the vendored diff --git a/Engine/Source/UI/ImGuiLayer.cpp b/Engine/Source/UI/ImGuiLayer.cpp index fb01e7f..1b8dc21 100644 --- a/Engine/Source/UI/ImGuiLayer.cpp +++ b/Engine/Source/UI/ImGuiLayer.cpp @@ -1,5 +1,6 @@ #include "ImGuiLayer.h" +#include "Core/Paths/PathService.h" #include "Window.h" #include "Scene/Scene.h" #include "RHI/DynamicRHI.h" @@ -11,6 +12,7 @@ #include "RHI/D3D12/D3D12CommandList.h" #include "Core/Engine.h" #include "Core/Profiler.h" +#include "Developer/Document/EditorDomainServices.h" #include "EditorShell.h" #include "Developer/RenderDocIntegration.h" #include "EditorStyle.h" @@ -207,7 +209,19 @@ void FImGuiLayer::Init() ImGuiIO& IO = ImGui::GetIO(); IO.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; IO.ConfigFlags |= ImGuiConfigFlags_DockingEnable; - static std::string IniPath = std::string(ENGINE_TEMP_FOLDER) + "imgui.ini"; + static const std::string IniPath = []() + { + const std::filesystem::path Path = WavePaths::ResolveRequiredWritePath( + WavePaths::EPathDomain::UserSaved, + "Config/imgui.ini"); + std::error_code Error; + std::filesystem::create_directories(Path.parent_path(), Error); + if (Error) + { + throw std::runtime_error("Could not create the ImGui config directory."); + } + return Path.string(); + }(); IO.IniFilename = IniPath.c_str(); check(FEditorStyle::Initialize(1.0f), "Failed to initialize editor fonts and style"); @@ -250,10 +264,13 @@ void FImGuiLayer::Init() []() { return FRenderDocIntegration::Get().RequestCapture(); }, []() { return FRenderDocIntegration::Get().GetStatusText(); }, }); + // Hydrate the existing settings document before the authoring ledger + // establishes its baseline and before any domain MCP adapter is visible. + ApplyConfigToScene(); + bConfigLoaded = true; EditorShell->Initialize(FMcpRegistry::Get()); bInitialized = true; - bConfigLoaded = false; } catch (...) { @@ -277,6 +294,7 @@ void FImGuiLayer::Init() DescriptorHeap = nullptr; SceneViewTextureUsedByDrawData.reset(); bInitialized = false; + bConfigLoaded = false; throw; } } @@ -372,6 +390,9 @@ void FImGuiLayer::ApplyConfigToScene() { return; } + check( + UiContext.DomainServices != nullptr, + "Config hydration requires initialized editor domain services"); FUiConfig Config; if (FUiConfig::Load(Config)) @@ -382,41 +403,115 @@ void FImGuiLayer::ApplyConfigToScene() return std::isfinite(Value) ? std::clamp(Value, MinValue, MaxValue) : DefaultValue; }; - if (Scene->MainLight) + FVector3 LightDirection( + ClampFinite( + Config.LightDirection[0], + Defaults.LightDirection[0], + -1.0f, + 1.0f), + ClampFinite( + Config.LightDirection[1], + Defaults.LightDirection[1], + -1.0f, + 1.0f), + ClampFinite( + Config.LightDirection[2], + Defaults.LightDirection[2], + -1.0f, + 1.0f)); + if (Length(LightDirection) <= 1e-4f) { - Scene->MainLight->Color = FVector3( - ClampFinite(Config.LightColor[0], Defaults.LightColor[0], 0.0f, 1.0f), - ClampFinite(Config.LightColor[1], Defaults.LightColor[1], 0.0f, 1.0f), - ClampFinite(Config.LightColor[2], Defaults.LightColor[2], 0.0f, 1.0f)); - Scene->MainLight->Intensity = ClampFinite(Config.LightIntensity, Defaults.LightIntensity, 0.0f, 20.0f); - - FVector3 LightDirection( - ClampFinite(Config.LightDirection[0], Defaults.LightDirection[0], -1.0f, 1.0f), - ClampFinite(Config.LightDirection[1], Defaults.LightDirection[1], -1.0f, 1.0f), - ClampFinite(Config.LightDirection[2], Defaults.LightDirection[2], -1.0f, 1.0f)); - if (Length(LightDirection) <= 1e-4f) - { - LightDirection = FVector3(Defaults.LightDirection[0], Defaults.LightDirection[1], Defaults.LightDirection[2]); - } - Scene->MainLight->Direction = Normalize(LightDirection); + LightDirection = FVector3( + Defaults.LightDirection[0], + Defaults.LightDirection[1], + Defaults.LightDirection[2]); } - Scene->LODErrorThreshold = ClampFinite(Config.LODError, Defaults.LODError, 0.0001f, 1.0f); - Scene->DebugViewMode = static_cast(std::clamp(Config.DebugView, 0, 2)); - Scene->bAutoExposureEnabled = Config.AutoExposureEnabled != 0; - Scene->AutoExposureSpeed = ClampFinite(Config.AutoExposureSpeed, Defaults.AutoExposureSpeed, 0.1f, 10.0f); - Scene->AutoExposureMin = ClampFinite(Config.AutoExposureMin, Defaults.AutoExposureMin, 0.01f, 2.0f); - Scene->AutoExposureMax = ClampFinite(Config.AutoExposureMax, Defaults.AutoExposureMax, 1.0f, 20.0f); - if (Scene->AutoExposureMin > Scene->AutoExposureMax) + LightDirection = Normalize(LightDirection); + (void)UiContext.DomainServices->LightingAuthoring() + .HydrateMainLight({ + { + ClampFinite( + Config.LightColor[0], + Defaults.LightColor[0], + 0.0f, + 1.0f), + ClampFinite( + Config.LightColor[1], + Defaults.LightColor[1], + 0.0f, + 1.0f), + ClampFinite( + Config.LightColor[2], + Defaults.LightColor[2], + 0.0f, + 1.0f) + }, + ClampFinite( + Config.LightIntensity, + Defaults.LightIntensity, + 0.0f, + 20.0f), + { + LightDirection.x, + LightDirection.y, + LightDirection.z + } + }); + + float AutoExposureMin = ClampFinite( + Config.AutoExposureMin, + Defaults.AutoExposureMin, + 0.01f, + 2.0f); + float AutoExposureMax = ClampFinite( + Config.AutoExposureMax, + Defaults.AutoExposureMax, + 1.0f, + 20.0f); + if (AutoExposureMin > AutoExposureMax) { - std::swap(Scene->AutoExposureMin, Scene->AutoExposureMax); + std::swap(AutoExposureMin, AutoExposureMax); } - Scene->AutoExposureMiddleGrey = ClampFinite(Config.AutoExposureMiddleGrey, Defaults.AutoExposureMiddleGrey, 0.05f, 0.5f); - Scene->ManualExposure = ClampFinite(Config.ManualExposure, Defaults.ManualExposure, 0.1f, 10.0f); - Scene->RenderMode = static_cast(std::clamp(Config.RenderMode, 0, 1)); - Scene->PTMaxBounces = static_cast(std::clamp(Config.PTMaxBounces, 1, 16)); - Scene->PTSamplesPerPixel = static_cast(std::clamp(Config.PTSamplesPerPixel, 1, 8)); - Scene->PTRussianRouletteStartBounce = static_cast(std::clamp(Config.PTRussianRouletteStartBounce, 0, 8)); - Scene->bPTAccumulationEnabled = Config.PTAccumulationEnabled != 0; + (void)UiContext.DomainServices->RenderSettingsAuthoring() + .HydrateSettings({ + static_cast( + std::clamp(Config.DebugView, 0, 2)), + static_cast( + std::clamp(Config.RenderMode, 0, 1)), + ClampFinite( + Config.LODError, + Defaults.LODError, + 0.0001f, + 1.0f), + Config.AutoExposureEnabled != 0, + ClampFinite( + Config.AutoExposureSpeed, + Defaults.AutoExposureSpeed, + 0.1f, + 10.0f), + AutoExposureMin, + AutoExposureMax, + ClampFinite( + Config.AutoExposureMiddleGrey, + Defaults.AutoExposureMiddleGrey, + 0.05f, + 0.5f), + ClampFinite( + Config.ManualExposure, + Defaults.ManualExposure, + 0.1f, + 10.0f), + static_cast( + std::clamp(Config.PTMaxBounces, 1, 16)), + static_cast( + std::clamp(Config.PTSamplesPerPixel, 1, 8)), + static_cast( + std::clamp( + Config.PTRussianRouletteStartBounce, + 0, + 8)), + Config.PTAccumulationEnabled != 0 + }); } } @@ -498,36 +593,17 @@ void FImGuiLayer::RequestDiscardAndExit() void FImGuiLayer::TrackExternalSceneChanges() { - if (!UiContext.Scene) + if (!UiContext.Scene || !UiContext.DomainServices) { - bCameraSnapshotInitialized = false; return; } - const FCamera& Camera = UiContext.Scene->Camera; - if (!bCameraSnapshotInitialized) - { - LastCameraPosition = Camera.Position; - LastCameraRotation = Camera.Rotation; - bCameraSnapshotInitialized = true; - return; - } - const auto Different = [](float A, float B) - { - return std::abs(A - B) > 1e-5f; - }; - const bool bChanged = - Different(Camera.Position.x, LastCameraPosition.x) - || Different(Camera.Position.y, LastCameraPosition.y) - || Different(Camera.Position.z, LastCameraPosition.z) - || Different(Camera.Rotation.Pitch, LastCameraRotation.Pitch) - || Different(Camera.Rotation.Yaw, LastCameraRotation.Yaw) - || Different(Camera.Rotation.Roll, LastCameraRotation.Roll); - if (bChanged) - { - UiContext.MarkSceneDirty(); - LastCameraPosition = Camera.Position; - LastCameraRotation = Camera.Rotation; - } + const WaveEditorDomain::FEditorCommandContext Context = + UiContext.DomainServices->MakeCommandContext( + WaveEditorDomain::SceneDomain, + "editor.camera.navigation.observe", + WaveEditorDomain::EEditorInvocationMode::ExternalObservation); + (void)UiContext.DomainServices->CameraAuthoring() + .ObserveExternalCameraChange(Context); } void FImGuiLayer::UpdateWindowTitle() diff --git a/Engine/Source/UI/ImGuiLayer.h b/Engine/Source/UI/ImGuiLayer.h index 48a0478..eb4a69e 100644 --- a/Engine/Source/UI/ImGuiLayer.h +++ b/Engine/Source/UI/ImGuiLayer.h @@ -55,10 +55,7 @@ class FImGuiLayer float LastDpiScale = 0.0f; bool bFontDirty = false; bool bConfigLoaded = false; - bool bCameraSnapshotInitialized = false; bool bDiscardDirtyOnShutdown = false; - FVector3 LastCameraPosition; - FRotator LastCameraRotation; std::string LastWindowTitle; // Exact texture referenced by the ImGui draw commands built this frame. The // tonemap output may be replaced before Render(), so retaining the current diff --git a/Engine/Source/UI/Panels/AssetsPanel.cpp b/Engine/Source/UI/Panels/AssetsPanel.cpp index c1d7e55..baa5915 100644 --- a/Engine/Source/UI/Panels/AssetsPanel.cpp +++ b/Engine/Source/UI/Panels/AssetsPanel.cpp @@ -1,6 +1,8 @@ #include "AssetsPanel.h" -#include "UI/EditorCommands.h" +#include "Core/Paths/PathService.h" +#include "Developer/Document/EditorDomainServices.h" +#include "Developer/Scene/SceneAuthoringService.h" #include "UI/EditorStyle.h" #include "UI/EditorWidgets.h" #include "Mcp/McpRegistry.h" @@ -175,7 +177,9 @@ FAssetsPanel::FAssetScanResult FAssetsPanel::ScanAssetIndex() { FAssetScanResult Result; - const std::filesystem::path Root = std::filesystem::path(ASSETS_FOLDER).lexically_normal(); + const std::filesystem::path Root = WavePaths::ResolveRequiredReadPath( + WavePaths::EPathDomain::ProjectSource, + "Assets"); std::error_code Error; const bool bRootExists = std::filesystem::exists(Root, Error); if (Error || !bRootExists) @@ -800,7 +804,25 @@ uint32 FAssetsPanel::McpSpawn(std::string Path, } Inst->SetDirty(); - EditorCommands::AddInstance(Context, Inst, "Spawn Asset"); + check( + Context.DomainServices != nullptr, + "Asset spawn requires initialized SceneAuthoring"); + const WaveEditorDomain::FEditorCommandContext CommandContext = + Context.DomainServices->MakeLegacyCommandContext( + WaveEditorDomain::SceneDomain, + "editor.assets.spawn"); + const WaveEditorDomain::FEditorLedgerOutcome Outcome = + Context.DomainServices->SceneAuthoring().AddInstance( + Inst, + CommandContext, + "Spawn Asset"); + if (!Outcome.Result.has_value()) + { + throw std::runtime_error( + Outcome.Diagnostics.empty() + ? "SceneAuthoring rejected asset spawn" + : Outcome.Diagnostics.front().Message); + } return Inst->McpId; } diff --git a/Engine/Source/UI/Panels/EngineSettingsPanel.cpp b/Engine/Source/UI/Panels/EngineSettingsPanel.cpp index c4b85a9..3d1e747 100644 --- a/Engine/Source/UI/Panels/EngineSettingsPanel.cpp +++ b/Engine/Source/UI/Panels/EngineSettingsPanel.cpp @@ -1,19 +1,18 @@ #include "EngineSettingsPanel.h" +#include "Developer/Document/EditorDomainServices.h" +#include "Developer/Render/RenderSettingsAuthoringService.h" +#include "Developer/Scene/LightingAuthoringService.h" #include "UI/EditorStyle.h" #include "UI/EditorWidgets.h" #include "UI/UiContext.h" #include "UI/UiConfig.h" #include "Mcp/McpRegistry.h" -#include "Mcp/McpValidation.h" -#include "Scene/Scene.h" -#include "Scene/Light.h" #include "Misc/Log.h" #include "RHI/DynamicRHI.h" #include #include -#include FEngineSettingsPanel::FEngineSettingsPanel(FUiContext& InContext) : FUiPanel(InContext) @@ -24,6 +23,7 @@ void FEngineSettingsPanel::Draw(bool* bOpen) { if (!ImGui::Begin("Settings", bOpen, DockedFlags)) { + CommitSettingsEdit(); ImGui::End(); return; } @@ -35,6 +35,7 @@ void FEngineSettingsPanel::Draw(bool* bOpen) EEditorIcon::Settings); if (!Context.Scene) { + CommitSettingsEdit(); EditorWidgets::EmptyState( "Scene is loading", "Render settings will be available when scene initialization completes.", @@ -42,6 +43,9 @@ void FEngineSettingsPanel::Draw(bool* bOpen) ImGui::End(); return; } + check( + Context.DomainServices != nullptr, + "Settings panel requires initialized RenderSettingsAuthoring"); if (bResetRequested) { ImGui::OpenPopup("Reset Project Settings"); @@ -105,6 +109,7 @@ void FEngineSettingsPanel::Draw(bool* bOpen) ImGuiSelectableFlags_None, 32.0f)) { + CommitSettingsEdit(); ActiveCategory = Index; } ImGui::PopID(); @@ -122,6 +127,7 @@ void FEngineSettingsPanel::Draw(bool* bOpen) { if (ImGui::Selectable(Categories[Index], ActiveCategory == Index)) { + CommitSettingsEdit(); ActiveCategory = Index; } } @@ -139,12 +145,25 @@ void FEngineSettingsPanel::Draw(bool* bOpen) default: checkNoEntry(); break; } ImGui::EndChild(); + if (ActiveSettingsEdit != ERenderSettingsEdit::None + && !ImGui::IsAnyItemActive()) + { + CommitSettingsEdit(); + } ImGui::End(); } +void FEngineSettingsPanel::FlushPendingEdits() +{ + CommitSettingsEdit(); +} + void FEngineSettingsPanel::DrawRenderingSettings() { - FScene* Scene = Context.Scene.get(); + WaveEditorDomain::FRenderSettingsAuthoringService& Authoring = + Context.DomainServices->RenderSettingsAuthoring(); + const WaveEditorDomain::FEditorRenderSettingsState Settings = + Authoring.GetSettings().Value; EditorWidgets::PanelHeader( "Rendering", "Primary renderer and mesh level-of-detail quality.", @@ -156,7 +175,7 @@ void FEngineSettingsPanel::DrawRenderingSettings() } PropertyRow("Render mode"); - int RenderMode = static_cast(Scene->RenderMode); + int RenderMode = static_cast(Settings.RenderMode); const char* RenderModes[] = {"Raster", "Path Tracing"}; const bool bRayTracingSupported = GDynamicRHI && GDynamicRHI->RHISupportsRayTracing(); if (ImGui::BeginCombo("##RenderMode", RenderModes[RenderMode])) @@ -167,9 +186,12 @@ void FEngineSettingsPanel::DrawRenderingSettings() ImGui::BeginDisabled(!bSupported); if (ImGui::Selectable(RenderModes[Index], RenderMode == Index)) { - Scene->RenderMode = static_cast(Index); - Scene->bPTResetAccumulation = true; - Context.MarkSettingsDirty(); + WaveEditorDomain::FEditorRenderSettingsState After = + Authoring.GetSettings().Value; + After.RenderMode = + static_cast( + Index); + ApplySettings(After); } ImGui::EndDisabled(); } @@ -177,10 +199,24 @@ void FEngineSettingsPanel::DrawRenderingSettings() } PropertyRow("LOD error"); - if (ImGui::DragFloat("##LodError", &Scene->LODErrorThreshold, 0.00005f, 0.0001f, 1.0f, "%.6f")) - { - Scene->LODErrorThreshold = std::clamp(Scene->LODErrorThreshold, 0.0001f, 1.0f); - Context.MarkSettingsDirty(); + float LodError = + static_cast(Settings.LodErrorThreshold); + if (ImGui::DragFloat( + "##LodError", + &LodError, + 0.00005f, + 0.0001f, + 1.0f, + "%.6f")) + { + BeginSettingsEdit( + ERenderSettingsEdit::LodError, + Settings); + WaveEditorDomain::FEditorRenderSettingsState After = + Authoring.GetSettings().Value; + After.LodErrorThreshold = + std::clamp(LodError, 0.0001f, 1.0f); + PreviewSettings(After); } ImGui::EndTable(); } @@ -200,7 +236,10 @@ void FEngineSettingsPanel::DrawLightingSettings() void FEngineSettingsPanel::DrawExposureSettings() { - FScene* Scene = Context.Scene.get(); + WaveEditorDomain::FRenderSettingsAuthoringService& Authoring = + Context.DomainServices->RenderSettingsAuthoring(); + const WaveEditorDomain::FEditorRenderSettingsState Settings = + Authoring.GetSettings().Value; EditorWidgets::PanelHeader( "Exposure", "Automatic luminance adaptation and manual fallback exposure.", @@ -212,43 +251,118 @@ void FEngineSettingsPanel::DrawExposureSettings() } PropertyRow("Auto exposure"); - if (ImGui::Checkbox("##AutoExposure", &Scene->bAutoExposureEnabled)) + bool bAutoExposureEnabled = Settings.bAutoExposureEnabled; + if (ImGui::Checkbox( + "##AutoExposure", + &bAutoExposureEnabled)) { - Context.MarkSettingsDirty(); + WaveEditorDomain::FEditorRenderSettingsState After = + Authoring.GetSettings().Value; + After.bAutoExposureEnabled = bAutoExposureEnabled; + ApplySettings(After); } - ImGui::BeginDisabled(!Scene->bAutoExposureEnabled); + ImGui::BeginDisabled(!Settings.bAutoExposureEnabled); PropertyRow("Adaptation speed"); - if (ImGui::SliderFloat("##ExposureSpeed", &Scene->AutoExposureSpeed, 0.1f, 10.0f, "%.2f")) - { - Context.MarkSettingsDirty(); + float AutoExposureSpeed = + static_cast(Settings.AutoExposureSpeed); + if (ImGui::SliderFloat( + "##ExposureSpeed", + &AutoExposureSpeed, + 0.1f, + 10.0f, + "%.2f")) + { + BeginSettingsEdit( + ERenderSettingsEdit::AutoExposureSpeed, + Settings); + WaveEditorDomain::FEditorRenderSettingsState After = + Authoring.GetSettings().Value; + After.AutoExposureSpeed = AutoExposureSpeed; + PreviewSettings(After); } PropertyRow("Minimum"); - if (ImGui::SliderFloat("##ExposureMin", &Scene->AutoExposureMin, 0.01f, 2.0f, "%.2f")) - { - Scene->AutoExposureMax = std::max(Scene->AutoExposureMax, Scene->AutoExposureMin); - Context.MarkSettingsDirty(); + float AutoExposureMin = + static_cast(Settings.AutoExposureMin); + if (ImGui::SliderFloat( + "##ExposureMin", + &AutoExposureMin, + 0.01f, + 2.0f, + "%.2f")) + { + BeginSettingsEdit( + ERenderSettingsEdit::AutoExposureMin, + Settings); + WaveEditorDomain::FEditorRenderSettingsState After = + Authoring.GetSettings().Value; + After.AutoExposureMin = AutoExposureMin; + After.AutoExposureMax = std::max( + After.AutoExposureMax, + After.AutoExposureMin); + PreviewSettings(After); } PropertyRow("Maximum"); - if (ImGui::SliderFloat("##ExposureMax", &Scene->AutoExposureMax, 1.0f, 20.0f, "%.2f")) - { - Scene->AutoExposureMin = std::min(Scene->AutoExposureMin, Scene->AutoExposureMax); - Context.MarkSettingsDirty(); + float AutoExposureMax = + static_cast(Settings.AutoExposureMax); + if (ImGui::SliderFloat( + "##ExposureMax", + &AutoExposureMax, + 1.0f, + 20.0f, + "%.2f")) + { + BeginSettingsEdit( + ERenderSettingsEdit::AutoExposureMax, + Settings); + WaveEditorDomain::FEditorRenderSettingsState After = + Authoring.GetSettings().Value; + After.AutoExposureMax = AutoExposureMax; + After.AutoExposureMin = std::min( + After.AutoExposureMin, + After.AutoExposureMax); + PreviewSettings(After); } PropertyRow("Middle grey"); - if (ImGui::SliderFloat("##MiddleGrey", &Scene->AutoExposureMiddleGrey, 0.05f, 0.5f, "%.3f")) - { - Context.MarkSettingsDirty(); + float AutoExposureMiddleGrey = + static_cast(Settings.AutoExposureMiddleGrey); + if (ImGui::SliderFloat( + "##MiddleGrey", + &AutoExposureMiddleGrey, + 0.05f, + 0.5f, + "%.3f")) + { + BeginSettingsEdit( + ERenderSettingsEdit::AutoExposureMiddleGrey, + Settings); + WaveEditorDomain::FEditorRenderSettingsState After = + Authoring.GetSettings().Value; + After.AutoExposureMiddleGrey = AutoExposureMiddleGrey; + PreviewSettings(After); } ImGui::EndDisabled(); PropertyRow("Manual exposure"); - ImGui::BeginDisabled(Scene->bAutoExposureEnabled); - if (ImGui::SliderFloat("##ManualExposure", &Scene->ManualExposure, 0.1f, 10.0f, "%.2f")) - { - Context.MarkSettingsDirty(); + ImGui::BeginDisabled(Settings.bAutoExposureEnabled); + float ManualExposure = + static_cast(Settings.ManualExposure); + if (ImGui::SliderFloat( + "##ManualExposure", + &ManualExposure, + 0.1f, + 10.0f, + "%.2f")) + { + BeginSettingsEdit( + ERenderSettingsEdit::ManualExposure, + Settings); + WaveEditorDomain::FEditorRenderSettingsState After = + Authoring.GetSettings().Value; + After.ManualExposure = ManualExposure; + PreviewSettings(After); } ImGui::EndDisabled(); ImGui::EndTable(); - if (Scene->bAutoExposureEnabled) + if (Settings.bAutoExposureEnabled) { ImGui::Dummy(ImVec2(0.0f, FEditorStyle::Scale(7.0f))); EditorWidgets::Message( @@ -259,7 +373,10 @@ void FEngineSettingsPanel::DrawExposureSettings() void FEngineSettingsPanel::DrawPathTracingSettings() { - FScene* Scene = Context.Scene.get(); + WaveEditorDomain::FRenderSettingsAuthoringService& Authoring = + Context.DomainServices->RenderSettingsAuthoring(); + const WaveEditorDomain::FEditorRenderSettingsState Settings = + Authoring.GetSettings().Value; const bool bRayTracingSupported = GDynamicRHI && GDynamicRHI->RHISupportsRayTracing(); EditorWidgets::PanelHeader( "Path Tracing", @@ -273,7 +390,8 @@ void FEngineSettingsPanel::DrawPathTracingSettings() EEditorBadge::Warning); return; } - if (Scene->RenderMode != FScene::ERenderMode::PathTracing) + if (Settings.RenderMode + != WaveEditorDomain::EEditorRenderMode::PathTracing) { EditorWidgets::Message( "Path tracing controls are inactive while the viewport uses Raster mode.", @@ -285,9 +403,11 @@ void FEngineSettingsPanel::DrawPathTracingSettings() "Switch to Path Trace", EEditorButtonStyle::Primary)) { - Scene->RenderMode = FScene::ERenderMode::PathTracing; - Scene->bPTResetAccumulation = true; - Context.MarkSettingsDirty(); + WaveEditorDomain::FEditorRenderSettingsState After = + Authoring.GetSettings().Value; + After.RenderMode = + WaveEditorDomain::EEditorRenderMode::PathTracing; + ApplySettings(After); } ImGui::Dummy(ImVec2(0.0f, FEditorStyle::Scale(8.0f))); } @@ -295,40 +415,69 @@ void FEngineSettingsPanel::DrawPathTracingSettings() { return; } - ImGui::BeginDisabled(Scene->RenderMode != FScene::ERenderMode::PathTracing); + ImGui::BeginDisabled( + Settings.RenderMode + != WaveEditorDomain::EEditorRenderMode::PathTracing); PropertyRow("Max bounces"); - int Bounces = static_cast(Scene->PTMaxBounces); + int Bounces = + static_cast(Settings.PathTracingMaxBounces); if (ImGui::SliderInt("##PathBounces", &Bounces, 1, 16)) { - Scene->PTMaxBounces = static_cast(Bounces); - Scene->bPTResetAccumulation = true; - Context.MarkSettingsDirty(); + BeginSettingsEdit( + ERenderSettingsEdit::PathTracingMaxBounces, + Settings); + WaveEditorDomain::FEditorRenderSettingsState After = + Authoring.GetSettings().Value; + After.PathTracingMaxBounces = + static_cast(Bounces); + PreviewSettings(After); } PropertyRow("Samples / frame"); - int Samples = static_cast(Scene->PTSamplesPerPixel); + int Samples = + static_cast(Settings.PathTracingSamplesPerPixel); if (ImGui::SliderInt("##PathSamples", &Samples, 1, 8)) { - Scene->PTSamplesPerPixel = static_cast(Samples); - Scene->bPTResetAccumulation = true; - Context.MarkSettingsDirty(); + BeginSettingsEdit( + ERenderSettingsEdit::PathTracingSamples, + Settings); + WaveEditorDomain::FEditorRenderSettingsState After = + Authoring.GetSettings().Value; + After.PathTracingSamplesPerPixel = + static_cast(Samples); + PreviewSettings(After); } PropertyRow("RR start bounce"); - int RussianRoulette = static_cast(Scene->PTRussianRouletteStartBounce); + int RussianRoulette = static_cast( + Settings.PathTracingRussianRouletteStart); if (ImGui::SliderInt("##PathRR", &RussianRoulette, 0, 8)) { - Scene->PTRussianRouletteStartBounce = static_cast(RussianRoulette); - Scene->bPTResetAccumulation = true; - Context.MarkSettingsDirty(); + BeginSettingsEdit( + ERenderSettingsEdit::PathTracingRussianRoulette, + Settings); + WaveEditorDomain::FEditorRenderSettingsState After = + Authoring.GetSettings().Value; + After.PathTracingRussianRouletteStart = + static_cast(RussianRoulette); + PreviewSettings(After); } PropertyRow("Accumulate"); - if (ImGui::Checkbox("##PathAccumulate", &Scene->bPTAccumulationEnabled)) - { - Scene->bPTResetAccumulation = true; - Context.MarkSettingsDirty(); + bool bAccumulationEnabled = + Settings.bPathTracingAccumulationEnabled; + if (ImGui::Checkbox( + "##PathAccumulate", + &bAccumulationEnabled)) + { + WaveEditorDomain::FEditorRenderSettingsState After = + Authoring.GetSettings().Value; + After.bPathTracingAccumulationEnabled = + bAccumulationEnabled; + ApplySettings(After); } PropertyRow("Accumulated"); - ImGui::Text("%u samples", Scene->PTAccumulatedSamplesDisplay); + ImGui::Text( + "%u samples", + Authoring.GetAccumulatedSamplesDisplay()); PropertyRow("Restart"); if (EditorWidgets::Button( "##ResetAccumulation", @@ -336,7 +485,7 @@ void FEngineSettingsPanel::DrawPathTracingSettings() "Reset Accumulation", EEditorButtonStyle::Default)) { - Scene->bPTResetAccumulation = true; + Authoring.RequestPathTracingReset(); } ImGui::EndDisabled(); ImGui::EndTable(); @@ -344,185 +493,127 @@ void FEngineSettingsPanel::DrawPathTracingSettings() void FEngineSettingsPanel::ResetToDefaults() { - FScene* Scene = Context.Scene.get(); - if (!Scene) + if (!Context.Scene || !Context.DomainServices) { return; } + CommitSettingsEdit(); const FUiConfig Defaults; - if (Scene->MainLight) - { - Scene->MainLight->Color = FVector3(Defaults.LightColor[0], Defaults.LightColor[1], Defaults.LightColor[2]); - Scene->MainLight->Intensity = Defaults.LightIntensity; - Scene->MainLight->Direction = Normalize(FVector3( - Defaults.LightDirection[0], Defaults.LightDirection[1], Defaults.LightDirection[2])); - } - Scene->LODErrorThreshold = Defaults.LODError; - Scene->DebugViewMode = static_cast(Defaults.DebugView); - Scene->bAutoExposureEnabled = Defaults.AutoExposureEnabled != 0; - Scene->AutoExposureSpeed = Defaults.AutoExposureSpeed; - Scene->AutoExposureMin = Defaults.AutoExposureMin; - Scene->AutoExposureMax = Defaults.AutoExposureMax; - Scene->AutoExposureMiddleGrey = Defaults.AutoExposureMiddleGrey; - Scene->ManualExposure = Defaults.ManualExposure; - Scene->RenderMode = static_cast(Defaults.RenderMode); - Scene->PTMaxBounces = static_cast(std::max(1, Defaults.PTMaxBounces)); - Scene->PTSamplesPerPixel = static_cast(std::max(1, Defaults.PTSamplesPerPixel)); - Scene->PTRussianRouletteStartBounce = static_cast(std::max(0, Defaults.PTRussianRouletteStartBounce)); - Scene->bPTAccumulationEnabled = Defaults.PTAccumulationEnabled != 0; - Scene->bPTResetAccumulation = true; - Context.MarkSettingsDirty(); + WaveEditorDomain::FEditorCommandContext LightContext = + Context.DomainServices->MakeUiCommandContext( + WaveEditorDomain::RenderSettingsDomain, + "editor.lighting.set_main"); + (void)Context.DomainServices->LightingAuthoring().SetMainLight( + { + { + Defaults.LightColor[0], + Defaults.LightColor[1], + Defaults.LightColor[2] + }, + Defaults.LightIntensity, + { + Defaults.LightDirection[0], + Defaults.LightDirection[1], + Defaults.LightDirection[2] + } + }, + LightContext); + + WaveEditorDomain::FEditorRenderSettingsState Settings; + Settings.DebugView = + static_cast( + Defaults.DebugView); + Settings.RenderMode = + static_cast( + Defaults.RenderMode); + Settings.LodErrorThreshold = Defaults.LODError; + Settings.bAutoExposureEnabled = + Defaults.AutoExposureEnabled != 0; + Settings.AutoExposureSpeed = Defaults.AutoExposureSpeed; + Settings.AutoExposureMin = Defaults.AutoExposureMin; + Settings.AutoExposureMax = Defaults.AutoExposureMax; + Settings.AutoExposureMiddleGrey = + Defaults.AutoExposureMiddleGrey; + Settings.ManualExposure = Defaults.ManualExposure; + Settings.PathTracingMaxBounces = + static_cast( + std::max(1, Defaults.PTMaxBounces)); + Settings.PathTracingSamplesPerPixel = + static_cast( + std::max(1, Defaults.PTSamplesPerPixel)); + Settings.PathTracingRussianRouletteStart = + static_cast( + std::max(0, Defaults.PTRussianRouletteStartBounce)); + Settings.bPathTracingAccumulationEnabled = + Defaults.PTAccumulationEnabled != 0; + ApplySettings(Settings); } -void FEngineSettingsPanel::RegisterMcpTools(FMcpRegistry& Registry) +void FEngineSettingsPanel::ApplySettings( + const WaveEditorDomain::FEditorRenderSettingsState& Settings, + std::string_view Capability) { - Registry.AddReadOnly( - "editor.lighting.get_main", - "Read the main directional light settings", - {}, - this, - &FEngineSettingsPanel::McpLightingGetMain); - Registry.Add( - "editor.lighting.set_main", - "Set main directional light color, intensity, and direction", - { "color", "intensity", "direction" }, - this, - &FEngineSettingsPanel::McpLightingSetMain); - Registry.AddReadOnly( - "editor.renderer.get_settings", - "Read renderer settings exposed by the editor", - {}, - this, - &FEngineSettingsPanel::McpRendererGetSettings); - Registry.Add( - "editor.renderer.set_setting", - "Set one renderer setting through its legacy path", - { "path", "value" }, - this, - &FEngineSettingsPanel::McpRendererSetSetting); - Registry.AddReadOnly( - "editor.console.log_tail", - "Read a bounded tail of in-memory editor log lines", - { "count" }, - this, - &FEngineSettingsPanel::McpConsoleLogTail); + CommitSettingsEdit(); + const WaveEditorDomain::FEditorCommandContext CommandContext = + Context.DomainServices->MakeUiCommandContext( + WaveEditorDomain::RenderSettingsDomain, + Capability); + (void)Context.DomainServices->RenderSettingsAuthoring() + .SetSettings( + Settings, + CommandContext, + Capability); } -// ---- Lighting ---- - -FJson FEngineSettingsPanel::McpLightingGetMain() +void FEngineSettingsPanel::BeginSettingsEdit( + ERenderSettingsEdit Edit, + const WaveEditorDomain::FEditorRenderSettingsState& Before) { - if (!Context.Scene || !Context.Scene->MainLight) + if (ActiveSettingsEdit == Edit) { - throw std::runtime_error("MainLight not initialized"); + return; } - const FLight& L = *Context.Scene->MainLight; - FJson J; - J["color"] = TJsonSerializer::ToJson(L.Color); - J["intensity"] = L.Intensity; - J["direction"] = TJsonSerializer::ToJson(L.Direction); - return J; + CommitSettingsEdit(); + ActiveSettingsEdit = Edit; + SettingsEditBefore = Before; + SettingsEditContext = + Context.DomainServices->MakeUiCommandContext( + WaveEditorDomain::RenderSettingsDomain, + "editor.renderer.set_setting"); } -void FEngineSettingsPanel::McpLightingSetMain(FVector3 Color, float Intensity, FVector3 Direction) +void FEngineSettingsPanel::PreviewSettings( + const WaveEditorDomain::FEditorRenderSettingsState& Settings) { - if (!Context.Scene || !Context.Scene->MainLight) - { - throw std::runtime_error("MainLight not initialized"); - } - McpValidation::RequireRange(Color, 0.0f, 1.0f, "color"); - McpValidation::RequireRange(Intensity, 0.0f, 20.0f, "intensity"); - const FVector3 NormalizedDirection = McpValidation::NormalizeDirection(Direction, "direction"); - - FLight& L = *Context.Scene->MainLight; - L.Color = Color; - L.Intensity = Intensity; - L.Direction = NormalizedDirection; - Context.Scene->bPTResetAccumulation = true; - Context.MarkSettingsDirty(); + (void)Context.DomainServices->RenderSettingsAuthoring() + .PreviewSettings( + Settings, + SettingsEditContext); } -// ---- Renderer ---- - -FJson FEngineSettingsPanel::McpRendererGetSettings() +void FEngineSettingsPanel::CommitSettingsEdit() { - if (!Context.Scene) + if (ActiveSettingsEdit == ERenderSettingsEdit::None + || !Context.DomainServices) { - throw std::runtime_error("Scene not initialized"); - } - FScene* S = Context.Scene.get(); - FJson J; - J["render_mode"] = (S->RenderMode == FScene::ERenderMode::Forward) ? "forward" : "path_tracing"; - J["auto_exposure_enabled"] = S->bAutoExposureEnabled; - J["manual_exposure"] = S->ManualExposure; - J["pt_max_bounces"] = S->PTMaxBounces; - J["pt_samples_per_pixel"] = S->PTSamplesPerPixel; - J["pt_accumulation_enabled"] = S->bPTAccumulationEnabled; - J["lod_error_threshold"] = S->LODErrorThreshold; - return J; + ActiveSettingsEdit = ERenderSettingsEdit::None; + return; + } + (void)Context.DomainServices->RenderSettingsAuthoring() + .CommitSettingsPreview( + SettingsEditBefore, + SettingsEditContext); + ActiveSettingsEdit = ERenderSettingsEdit::None; } -void FEngineSettingsPanel::McpRendererSetSetting(std::string Path, FJson Value) +void FEngineSettingsPanel::RegisterMcpTools(FMcpRegistry& Registry) { - if (!Context.Scene) - { - throw std::runtime_error("Scene not initialized"); - } - FScene* S = Context.Scene.get(); - if (Path == "render_mode") - { - const std::string Mode = Value.get(); - if (Mode == "forward") S->RenderMode = FScene::ERenderMode::Forward; - else if (Mode == "path_tracing") S->RenderMode = FScene::ERenderMode::PathTracing; - else throw std::runtime_error("Unknown render_mode: " + Mode); - S->bPTResetAccumulation = true; - } - else if (Path == "auto_exposure_enabled") S->bAutoExposureEnabled = TJsonSerializer::FromJson(Value); - else if (Path == "manual_exposure") - { - const float Exposure = TJsonSerializer::FromJson(Value); - if (Exposure < 0.1f || Exposure > 10.0f) - { - throw std::invalid_argument("manual_exposure must be in [0.1, 10.0]"); - } - S->ManualExposure = Exposure; - } - else if (Path == "pt_max_bounces") - { - const uint32 Bounces = TJsonSerializer::FromJson(Value); - if (Bounces < 1 || Bounces > 16) - { - throw std::invalid_argument("pt_max_bounces must be in [1, 16]"); - } - S->PTMaxBounces = Bounces; - S->bPTResetAccumulation = true; - } - else if (Path == "pt_samples_per_pixel") - { - const uint32 Samples = TJsonSerializer::FromJson(Value); - if (Samples < 1 || Samples > 8) - { - throw std::invalid_argument("pt_samples_per_pixel must be in [1, 8]"); - } - S->PTSamplesPerPixel = Samples; - S->bPTResetAccumulation = true; - } - else if (Path == "pt_accumulation_enabled") - { - S->bPTAccumulationEnabled = TJsonSerializer::FromJson(Value); - S->bPTResetAccumulation = true; - } - else if (Path == "lod_error_threshold") - { - const float Threshold = TJsonSerializer::FromJson(Value); - if (Threshold < 0.0001f || Threshold > 1.0f) - { - throw std::invalid_argument("lod_error_threshold must be in [0.0001, 1.0]"); - } - S->LODErrorThreshold = Threshold; - } - else throw std::runtime_error("Unknown setting path: " + Path); - Context.MarkSettingsDirty(); + Registry.AddReadOnly( + "editor.console.log_tail", + "Read a bounded tail of in-memory editor log lines", + { "count" }, + this, + &FEngineSettingsPanel::McpConsoleLogTail); } // ---- Console ---- diff --git a/Engine/Source/UI/Panels/EngineSettingsPanel.h b/Engine/Source/UI/Panels/EngineSettingsPanel.h index 023fb68..c7ac43e 100644 --- a/Engine/Source/UI/Panels/EngineSettingsPanel.h +++ b/Engine/Source/UI/Panels/EngineSettingsPanel.h @@ -1,9 +1,10 @@ #pragma once +#include "Developer/Scene/EditorDomainContracts.h" #include "UiPanelBase.h" -#include "Mcp/JsonSerializer.h" #include +#include #include class FMcpRegistry; @@ -14,25 +15,46 @@ class FEngineSettingsPanel : public FUiPanel explicit FEngineSettingsPanel(FUiContext& InContext); void Draw(bool* bOpen) override; + void FlushPendingEdits() override; void RegisterMcpTools(FMcpRegistry& Registry) override; - // Lighting - FJson McpLightingGetMain(); - void McpLightingSetMain(FVector3 Color, float Intensity, FVector3 Direction); - - // Renderer - FJson McpRendererGetSettings(); - void McpRendererSetSetting(std::string Path, FJson Value); - // Console std::vector McpConsoleLogTail(uint32 N); private: + enum class ERenderSettingsEdit : uint8 + { + None, + LodError, + AutoExposureSpeed, + AutoExposureMin, + AutoExposureMax, + AutoExposureMiddleGrey, + ManualExposure, + PathTracingMaxBounces, + PathTracingSamples, + PathTracingRussianRoulette, + }; + void DrawRenderingSettings(); void DrawLightingSettings(); void DrawExposureSettings(); void DrawPathTracingSettings(); void ResetToDefaults(); + void ApplySettings( + const WaveEditorDomain::FEditorRenderSettingsState& Settings, + std::string_view Capability = + "editor.renderer.set_setting"); + void BeginSettingsEdit( + ERenderSettingsEdit Edit, + const WaveEditorDomain::FEditorRenderSettingsState& Before); + void PreviewSettings( + const WaveEditorDomain::FEditorRenderSettingsState& Settings); + void CommitSettingsEdit(); int ActiveCategory = 0; + ERenderSettingsEdit ActiveSettingsEdit = + ERenderSettingsEdit::None; + WaveEditorDomain::FEditorRenderSettingsState SettingsEditBefore; + WaveEditorDomain::FEditorCommandContext SettingsEditContext; }; diff --git a/Engine/Source/UI/Panels/HierarchyPanel.cpp b/Engine/Source/UI/Panels/HierarchyPanel.cpp index 1be8423..bca1caf 100644 --- a/Engine/Source/UI/Panels/HierarchyPanel.cpp +++ b/Engine/Source/UI/Panels/HierarchyPanel.cpp @@ -1,19 +1,17 @@ #include "HierarchyPanel.h" -#include "UI/EditorCommands.h" +#include "Developer/Document/EditorDomainServices.h" +#include "Developer/Scene/SceneAuthoringService.h" #include "UI/EditorStyle.h" #include "UI/EditorWidgets.h" #include "UI/UiContext.h" #include "Scene/Scene.h" #include "Scene/Instance.h" -#include "Mcp/McpRegistry.h" -#include "Mcp/McpIdentity.h" #include "imgui.h" #include #include #include #include -#include FHierarchyPanel::FHierarchyPanel(FUiContext& InContext) : FUiPanel(InContext) @@ -44,7 +42,13 @@ void FHierarchyPanel::Draw(bool* bOpen) } FScene* Scene = Context.Scene.get(); - std::vector> Instances = Scene->GetVisibleInstances(); + check( + Context.DomainServices != nullptr, + "Hierarchy requires initialized editor domain services"); + WaveEditorDomain::FSceneAuthoringService& SceneAuthoring = + Context.DomainServices->SceneAuthoring(); + std::vector> Instances = + SceneAuthoring.GetVisibleRuntimeInstances(); std::sort(Instances.begin(), Instances.end(), [](const TRefCountPtr& A, const TRefCountPtr& B) @@ -75,7 +79,7 @@ void FHierarchyPanel::Draw(bool* bOpen) if (!Context.SelectedInstance || std::find(Instances.begin(), Instances.end(), Context.SelectedInstance) == Instances.end()) { - Context.ClearSelection(); + SceneAuthoring.ClearSelection(); } } if (Context.SelectedType == FUiContext::ESelectionType::MainLight && !Scene->MainLight) @@ -226,7 +230,15 @@ void FHierarchyPanel::DrawEntry(FUiContext::ESelectionType Type, bSelected, Metadata[0] != '\0' ? Metadata : nullptr)) { - Context.Select(Type, Instance); + if (Type == FUiContext::ESelectionType::Instance && Instance) + { + Context.DomainServices->SceneAuthoring().SelectInstance( + WaveEditorDomain::MakeInstanceRef(Instance->McpId)); + } + else + { + Context.Select(Type, Instance); + } } if (Type == FUiContext::ESelectionType::Instance && Instance) @@ -291,7 +303,14 @@ void FHierarchyPanel::DrawRenameDialog() ImVec2(110.0f * Context.UiScale, 0.0f)); if (bHasName && (bSubmitted || bRenameClicked)) { - EditorCommands::RenameInstance(Context, RenamingInstance, RenameBuffer.data()); + const WaveEditorDomain::FEditorCommandContext CommandContext = + Context.DomainServices->MakeUiCommandContext( + WaveEditorDomain::SceneDomain, + "editor.hierarchy.rename"); + (void)Context.DomainServices->SceneAuthoring().RenameInstance( + WaveEditorDomain::MakeInstanceRef(RenamingInstance->McpId), + RenameBuffer.data(), + CommandContext); RenamingInstance.reset(); ImGui::CloseCurrentPopup(); } @@ -333,7 +352,14 @@ void FHierarchyPanel::DrawDeleteDialog() { if (PendingDeleteInstance) { - EditorCommands::RemoveInstance(Context, PendingDeleteInstance); + const WaveEditorDomain::FEditorCommandContext CommandContext = + Context.DomainServices->MakeUiCommandContext( + WaveEditorDomain::SceneDomain, + "editor.hierarchy.delete"); + (void)Context.DomainServices->SceneAuthoring().RemoveInstance( + WaveEditorDomain::MakeInstanceRef( + PendingDeleteInstance->McpId), + CommandContext); } PendingDeleteInstance.reset(); ImGui::CloseCurrentPopup(); @@ -364,83 +390,12 @@ void FHierarchyPanel::DuplicateInstance(const TRefCountPtr& Instance) Copy->Rotation = Instance->Rotation; Copy->Scale = Instance->Scale; Copy->SetDirty(); - EditorCommands::AddInstance(Context, Copy, "Duplicate Object"); -} - -void FHierarchyPanel::RegisterMcpTools(FMcpRegistry& Registry) -{ - Registry.AddReadOnly( - "editor.hierarchy.list", - "List scene hierarchy entries and current selection", - {}, - this, - &FHierarchyPanel::McpList); - Registry.Add( - "editor.hierarchy.select", - "Select one session-scoped scene instance", - { "id" }, - this, - &FHierarchyPanel::McpSelect); - Registry.Add( - "editor.hierarchy.delete", - "Delete one session-scoped scene instance", - { "id" }, - this, - &FHierarchyPanel::McpDelete); - Registry.Add( - "editor.hierarchy.rename", - "Rename one session-scoped scene instance display name", - { "id", "new_name" }, - this, - &FHierarchyPanel::McpRename); -} - -FJson FHierarchyPanel::McpList() -{ - FJson Arr = FJson::array(); - auto Instances = McpIdentity::GetVisibleSorted(Context); - for (const auto& Inst : Instances) - { - if (!Inst) continue; - FJson Item; - Item["id"] = Inst->McpId; - Item["name"] = Inst->DisplayName; - Item["position"] = TJsonSerializer::ToJson(Inst->Position); - Arr.push_back(Item); - } - return Arr; -} - -void FHierarchyPanel::McpSelect(uint32 Id) -{ - auto Inst = McpIdentity::ResolveById(Context, Id); - if (!Inst) - { - throw std::runtime_error("Instance id not found"); - } - Context.Select(FUiContext::ESelectionType::Instance, Inst); -} - -void FHierarchyPanel::McpDelete(uint32 Id) -{ - auto Inst = McpIdentity::ResolveById(Context, Id); - if (!Inst) - { - throw std::runtime_error("Instance id not found"); - } - EditorCommands::RemoveInstance(Context, Inst); -} - -void FHierarchyPanel::McpRename(uint32 Id, std::string NewName) -{ - auto Inst = McpIdentity::ResolveById(Context, Id); - if (!Inst) - { - throw std::runtime_error("Instance id not found"); - } - if (NewName.empty() || NewName.size() > 256) - { - throw std::invalid_argument("Instance name must contain 1 to 256 bytes"); - } - EditorCommands::RenameInstance(Context, Inst, std::move(NewName)); + const WaveEditorDomain::FEditorCommandContext CommandContext = + Context.DomainServices->MakeUiCommandContext( + WaveEditorDomain::SceneDomain, + "editor.scene.add_instance"); + (void)Context.DomainServices->SceneAuthoring().AddInstance( + Copy, + CommandContext, + "Duplicate Object"); } diff --git a/Engine/Source/UI/Panels/HierarchyPanel.h b/Engine/Source/UI/Panels/HierarchyPanel.h index 6025403..7b5055c 100644 --- a/Engine/Source/UI/Panels/HierarchyPanel.h +++ b/Engine/Source/UI/Panels/HierarchyPanel.h @@ -2,13 +2,11 @@ #include "UiPanelBase.h" #include "UI/UiContext.h" -#include "Mcp/JsonSerializer.h" #include #include #include class FInstance; -class FMcpRegistry; class FHierarchyPanel : public FUiPanel { @@ -17,14 +15,6 @@ class FHierarchyPanel : public FUiPanel void Draw(bool* bOpen) override; - void RegisterMcpTools(FMcpRegistry& Registry) override; - - // MCP tools — id 是 FInstance::McpId(稳定) - FJson McpList(); - void McpSelect(uint32 Id); - void McpDelete(uint32 Id); - void McpRename(uint32 Id, std::string NewName); - private: void DrawSearchBox(); void DrawEntry(FUiContext::ESelectionType Type, diff --git a/Engine/Source/UI/Panels/InspectorPanel.cpp b/Engine/Source/UI/Panels/InspectorPanel.cpp index 8429346..8e6675c 100644 --- a/Engine/Source/UI/Panels/InspectorPanel.cpp +++ b/Engine/Source/UI/Panels/InspectorPanel.cpp @@ -1,21 +1,23 @@ #include "InspectorPanel.h" -#include "UI/EditorCommands.h" +#include "Developer/Document/EditorDomainServices.h" +#include "Developer/Scene/LightingAuthoringService.h" +#include "Developer/Scene/SceneAuthoringService.h" #include "UI/EditorStyle.h" #include "UI/EditorWidgets.h" #include "UI/UiContext.h" #include "Scene/Scene.h" #include "Scene/Instance.h" -#include "Scene/Light.h" #include "Scene/Mesh.h" -#include "Mcp/McpRegistry.h" -#include "Mcp/McpValidation.h" -#include "Mcp/McpIdentity.h" #include #include #include -#include + +namespace +{ + constexpr double MinimumUiLightDirectionLength = 1.0e-4; +} FInspectorPanel::FInspectorPanel(FUiContext& InContext) : FUiPanel(InContext) @@ -52,6 +54,11 @@ void FInspectorPanel::Draw(bool* bOpen) { CommitCameraEdit(); } + if (ActiveLightEdit != ELightEdit::None + && Context.SelectedType != FUiContext::ESelectionType::MainLight) + { + CommitLightEdit(); + } switch (Context.SelectedType) { @@ -78,6 +85,7 @@ void FInspectorPanel::FlushPendingEdits() CommitInstanceNameEdit(); CommitTransformEdit(); CommitCameraEdit(); + CommitLightEdit(); } void FInspectorPanel::DrawEmptySelection() @@ -95,7 +103,13 @@ void FInspectorPanel::DrawEmptySelection() void FInspectorPanel::DrawCameraProperties() { - FScene* Scene = Context.Scene.get(); + check( + Context.DomainServices != nullptr, + "Inspector Camera requires initialized CameraAuthoring"); + WaveEditorDomain::FCameraAuthoringService& CameraAuthoring = + Context.DomainServices->CameraAuthoring(); + const WaveEditorDomain::FEditorCameraSnapshotState CameraSnapshot = + CameraAuthoring.GetCamera().Value; EditorWidgets::PanelHeader( "Camera", "Active editor viewport camera.", @@ -109,24 +123,47 @@ void FInspectorPanel::DrawCameraProperties() if (BeginPropertyTable("CameraTransform")) { PropertyRow("Position"); - FVector3 Position = Scene->Camera.Position; - const FCameraTransformSnapshot PositionBefore = FCameraTransformSnapshot::Capture(Scene->Camera); + FVector3 Position( + static_cast(CameraSnapshot.Transform.Position.X), + static_cast(CameraSnapshot.Transform.Position.Y), + static_cast(CameraSnapshot.Transform.Position.Z)); + const WaveEditorDomain::FEditorCameraState PositionBefore = + CameraSnapshot.Transform; if (EditorWidgets::Vector3Edit("CameraPosition", Position, 0.05f)) { BeginCameraEdit(ECameraEdit::Position, PositionBefore); - Scene->Camera.Position = Position; - Scene->Camera.SetDirty(); - Context.MarkSceneDirty(); + WaveEditorDomain::FEditorCameraState After = + CameraAuthoring.GetCamera().Value.Transform; + After.Position = { + Position.x, + Position.y, + Position.z + }; + (void)CameraAuthoring.PreviewCamera( + After, + CameraEditContext); } PropertyRow("Pitch / Yaw / Roll"); - FRotator Rotation = Scene->Camera.Rotation; - const FCameraTransformSnapshot RotationBefore = FCameraTransformSnapshot::Capture(Scene->Camera); + FRotator Rotation( + static_cast(CameraSnapshot.Transform.Rotation.Pitch), + static_cast(CameraSnapshot.Transform.Rotation.Yaw), + static_cast(CameraSnapshot.Transform.Rotation.Roll)); + const WaveEditorDomain::FEditorCameraState RotationBefore = + CameraSnapshot.Transform; if (EditorWidgets::RotatorEdit("CameraRotation", Rotation, 0.2f)) { BeginCameraEdit(ECameraEdit::Rotation, RotationBefore); - Scene->Camera.SetRotation(Rotation); - Context.MarkSceneDirty(); + WaveEditorDomain::FEditorCameraState After = + CameraAuthoring.GetCamera().Value.Transform; + After.Rotation = { + Rotation.Pitch, + Rotation.Yaw, + Rotation.Roll + }; + (void)CameraAuthoring.PreviewCamera( + After, + CameraEditContext); } ImGui::EndTable(); } @@ -137,11 +174,17 @@ void FInspectorPanel::DrawCameraProperties() if (BeginPropertyTable("CameraLens")) { PropertyRow("Field of view"); - ImGui::Text("%.1f deg", Scene->Camera.GetFov()); + ImGui::Text( + "%.1f deg", + static_cast(CameraSnapshot.FieldOfViewDegrees)); PropertyRow("Near plane"); - ImGui::Text("%.4f", Scene->Camera.GetNearPlane()); + ImGui::Text( + "%.4f", + static_cast(CameraSnapshot.NearPlane)); PropertyRow("Far plane"); - ImGui::Text("%.0f", Scene->Camera.GetFarPlane()); + ImGui::Text( + "%.0f", + static_cast(CameraSnapshot.FarPlane)); ImGui::EndTable(); } } @@ -153,14 +196,19 @@ void FInspectorPanel::DrawCameraProperties() void FInspectorPanel::DrawMainLightProperties() { - FScene* Scene = Context.Scene.get(); - if (!Scene->MainLight) + check( + Context.DomainServices != nullptr, + "Inspector Main Light requires initialized LightingAuthoring"); + WaveEditorDomain::FLightingAuthoringService& LightingAuthoring = + Context.DomainServices->LightingAuthoring(); + if (!LightingAuthoring.IsAvailable()) { DrawEmptySelection(); return; } - TRefCountPtr Light = Scene->MainLight; + const WaveEditorDomain::FEditorMainLightState Light = + LightingAuthoring.GetMainLight().Value; EditorWidgets::PanelHeader( "Main Light", "Primary directional light for the active scene.", @@ -173,36 +221,75 @@ void FInspectorPanel::DrawMainLightProperties() { if (BeginPropertyTable("MainLightDetails")) { - float Color[3] = {Light->Color.x, Light->Color.y, Light->Color.z}; + float Color[3] = { + static_cast(Light.Color.X), + static_cast(Light.Color.Y), + static_cast(Light.Color.Z) + }; PropertyRow("Color"); if (ImGui::ColorEdit3("##LightColor", Color)) { - Light->Color = FVector3(Color[0], Color[1], Color[2]); - Scene->bPTResetAccumulation = true; - Context.MarkSettingsDirty(); + BeginLightEdit(ELightEdit::Color, Light); + WaveEditorDomain::FEditorMainLightState After = + LightingAuthoring.GetMainLight().Value; + After.Color = { + Color[0], + Color[1], + Color[2] + }; + (void)LightingAuthoring.PreviewMainLight( + After, + LightEditContext, + MinimumUiLightDirectionLength); } PropertyRow("Intensity"); - if (ImGui::SliderFloat("##LightIntensity", &Light->Intensity, 0.0f, 20.0f, "%.2f")) + float Intensity = static_cast(Light.Intensity); + if (ImGui::SliderFloat( + "##LightIntensity", + &Intensity, + 0.0f, + 20.0f, + "%.2f")) { - Scene->bPTResetAccumulation = true; - Context.MarkSettingsDirty(); + BeginLightEdit(ELightEdit::Intensity, Light); + WaveEditorDomain::FEditorMainLightState After = + LightingAuthoring.GetMainLight().Value; + After.Intensity = Intensity; + (void)LightingAuthoring.PreviewMainLight( + After, + LightEditContext, + MinimumUiLightDirectionLength); } PropertyRow("Direction"); - FVector3 Direction = Light->Direction; + FVector3 Direction( + static_cast(Light.Direction.X), + static_cast(Light.Direction.Y), + static_cast(Light.Direction.Z)); if (EditorWidgets::Vector3Edit("LightDirection", Direction, 0.01f)) { - if (Length(Direction) > 1e-4f) - { - Light->Direction = Normalize(Direction); - Scene->bPTResetAccumulation = true; - Context.MarkSettingsDirty(); - } + BeginLightEdit(ELightEdit::Direction, Light); + WaveEditorDomain::FEditorMainLightState After = + LightingAuthoring.GetMainLight().Value; + After.Direction = { + Direction.x, + Direction.y, + Direction.z + }; + (void)LightingAuthoring.PreviewMainLight( + After, + LightEditContext, + MinimumUiLightDirectionLength); } ImGui::EndTable(); } } + if (ActiveLightEdit != ELightEdit::None + && !ImGui::IsAnyItemActive()) + { + CommitLightEdit(); + } } void FInspectorPanel::DrawInstanceProperties() @@ -214,6 +301,11 @@ void FInspectorPanel::DrawInstanceProperties() } FInstance* Instance = Context.SelectedInstance.get(); + check( + Context.DomainServices != nullptr, + "Inspector requires initialized editor domain services"); + WaveEditorDomain::FSceneAuthoringService& SceneAuthoring = + Context.DomainServices->SceneAuthoring(); SynchronizeInstanceName(Instance); std::string AssetSummary = "Scene object"; if (Instance->Mesh && !Instance->Mesh->AssetPath.empty()) @@ -238,38 +330,67 @@ void FInspectorPanel::DrawInstanceProperties() { PropertyRow("Position"); FVector3 Position = Instance->Position; - const FInstanceTransformSnapshot PositionBefore = FInstanceTransformSnapshot::Capture(*Instance); + const WaveEditorDomain::FEditorTransform PositionBefore = + WaveEditorDomain::CaptureEditorTransform(*Instance); if (EditorWidgets::Vector3Edit("InstancePosition", Position, 0.05f)) { BeginTransformEdit(ETransformEdit::Position, Context.SelectedInstance, PositionBefore); - Instance->Position = Position; - Instance->SetDirty(); - Context.MarkSceneDirty(); + WaveEditorDomain::FEditorTransform After = + WaveEditorDomain::CaptureEditorTransform(*Instance); + After.Position = { + Position.x, + Position.y, + Position.z + }; + (void)SceneAuthoring.PreviewInstanceTransform( + WaveEditorDomain::MakeInstanceRef(Instance->McpId), + After, + TransformEditContext); } PropertyRow("Pitch / Yaw / Roll"); FRotator Rotation = FRotator::FromQuaternion(Instance->Rotation); - const FInstanceTransformSnapshot RotationBefore = FInstanceTransformSnapshot::Capture(*Instance); + const WaveEditorDomain::FEditorTransform RotationBefore = + WaveEditorDomain::CaptureEditorTransform(*Instance); if (EditorWidgets::RotatorEdit("InstanceRotation", Rotation, 0.5f)) { BeginTransformEdit(ETransformEdit::Rotation, Context.SelectedInstance, RotationBefore); - Instance->Rotation = Rotation.Quaternion(); - Instance->SetDirty(); - Context.MarkSceneDirty(); + const FQuat Quaternion = Rotation.Quaternion(); + WaveEditorDomain::FEditorTransform After = + WaveEditorDomain::CaptureEditorTransform(*Instance); + After.Rotation = { + Quaternion.x, + Quaternion.y, + Quaternion.z, + Quaternion.w + }; + (void)SceneAuthoring.PreviewInstanceTransform( + WaveEditorDomain::MakeInstanceRef(Instance->McpId), + After, + TransformEditContext); } PropertyRow("Scale"); FVector3 Scale = Instance->Scale; - const FInstanceTransformSnapshot ScaleBefore = FInstanceTransformSnapshot::Capture(*Instance); + const WaveEditorDomain::FEditorTransform ScaleBefore = + WaveEditorDomain::CaptureEditorTransform(*Instance); if (EditorWidgets::Vector3Edit("InstanceScale", Scale, 0.02f, 1.0f)) { BeginTransformEdit(ETransformEdit::Scale, Context.SelectedInstance, ScaleBefore); Scale.x = std::max(Scale.x, 0.001f); Scale.y = std::max(Scale.y, 0.001f); Scale.z = std::max(Scale.z, 0.001f); - Instance->Scale = Scale; - Instance->SetDirty(); - Context.MarkSceneDirty(); + WaveEditorDomain::FEditorTransform After = + WaveEditorDomain::CaptureEditorTransform(*Instance); + After.Scale = { + Scale.x, + Scale.y, + Scale.z + }; + (void)SceneAuthoring.PreviewInstanceTransform( + WaveEditorDomain::MakeInstanceRef(Instance->McpId), + After, + TransformEditContext); } ImGui::EndTable(); } @@ -374,10 +495,15 @@ void FInspectorPanel::CommitInstanceNameEdit() { if (InstanceNameEditInstance->DisplayName != InstanceNameBuffer.data()) { - EditorCommands::RenameInstance( - Context, - InstanceNameEditInstance, - InstanceNameBuffer.data()); + const WaveEditorDomain::FEditorCommandContext CommandContext = + Context.DomainServices->MakeUiCommandContext( + WaveEditorDomain::SceneDomain, + "editor.hierarchy.rename"); + (void)Context.DomainServices->SceneAuthoring().RenameInstance( + WaveEditorDomain::MakeInstanceRef( + InstanceNameEditInstance->McpId), + InstanceNameBuffer.data(), + CommandContext); } LastSynchronizedInstanceName = InstanceNameEditInstance->DisplayName; } @@ -397,7 +523,7 @@ void FInspectorPanel::CommitInstanceNameEdit() void FInspectorPanel::BeginTransformEdit( ETransformEdit Edit, const TRefCountPtr& Instance, - const FInstanceTransformSnapshot& Before) + const WaveEditorDomain::FEditorTransform& Before) { if (ActiveTransformEdit == Edit && TransformEditInstance == Instance) { @@ -407,6 +533,10 @@ void FInspectorPanel::BeginTransformEdit( ActiveTransformEdit = Edit; TransformEditInstance = Instance; TransformEditBefore = Before; + TransformEditContext = + Context.DomainServices->MakeUiCommandContext( + WaveEditorDomain::SceneDomain, + "editor.inspector.set_field"); } void FInspectorPanel::CommitTransformEdit() @@ -426,18 +556,21 @@ void FInspectorPanel::CommitTransformEdit() case ETransformEdit::Scale: Label = "Scale Object"; break; case ETransformEdit::None: break; } - EditorCommands::SetInstanceTransform( - Context, - TransformEditInstance, + (void)Context.DomainServices->SceneAuthoring() + .CommitInstanceTransformPreview( + WaveEditorDomain::MakeInstanceRef( + TransformEditInstance->McpId), TransformEditBefore, - FInstanceTransformSnapshot::Capture(*TransformEditInstance), + TransformEditContext, Label, - true); + WaveEditorDomain::EEditorScaleInputPolicy::Domain); ActiveTransformEdit = ETransformEdit::None; TransformEditInstance.reset(); } -void FInspectorPanel::BeginCameraEdit(ECameraEdit Edit, const FCameraTransformSnapshot& Before) +void FInspectorPanel::BeginCameraEdit( + ECameraEdit Edit, + const WaveEditorDomain::FEditorCameraState& Before) { if (ActiveCameraEdit == Edit) { @@ -446,123 +579,58 @@ void FInspectorPanel::BeginCameraEdit(ECameraEdit Edit, const FCameraTransformSn CommitCameraEdit(); ActiveCameraEdit = Edit; CameraEditBefore = Before; + CameraEditContext = + Context.DomainServices->MakeUiCommandContext( + WaveEditorDomain::SceneDomain, + "editor.viewport.camera.set"); } void FInspectorPanel::CommitCameraEdit() { - if (ActiveCameraEdit == ECameraEdit::None || !Context.Scene) + if (ActiveCameraEdit == ECameraEdit::None + || !Context.DomainServices) { ActiveCameraEdit = ECameraEdit::None; return; } const char* Label = ActiveCameraEdit == ECameraEdit::Position ? "Move Camera" : "Rotate Camera"; - EditorCommands::SetCameraTransform( - Context, + (void)Context.DomainServices->CameraAuthoring() + .CommitCameraPreview( CameraEditBefore, - FCameraTransformSnapshot::Capture(Context.Scene->Camera), - Label, - true); + CameraEditContext, + Label); ActiveCameraEdit = ECameraEdit::None; } -void FInspectorPanel::RegisterMcpTools(FMcpRegistry& Registry) -{ - Registry.AddReadOnly( - "editor.inspector.get", - "Read inspectable fields for one session-scoped scene instance", - { "id" }, - this, - &FInspectorPanel::McpGet); - Registry.Add( - "editor.inspector.set_field", - "Set one inspectable field on a session-scoped scene instance", - { "id", "path", "value" }, - this, - &FInspectorPanel::McpSetField); - Registry.AddReadOnly( - "editor.inspector.list_fields", - "List editable field metadata for one session-scoped scene instance", - { "id" }, - this, - &FInspectorPanel::McpListFields); -} - -FJson FInspectorPanel::McpGet(uint32 Id) +void FInspectorPanel::BeginLightEdit( + ELightEdit Edit, + const WaveEditorDomain::FEditorMainLightState& Before) { - auto Instance = McpIdentity::ResolveById(Context, Id); - if (!Instance) + if (ActiveLightEdit == Edit) { - throw std::runtime_error("Instance id not found"); + return; } - FJson Result; - Result["name"] = Instance->DisplayName; - Result["position"] = TJsonSerializer::ToJson(Instance->Position); - Result["rotation"] = TJsonSerializer::ToJson(Instance->Rotation); - const FRotator Rotator = FRotator::FromQuaternion(Instance->Rotation); - Result["rotation_euler_deg"] = FJson::array({Rotator.Pitch, Rotator.Yaw, Rotator.Roll}); - Result["coordinate_system"] = "LH_XForward_YRight_ZUp"; - Result["scale"] = TJsonSerializer::ToJson(Instance->Scale); - return Result; + CommitLightEdit(); + ActiveLightEdit = Edit; + LightEditBefore = Before; + LightEditContext = + Context.DomainServices->MakeUiCommandContext( + WaveEditorDomain::RenderSettingsDomain, + "editor.lighting.set_main"); } -void FInspectorPanel::McpSetField(uint32 Id, std::string Path, FJson Value) +void FInspectorPanel::CommitLightEdit() { - auto Instance = McpIdentity::ResolveById(Context, Id); - if (!Instance) - { - throw std::runtime_error("Instance id not found"); - } - - const FInstanceTransformSnapshot Before = FInstanceTransformSnapshot::Capture(*Instance); - FInstanceTransformSnapshot After = Before; - const char* CommandLabel = "Edit Transform"; - if (Path == "transform.position") - { - const FVector3 Position = TJsonSerializer::FromJson(Value); - McpValidation::RequireFinite(Position, "position"); - After.Position = Position; - CommandLabel = "Move Object"; - } - else if (Path == "transform.scale") - { - After.Scale = McpValidation::ValidateScale(TJsonSerializer::FromJson(Value)); - CommandLabel = "Scale Object"; - } - else if (Path == "transform.rotation") - { - After.Rotation = McpValidation::NormalizeQuaternion( - TJsonSerializer::FromJson(Value), - "rotation"); - CommandLabel = "Rotate Object"; - } - else if (Path == "transform.rotation_euler_deg") + if (ActiveLightEdit == ELightEdit::None + || !Context.DomainServices) { - const FVector3 EulerDegrees = McpValidation::NormalizeEulerDegrees( - TJsonSerializer::FromJson(Value), - "rotation_euler_deg"); - After.Rotation = FRotator(EulerDegrees.x, EulerDegrees.y, EulerDegrees.z).Quaternion(); - CommandLabel = "Rotate Object"; - } - else - { - throw std::runtime_error("Unknown field path: " + Path); + ActiveLightEdit = ELightEdit::None; + return; } - EditorCommands::SetInstanceTransform(Context, Instance, Before, After, CommandLabel); -} - -FJson FInspectorPanel::McpListFields(uint32 /*Id*/) -{ - FJson Result = FJson::array(); - const auto Add = [&](const char* Path, const char* Type) - { - FJson Entry; - Entry["path"] = Path; - Entry["type"] = Type; - Result.push_back(Entry); - }; - Add("transform.position", "vec3"); - Add("transform.rotation", "vec4"); - Add("transform.rotation_euler_deg", "vec3_deg"); - Add("transform.scale", "vec3"); - return Result; + (void)Context.DomainServices->LightingAuthoring() + .CommitMainLightPreview( + LightEditBefore, + LightEditContext, + MinimumUiLightDirectionLength); + ActiveLightEdit = ELightEdit::None; } diff --git a/Engine/Source/UI/Panels/InspectorPanel.h b/Engine/Source/UI/Panels/InspectorPanel.h index 386d133..0f103c1 100644 --- a/Engine/Source/UI/Panels/InspectorPanel.h +++ b/Engine/Source/UI/Panels/InspectorPanel.h @@ -1,13 +1,11 @@ #pragma once -#include "UI/EditorCommands.h" +#include "Developer/Scene/SceneAuthoringService.h" #include "UiPanelBase.h" -#include "Mcp/JsonSerializer.h" #include #include -class FMcpRegistry; class FInstance; class FInspectorPanel : public FUiPanel @@ -17,11 +15,6 @@ class FInspectorPanel : public FUiPanel void Draw(bool* bOpen) override; void FlushPendingEdits() override; - void RegisterMcpTools(FMcpRegistry& Registry) override; - - FJson McpGet(uint32 Id); - void McpSetField(uint32 Id, std::string Path, FJson Value); - FJson McpListFields(uint32 Id); private: enum class ETransformEdit : uint8 @@ -37,6 +30,13 @@ class FInspectorPanel : public FUiPanel Position, Rotation, }; + enum class ELightEdit : uint8 + { + None, + Color, + Intensity, + Direction, + }; void DrawEmptySelection(); void DrawCameraProperties(); @@ -47,10 +47,16 @@ class FInspectorPanel : public FUiPanel void BeginTransformEdit( ETransformEdit Edit, const TRefCountPtr& Instance, - const FInstanceTransformSnapshot& Before); + const WaveEditorDomain::FEditorTransform& Before); void CommitTransformEdit(); - void BeginCameraEdit(ECameraEdit Edit, const FCameraTransformSnapshot& Before); + void BeginCameraEdit( + ECameraEdit Edit, + const WaveEditorDomain::FEditorCameraState& Before); void CommitCameraEdit(); + void BeginLightEdit( + ELightEdit Edit, + const WaveEditorDomain::FEditorMainLightState& Before); + void CommitLightEdit(); FInstance* LastInspectedInstance = nullptr; std::array InstanceNameBuffer{}; @@ -59,7 +65,12 @@ class FInspectorPanel : public FUiPanel TRefCountPtr InstanceNameEditInstance; ETransformEdit ActiveTransformEdit = ETransformEdit::None; TRefCountPtr TransformEditInstance; - FInstanceTransformSnapshot TransformEditBefore; + WaveEditorDomain::FEditorTransform TransformEditBefore; + WaveEditorDomain::FEditorCommandContext TransformEditContext; ECameraEdit ActiveCameraEdit = ECameraEdit::None; - FCameraTransformSnapshot CameraEditBefore; + WaveEditorDomain::FEditorCameraState CameraEditBefore; + WaveEditorDomain::FEditorCommandContext CameraEditContext; + ELightEdit ActiveLightEdit = ELightEdit::None; + WaveEditorDomain::FEditorMainLightState LightEditBefore; + WaveEditorDomain::FEditorCommandContext LightEditContext; }; diff --git a/Engine/Source/UI/Panels/ProfilerPanel.cpp b/Engine/Source/UI/Panels/ProfilerPanel.cpp index d99220d..b0a3905 100644 --- a/Engine/Source/UI/Panels/ProfilerPanel.cpp +++ b/Engine/Source/UI/Panels/ProfilerPanel.cpp @@ -2,6 +2,7 @@ #include "Core/Core.h" #include "Core/Engine.h" +#include "Core/Paths/PathService.h" #include "Misc/Log.h" #include "Mcp/McpRegistry.h" #include "Trace/TraceRecorder.h" @@ -28,7 +29,7 @@ namespace { - // WaveTrace 录制文件默认落在 ENGINE_TEMP_FOLDER/Traces/ 下,文件名带时间戳, + // WaveTrace 录制文件默认落在 UserSaved/Traces/ 下,文件名带时间戳, // 便于同一进程多次录制不互相覆盖。TraceRecorder::Start 会在目录不存在时创建它。 std::string MakeTraceFilePath() { @@ -42,8 +43,8 @@ namespace #else localtime_r(&Now, &LocalTime); #endif - std::ostringstream Stream; - Stream << ENGINE_TEMP_FOLDER "Traces/trace_" + std::ostringstream FileName; + FileName << "trace_" << std::put_time(&LocalTime, "%Y%m%d_%H%M%S") << '_' << std::setw(3) << std::setfill('0') << Milliseconds #if defined(_WIN32) @@ -51,7 +52,9 @@ namespace #endif << '_' << Sequence.fetch_add(1, std::memory_order_relaxed) << ".wetrace"; - return Stream.str(); + return WavePaths::ResolveRequiredWritePath( + WavePaths::EPathDomain::UserSaved, + "Traces/" + FileName.str()).string(); } } @@ -250,7 +253,7 @@ void FProfilerPanel::DrawControls(bool bSessionActive) ImGui::PushStyleColor(ImGuiCol_Text, FEditorStyle::Color(EEditorColor::TextMuted)); ImGui::TextWrapped( - "Recording writes a .wetrace file under Engine/Save/Traces/. " + "Recording writes a .wetrace file under UserSaved/Traces. " "Enable the live listener to let WaveTraceViewer connect while capturing."); ImGui::PopStyleColor(); } @@ -316,7 +319,10 @@ void FProfilerPanel::OpenTracesFolder() const { #if defined(_WIN32) std::error_code Ec; - const std::filesystem::path TracesPath(ENGINE_TEMP_FOLDER "Traces/"); + const std::filesystem::path TracesPath = + WavePaths::ResolveRequiredWritePath( + WavePaths::EPathDomain::UserSaved, + "Traces/root.marker").parent_path(); std::filesystem::create_directories(TracesPath, Ec); const std::filesystem::path AbsolutePath = std::filesystem::absolute(TracesPath, Ec); // path::wstring() 直接产出原生宽字符路径;绝不能走 path::string()(ACP diff --git a/Engine/Source/UI/Panels/ViewportPanel.cpp b/Engine/Source/UI/Panels/ViewportPanel.cpp index 8fefbea..d51505a 100644 --- a/Engine/Source/UI/Panels/ViewportPanel.cpp +++ b/Engine/Source/UI/Panels/ViewportPanel.cpp @@ -1,11 +1,12 @@ #include "ViewportPanel.h" +#include "Developer/Document/EditorDomainServices.h" +#include "Developer/Render/RenderSettingsAuthoringService.h" +#include "Developer/Scene/CameraAuthoringService.h" #include "UI/EditorCommands.h" #include "UI/EditorStyle.h" #include "UI/EditorWidgets.h" #include "UI/ImGuiLayer.h" -#include "Mcp/McpRegistry.h" -#include "Mcp/McpValidation.h" #include "Scene/Scene.h" #include "Scene/Camera.h" #include "Scene/Instance.h" @@ -21,7 +22,6 @@ #include #include #include -#include #include namespace @@ -489,14 +489,29 @@ void FViewportPanel::DrawViewportToolbar(const ImVec2& ImagePosition, const ImVe ImGui::SameLine(0.0f, 12.0f * Scale); } - FScene* Scene = Context.Scene.get(); - int DebugMode = static_cast(Scene->DebugViewMode); + check( + Context.DomainServices != nullptr, + "Viewport toolbar requires initialized RenderSettingsAuthoring"); + WaveEditorDomain::FRenderSettingsAuthoringService& Authoring = + Context.DomainServices->RenderSettingsAuthoring(); + WaveEditorDomain::FEditorRenderSettingsState Settings = + Authoring.GetSettings().Value; + int DebugMode = static_cast(Settings.DebugView); const char* DebugModes[] = {"Lit", "Meshlets", "Groups"}; ImGui::SetNextItemWidth((bCompact ? 102.0f : 112.0f) * Scale); if (ImGui::Combo("##ViewportDebugMode", &DebugMode, DebugModes, IM_ARRAYSIZE(DebugModes))) { - Scene->DebugViewMode = static_cast(DebugMode); - Context.MarkSettingsDirty(); + Settings.DebugView = + static_cast( + DebugMode); + const WaveEditorDomain::FEditorCommandContext CommandContext = + Context.DomainServices->MakeUiCommandContext( + WaveEditorDomain::RenderSettingsDomain, + "editor.viewport.set_debug_view"); + (void)Authoring.SetSettings( + Settings, + CommandContext, + "editor.viewport.set_debug_view"); } if (Context.SelectedType == FUiContext::ESelectionType::Instance && Context.SelectedInstance) { @@ -1259,16 +1274,37 @@ void FViewportPanel::FocusSelection() const FVector3 Center = (BoundsMin + BoundsMax) * 0.5f; const float Radius = std::max(Length(BoundsMax - BoundsMin) * 0.5f, 0.5f); FCamera& Camera = Context.Scene->Camera; - const FCameraTransformSnapshot Before = FCameraTransformSnapshot::Capture(Camera); - FCameraTransformSnapshot After = Before; - After.Position = Center - Camera.Rotation.Vector() * (Radius * 2.5f); - const FVector3 Direction = Normalize(Center - After.Position); + check( + Context.DomainServices != nullptr, + "Frame Selected requires initialized CameraAuthoring"); + const WaveEditorDomain::FEditorCameraState Before = + Context.DomainServices->CameraAuthoring() + .GetCamera().Value.Transform; + WaveEditorDomain::FEditorCameraState After = Before; + const FVector3 NewPosition = + Center - Camera.Rotation.Vector() * (Radius * 2.5f); + After.Position = { + NewPosition.x, + NewPosition.y, + NewPosition.z + }; + const FVector3 Direction = Normalize(Center - NewPosition); constexpr float RadiansToDegrees = 180.0f / PI; - After.Rotation = FRotator( - std::asin(std::clamp(Direction.z, -1.0f, 1.0f)) * RadiansToDegrees, + After.Rotation = { + std::asin(std::clamp(Direction.z, -1.0f, 1.0f)) + * RadiansToDegrees, std::atan2(Direction.y, Direction.x) * RadiansToDegrees, - 0.0f); - EditorCommands::SetCameraTransform(Context, Before, After, "Frame Selected"); + 0.0 + }; + const WaveEditorDomain::FEditorCommandContext CommandContext = + Context.DomainServices->MakeUiCommandContext( + WaveEditorDomain::SceneDomain, + "editor.viewport.camera.look_at"); + (void)Context.DomainServices->CameraAuthoring().SetCamera( + After, + CommandContext, + "Frame Selected", + "editor.viewport.camera.look_at"); } void FViewportPanel::CommitGizmoEdit() @@ -1299,7 +1335,6 @@ void FViewportPanel::CommitGizmoEdit() GizmoEditInstance.reset(); GizmoAccumulatedDelta = 0.0f; } - void FViewportPanel::CancelGizmoEdit() { if (GizmoEditInstance) @@ -1310,138 +1345,3 @@ void FViewportPanel::CancelGizmoEdit() GizmoEditInstance.reset(); GizmoAccumulatedDelta = 0.0f; } - -void FViewportPanel::RegisterMcpTools(FMcpRegistry& Registry) -{ - Registry.AddReadOnly( - "editor.viewport.camera.get", - "Read the editor camera transform and projection", - {}, - this, - &FViewportPanel::McpCameraGet); - Registry.Add( - "editor.viewport.camera.set", - "Set the editor camera position and Pitch/Yaw/Roll rotation", - { "position", "rotation_deg" }, - this, - &FViewportPanel::McpCameraSet); - Registry.Add( - "editor.viewport.camera.move", - "Move the editor camera by a world-space delta", - { "delta_world" }, - this, - &FViewportPanel::McpCameraMove); - Registry.Add( - "editor.viewport.camera.look_at", - "Rotate the editor camera to face a world-space target", - { "target" }, - this, - &FViewportPanel::McpCameraLookAt); - Registry.Add( - "editor.viewport.set_debug_view", - "Set the renderer debug visualization mode", - { "mode" }, - this, - &FViewportPanel::McpSetDebugView); -} - -FJson FViewportPanel::McpCameraGet() -{ - FCamera* Cam = FCamera::GetInstance(); - if (!Cam) - { - throw std::runtime_error("Camera not initialized"); - } - FJson J; - J["position"] = TJsonSerializer::ToJson(Cam->Position); - J["rotation_deg"] = FJson::array({ Cam->Rotation.Pitch, Cam->Rotation.Yaw, Cam->Rotation.Roll }); - J["coordinate_system"] = "LH_XForward_YRight_ZUp"; - J["fov"] = Cam->GetFov(); - J["near"] = Cam->GetNearPlane(); - J["far"] = Cam->GetFarPlane(); - return J; -} - -void FViewportPanel::McpCameraSet(FVector3 Position, FVector3 RotationDeg) -{ - FCamera* Cam = FCamera::GetInstance(); - if (!Cam) - { - throw std::runtime_error("Camera not initialized"); - } - McpValidation::RequireFinite(Position, "position"); - const FVector3 SafeRotation = McpValidation::NormalizeEulerDegrees(RotationDeg, "rotation_deg"); - const FCameraTransformSnapshot Before = FCameraTransformSnapshot::Capture(*Cam); - FCameraTransformSnapshot After = Before; - After.Position = Position; - After.Rotation = FRotator(SafeRotation.x, SafeRotation.y, SafeRotation.z); - EditorCommands::SetCameraTransform(Context, Before, After, "Set Camera"); -} - -void FViewportPanel::McpCameraMove(FVector3 DeltaWorld) -{ - FCamera* Cam = FCamera::GetInstance(); - if (!Cam) - { - throw std::runtime_error("Camera not initialized"); - } - McpValidation::RequireFinite(DeltaWorld, "delta_world"); - const FVector3 NewPosition = Cam->Position + DeltaWorld; - McpValidation::RequireFinite(NewPosition, "camera position"); - const FCameraTransformSnapshot Before = FCameraTransformSnapshot::Capture(*Cam); - FCameraTransformSnapshot After = Before; - After.Position = NewPosition; - EditorCommands::SetCameraTransform(Context, Before, After, "Move Camera"); -} - -void FViewportPanel::McpCameraLookAt(FVector3 Target) -{ - // Match UE FRotator::Vector(): +X forward, +Y right, +Z up. - FCamera* Cam = FCamera::GetInstance(); - if (!Cam) - { - throw std::runtime_error("Camera not initialized"); - } - - McpValidation::RequireFinite(Target, "target"); - McpValidation::RequireFinite(Cam->Position, "camera position"); - const double DeltaX = static_cast(Target.x) - static_cast(Cam->Position.x); - const double DeltaY = static_cast(Target.y) - static_cast(Cam->Position.y); - const double DeltaZ = static_cast(Target.z) - static_cast(Cam->Position.z); - const double Len = std::hypot(DeltaX, DeltaY, DeltaZ); - if (!std::isfinite(Len) || Len < 1e-6) - { - throw std::runtime_error("Target equals camera position"); - } - const FVector3 Fwd( - static_cast(DeltaX / Len), - static_cast(DeltaY / Len), - static_cast(DeltaZ / Len)); - - const float PitchRad = std::asin(std::clamp(Fwd.z, -1.0f, 1.0f)); - const float YawRad = std::atan2(Fwd.y, Fwd.x); - - constexpr float RadToDeg = 180.0f / PI; - const FCameraTransformSnapshot Before = FCameraTransformSnapshot::Capture(*Cam); - FCameraTransformSnapshot After = Before; - After.Rotation = FRotator(PitchRad * RadToDeg, YawRad * RadToDeg, 0.0f); - EditorCommands::SetCameraTransform(Context, Before, After, "Aim Camera"); -} - -void FViewportPanel::McpSetDebugView(std::string Mode) -{ - FScene* Scene = Context.Scene.get(); - if (!Scene) - { - throw std::runtime_error("Scene not initialized"); - } - - uint32 NewMode = 0; - if (Mode == "none") NewMode = 0; - else if (Mode == "meshlet_index") NewMode = 1; - else if (Mode == "group_id") NewMode = 2; - else throw std::runtime_error("Unknown debug view mode: " + Mode); - - Scene->DebugViewMode = NewMode; - Context.MarkSettingsDirty(); -} diff --git a/Engine/Source/UI/Panels/ViewportPanel.h b/Engine/Source/UI/Panels/ViewportPanel.h index 4d5f6bb..0dbb999 100644 --- a/Engine/Source/UI/Panels/ViewportPanel.h +++ b/Engine/Source/UI/Panels/ViewportPanel.h @@ -2,11 +2,9 @@ #include "UiPanelBase.h" #include "UI/ImGuiLayer.h" -#include "Mcp/JsonSerializer.h" #include -class FMcpRegistry; class FD3D12DescriptorHeap; class FViewportPanel : public FUiPanel @@ -18,16 +16,6 @@ class FViewportPanel : public FUiPanel void FlushPendingEdits() override; void OnVisibilityChanged(bool bVisible) override; - void RegisterMcpTools(FMcpRegistry& Registry) override; - - // MCP tools - // rotation_deg follows UE FRotator order: [Pitch, Yaw, Roll]. - FJson McpCameraGet(); - void McpCameraSet(FVector3 Position, FVector3 RotationDeg); - void McpCameraMove(FVector3 DeltaWorld); - void McpCameraLookAt(FVector3 Target); - void McpSetDebugView(std::string Mode); - private: enum class EGizmoOperation : uint8 { diff --git a/Engine/Source/UI/UiConfig.cpp b/Engine/Source/UI/UiConfig.cpp index e5dee96..b1d1b3c 100644 --- a/Engine/Source/UI/UiConfig.cpp +++ b/Engine/Source/UI/UiConfig.cpp @@ -1,24 +1,51 @@ #include "UiConfig.h" -#include "Misc/Utils.h" +#include "Core/Paths/AtomicFileStore.h" +#include "Core/Paths/PathService.h" +#include "Misc/Log.h" #include #include +#include +#include #include #include namespace { - std::filesystem::path GetConfigPath() + std::optional GetConfigReadPath() { - return std::filesystem::path(ENGINE_TEMP_FOLDER) / "ui_config.cfg"; + const WavePaths::FPathService& Paths = WavePaths::GetPathService(); + const WavePaths::FPathResolveResult Current = Paths.ResolveRead( + Paths.MakeRef( + WavePaths::EPathDomain::ProjectSaved, + "Config/ui_config.cfg")); + if (Current.Resolved) + { + return Current.Resolved->NativePath; + } + const WavePaths::FPathResolveResult Legacy = Paths.ResolveRead( + Paths.MakeRef( + WavePaths::EPathDomain::EngineContent, + "Save/ui_config.cfg")); + if (Legacy.Resolved) + { + Log::Warning( + "Loading legacy Engine/Save/ui_config.cfg read-only; future saves use ProjectSaved."); + return Legacy.Resolved->NativePath; + } + return std::nullopt; } } bool FUiConfig::Load(FUiConfig& OutConfig) { - const auto Path = GetConfigPath(); - std::ifstream In(Path); + const std::optional Path = GetConfigReadPath(); + if (!Path) + { + return false; + } + std::ifstream In(*Path); if (!In) { return false; @@ -133,7 +160,6 @@ bool FUiConfig::Load(FUiConfig& OutConfig) bool FUiConfig::Save(const FUiConfig& Config) { - const auto Path = GetConfigPath(); std::ostringstream Out; Out << "CoordinateSystemVersion " << CurrentCoordinateSystemVersion << "\n"; @@ -159,9 +185,16 @@ bool FUiConfig::Save(const FUiConfig& Config) } const std::string Text = Out.str(); - return Utils::StoreFile( - Path, - reinterpret_cast(Text.data()), - Text.size(), - "wb"); + const WavePaths::FPathService& Paths = WavePaths::GetPathService(); + const WavePaths::FAtomicStoreResult Stored = WavePaths::AtomicStoreBytes( + Paths, + { + Paths.MakeRef( + WavePaths::EPathDomain::ProjectSaved, + "Config/ui_config.cfg"), + std::span( + reinterpret_cast(Text.data()), + Text.size()), + }); + return Stored.Evidence.bCommitted; } diff --git a/Engine/Source/UI/UiContext.cpp b/Engine/Source/UI/UiContext.cpp index 722ad2f..14e5a68 100644 --- a/Engine/Source/UI/UiContext.cpp +++ b/Engine/Source/UI/UiContext.cpp @@ -9,6 +9,41 @@ namespace { return static_cast(Domain); } + + FInstanceTransformSnapshot ToRuntimeSnapshot( + const WaveEditorDomain::FEditorTransform& Transform) + { + return { + FVector3( + static_cast(Transform.Position.X), + static_cast(Transform.Position.Y), + static_cast(Transform.Position.Z)), + FQuat( + static_cast(Transform.Rotation.X), + static_cast(Transform.Rotation.Y), + static_cast(Transform.Rotation.Z), + static_cast(Transform.Rotation.W)), + FVector3( + static_cast(Transform.Scale.X), + static_cast(Transform.Scale.Y), + static_cast(Transform.Scale.Z)) + }; + } + + FCameraTransformSnapshot ToRuntimeSnapshot( + const WaveEditorDomain::FEditorCameraState& Camera) + { + return { + FVector3( + static_cast(Camera.Position.X), + static_cast(Camera.Position.Y), + static_cast(Camera.Position.Z)), + FRotator( + static_cast(Camera.Rotation.Pitch), + static_cast(Camera.Rotation.Yaw), + static_cast(Camera.Rotation.Roll)) + }; + } } void FEditorDocumentState::MarkDirty(EEditorDirtyDomain Domain) @@ -76,3 +111,108 @@ void FUiContext::MarkLayoutDirty() { DocumentState.MarkDirty(EEditorDirtyDomain::Layout); } + +FScene* FUiContext::GetEditorScene() const +{ + return Scene.get(); +} + +void FUiContext::SelectEditorInstance( + const TRefCountPtr& Instance) +{ + Select(ESelectionType::Instance, Instance); +} + +void FUiContext::ClearEditorSelection() +{ + ClearSelection(); +} + +void FUiContext::ApplyAddInstance( + const TRefCountPtr& Instance, + std::string_view Label) +{ + const std::string OwnedLabel(Label); + EditorCommands::AddInstance( + *this, + Instance, + OwnedLabel.c_str()); +} + +void FUiContext::ApplyRemoveInstance( + const TRefCountPtr& Instance) +{ + EditorCommands::RemoveInstance(*this, Instance); +} + +void FUiContext::ApplyRenameInstance( + const TRefCountPtr& Instance, + std::string Name) +{ + EditorCommands::RenameInstance( + *this, + Instance, + std::move(Name)); +} + +void FUiContext::ApplyInstanceTransform( + const TRefCountPtr& Instance, + const WaveEditorDomain::FEditorTransform& Before, + const WaveEditorDomain::FEditorTransform& After, + std::string_view Label, + bool bAlreadyExecuted) +{ + const std::string OwnedLabel(Label); + EditorCommands::SetInstanceTransform( + *this, + Instance, + ToRuntimeSnapshot(Before), + ToRuntimeSnapshot(After), + OwnedLabel.c_str(), + bAlreadyExecuted); +} + +void FUiContext::ApplyCameraTransform( + const WaveEditorDomain::FEditorCameraState& Before, + const WaveEditorDomain::FEditorCameraState& After, + std::string_view Label, + bool bAlreadyExecuted) +{ + const std::string OwnedLabel(Label); + EditorCommands::SetCameraTransform( + *this, + ToRuntimeSnapshot(Before), + ToRuntimeSnapshot(After), + OwnedLabel.c_str(), + bAlreadyExecuted); +} + +void FUiContext::MarkEditorSceneDirty() +{ + MarkSceneDirty(); +} + +void FUiContext::MarkEditorSettingsDirty() +{ + MarkSettingsDirty(); +} + +bool FUiContext::CanUndoEditorCommand() const +{ + return CommandStack.CanUndo(); +} + +bool FUiContext::CanRedoEditorCommand() const +{ + return CommandStack.CanRedo(); +} + +void FUiContext::UndoEditorCommand() +{ + CommandStack.Undo(*this); +} + +void FUiContext::RedoEditorCommand() +{ + CommandStack.Redo(*this); +} diff --git a/Engine/Source/UI/UiContext.h b/Engine/Source/UI/UiContext.h index e28b935..596537a 100644 --- a/Engine/Source/UI/UiContext.h +++ b/Engine/Source/UI/UiContext.h @@ -1,12 +1,18 @@ #pragma once #include "Core/Core.h" +#include "Developer/Scene/SceneAuthoringService.h" #include "EditorAssetLoader.h" #include "EditorCommands.h" class FScene; class FInstance; +namespace WaveEditorDomain +{ + class FEditorDomainServices; +} + enum class EEditorDirtyDomain : uint8 { None = 0, @@ -28,7 +34,7 @@ class FEditorDocumentState uint8 DirtyMask = 0; }; -struct FUiContext +struct FUiContext : public WaveEditorDomain::IEditorAuthoringHost { enum class ESelectionType : uint8 { @@ -55,6 +61,7 @@ struct FUiContext FEditorDocumentState DocumentState; FEditorCommandStack CommandStack; FEditorAssetLoader AssetLoader; + WaveEditorDomain::FEditorDomainServices* DomainServices = nullptr; void ClearSelection(); bool IsSelected(ESelectionType InType, const TRefCountPtr& InInstance = nullptr) const; @@ -62,4 +69,34 @@ struct FUiContext void MarkSceneDirty(); void MarkSettingsDirty(); void MarkLayoutDirty(); + + [[nodiscard]] FScene* GetEditorScene() const override; + void SelectEditorInstance( + const TRefCountPtr& Instance) override; + void ClearEditorSelection() override; + void ApplyAddInstance( + const TRefCountPtr& Instance, + std::string_view Label) override; + void ApplyRemoveInstance( + const TRefCountPtr& Instance) override; + void ApplyRenameInstance( + const TRefCountPtr& Instance, + std::string Name) override; + void ApplyInstanceTransform( + const TRefCountPtr& Instance, + const WaveEditorDomain::FEditorTransform& Before, + const WaveEditorDomain::FEditorTransform& After, + std::string_view Label, + bool bAlreadyExecuted) override; + void ApplyCameraTransform( + const WaveEditorDomain::FEditorCameraState& Before, + const WaveEditorDomain::FEditorCameraState& After, + std::string_view Label, + bool bAlreadyExecuted) override; + void MarkEditorSceneDirty() override; + void MarkEditorSettingsDirty() override; + [[nodiscard]] bool CanUndoEditorCommand() const override; + [[nodiscard]] bool CanRedoEditorCommand() const override; + void UndoEditorCommand() override; + void RedoEditorCommand() override; }; diff --git a/Engine/Source/WaveEditor.cpp b/Engine/Source/WaveEditor.cpp index 5a79498..be9f402 100644 --- a/Engine/Source/WaveEditor.cpp +++ b/Engine/Source/WaveEditor.cpp @@ -1,23 +1,4 @@ -#include "Window.h" -#include "Input/InputSystem.h" -#include "RHI/DynamicRHI.h" -#include "RHI/RHIThread.h" -#include "Renderer/Renderer.h" -#include "Scene/Scene.h" -#include "Shader/Shader.h" -#include "Core/Profiler.h" -#include "Core/Engine.h" -#include "Misc/Log.h" -#include "Mcp/McpServer.h" -#include "Mcp/MainThreadDispatcher.h" -#include "RenderGraph/RenderGraphResourcePool.h" -#include "Trace/TraceRecorder.h" -#include "Developer/RenderDocIntegration.h" -#include "UI/EditorFrontend.h" - -#include -#include -#include +#include "UI/EditorApplication.h" #if defined(_WIN32) #include @@ -41,186 +22,5 @@ namespace int main() { HidePrivateConsoleWindow(); - - if (!Log::OpenLog("WaveEngine.log")) - { - std::cerr << "Warning: failed to open WaveEngine.log; continuing with console logging.\n"; - } - int ExitCode = 0; - TRefCountPtr Window; - TRefCountPtr Scene; - std::unique_ptr Renderer; - std::unique_ptr EditorFrontend; - - try - { - uint32 Width = 1600, Height = 900; - - FWindow::Init(Width, Height); - Window = FWindow::GetInstance(); - FInputSystem::Init(Window->GetGLFWWindow()); - FRenderDocIntegration::Get().Initialize(); - - FDynamicRHI::Init(); - FShaderMap::Init(); - Scene = FScene::Init(); - Renderer = std::make_unique(); - EditorFrontend = std::make_unique(); - EditorFrontend->Init(); - FFPSCounter FPSCounter; - SET_THREAD_NAME("Main Thread"); - - // MCP server 必须在 Editor frontend 初始化完(所有 Panel 工具注册完) - // 之后启动,否则 server 线程可能在 Tools map 还在被填的中间读到坏 bucket。 - FMcpServer::Get().StartStdio(); - - while (!Window->ShouldClose()) - { - FRHIThread::RethrowIfFailed(); - MARK_FRAME(); - { - SCOPED_CPU_EVENT("Frame"); - - { - SCOPED_CPU_EVENT("Input"); - FInputSystem::Get().BeginFrame(); - Window->PollEvents(); - } - { - SCOPED_CPU_EVENT("MCP"); - FMainThreadDispatcher::Get().Drain(5.0); - } - - { - SCOPED_CPU_EVENT("Scene Tick"); - const double FrameTimeMs = GetCurrentFrameTimeMs(); - const float DeltaTimeSeconds = static_cast((FrameTimeMs > 0.0 ? FrameTimeMs : 16.6666666667) * 0.001); - Scene->Tick(DeltaTimeSeconds, EditorFrontend->GetViewInput()); - } - { - SCOPED_CPU_EVENT("Renderer"); - FRenderDocIntegration& RenderDoc = FRenderDocIntegration::Get(); - void* NativeWindow = glfwGetWin32Window(Window->GetGLFWWindow()); - const bool bRenderDocCaptureStarted = !Window->IsMinimized() - && RenderDoc.BeginFrameCapture(NativeWindow); - try - { - EditorFrontend->Render(*Renderer); - if (bRenderDocCaptureStarted) - { - FRHIThread::WaitForLastPresent(); - RenderDoc.EndFrameCapture(NativeWindow); - } - } - catch (...) - { - if (bRenderDocCaptureStarted) - { - RenderDoc.CancelFrameCapture(NativeWindow); - } - throw; - } - } - - FPSCounter.Tick(); - GlobalContext.FrameIndex++; - } - } - } - catch (const std::exception& E) - { - Log::Error("Fatal std::exception: ", E.what()); - ExitCode = 1; - } - catch (...) - { - Log::Error("Fatal unknown exception."); - ExitCode = 1; - } - - // Renderer 与 Editor frontend 必须跨过 catch 保持存活:延迟命令可能仍引用 ImGui backend。 - // 先拒绝/取消外部请求,再排空 RHI/GPU,最后释放 UI 与场景 GPU 资源。 - FMainThreadDispatcher::Get().Shutdown(); - FMcpServer::Get().Shutdown(); - WaveTrace::FTraceRecorder::Get().Stop(); - - bool bGpuIdleProven = GDynamicRHI == nullptr; - if (GDynamicRHI) - { - try - { - GDynamicRHI->WaitForGpuIdle(); - bGpuIdleProven = true; - } - catch (const std::exception& E) - { - Log::Error("Failed to wait for GPU idle during shutdown: ", E.what()); - ExitCode = 1; - FRHIThread::Stop(); - } - catch (...) - { - Log::Error("Failed to wait for GPU idle during shutdown: unknown exception."); - ExitCode = 1; - FRHIThread::Stop(); - } - } - - if (!bGpuIdleProven) - { - // A failed fence cannot prove that ImGui backend allocations, descriptors, - // scene resources, or command allocators are no longer in use. Do not run - // their destructors. Keep the submitted command-list slots quarantined and - // terminate without C++ stack/static teardown; the OS owns final reclamation. - Log::Error("GPU idle was not proven; quarantining the GPU object graph and terminating without teardown."); - Log::CloseLog(); - std::_Exit(ExitCode != 0 ? ExitCode : 1); - } - - if (EditorFrontend) - { - EditorFrontend->Shutdown(); - EditorFrontend.reset(); - } - if (Renderer) - { - Renderer->Shutdown(); - Renderer.reset(); - } - FRenderGraphResourcePool::Get()->Reset(); - Scene.reset(); - FScene::Shutdown(); - FShaderMap::Shutdown(); - FInputSystem::Shutdown(); - - bool bRhiDestroyed = GDynamicRHI == nullptr; - if (GDynamicRHI) - { - try - { - FDynamicRHI::Destroy(); - bRhiDestroyed = true; - } - catch (const std::exception& E) - { - Log::Error("Failed to shut down RHI: ", E.what()); - ExitCode = 1; - } - catch (...) - { - Log::Error("Failed to shut down RHI: unknown exception."); - ExitCode = 1; - } - } - if (!bRhiDestroyed) - { - Log::Error("RHI teardown did not complete; quarantining the backend and terminating without further teardown."); - Log::CloseLog(); - std::_Exit(ExitCode != 0 ? ExitCode : 1); - } - - Window.reset(); - FWindow::Shutdown(); - Log::CloseLog(); - return ExitCode; + return RunEditorApplication(); } diff --git a/Tests/ApplicationContracts.cpp b/Tests/ApplicationContracts.cpp new file mode 100644 index 0000000..1f984c5 --- /dev/null +++ b/Tests/ApplicationContracts.cpp @@ -0,0 +1,692 @@ +#include "ApplicationContracts.h" + +#include "Core/Application/ApplicationClock.h" +#include "Core/Application/ApplicationConfig.h" +#include "Core/Application/ApplicationLifecycle.h" +#include "Core/Application/ApplicationRunner.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace WaveContracts +{ + namespace + { + using namespace WaveApplication; + + void Require(bool bCondition, std::string_view Message) + { + if (!bCondition) + { + throw std::runtime_error(std::string(Message)); + } + } + + bool HasDiagnostic( + const std::vector& Diagnostics, + std::string_view Code, + std::string_view Path = {}) + { + for (const FApplicationDiagnostic& Diagnostic : Diagnostics) + { + if (Diagnostic.Code == Code && (Path.empty() || Diagnostic.Path == Path)) + { + return true; + } + } + return false; + } + + void RunConfigContracts() + { + const std::span Schema = + DescribeApplicationConfig(); + Require(Schema.size() == 17, "application config schema field count"); + Require(Schema.front().Name == "schema_major", "application config schema ordering"); + + FApplicationConfig Editor = MakeDefaultApplicationConfig(EApplicationMode::Editor); + Require(ValidateApplicationConfig(Editor).empty(), "default editor config validation"); + Require(Editor.Window.Width == 1600 && Editor.Window.Height == 900, + "default editor extent"); + Require(!Editor.Timing.FixedDeltaSeconds, "editor fixed delta disabled"); + + FApplicationConfig Contract = MakeDefaultApplicationConfig(EApplicationMode::ContractTest); + Require(ValidateApplicationConfig(Contract).empty(), "default contract config validation"); + Require(!Contract.Window.bEnabled && !Contract.Render.bEnabled, + "contract mode must be headless"); + Require(Contract.Limits.MaxTicks == 1, "contract mode finite default"); + + const uint64 BeforeHash = ComputeApplicationConfigHash(Contract); + const FApplicationConfigResult Applied = ApplyApplicationConfigFields( + Contract, + std::array{ + FApplicationConfigField{"timing.fixed_delta_seconds", "0.02"}, + FApplicationConfigField{"limits.max_ticks", "4"}, + FApplicationConfigField{"services", "contract.alpha,contract.beta"}, + }, + EApplicationConfigSource::ExplicitOverride); + Require(Applied.bApplied && !Applied.HasErrors(), "valid config override"); + Require(Contract.Timing.FixedDeltaSeconds == 0.02, "fixed delta override"); + Require(Contract.EnabledServices.size() == 2, "service list override"); + Require(ComputeApplicationConfigHash(Contract) != BeforeHash, "config hash changes"); + Require(ComputeApplicationConfigHash(Contract) == ComputeApplicationConfigHash(Contract), + "config hash deterministic"); + + const FApplicationConfig BeforeInvalid = Contract; + const FApplicationConfigResult Unknown = ApplyApplicationConfigFields( + Contract, + std::array{FApplicationConfigField{"future.magic", "1"}}, + EApplicationConfigSource::ExplicitOverride); + Require(!Unknown.bApplied && Unknown.HasErrors(), "unknown config field rejected"); + Require(HasDiagnostic(Unknown.Diagnostics, "application.config.unknown_key", "future.magic"), + "unknown config diagnostic"); + Require(ComputeApplicationConfigHash(Contract) == ComputeApplicationConfigHash(BeforeInvalid), + "invalid config layer is atomic"); + + const FApplicationConfigResult WrongType = ApplyApplicationConfigFields( + Contract, + std::array{FApplicationConfigField{"limits.max_ticks", "-1"}}, + EApplicationConfigSource::ExplicitOverride); + Require(!WrongType.bApplied + && HasDiagnostic(WrongType.Diagnostics, "application.config.wrong_type"), + "wrong config type rejected"); + + FApplicationConfig InvalidEditor = Editor; + InvalidEditor.Timing.FixedDeltaSeconds = 1.0 / 60.0; + Require(HasDiagnostic( + ValidateApplicationConfig(InvalidEditor), + "application.config.mode_restriction", + "timing.fixed_delta_seconds"), + "fixed delta restricted to contract mode"); + + FApplicationConfig InvalidContract = Contract; + InvalidContract.Render.bEnabled = true; + Require(HasDiagnostic( + ValidateApplicationConfig(InvalidContract), + "application.config.feature_dependency", + "render.enabled"), + "headless render dependency rejected"); + + FApplicationConfig DuplicateServices = Contract; + DuplicateServices.EnabledServices = {"duplicate", "duplicate"}; + Require(HasDiagnostic( + ValidateApplicationConfig(DuplicateServices), + "application.config.duplicate_service"), + "duplicate service rejected"); + + FApplicationConfig UnknownMode = Contract; + UnknownMode.Mode = static_cast(255); + Require(HasDiagnostic( + ValidateApplicationConfig(UnknownMode), + "application.config.unknown_mode"), + "unknown application mode rejected"); + + Require(ParseApplicationMode("editor") == EApplicationMode::Editor, + "editor mode parser"); + Require(!ParseApplicationMode("player"), "unimplemented player mode unavailable"); + } + + void RunClockContracts() + { + FApplicationTimingConfig VariableConfig; + VariableConfig.MaxDeltaSeconds = 0.25; + FApplicationClock VariableClock(VariableConfig); + const FApplicationClockResult First = VariableClock.Tick(10.0); + Require(First.Sample.has_value(), "first clock sample"); + Require(First.Sample->bFirstTick, "first clock flag"); + Require(std::abs(First.Sample->GameDeltaSeconds - 1.0 / 60.0) < 1e-9, + "first game delta"); + + const FApplicationClockResult Clamped = VariableClock.Tick(11.0); + Require(Clamped.Sample && Clamped.Sample->bClamped, "variable delta clamped"); + Require(Clamped.Sample->GameDeltaSeconds == 0.25, "clamped game delta value"); + Require(HasDiagnostic(Clamped.Diagnostics, "application.clock.delta_clamped"), + "clock clamp warning"); + + const FApplicationClockResult Regressed = VariableClock.Tick(10.5); + Require(!Regressed.Sample + && HasDiagnostic(Regressed.Diagnostics, "application.clock.regressed"), + "clock regression rejected"); + const FApplicationClockResult NonFinite = + VariableClock.Tick(std::numeric_limits::quiet_NaN()); + Require(!NonFinite.Sample + && HasDiagnostic(NonFinite.Diagnostics, "application.clock.non_finite"), + "non-finite clock rejected"); + + FApplicationTimingConfig FixedConfig; + FixedConfig.FixedDeltaSeconds = 0.02; + FApplicationClock FixedClock(FixedConfig); + Require(FixedClock.Tick(1.0).Sample->GameDeltaSeconds == 0.02, + "fixed delta first tick"); + const FApplicationClockResult FixedSecond = FixedClock.Tick(1.001); + Require(FixedSecond.Sample->bFixed + && FixedSecond.Sample->GameDeltaSeconds == 0.02, + "fixed game delta independent of real delta"); + + bool bRejectedInvalidClockConfig = false; + try + { + FApplicationTimingConfig InvalidConfig; + InvalidConfig.MaxDeltaSeconds = 0.0; + (void)FApplicationClock(InvalidConfig); + } + catch (const std::invalid_argument&) + { + bRejectedInvalidClockConfig = true; + } + Require(bRejectedInvalidClockConfig, "invalid clock config rejected"); + } + + void RunLifecycleContracts() + { + FApplicationConfig Config = MakeDefaultApplicationConfig(EApplicationMode::ContractTest); + FApplicationLifecycle Lifecycle("contract-session"); + Require(Lifecycle.QueryStatus().Revision == 0, "initial lifecycle revision"); + Lifecycle.Configure(Config); + Lifecycle.BeginInitializing(); + Lifecycle.MarkRunning(); + const uint64 RunningRevision = Lifecycle.QueryStatus().Revision; + Lifecycle.RecordCompletedTick(false); + Require(Lifecycle.QueryStatus().TickSequence == 1, "tick sequence"); + Require(Lifecycle.QueryStatus().CompletedFrameCount == 0, "headless frame identity"); + + Require(Lifecycle.RequestStop({"contract", "max_ticks", 1.0, 1}), + "first stop request"); + const FApplicationStopRecord FirstStop = *Lifecycle.QueryStatus().FirstStop; + Require(Lifecycle.RequestStop({"later", "ignored", 2.0, 1}), + "second stop request is acknowledged"); + Require(Lifecycle.QueryStatus().FirstStop->Source == FirstStop.Source + && Lifecycle.QueryStatus().FirstStop->Reason == FirstStop.Reason, + "first stop reason immutable"); + Require(Lifecycle.QueryStatus().Revision > RunningRevision, + "lifecycle revision monotonic"); + Lifecycle.MarkFailed({ + "application.runtime.failure_after_stop", + "runtime", + "Injected fatal after normal stop request.", + EApplicationDiagnosticSeverity::Fatal, + false, + }); + Require(Lifecycle.QueryStatus().FirstStop->Reason == FirstStop.Reason, + "fatal severity preserves first stop reason"); + Require(Lifecycle.QueryStatus().ExitCode != 0, "fatal raises exit severity"); + Lifecycle.BeginDraining(); + Lifecycle.MarkGpuIdleProven(); + Lifecycle.MarkStopped(); + Require(Lifecycle.QueryStatus().State == EApplicationLifecycleState::Stopped, + "normal lifecycle terminal state"); + Require(Lifecycle.QueryStatus().bGpuIdleProven, "normal lifecycle idle proof"); + + FApplicationLifecycle Failure("failure-session"); + Failure.Configure(Config); + Failure.BeginInitializing(); + Failure.MarkFailed({ + "application.service.init_failed", + "services.fake", + "Injected initialization failure.", + EApplicationDiagnosticSeverity::Fatal, + false, + }); + Failure.BeginDraining(); + Failure.MarkQuarantined({ + "application.gpu.idle_unproven", + "gpu_idle", + "Injected idle failure.", + EApplicationDiagnosticSeverity::Fatal, + false, + }); + Require(Failure.QueryStatus().State == EApplicationLifecycleState::Quarantined, + "quarantine terminal state"); + Require(Failure.QueryStatus().ExitCode != 0, "quarantine nonzero exit"); + + bool bRejectedInvalidTransition = false; + try + { + Failure.MarkStopped(); + } + catch (const std::logic_error&) + { + bRejectedInvalidTransition = true; + } + Require(bRejectedInvalidTransition, "terminal lifecycle transition rejected"); + } + + class FFakeApplicationService final : public IApplicationService + { + public: + FFakeApplicationService( + FApplicationServiceDescriptor InDescriptor, + std::vector& InEvents) + : Descriptor(std::move(InDescriptor)) + , Events(InEvents) + { + } + + const FApplicationServiceDescriptor& GetDescriptor() const override + { + return Descriptor; + } + + void Initialize(const FApplicationConfig&) override + { + Events.push_back("init:" + Descriptor.ServiceId); + if (bFailInitialize) + { + throw std::runtime_error("injected init failure"); + } + } + + FApplicationServiceTickResult Tick(const FApplicationTickContext& Context) override + { + Events.push_back( + "tick:" + Descriptor.ServiceId + ":" + std::to_string(static_cast(Context.Phase))); + if (bFailTick) + { + throw std::runtime_error("injected tick failure"); + } + FApplicationServiceTickResult Result; + Result.bCompletedRenderFrame = + Context.Phase == EApplicationTickPhase::RenderPresent && bCompletesRenderFrame; + if (bRequestStop && Context.Phase == StopPhase) + { + Result.StopRequest = FApplicationStopRequest{ + Descriptor.ServiceId, + "service_requested_stop", + }; + } + return Result; + } + + void RequestStop() override + { + Events.push_back("stop:" + Descriptor.ServiceId); + } + + void Drain() override + { + Events.push_back("drain:" + Descriptor.ServiceId); + } + + void Shutdown() override + { + Events.push_back("shutdown:" + Descriptor.ServiceId); + } + + FApplicationServiceDescriptor Descriptor; + std::vector& Events; + bool bFailInitialize = false; + bool bFailTick = false; + bool bCompletesRenderFrame = false; + bool bRequestStop = false; + EApplicationTickPhase StopPhase = EApplicationTickPhase::EndFrame; + }; + + class FFakeIdleCoordinator final : public IApplicationIdleCoordinator + { + public: + explicit FFakeIdleCoordinator(std::vector& InEvents) + : Events(InEvents) + { + } + + FApplicationIdleResult WaitForIdle() override + { + Events.push_back("idle"); + if (bProveIdle) + { + return {true, {}}; + } + return { + false, + {{ + "application.gpu.idle_unproven", + "gpu_idle", + "Injected idle failure.", + EApplicationDiagnosticSeverity::Fatal, + false, + }}, + }; + } + + std::vector& Events; + bool bProveIdle = true; + }; + + FApplicationServiceDescriptor MakeServiceDescriptor( + std::string ServiceId, + EApplicationShutdownClass ShutdownClass, + std::vector Dependencies = {}, + std::vector TickPhases = {}) + { + FApplicationServiceDescriptor Descriptor; + Descriptor.ServiceId = std::move(ServiceId); + Descriptor.Dependencies = std::move(Dependencies); + Descriptor.AllowedModes = {EApplicationMode::ContractTest}; + Descriptor.TickPhases = std::move(TickPhases); + Descriptor.ShutdownClass = ShutdownClass; + return Descriptor; + } + + void RequireOrdered( + const std::vector& Events, + std::string_view Before, + std::string_view After, + std::string_view Message) + { + const auto BeforeIt = std::find(Events.begin(), Events.end(), Before); + const auto AfterIt = std::find(Events.begin(), Events.end(), After); + Require(BeforeIt != Events.end() && AfterIt != Events.end() && BeforeIt < AfterIt, Message); + } + + void RunRunnerContracts() + { + FApplicationConfig Config = MakeDefaultApplicationConfig(EApplicationMode::ContractTest); + Config.Limits.MaxTicks = 1; + std::vector Events; + FFakeApplicationService Platform( + MakeServiceDescriptor("platform", EApplicationShutdownClass::Platform), + Events); + FFakeApplicationService Runtime( + MakeServiceDescriptor( + "runtime", + EApplicationShutdownClass::RuntimeState, + {"platform"}, + {EApplicationTickPhase::Simulation}), + Events); + FFakeApplicationService Adapter( + MakeServiceDescriptor( + "adapter", + EApplicationShutdownClass::Adapter, + {"runtime"}, + {EApplicationTickPhase::ExternalRequests}), + Events); + FFakeApplicationService EndFrame( + MakeServiceDescriptor( + "end_frame", + EApplicationShutdownClass::Adapter, + {"runtime"}, + {EApplicationTickPhase::EndFrame}), + Events); + EndFrame.bRequestStop = true; + + FApplicationRunner Runner(Config, "runner-order"); + Runner.RegisterService(Runtime); + Runner.RegisterService(Adapter); + Runner.RegisterService(Platform); + Runner.RegisterService(EndFrame); + FFakeIdleCoordinator Idle(Events); + Require(Runner.Initialize(Idle), "runner initialization"); + const std::span InitializationOrder = + Runner.GetInitializationOrder(); + Require(InitializationOrder.size() == 4, "runner initialization order size"); + Require(InitializationOrder[0]->GetDescriptor().ServiceId == "platform" + && InitializationOrder[1]->GetDescriptor().ServiceId == "runtime" + && InitializationOrder[2]->GetDescriptor().ServiceId == "adapter" + && InitializationOrder[3]->GetDescriptor().ServiceId == "end_frame", + "runner stable topological order"); + + Require(!Runner.Tick(1.0), "runner tick limit requests stop"); + Require(Runner.QueryStatus().State == EApplicationLifecycleState::StopRequested, + "runner stop requested state"); + Require(Runner.QueryStatus().TickSequence == 1, "runner completed tick identity"); + Require(Runner.QueryStatus().FirstStop + && Runner.QueryStatus().FirstStop->Source == "end_frame" + && Runner.QueryStatus().FirstStop->Reason == "service_requested_stop", + "service stop request keeps completed tick semantics"); + RequireOrdered( + Events, + "tick:adapter:1", + "tick:runtime:2", + "external requests precede simulation"); + RequireOrdered( + Events, + "tick:runtime:2", + "tick:end_frame:4", + "simulation precedes end frame"); + + Runner.Shutdown(Idle); + Require(Runner.QueryStatus().State == EApplicationLifecycleState::Stopped, + "runner stopped state"); + RequireOrdered(Events, "shutdown:end_frame", "idle", "adapter shuts down before idle"); + RequireOrdered(Events, "idle", "shutdown:runtime", "runtime shuts down after idle"); + RequireOrdered(Events, "shutdown:runtime", "shutdown:platform", "reverse dependency shutdown"); + + FApplicationConfig EditorLimit = + MakeDefaultApplicationConfig(EApplicationMode::Editor); + EditorLimit.Limits.MaxCompletedFrames = 2; + std::vector EditorLimitEvents; + FApplicationServiceDescriptor PresenterDescriptor = MakeServiceDescriptor( + "editor.presenter", + EApplicationShutdownClass::GpuBacked, + {}, + {EApplicationTickPhase::RenderPresent}); + PresenterDescriptor.AllowedModes = {EApplicationMode::Editor}; + FFakeApplicationService Presenter( + std::move(PresenterDescriptor), + EditorLimitEvents); + Presenter.bCompletesRenderFrame = true; + FApplicationRunner EditorLimitRunner(EditorLimit, "runner-editor-limit"); + EditorLimitRunner.RegisterService(Presenter); + FFakeIdleCoordinator EditorLimitIdle(EditorLimitEvents); + Require(EditorLimitRunner.Initialize(EditorLimitIdle), + "editor frame-limit runner initialization"); + Require(EditorLimitRunner.Tick(2.0), "first presented frame keeps running"); + Require(!EditorLimitRunner.Tick(2.01), "completed frame limit requests stop"); + Require(EditorLimitRunner.QueryStatus().CompletedFrameCount == 2, + "completed frame limit counts presents"); + Require(EditorLimitRunner.QueryStatus().FirstStop + && EditorLimitRunner.QueryStatus().FirstStop->Reason == "max_completed_frames", + "completed frame limit structured stop"); + EditorLimitRunner.Shutdown(EditorLimitIdle); + + FApplicationRunner LimitRunner(Config, "runner-limit"); + FFakeIdleCoordinator LimitIdle(Events); + Require(LimitRunner.Initialize(LimitIdle), "headless limit runner initialization"); + Require(!LimitRunner.Tick(3.0), "headless tick limit requests stop"); + Require(LimitRunner.QueryStatus().FirstStop + && LimitRunner.QueryStatus().FirstStop->Reason == "max_ticks", + "headless max tick stop reason"); + LimitRunner.Shutdown(LimitIdle); + + std::vector CycleEvents; + FFakeApplicationService CycleA( + MakeServiceDescriptor("cycle.a", EApplicationShutdownClass::RuntimeState, {"cycle.b"}), + CycleEvents); + FFakeApplicationService CycleB( + MakeServiceDescriptor("cycle.b", EApplicationShutdownClass::RuntimeState, {"cycle.a"}), + CycleEvents); + FApplicationRunner CycleRunner(Config, "runner-cycle"); + CycleRunner.RegisterService(CycleA); + CycleRunner.RegisterService(CycleB); + FFakeIdleCoordinator CycleIdle(CycleEvents); + Require(!CycleRunner.Initialize(CycleIdle), "service cycle rejected"); + Require(CycleRunner.QueryStatus().State == EApplicationLifecycleState::Stopped, + "cycle failure performs headless cleanup"); + Require(HasDiagnostic( + CycleRunner.QueryStatus().Diagnostics, + "application.service.cycle"), + "cycle diagnostic"); + + std::vector FailureEvents; + FFakeApplicationService FailurePlatform( + MakeServiceDescriptor("failure.platform", EApplicationShutdownClass::Platform), + FailureEvents); + FFakeApplicationService FailureRuntime( + MakeServiceDescriptor( + "failure.runtime", + EApplicationShutdownClass::RuntimeState, + {"failure.platform"}), + FailureEvents); + FailureRuntime.bFailInitialize = true; + FApplicationRunner FailureRunner(Config, "runner-init-failure"); + FailureRunner.RegisterService(FailureRuntime); + FailureRunner.RegisterService(FailurePlatform); + FFakeIdleCoordinator FailureIdle(FailureEvents); + Require(!FailureRunner.Initialize(FailureIdle), "init failure rejected"); + Require(FailureRunner.QueryStatus().State == EApplicationLifecycleState::Stopped, + "init failure cleanup completes"); + RequireOrdered( + FailureEvents, + "init:failure.runtime", + "shutdown:failure.runtime", + "failing service participates in rollback"); + RequireOrdered( + FailureEvents, + "shutdown:failure.runtime", + "shutdown:failure.platform", + "init rollback reverse dependency order"); + + std::vector QuarantineEvents; + FFakeApplicationService QuarantineGpu( + MakeServiceDescriptor("quarantine.gpu", EApplicationShutdownClass::GpuBacked), + QuarantineEvents); + FApplicationRunner QuarantineRunner(Config, "runner-quarantine"); + QuarantineRunner.RegisterService(QuarantineGpu); + FFakeIdleCoordinator QuarantineIdle(QuarantineEvents); + QuarantineIdle.bProveIdle = false; + Require(QuarantineRunner.Initialize(QuarantineIdle), "quarantine runner initialization"); + Require(QuarantineRunner.RequestStop("contract", "injected"), + "quarantine runner stop request"); + QuarantineRunner.Shutdown(QuarantineIdle); + Require( + QuarantineRunner.QueryStatus().State == EApplicationLifecycleState::Quarantined, + "idle failure quarantines runner"); + Require( + std::find( + QuarantineEvents.begin(), + QuarantineEvents.end(), + "shutdown:quarantine.gpu") == QuarantineEvents.end(), + "quarantine skips unsafe gpu-backed teardown"); + } + + class FContractTickService final : public IApplicationService + { + public: + const FApplicationServiceDescriptor& GetDescriptor() const override + { + return Descriptor; + } + + void Initialize(const FApplicationConfig& Config) override + { + Require(!Config.Window.bEnabled, "contract composition window disabled"); + Require(!Config.Render.bEnabled, "contract composition render disabled"); + ++InitializeCount; + } + + FApplicationServiceTickResult Tick(const FApplicationTickContext& Context) override + { + GameDeltas.push_back(Context.Clock.GameDeltaSeconds); + return {}; + } + + void RequestStop() override + { + ++RequestStopCount; + } + + void Drain() override + { + ++DrainCount; + } + + void Shutdown() override + { + ++ShutdownCount; + } + + FApplicationServiceDescriptor Descriptor{ + "contract.executor", + 1, + 0, + {}, + {EApplicationMode::ContractTest}, + {EApplicationTickPhase::Simulation}, + EApplicationShutdownClass::RuntimeState, + false, + false, + }; + std::vector GameDeltas; + uint32 InitializeCount = 0; + uint32 RequestStopCount = 0; + uint32 DrainCount = 0; + uint32 ShutdownCount = 0; + }; + + void RunHeadlessCompositionContracts() + { + FApplicationConfig Config = + MakeDefaultApplicationConfig(EApplicationMode::ContractTest); + Config.Timing.FixedDeltaSeconds = 0.02; + Config.Limits.MaxTicks = 3; + Config.EnabledServices = {"contract.executor"}; + const uint64 ExpectedConfigHash = ComputeApplicationConfigHash(Config); + + FContractTickService Executor; + FNoGpuIdleCoordinator Idle; + FApplicationRunner Runner(Config, "contract-composition"); + Runner.RegisterService(Executor); + Require(Runner.Initialize(Idle), "contract composition initializes"); + + double RealSeconds = 10.0; + while (Runner.QueryStatus().State == EApplicationLifecycleState::Running) + { + (void)Runner.Tick(RealSeconds); + RealSeconds += 0.001; + } + Runner.Shutdown(Idle); + + const FApplicationRunEvidence Evidence = Runner.QueryEvidence(); + Require( + Evidence.SchemaMajor == ApplicationRunEvidenceSchemaMajor + && Evidence.SchemaMinor == ApplicationRunEvidenceSchemaMinor, + "application evidence schema"); + Require(Evidence.ConfigHash == ExpectedConfigHash, "application evidence config hash"); + Require(!Evidence.bWindowEnabled && !Evidence.bRenderEnabled, + "headless evidence feature closure"); + Require( + Evidence.ResolvedServiceIds + == std::vector{"contract.executor"}, + "headless evidence service closure"); + Require(Evidence.Status.State == EApplicationLifecycleState::Stopped, + "headless composition stopped"); + Require(Evidence.Status.TickSequence == 3, "headless composition finite ticks"); + Require(Evidence.Status.CompletedFrameCount == 0, + "headless composition does not claim render frames"); + Require(Evidence.Status.FirstStop + && Evidence.Status.FirstStop->Source == "application.limit" + && Evidence.Status.FirstStop->Reason == "max_ticks", + "headless composition structured stop"); + Require(Evidence.Status.bGpuIdleProven, "headless no-gpu proof"); + Require(Executor.GameDeltas == std::vector{0.02, 0.02, 0.02}, + "headless fixed game delta"); + Require(Executor.InitializeCount == 1 + && Executor.RequestStopCount == 1 + && Executor.DrainCount == 1 + && Executor.ShutdownCount == 1, + "headless service lifecycle"); + } + } + + int RunApplicationContracts() + { + try + { + RunConfigContracts(); + RunClockContracts(); + RunLifecycleContracts(); + RunRunnerContracts(); + RunHeadlessCompositionContracts(); + return 0; + } + catch (...) + { + return 1; + } + } +} diff --git a/Tests/ApplicationContracts.h b/Tests/ApplicationContracts.h new file mode 100644 index 0000000..9dd33b6 --- /dev/null +++ b/Tests/ApplicationContracts.h @@ -0,0 +1,6 @@ +#pragma once + +namespace WaveContracts +{ + int RunApplicationContracts(); +} diff --git a/Tests/CoreRuntimeContracts.cpp b/Tests/CoreRuntimeContracts.cpp index c0289d8..53fe10e 100644 --- a/Tests/CoreRuntimeContracts.cpp +++ b/Tests/CoreRuntimeContracts.cpp @@ -1,6 +1,8 @@ #include "CoreRuntimeContracts.h" +#include "ApplicationContracts.h" #include "ContractCoreContracts.h" +#include "PathContracts.h" #include "Core/Core.h" #include "Core/Math.h" @@ -79,6 +81,8 @@ namespace WaveContracts check(WaveTrace::RunTraceContracts() == 0, "trace codec contracts"); check(RunContractCoreContracts() == 0, "Contract Core contracts"); + check(RunApplicationContracts() == 0, "Application contracts"); + check(RunPathContracts() == 0, "Path contracts"); check(false, "runtime check contract"); } diff --git a/Tests/EditorDomainContractTests.cpp b/Tests/EditorDomainContractTests.cpp new file mode 100644 index 0000000..0fa3910 --- /dev/null +++ b/Tests/EditorDomainContractTests.cpp @@ -0,0 +1,517 @@ +#include "EditorDomainContractTests.h" + +#include "Contract/ContractCore.h" +#include "Developer/Document/EditorDocumentLedger.h" +#include "Developer/Scene/EditorDomainContracts.h" + +#include +#include +#include +#include +#include +#include + +namespace WaveContracts +{ + namespace + { + using namespace WaveContract; + using namespace WaveEditorDomain; + + void Require(bool bCondition, std::string_view Message) + { + if (!bCondition) + { + throw std::runtime_error(std::string(Message)); + } + } + + FEditorCommandContext MakeTypedContext( + std::string_view Domain, + uint64_t Revision, + std::string RequestId) + { + FEditorCommandContext Context; + Context.ExpectedRevision = + FRevision{ std::string(Domain), Revision, { 1, 0 } }; + Context.RequestId = std::move(RequestId); + Context.CorrelationId = Context.RequestId + "-correlation"; + return Context; + } + + FEditorMutation MakeMutation( + std::string Capability, + std::string TargetId, + std::string Field = "transform.position") + { + FEditorMutation Mutation; + Mutation.Capability = std::move(Capability); + Mutation.Targets = { + MakeSessionObjectRef("scene.instance", std::move(TargetId)) + }; + Mutation.ChangedFields = { std::move(Field) }; + Mutation.ValidatorResults = { + MakeValidationPassed("wave.editor.contract.tests") + }; + return Mutation; + } + + void TestSchemasAndBehaviorMatrix() + { + const FValueSchema Schemas[] = { + MakeInstanceTransformSchema(), + MakeCameraStateSchema(), + MakeMainLightStateSchema(), + MakeRenderSettingsStateSchema(), + MakeEditorCommandResultSchema() + }; + for (const FValueSchema& Schema : Schemas) + { + Require(!ValidateSchema(Schema).has_value(), "editor schema rejected"); + Require( + !SerializeSchemaCanonicalJson(Schema).empty(), + "editor schema did not serialize canonically"); + } + const std::vector Descriptors = + MakeEditorDomainCapabilityDescriptors(); + Require( + Descriptors.size() == 16, + "editor typed capability descriptor count changed"); + std::set DescriptorNames; + for (const FCapabilityDescriptor& Descriptor : Descriptors) + { + const std::optional Diagnostic = + ValidateDescriptor(Descriptor); + if (Descriptor.Version != FContractVersion{ 2, 0 } + || !Descriptor.bAvailable + || Diagnostic.has_value()) + { + throw std::runtime_error( + "editor typed capability descriptor rejected: " + + Descriptor.Name + + " " + + (Diagnostic.has_value() + ? Diagnostic->Code + " " + Diagnostic->Message + : "version or availability")); + } + Require( + DescriptorNames.insert(Descriptor.Name).second, + "duplicate editor typed capability descriptor"); + Require( + !SerializeDescriptorCanonicalJson(Descriptor).empty(), + "editor typed capability descriptor did not serialize"); + } + Require( + DescriptorNames.contains("editor.hierarchy.list") + && DescriptorNames.contains("editor.inspector.set_field") + && DescriptorNames.contains("editor.viewport.camera.get") + && DescriptorNames.contains("editor.lighting.set_main") + && DescriptorNames.contains("editor.renderer.set_setting") + && DescriptorNames.contains( + "editor.viewport.set_debug_view"), + "editor typed capability descriptor coverage changed"); + + const FObjectRef InstanceRef = + MakeSessionObjectRef("scene.instance", "42"); + Require( + InstanceRef.Scope == EReferenceScope::Session + && InstanceRef.Project.empty() + && !ValidateObjectRef(InstanceRef).has_value(), + "session instance identity contract"); + + Require(ValidateInstanceName("Cube").Succeeded(), "valid instance name"); + Require( + ValidateInstanceName(std::string(256, 'x')).Succeeded(), + "256-byte instance name"); + Require(!ValidateInstanceName("").Succeeded(), "empty instance name accepted"); + Require( + !ValidateInstanceName(std::string(257, 'x')).Succeeded(), + "257-byte instance name accepted"); + const std::string InvalidUtf8("\xc0\x80", 2); + Require( + !ValidateInstanceName(InvalidUtf8).Succeeded(), + "overlong UTF-8 instance name accepted"); + + Require( + ValidatePosition({ 1.0, -2.0, 3.0 }).Succeeded(), + "finite position rejected"); + Require( + !ValidatePosition({ + std::numeric_limits::infinity(), + 0.0, + 0.0 + }).Succeeded(), + "non-finite position accepted"); + + FEditorQuaternion Quaternion{ 0.0, 0.0, 0.0, 2.0 }; + Require( + ValidateAndNormalizeQuaternion(Quaternion).Succeeded() + && Quaternion == FEditorQuaternion{}, + "quaternion normalization contract"); + Quaternion = { 0.0, 0.0, 0.0, 0.0 }; + Require( + !ValidateAndNormalizeQuaternion(Quaternion).Succeeded(), + "zero quaternion accepted"); + + const FEditorVector3 WideScale{ 101.0, 2.0, 3.0 }; + Require( + ValidateScale(WideScale, EEditorScaleInputPolicy::Domain).Succeeded(), + "Inspector-compatible scale was narrowed to legacy maximum"); + Require( + !ValidateScale( + WideScale, + EEditorScaleInputPolicy::LegacyMcpOrGizmo).Succeeded(), + "legacy scale maximum was not retained"); + Require( + !ValidateScale({ 0.0009, 1.0, 1.0 }).Succeeded(), + "scale below common minimum accepted"); + + FEditorRotator CameraRotation{ 100.0, 540.0, -540.0 }; + Require( + ValidateAndNormalizeCameraRotation(CameraRotation).Succeeded(), + "finite camera rotation rejected"); + Require( + CameraRotation.Pitch == 89.0 + && CameraRotation.Yaw == -180.0 + && CameraRotation.Roll == 180.0, + "camera clamp/NormalizeAxis behavior changed"); + + FEditorMainLightState Light; + Light.Direction = { 0.0, 3.0, 4.0 }; + Require( + ValidateAndNormalizeMainLight(Light).Succeeded() + && std::abs(Light.Direction.Y - 0.6) < 1.0e-12 + && std::abs(Light.Direction.Z - 0.8) < 1.0e-12, + "light direction normalization contract"); + FEditorMainLightState TinyDirection; + TinyDirection.Direction = { 1.0e-6, 0.0, 0.0 }; + Require( + !ValidateAndNormalizeMainLight( + TinyDirection, + MinimumLegacyMcpDirectionLength).Succeeded(), + "legacy MCP direction threshold changed"); + TinyDirection.Direction = { 1.0e-5, 0.0, 0.0 }; + Require( + !ValidateAndNormalizeMainLight( + TinyDirection, + MinimumUiLightDirectionLength).Succeeded(), + "UI direction threshold changed"); + TinyDirection.Direction = { 1.0e-8, 0.0, 0.0 }; + Require( + ValidateAndNormalizeMainLight(TinyDirection).Succeeded(), + "common non-zero direction invariant was over-narrowed"); + FEditorMainLightState InvalidLight; + InvalidLight.Color.X = 1.1; + InvalidLight.Intensity = 21.0; + const FEditorMainLightState InvalidLightBefore = InvalidLight; + Require( + !ValidateAndNormalizeMainLight(InvalidLight).Succeeded() + && InvalidLight == InvalidLightBefore, + "out-of-range light accepted or partially normalized"); + + FEditorRenderSettingsState Settings; + Require( + ValidateRenderSettings(Settings).Succeeded(), + "default render settings rejected"); + FEditorRenderSettingsState InvalidSettings = Settings; + InvalidSettings.AutoExposureMin = 2.0; + InvalidSettings.AutoExposureMax = 1.0; + Require( + !ValidateRenderSettings(InvalidSettings).Succeeded(), + "invalid exposure pair accepted"); + InvalidSettings = Settings; + InvalidSettings.PathTracingMaxBounces = 17; + InvalidSettings.PathTracingSamplesPerPixel = 0; + InvalidSettings.PathTracingRussianRouletteStart = 9; + InvalidSettings.LodErrorThreshold = + std::numeric_limits::quiet_NaN(); + Require( + !ValidateRenderSettings(InvalidSettings).Succeeded(), + "invalid renderer ranges accepted"); + InvalidSettings = Settings; + InvalidSettings.DebugView = static_cast(3); + InvalidSettings.RenderMode = static_cast(2); + Require( + !ValidateRenderSettings(InvalidSettings).Succeeded(), + "invalid renderer enums accepted"); + + FEditorRenderSettingsState ResetSettings = Settings; + ResetSettings.ManualExposure = 2.0; + Require( + !RequiresPathTracingReset(Settings, ResetSettings), + "manual exposure unexpectedly resets path tracing"); + ResetSettings = Settings; + ResetSettings.PathTracingMaxBounces = 5; + Require( + RequiresPathTracingReset(Settings, ResetSettings), + "path tracing setting failed to request accumulation reset"); + FEditorMainLightState ChangedLight = Light; + ChangedLight.Intensity = 2.0; + Require( + RequiresPathTracingReset(Light, ChangedLight), + "light mutation failed to request accumulation reset"); + } + + void TestLedgerRevisionAndSideEffects() + { + FEditorDocumentLedger Ledger; + const FEditorCommandContext BeforeBaseline = + MakeTypedContext(SceneDomain, 0, "before-baseline"); + const FEditorLedgerOutcome RejectedBeforeBaseline = + Ledger.CommitValidated( + SceneDomain, + BeforeBaseline, + MakeMutation("editor.inspector.set_field", "1")); + Require( + !RejectedBeforeBaseline.HasResult() + && !RejectedBeforeBaseline.Diagnostics.empty() + && RejectedBeforeBaseline.Diagnostics.front().Code + == "editor.ledger.baseline_required", + "command ran before hydrated baseline"); + + Ledger.EstablishHydratedBaseline(7, 11); + Require( + Ledger.HasHydratedBaseline() + && Ledger.GetRevision(SceneDomain)->Value == 7 + && Ledger.GetRevision(RenderSettingsDomain)->Value == 11, + "hydrated baseline revisions"); + + FEditorCommandContext MissingExpected; + MissingExpected.RequestId = "missing-expected"; + const FEditorLedgerOutcome MissingExpectedOutcome = + Ledger.CommitValidated( + SceneDomain, + MissingExpected, + MakeMutation("editor.inspector.set_field", "1")); + Require( + !MissingExpectedOutcome.HasResult() + && MissingExpectedOutcome.Diagnostics.front().Code + == "editor.revision.expected_required" + && Ledger.GetRevision(SceneDomain)->Value == 7, + "typed command without expected revision changed state"); + + const FEditorLedgerOutcome Applied = Ledger.CommitValidated( + SceneDomain, + MakeTypedContext(SceneDomain, 7, "apply-1"), + MakeMutation("editor.inspector.set_field", "1")); + Require( + Applied.HasResult() + && Applied.Result->Status == EEditorCommandStatus::Applied + && Applied.Result->BeforeRevision.Value == 7 + && Applied.Result->AfterRevision.Value == 8 + && Applied.Result->ChangeSet.has_value() + && Applied.Result->Evidence.has_value() + && Ledger.GetRevision(SceneDomain)->Value == 8, + "applied command did not advance exactly one revision"); + + const FEditorEventPage EventPage = + Ledger.ReadEvents(SceneDomain, std::nullopt, 16); + const FEditorEvidencePage EvidencePage = + Ledger.ReadEvidence(SceneDomain, 16); + Require( + EventPage.Status == EChangeCursorStatus::Current + && EventPage.Events.size() == 1 + && EventPage.Events.front().Sequence == 1 + && EventPage.Events.front().SourceRevision.Value == 8 + && EvidencePage.Evidence.size() == 1, + "applied command event/evidence contract"); + + const FEditorLedgerOutcome NoOp = Ledger.ResolveNoOp( + SceneDomain, + MakeTypedContext(SceneDomain, 8, "no-op")); + Require( + NoOp.HasResult() + && NoOp.Result->Status == EEditorCommandStatus::NoOp + && NoOp.Result->BeforeRevision.Value == 8 + && NoOp.Result->AfterRevision.Value == 8 + && !NoOp.Result->ChangeSet.has_value() + && !NoOp.Result->Evidence.has_value() + && Ledger.GetRevision(SceneDomain)->Value == 8 + && Ledger.ReadEvents(SceneDomain, std::nullopt, 16).Events.size() == 1 + && Ledger.ReadEvidence(SceneDomain, 16).Evidence.size() == 1, + "no-op created revision/event/evidence side effects"); + + const FEditorLedgerOutcome Conflict = Ledger.CommitValidated( + SceneDomain, + MakeTypedContext(SceneDomain, 7, "stale"), + MakeMutation("editor.inspector.set_field", "1")); + Require( + Conflict.HasResult() + && Conflict.Result->Status == EEditorCommandStatus::Conflict + && Conflict.Result->Diagnostics.front().Code + == "editor.revision.conflict" + && Ledger.GetRevision(SceneDomain)->Value == 8 + && Ledger.ReadEvidence(SceneDomain, 16).Evidence.size() == 1, + "revision conflict left side effects"); + + FEditorMutation PersistentMutation = + MakeMutation("editor.inspector.set_field", "1"); + PersistentMutation.Targets.front().Scope = EReferenceScope::Persistent; + const FEditorLedgerOutcome PersistentRejected = + Ledger.CommitValidated( + SceneDomain, + MakeTypedContext(SceneDomain, 8, "persistent"), + PersistentMutation); + Require( + !PersistentRejected.HasResult() + && PersistentRejected.Diagnostics.front().Code + == "editor.mutation.target_scope_invalid" + && Ledger.GetRevision(SceneDomain)->Value == 8, + "fake persistent Editor identity accepted"); + + FEditorMutation MissingValidator = + MakeMutation("editor.inspector.set_field", "1"); + MissingValidator.ValidatorResults.clear(); + const FEditorLedgerOutcome MissingValidatorRejected = + Ledger.CommitValidated( + SceneDomain, + MakeTypedContext(SceneDomain, 8, "missing-validator"), + MissingValidator); + Require( + !MissingValidatorRejected.HasResult() + && MissingValidatorRejected.Diagnostics.front().Code + == "editor.mutation.validator_result_required" + && Ledger.GetRevision(SceneDomain)->Value == 8, + "mutation without validator evidence was committed"); + + FEditorCommandContext LegacyContext; + LegacyContext.InvocationMode = + EEditorInvocationMode::LegacyCompatibility; + LegacyContext.RequestId = "legacy"; + const FEditorLedgerOutcome LegacyApplied = Ledger.CommitValidated( + SceneDomain, + LegacyContext, + MakeMutation("editor.hierarchy.rename", "1", "display_name")); + Require( + LegacyApplied.HasResult() + && LegacyApplied.Result->Status == EEditorCommandStatus::Applied + && LegacyApplied.Result->AfterRevision.Value == 9 + && LegacyApplied.Result->Evidence->ValidatorResults.back().Code + == "editor.revision.legacy_base_captured", + "legacy adapter base capture was not explicit evidence"); + + const FEditorLedgerOutcome WrongObservationMode = + Ledger.ObserveExternal( + SceneDomain, + LegacyContext, + MakeMutation("editor.viewport.camera.observe", "camera", "camera.position")); + Require( + !WrongObservationMode.HasResult() + && WrongObservationMode.Diagnostics.front().Code + == "editor.observation.mode_required", + "external observation accepted legacy invocation mode"); + FEditorCommandContext ObservationContext; + ObservationContext.InvocationMode = + EEditorInvocationMode::ExternalObservation; + ObservationContext.RequestId = "camera-navigation"; + FEditorMutation Observation = + MakeMutation( + "editor.viewport.camera.observe", + "camera", + "camera.position"); + Observation.Targets.front() = + MakeSessionObjectRef("scene.camera", "main"); + const FEditorLedgerOutcome Observed = Ledger.ObserveExternal( + SceneDomain, + ObservationContext, + Observation); + Require( + Observed.HasResult() + && Observed.Result->Status == EEditorCommandStatus::Applied + && Observed.Result->AfterRevision.Value == 10 + && Ledger.GetRevision(RenderSettingsDomain)->Value == 11, + "external observation or independent domain revision contract"); + + const FEditorLedgerOutcome Undo = Ledger.CommitValidated( + SceneDomain, + MakeTypedContext(SceneDomain, 10, "undo"), + MakeMutation("editor.command.undo", "1")); + const FEditorLedgerOutcome Redo = Ledger.CommitValidated( + SceneDomain, + MakeTypedContext(SceneDomain, 11, "redo"), + MakeMutation("editor.command.redo", "1")); + Require( + Undo.Result->AfterRevision.Value == 11 + && Redo.Result->AfterRevision.Value == 12, + "Undo/Redo did not retain monotonic revisions"); + } + + void TestLedgerRetentionAndCursorGap() + { + FEditorDocumentLedger Ledger; + Ledger.EstablishHydratedBaseline(0, 0); + FEditorCommandContext LegacyContext; + LegacyContext.InvocationMode = + EEditorInvocationMode::LegacyCompatibility; + for (size_t Index = 0; Index < MaxEditorDomainEvents + 1; ++Index) + { + LegacyContext.RequestId = "retention-" + std::to_string(Index); + const FEditorLedgerOutcome Outcome = Ledger.CommitValidated( + SceneDomain, + LegacyContext, + MakeMutation( + "editor.inspector.set_field", + std::to_string(Index))); + Require( + Outcome.HasResult() + && Outcome.Result->Status == EEditorCommandStatus::Applied, + "retention fixture command failed"); + } + + const FEditorEventPage Retained = + Ledger.ReadEvents(SceneDomain, std::nullopt, MaxEditorDomainEvents); + Require( + Retained.Events.size() == MaxEditorDomainEvents + && Retained.Events.front().Sequence == 2 + && Retained.Events.back().Sequence + == MaxEditorDomainEvents + 1, + "event ring retention bound"); + + FChangeCursor ExpiredCursor; + ExpiredCursor.Provider = std::string(SceneDomain) + ".changes"; + ExpiredCursor.SchemaVersion = { 1, 0 }; + ExpiredCursor.NextSequence = 1; + ExpiredCursor.SourceRevision = { + std::string(SceneDomain), + 0, + { 1, 0 } + }; + const FEditorEventPage Gap = + Ledger.ReadEvents(SceneDomain, ExpiredCursor, 16); + Require( + Gap.Status == EChangeCursorStatus::ResyncRequired + && Gap.Events.empty() + && Gap.Diagnostics.front().Code + == "editor.change_feed.resync_required", + "expired cursor did not require resync"); + + const FEditorEvidencePage Evidence = + Ledger.ReadEvidence(SceneDomain, MaxEditorDomainEvidence); + Require( + Evidence.Evidence.size() == MaxEditorDomainEvidence + && Evidence.bTruncated, + "evidence ring retention bound"); + + FChangeCursor WrongProvider = Retained.NextCursor; + WrongProvider.Provider = "editor.other.changes"; + Require( + Ledger.ReadEvents(SceneDomain, WrongProvider, 16).Status + == EChangeCursorStatus::ResyncRequired, + "provider mismatch did not require resync"); + FChangeCursor WrongSchema = Retained.NextCursor; + WrongSchema.SchemaVersion.Major = 2; + Require( + Ledger.ReadEvents(SceneDomain, WrongSchema, 16).Status + == EChangeCursorStatus::ResyncRequired, + "schema mismatch did not require resync"); + } + } + + int RunEditorDomainContracts() + { + TestSchemasAndBehaviorMatrix(); + TestLedgerRevisionAndSideEffects(); + TestLedgerRetentionAndCursorGap(); + return 0; + } +} diff --git a/Tests/EditorDomainContractTests.h b/Tests/EditorDomainContractTests.h new file mode 100644 index 0000000..e138d94 --- /dev/null +++ b/Tests/EditorDomainContractTests.h @@ -0,0 +1,6 @@ +#pragma once + +namespace WaveContracts +{ + int RunEditorDomainContracts(); +} diff --git a/Tests/EditorDomainContractsMain.cpp b/Tests/EditorDomainContractsMain.cpp new file mode 100644 index 0000000..1be9ae5 --- /dev/null +++ b/Tests/EditorDomainContractsMain.cpp @@ -0,0 +1,22 @@ +#include "EditorDomainContractTests.h" + +#include +#include + +int main() +{ + try + { + return WaveContracts::RunEditorDomainContracts(); + } + catch (const std::exception& Error) + { + std::cerr << "WaveEditorDomainContracts failed: " << Error.what() << '\n'; + return 1; + } + catch (...) + { + std::cerr << "WaveEditorDomainContracts failed: unknown exception\n"; + return 2; + } +} diff --git a/Tests/PathContracts.cpp b/Tests/PathContracts.cpp new file mode 100644 index 0000000..03d6dd4 --- /dev/null +++ b/Tests/PathContracts.cpp @@ -0,0 +1,842 @@ +#include "PathContracts.h" + +#include "Core/Paths/AtomicFileStore.h" +#include "Core/Paths/LegacyMigration.h" +#include "Core/Paths/PathService.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) + #define WIN32_LEAN_AND_MEAN + #include + #include +#endif + +namespace WaveContracts +{ + namespace + { + using namespace WavePaths; + + void Require(bool bCondition, std::string_view Message) + { + if (!bCondition) + { + throw std::runtime_error(std::string(Message)); + } + } + + bool HasDiagnostic( + const std::vector& Diagnostics, + std::string_view Code) + { + return std::any_of( + Diagnostics.begin(), + Diagnostics.end(), + [Code](const FPathDiagnostic& Diagnostic) + { + return Diagnostic.Code == Code; + }); + } + + class FTemporaryDirectory + { + public: + explicit FTemporaryDirectory(std::string_view Name) + { + static uint64 Sequence = 0; + Path = std::filesystem::temp_directory_path() + / ("wave-path-contract-" + std::string(Name) + "-" + + std::to_string(++Sequence)); + std::error_code Error; + std::filesystem::remove_all(Path, Error); + Error.clear(); + std::filesystem::create_directories(Path, Error); + Require(!Error, "create path contract temp directory"); + } + + ~FTemporaryDirectory() + { + std::error_code Error; + std::filesystem::remove_all(Path, Error); + } + + std::filesystem::path Path; + }; + + bool CreateDirectoryLink( + const std::filesystem::path& Link, + const std::filesystem::path& Target) + { +#if defined(_WIN32) + if (CreateSymbolicLinkW( + Link.c_str(), + Target.c_str(), + SYMBOLIC_LINK_FLAG_DIRECTORY + | SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE)) + { + return true; + } + + std::error_code Error; + std::filesystem::create_directory(Link, Error); + if (Error) + { + return false; + } + const std::wstring Substitute = L"\\??\\" + Target.wstring(); + const std::wstring Print = Target.wstring(); + const size_t SubstituteBytes = Substitute.size() * sizeof(wchar_t); + const size_t PrintBytes = Print.size() * sizeof(wchar_t); + struct FMountPointBuffer + { + DWORD ReparseTag; + WORD ReparseDataLength; + WORD Reserved; + WORD SubstituteNameOffset; + WORD SubstituteNameLength; + WORD PrintNameOffset; + WORD PrintNameLength; + wchar_t PathBuffer[32768]; + }; + FMountPointBuffer Buffer{}; + Buffer.ReparseTag = IO_REPARSE_TAG_MOUNT_POINT; + Buffer.SubstituteNameOffset = 0; + Buffer.SubstituteNameLength = static_cast(SubstituteBytes); + Buffer.PrintNameOffset = static_cast( + SubstituteBytes + sizeof(wchar_t)); + Buffer.PrintNameLength = static_cast(PrintBytes); + memcpy(Buffer.PathBuffer, Substitute.data(), SubstituteBytes); + memcpy( + reinterpret_cast(Buffer.PathBuffer) + + Buffer.PrintNameOffset, + Print.data(), + PrintBytes); + const size_t PathBytes = + Buffer.PrintNameOffset + PrintBytes + sizeof(wchar_t); + Buffer.ReparseDataLength = static_cast( + sizeof(Buffer.SubstituteNameOffset) + + sizeof(Buffer.SubstituteNameLength) + + sizeof(Buffer.PrintNameOffset) + + sizeof(Buffer.PrintNameLength) + + PathBytes); + + const HANDLE Directory = CreateFileW( + Link.c_str(), + GENERIC_WRITE, + 0, + nullptr, + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, + nullptr); + if (Directory == INVALID_HANDLE_VALUE) + { + std::filesystem::remove(Link, Error); + return false; + } + DWORD BytesReturned = 0; + const DWORD InputBytes = static_cast( + offsetof(FMountPointBuffer, PathBuffer) + PathBytes); + const bool bCreated = DeviceIoControl( + Directory, + FSCTL_SET_REPARSE_POINT, + &Buffer, + InputBytes, + nullptr, + 0, + &BytesReturned, + nullptr) != FALSE; + CloseHandle(Directory); + if (!bCreated) + { + std::filesystem::remove(Link, Error); + } + return bCreated; +#else + std::error_code Error; + std::filesystem::create_directory_symlink(Target, Link, Error); + return !Error; +#endif + } + + FPathServiceConfig MakeSourceConfig(const std::filesystem::path& Root, uint64 Revision = 1) + { + FPathServiceConfig Config; + Config.Revision = Revision; + Config.Policy = EPathPolicyProfile::EditorSource; + Config.ProjectScope = {"path-contract"}; + Config.Roots = { + {EPathDomain::Executable, Root / "bin", EPathAccess::ReadOnly, "contract", false, true}, + {EPathDomain::EngineContent, Root / "engine", EPathAccess::ReadOnly, "contract", false, true}, + {EPathDomain::ProjectSource, Root / "project", EPathAccess::ReadOnly, "contract", false, true}, + {EPathDomain::Derived, Root / "derived", EPathAccess::ReadWrite, "contract", false, true}, + {EPathDomain::ProjectSaved, Root / "saved", EPathAccess::ReadWrite, "contract", false, true}, + {EPathDomain::UserSaved, Root / "user", EPathAccess::ReadWrite, "contract", true, true}, + {EPathDomain::PackageContent, {}, EPathAccess::Denied, "unavailable", false, false}, + {EPathDomain::Staging, Root / "staging", EPathAccess::ReadWrite, "contract", true, true}, + {EPathDomain::Temp, Root / "temp", EPathAccess::ReadWrite, "contract", true, true}, + {EPathDomain::External, {}, EPathAccess::Denied, "approval", true, false}, + }; + for (const FPathRootConfig& Descriptor : Config.Roots) + { + if (Descriptor.bAvailable) + { + std::filesystem::create_directories(Descriptor.AbsoluteRoot); + } + } + return Config; + } + + void WriteTextFile( + const std::filesystem::path& Path, + std::string_view Text); + + void RunPathSchemaContracts() + { + FPathRef Valid{ + PathSchemaMajor, + PathSchemaMinor, + EPathDomain::ProjectSource, + {"scope"}, + "Assets/场景/model.gltf", + 1, + }; + Require(ValidatePathRefSchema(Valid).empty(), "valid UTF-8 PathRef"); + + const auto Reject = [&Valid](std::string RelativePath, std::string_view Code) + { + FPathRef Candidate = Valid; + Candidate.RelativePath = std::move(RelativePath); + Require( + HasDiagnostic(ValidatePathRefSchema(Candidate), Code), + "invalid PathRef diagnostic"); + }; + Reject("../escape.txt", "path.schema.traversal"); + Reject("C:/escape.txt", "path.schema.absolute"); + Reject("//server/share", "path.schema.absolute"); + Reject("folder\\file.txt", "path.schema.non_generic_separator"); + Reject("folder//file.txt", "path.schema.empty_segment"); + Reject("folder/file.txt:stream", "path.schema.alternate_stream"); + Reject(std::string("folder/name\0suffix", 18), "path.schema.invalid_windows_name"); + Reject("folder/CON.txt", "path.schema.reserved_device"); + Reject("folder/name.", "path.schema.invalid_windows_name"); + Reject(std::string("folder/") + std::string(256, 'a'), "path.schema.segment_too_long"); + Reject(std::string("folder/") + "\xf0\x28\x8c\x28", "path.schema.invalid_utf8"); + + std::string TooManySegments = "a"; + for (size_t Index = 1; Index <= MaxPathRefSegments; ++Index) + { + TooManySegments += "/a"; + } + Reject(std::move(TooManySegments), "path.schema.too_many_segments"); + } + + void RunResolverContracts() + { + FTemporaryDirectory Temp("resolver"); + FPathService Service; + Require(Service.Configure(MakeSourceConfig(Temp.Path)).bConfigured, + "source path service config"); + Require(Service.QuerySnapshot().Revision == 1, "root revision snapshot"); + Require( + HasDiagnostic( + Service.Configure(MakeSourceConfig(Temp.Path)).Diagnostics, + "path.config.immutable"), + "path root configuration is immutable"); + + const FPathRef WriteRef = + Service.MakeRef(EPathDomain::ProjectSaved, "Scenes/default.wescn"); + const FPathResolveResult Write = + Service.ResolveWrite(WriteRef, EPathOperation::Replace); + Require(Write.Resolved.has_value(), "contained write resolve"); + Require( + Write.Resolved->NativePath.parent_path().filename() == "Scenes", + "native write path"); + + const FPathRef Missing = + Service.MakeRef(EPathDomain::ProjectSource, "Assets/missing.gltf"); + Require( + HasDiagnostic(Service.ResolveRead(Missing).Diagnostics, "path.resolve.not_found"), + "missing read diagnostic"); + + WriteTextFile( + Temp.Path / "saved" / "Case" / "MixedCase.txt", + "case"); + Require( + Service.ResolveRead(Service.MakeRef( + EPathDomain::ProjectSaved, + "case/mixedcase.txt")).Resolved.has_value(), + "Windows path containment is case-insensitive"); + + FPathRef WrongScope = WriteRef; + WrongScope.ProjectScope.Value = "other"; + Require( + HasDiagnostic(Service.ResolveWrite( + WrongScope, + EPathOperation::Replace).Diagnostics, + "path.ref.stale_scope"), + "stale scope rejected"); + + FPathService NewRevision; + Require( + NewRevision.Configure(MakeSourceConfig(Temp.Path, 2)).bConfigured, + "new root revision config"); + Require( + HasDiagnostic(NewRevision.ResolveWrite( + WriteRef, + EPathOperation::Replace).Diagnostics, + "path.ref.stale_revision"), + "stale root revision rejected"); + + const FPathRef ReadOnly = + Service.MakeRef(EPathDomain::EngineContent, "Shaders/test.hlsl"); + Require( + HasDiagnostic(Service.ResolveWrite( + ReadOnly, + EPathOperation::Replace).Diagnostics, + "path.domain.access_denied"), + "read-only root rejects write"); + const FPathRef Package = + Service.MakeRef(EPathDomain::PackageContent, "Content/test.bin"); + Require( + HasDiagnostic(Service.ResolveRead(Package).Diagnostics, "path.domain.unavailable"), + "unavailable domain diagnostic"); + + FPathRef Escape = WriteRef; + Escape.RelativePath = "Scenes/../../escape"; + Require( + HasDiagnostic(Service.ResolveWrite( + Escape, + EPathOperation::Replace).Diagnostics, + "path.schema.traversal"), + "traversal rejected before native resolve"); + + const std::filesystem::path Outside = Temp.Path / "outside"; + const std::filesystem::path Link = Temp.Path / "saved" / "escape-link"; + std::filesystem::create_directories(Outside); + Require(CreateDirectoryLink(Link, Outside), + "create directory link for containment contract"); + const FPathResolveResult ReparseEscape = Service.ResolveWrite( + Service.MakeRef( + EPathDomain::ProjectSaved, + "escape-link/outside.bin"), + EPathOperation::Replace); + Require( + HasDiagnostic(ReparseEscape.Diagnostics, "path.resolve.escape"), + "reparse escape rejected"); + std::error_code LinkError; + std::filesystem::remove(Link, LinkError); + Require(!LinkError, "remove containment contract link"); + + InstallPathService(Service); + Require(HasPathService(), "installed path service"); + Require(GetPathService().QuerySnapshot().Revision == 1, "current path service query"); + UninstallPathService(Service); + Require(!HasPathService(), "uninstalled path service"); + } + + void RunPackagePolicyContracts() + { + FTemporaryDirectory Temp("package"); + for (const std::string_view Directory : {"bin", "content", "user", "temp"}) + { + std::filesystem::create_directories(Temp.Path / Directory); + } + { + std::ofstream PackageFile(Temp.Path / "content" / "asset.bin"); + PackageFile << "package"; + } + + FPathServiceConfig Config; + Config.Revision = 7; + Config.Policy = EPathPolicyProfile::PackageRuntime; + Config.ProjectScope = {"package-contract"}; + Config.Roots = { + {EPathDomain::Executable, Temp.Path / "bin", EPathAccess::ReadOnly, "package", false, true}, + {EPathDomain::EngineContent, Temp.Path / "content", EPathAccess::ReadOnly, "package", false, true}, + {EPathDomain::PackageContent, Temp.Path / "content", EPathAccess::ReadOnly, "package", false, true}, + {EPathDomain::UserSaved, Temp.Path / "user", EPathAccess::ReadWrite, "user", true, true}, + {EPathDomain::Temp, Temp.Path / "temp", EPathAccess::ReadWrite, "temp", true, true}, + {EPathDomain::ProjectSource, {}, EPathAccess::Denied, "unavailable", false, false}, + {EPathDomain::Derived, {}, EPathAccess::Denied, "unavailable", false, false}, + {EPathDomain::ProjectSaved, {}, EPathAccess::Denied, "unavailable", false, false}, + {EPathDomain::Staging, {}, EPathAccess::Denied, "unavailable", true, false}, + {EPathDomain::External, {}, EPathAccess::Denied, "unavailable", true, false}, + }; + FPathService Service; + Require(Service.Configure(Config).bConfigured, "package path service config"); + Require( + Service.ResolveRead(Service.MakeRef( + EPathDomain::PackageContent, + "asset.bin")).Resolved.has_value(), + "package content read"); + Require( + HasDiagnostic(Service.ResolveRead(Service.MakeRef( + EPathDomain::ProjectSource, + "Assets/source.gltf")).Diagnostics, + "path.domain.unavailable"), + "package policy source unavailable"); + + FPathServiceConfig Invalid = Config; + Invalid.Roots.clear(); + Invalid.Roots.push_back({ + EPathDomain::Derived, + Temp.Path / "derived", + EPathAccess::ReadWrite, + "invalid", + false, + true, + }); + FPathService InvalidService; + Require( + HasDiagnostic(InvalidService.Configure(Invalid).Diagnostics, + "path.policy.package_domain"), + "package policy rejects developer root"); + } + + std::vector Bytes(std::string_view Text) + { + return std::vector(Text.begin(), Text.end()); + } + + void RunAtomicStoreContracts() + { + FTemporaryDirectory Temp("atomic"); + FPathService Service; + Require(Service.Configure(MakeSourceConfig(Temp.Path)).bConfigured, + "atomic path service config"); + const FPathRef Target = + Service.MakeRef(EPathDomain::ProjectSaved, "Config/settings.bin"); + const std::vector First = Bytes("first"); + const FAtomicStoreResult Created = AtomicStoreBytes( + Service, + {Target, First}); + Require(Created.Evidence.bCommitted, "atomic create"); + Require(!Created.Evidence.bPreviousFileExisted, "atomic create evidence"); + Require(Created.Evidence.NewHash == HashBytes(First), "atomic create hash"); + + const std::filesystem::path NativeTarget = + Service.ResolveRead(Target).Resolved->NativePath; + Require(HashFile(NativeTarget, 1024) == HashBytes(First), + "atomic create persisted bytes"); + + const std::vector ConflictData = Bytes("conflict"); + FAtomicStoreRequest ConflictRequest{Target, ConflictData}; + ConflictRequest.ExpectedExistingHash = 123; + const FAtomicStoreResult Conflict = + AtomicStoreBytes(Service, ConflictRequest); + Require(!Conflict.Evidence.bCommitted + && HasDiagnostic(Conflict.Diagnostics, "path.atomic.conflict"), + "atomic expected-hash conflict"); + Require(HashFile(NativeTarget, 1024) == HashBytes(First), + "atomic conflict preserves old file"); + + const std::vector InjectedData = Bytes("injected"); + for (const auto [FailurePoint, Diagnostic] : { + std::pair{ + EAtomicStoreFailurePoint::DuringTemporaryWrite, + std::string_view("path.atomic.injected_write")}, + std::pair{ + EAtomicStoreFailurePoint::BeforeTemporaryFlush, + std::string_view("path.atomic.injected_flush")}}) + { + FAtomicStoreRequest IoFailureRequest{Target, InjectedData}; + IoFailureRequest.ExpectedExistingHash = HashBytes(First); + IoFailureRequest.FailurePoint = FailurePoint; + const FAtomicStoreResult IoFailure = + AtomicStoreBytes(Service, IoFailureRequest); + Require(!IoFailure.Evidence.bCommitted + && HasDiagnostic(IoFailure.Diagnostics, Diagnostic), + "atomic write/flush injected failure"); + Require(HashFile(NativeTarget, 1024) == HashBytes(First), + "atomic write/flush failure preserves old file"); + } + + FAtomicStoreRequest InjectedRequest{Target, InjectedData}; + InjectedRequest.ExpectedExistingHash = HashBytes(First); + InjectedRequest.FailurePoint = EAtomicStoreFailurePoint::BeforeReplace; + const FAtomicStoreResult Injected = + AtomicStoreBytes(Service, InjectedRequest); + Require(!Injected.Evidence.bCommitted + && HasDiagnostic(Injected.Diagnostics, "path.atomic.injected_before_replace"), + "atomic injected failure"); + Require(HashFile(NativeTarget, 1024) == HashBytes(First), + "atomic injected failure preserves old file"); + for (const std::filesystem::directory_entry& Entry : + std::filesystem::directory_iterator(NativeTarget.parent_path())) + { + Require( + Entry.path().filename().string().find(".wave-tmp-") + == std::string::npos, + "failed atomic temporary cleaned"); + } + + const std::vector InvalidData = Bytes("invalid"); + FAtomicStoreRequest InvalidRequest{Target, InvalidData}; + InvalidRequest.Validator = []( + const std::filesystem::path&, + FPathDiagnostic& Diagnostic) + { + Diagnostic = { + "path.atomic.contract_validator", + "target", + "Injected validator rejection.", + EPathDiagnosticSeverity::Error, + false, + }; + return false; + }; + const FAtomicStoreResult Invalid = + AtomicStoreBytes(Service, InvalidRequest); + Require(!Invalid.Evidence.bCommitted + && HasDiagnostic(Invalid.Diagnostics, "path.atomic.contract_validator"), + "atomic validator failure"); + Require(HashFile(NativeTarget, 1024) == HashBytes(First), + "atomic validator preserves old file"); + + FAtomicStoreRequest RetainedRequest{Target, InvalidData}; + RetainedRequest.bRetainFailedTemporary = true; + RetainedRequest.FailurePoint = + EAtomicStoreFailurePoint::AfterTemporaryWrite; + const FAtomicStoreResult Retained = + AtomicStoreBytes(Service, RetainedRequest); + Require(!Retained.Evidence.bCommitted + && Retained.Evidence.bTemporaryRetained, + "atomic failed temporary retention evidence"); + bool bRemovedRetainedTemporary = false; + for (const std::filesystem::directory_entry& Entry : + std::filesystem::directory_iterator(NativeTarget.parent_path())) + { + if (Entry.path().filename().string().find(".wave-tmp-") + != std::string::npos) + { + std::filesystem::remove(Entry.path()); + bRemovedRetainedTemporary = true; + } + } + Require(bRemovedRetainedTemporary, + "atomic retained temporary is inspectable"); + + const FAtomicStoreResult AccessDenied = AtomicStoreBytes( + Service, + { + Service.MakeRef( + EPathDomain::EngineContent, + "Shaders/cache.bin"), + InvalidData, + }); + Require(!AccessDenied.Evidence.bCommitted + && HasDiagnostic( + AccessDenied.Diagnostics, + "path.domain.access_denied"), + "atomic store enforces domain permission"); + + const std::vector Second = Bytes("second"); + FAtomicStoreRequest ReplaceRequest{Target, Second}; + ReplaceRequest.ExpectedExistingHash = HashBytes(First); + const FAtomicStoreResult Replaced = + AtomicStoreBytes(Service, ReplaceRequest); + Require(Replaced.Evidence.bCommitted + && Replaced.Evidence.bPreviousFileExisted, + "atomic replace"); + Require(Replaced.Evidence.PreviousHash == HashBytes(First) + && Replaced.Evidence.NewHash == HashBytes(Second), + "atomic replace evidence"); + Require(HashFile(NativeTarget, 1024) == HashBytes(Second), + "atomic replace persisted bytes"); + + FAtomicStoreRequest Oversized{Target, Second}; + Oversized.MaxBytes = 2; + const FAtomicStoreResult SizeRejected = + AtomicStoreBytes(Service, Oversized); + Require(!SizeRejected.Evidence.bCommitted + && HasDiagnostic(SizeRejected.Diagnostics, "path.atomic.size_limit"), + "atomic size limit"); + + const FPathRef RaceTarget = + Service.MakeRef(EPathDomain::ProjectSaved, "Race/target.bin"); + const std::filesystem::path Outside = Temp.Path / "outside-race"; + std::filesystem::create_directories(Outside); + FAtomicStoreRequest RaceRequest{RaceTarget, Second}; + RaceRequest.Validator = [&Outside]( + const std::filesystem::path& Temporary, + FPathDiagnostic&) + { + std::error_code Error; + const std::filesystem::path OutsideTemporary = + Outside / Temporary.filename(); + std::filesystem::rename(Temporary, OutsideTemporary, Error); + if (Error) + { + return false; + } + const std::filesystem::path Parent = Temporary.parent_path(); + std::filesystem::remove(Parent, Error); + if (Error || !CreateDirectoryLink(Parent, Outside)) + { + return false; + } + return true; + }; + const FAtomicStoreResult Race = + AtomicStoreBytes(Service, RaceRequest); + Require(!Race.Evidence.bCommitted + && HasDiagnostic( + Race.Diagnostics, + "path.atomic.containment_changed"), + "atomic replace rechecks containment after validator"); + Require(!std::filesystem::exists(Outside / "target.bin"), + "TOCTOU containment failure did not write escaped target"); + const std::filesystem::path RaceParent = + Temp.Path / "saved" / "Race"; + std::error_code RaceCleanupError; + std::filesystem::remove(RaceParent, RaceCleanupError); + Require(!RaceCleanupError, "remove TOCTOU contract link"); + } + + const FLegacyMigrationEntry& FindLegacyEntry( + const FLegacyMigrationPlan& Plan, + std::string_view SourceSuffix) + { + const auto Found = std::find_if( + Plan.Entries.begin(), + Plan.Entries.end(), + [SourceSuffix](const FLegacyMigrationEntry& Entry) + { + return Entry.Source.RelativePath.ends_with(SourceSuffix); + }); + if (Found == Plan.Entries.end()) + { + throw std::runtime_error( + "missing legacy migration entry: " + + std::string(SourceSuffix)); + } + return *Found; + } + + void WriteTextFile( + const std::filesystem::path& Path, + std::string_view Text) + { + std::filesystem::create_directories(Path.parent_path()); + std::ofstream Out(Path, std::ios::binary); + Out.write(Text.data(), static_cast(Text.size())); + Require(static_cast(Out), "write legacy contract input"); + } + + void RunLegacyMigrationContracts() + { + FTemporaryDirectory EmptyTemp("legacy-empty"); + FPathService EmptyService; + Require( + EmptyService.Configure(MakeSourceConfig(EmptyTemp.Path)).bConfigured, + "empty legacy source config"); + const FLegacyMigrationPlan EmptyPlan = + BuildLegacyMigrationPlan(EmptyService, {}); + Require( + EmptyPlan.bComplete + && EmptyPlan.Entries.empty() + && EmptyPlan.Diagnostics.empty(), + "missing legacy root is an empty preview"); + + FTemporaryDirectory Temp("legacy"); + WriteTextFile( + Temp.Path / "engine" / "Save" / "Scenes" / "default.wescn", + "legacy-scene"); + WriteTextFile( + Temp.Path / "engine" / "Save" / "ui_config.cfg", + "legacy-ui"); + WriteTextFile( + Temp.Path / "engine" / "Save" / "Meshes" / "mesh.wemesh", + "legacy-mesh"); + WriteTextFile( + Temp.Path / "engine" / "Save" / "unknown.bin", + "unsupported"); + WriteTextFile( + Temp.Path / "engine" / "Save" / "Derived" / "ignored.bin", + "managed-root"); + WriteTextFile( + Temp.Path / "saved" / "Scenes" / "default.wescn", + "current-scene"); + + FPathService Service; + Require(Service.Configure(MakeSourceConfig(Temp.Path)).bConfigured, + "legacy source config"); + const FLegacyMigrationPlan Plan = BuildLegacyMigrationPlan( + Service, + {}); + Require(Plan.bComplete, "legacy migration preview complete"); + Require(Plan.InspectedEntries == 4, "legacy managed roots ignored"); + Require(Plan.Entries.size() == 4, "legacy preview entries"); + + const FLegacyMigrationEntry& Scene = + FindLegacyEntry(Plan, "Scenes/default.wescn"); + const FLegacyMigrationEntry& Ui = + FindLegacyEntry(Plan, "ui_config.cfg"); + const FLegacyMigrationEntry& Mesh = + FindLegacyEntry(Plan, "Meshes/mesh.wemesh"); + const FLegacyMigrationEntry& Unsupported = + FindLegacyEntry(Plan, "unknown.bin"); + Require(Scene.bSupported && Scene.bConflict, + "legacy target conflict reported"); + Require(Ui.bSupported && Mesh.bSupported, + "legacy supported mappings"); + Require(!Unsupported.bSupported, + "legacy unsupported mapping remains preview-only"); + + const std::filesystem::path SceneSource = + Service.ResolveRead(Scene.Source).Resolved->NativePath; + const uint64 SceneSourceHash = *HashFile(SceneSource, 1024); + const std::filesystem::path SceneTarget = + Service.ResolveRead(Scene.Target).Resolved->NativePath; + const uint64 SceneTargetHash = *HashFile(SceneTarget, 1024); + + const FLegacyStageResult Unapproved = StageLegacyMigrationEntry( + Service, + {Scene, false}); + Require(!Unapproved.Evidence.bStaged + && HasDiagnostic( + Unapproved.Diagnostics, + "path.migration.approval_required"), + "legacy staging requires approval"); + + FLegacyStageRequest ApprovedRequest{Scene, true}; + ApprovedRequest.Validator = []( + const std::filesystem::path& Path, + FPathDiagnostic&) + { + return std::filesystem::file_size(Path) > 0; + }; + const FLegacyStageResult Approved = + StageLegacyMigrationEntry(Service, ApprovedRequest); + Require( + Approved.Evidence.bStaged + && Approved.Evidence.bValidated + && Approved.Evidence.bSourcePreserved + && !Approved.Evidence.bPromoted, + "legacy approved copy stages without promotion"); + Require(*HashFile(SceneSource, 1024) == SceneSourceHash, + "legacy staging preserves source"); + Require(*HashFile(SceneTarget, 1024) == SceneTargetHash, + "legacy staging does not overwrite target conflict"); + const std::filesystem::path Staged = + Service.ResolveRead(Scene.Staging).Resolved->NativePath; + Require(*HashFile(Staged, 1024) == Scene.SourceHash, + "legacy staged hash"); + + FLegacyStageRequest ValidatorFailure{Ui, true}; + ValidatorFailure.Validator = []( + const std::filesystem::path&, + FPathDiagnostic& Diagnostic) + { + Diagnostic = { + "path.migration.validator_rejected", + "source", + "Injected legacy validator rejection.", + EPathDiagnosticSeverity::Error, + false, + }; + return false; + }; + const FLegacyStageResult Rejected = + StageLegacyMigrationEntry(Service, ValidatorFailure); + Require(!Rejected.Evidence.bStaged + && HasDiagnostic( + Rejected.Diagnostics, + "path.migration.validator_rejected"), + "legacy validator blocks staging"); + + FLegacyStageRequest Injected{Mesh, true}; + Injected.FailurePoint = EAtomicStoreFailurePoint::BeforeReplace; + const FLegacyStageResult Failed = + StageLegacyMigrationEntry(Service, Injected); + Require(!Failed.Evidence.bStaged + && HasDiagnostic( + Failed.Diagnostics, + "path.atomic.injected_before_replace"), + "legacy staging partial failure"); + + WriteTextFile(SceneSource, "changed-after-preview"); + const FLegacyStageResult Changed = StageLegacyMigrationEntry( + Service, + {Scene, true}); + Require(!Changed.Evidence.bStaged + && HasDiagnostic( + Changed.Diagnostics, + "path.migration.source_changed"), + "legacy source revision conflict"); + + FPathServiceConfig EntryLimitConfig = MakeSourceConfig( + Temp.Path / "entry-limit"); + WriteTextFile( + Temp.Path / "entry-limit" / "engine" / "Save" / "Scenes" / "one.wescn", + "one"); + WriteTextFile( + Temp.Path / "entry-limit" / "engine" / "Save" / "Scenes" / "two.wescn", + "two"); + EntryLimitConfig.Limits.MaxLegacyEntries = 1; + FPathService EntryLimitService; + Require(EntryLimitService.Configure(EntryLimitConfig).bConfigured, + "legacy entry limit config"); + const FLegacyMigrationPlan EntryLimited = + BuildLegacyMigrationPlan(EntryLimitService, {}); + Require(!EntryLimited.bComplete + && HasDiagnostic( + EntryLimited.Diagnostics, + "path.migration.entry_limit"), + "legacy entry quota"); + + FPathServiceConfig WorkspaceLimitConfig = MakeSourceConfig( + Temp.Path / "workspace-limit"); + WriteTextFile( + Temp.Path / "workspace-limit" / "engine" / "Save" / "Scenes" / "one.wescn", + "one"); + std::filesystem::create_directories( + Temp.Path / "workspace-limit" / "staging" / "Legacy" / "existing"); + WorkspaceLimitConfig.Limits.MaxStagingWorkspaces = 1; + FPathService WorkspaceLimitService; + Require(WorkspaceLimitService.Configure(WorkspaceLimitConfig).bConfigured, + "legacy workspace limit config"); + const FLegacyMigrationPlan WorkspaceLimited = + BuildLegacyMigrationPlan( + WorkspaceLimitService, + {EPathDomain::EngineContent, "Save", "new-workspace"}); + Require(!WorkspaceLimited.bComplete + && HasDiagnostic( + WorkspaceLimited.Diagnostics, + "path.migration.workspace_limit"), + "legacy staging workspace quota"); + } + } + + int RunPathContracts() + { + try + { + RunPathSchemaContracts(); + RunResolverContracts(); + RunPackagePolicyContracts(); + RunAtomicStoreContracts(); + RunLegacyMigrationContracts(); + return 0; + } + catch (const std::exception& Error) + { + std::cerr << "Path contract failed: " << Error.what() << '\n'; + return 1; + } + catch (...) + { + std::cerr << "Path contract failed with an unknown exception.\n"; + return 1; + } + } +} diff --git a/Tests/PathContracts.h b/Tests/PathContracts.h new file mode 100644 index 0000000..776efdd --- /dev/null +++ b/Tests/PathContracts.h @@ -0,0 +1,6 @@ +#pragma once + +namespace WaveContracts +{ + int RunPathContracts(); +} diff --git a/Tests/cmake_preset_contracts.py b/Tests/cmake_preset_contracts.py index 9086267..aefa2c5 100644 --- a/Tests/cmake_preset_contracts.py +++ b/Tests/cmake_preset_contracts.py @@ -41,7 +41,11 @@ def validate_build_preset( preset = find_named_preset(document, "buildPresets", name) require_equal(preset.get("configurePreset"), "vs2022-x64", f"{name}.configurePreset") require_equal(preset.get("configuration"), configuration, f"{name}.configuration") - require_equal(preset.get("targets"), ["WaveCoreContracts"], f"{name}.targets") + require_equal( + preset.get("targets"), + ["WaveCoreContracts", "WaveEditorDomainContracts"], + f"{name}.targets", + ) def validate_test_preset( @@ -109,7 +113,7 @@ def main() -> int: print( "CMake preset contracts passed: canonical VS 2022 x64 configure, " - "Debug/Release CPU-only build and non-empty CPU test gates" + "Debug/Release CPU-only core/editor-domain builds and non-empty CPU test gates" ) return 0 diff --git a/Tests/editor_camera_authoring_contracts.py b/Tests/editor_camera_authoring_contracts.py new file mode 100644 index 0000000..ad579e9 --- /dev/null +++ b/Tests/editor_camera_authoring_contracts.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Exercise the migrated CameraAuthoring compatibility and Undo path.""" + +from __future__ import annotations + +import json +import math +import queue +import subprocess +import sys +import threading +from pathlib import Path + + +RESPONSE_TIMEOUT_SECONDS = 30.0 + + +class RpcSession: + def __init__(self, process: subprocess.Popen[str]): + self.process = process + self.responses: queue.Queue[str | None] = queue.Queue() + self.stderr_lines: list[str] = [] + self.next_id = 0 + threading.Thread(target=self._read_stdout, daemon=True).start() + threading.Thread(target=self._read_stderr, daemon=True).start() + + def _read_stdout(self) -> None: + assert self.process.stdout is not None + try: + for line in self.process.stdout: + self.responses.put(line) + finally: + self.responses.put(None) + + def _read_stderr(self) -> None: + assert self.process.stderr is not None + for line in self.process.stderr: + self.stderr_lines.append(line.rstrip()) + + def send(self, method: str, params=None, expected_error: int | None = None): + assert self.process.stdin is not None + self.next_id += 1 + request = { + "jsonrpc": "2.0", + "id": self.next_id, + "method": method, + "params": [] if params is None else params, + } + self.process.stdin.write( + json.dumps(request, separators=(",", ":")) + "\n" + ) + self.process.stdin.flush() + try: + line = self.responses.get(timeout=RESPONSE_TIMEOUT_SECONDS) + except queue.Empty as error: + raise AssertionError(f"timed out waiting for {method}") from error + if line is None: + raise AssertionError( + f"WaveEditor exited while waiting for {method}: " + + "\n".join(self.stderr_lines[-30:]) + ) + response = json.loads(line) + assert response.get("id") == self.next_id, response + if expected_error is None: + assert "error" not in response and "result" in response, response + return response["result"] + assert response.get("error", {}).get("code") == expected_error, response + return response["error"] + + +def close_enough(left, right, tolerance: float = 1.0e-4) -> bool: + if isinstance(left, dict) and isinstance(right, dict): + return left.keys() == right.keys() and all( + close_enough(left[key], right[key], tolerance) for key in left + ) + if isinstance(left, list) and isinstance(right, list): + return len(left) == len(right) and all( + close_enough(a, b, tolerance) for a, b in zip(left, right) + ) + if isinstance(left, (int, float)) and isinstance(right, (int, float)): + return math.isclose(float(left), float(right), abs_tol=tolerance) + return left == right + + +def main() -> int: + if len(sys.argv) != 2: + raise SystemExit("expected path to WaveEditor.exe") + executable = Path(sys.argv[1]).resolve() + if not executable.is_file(): + raise SystemExit(f"executable not found: {executable}") + + repository = Path(__file__).resolve().parents[1] + process = subprocess.Popen( + [str(executable)], + cwd=repository, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + ) + session = RpcSession(process) + exit_requested = False + try: + original = session.send("editor.viewport.camera.get") + assert set(original) == { + "position", + "rotation_deg", + "coordinate_system", + "fov", + "near", + "far", + } + assert original["coordinate_system"] == "LH_XForward_YRight_ZUp" + assert session.send("editor.history.can_undo") is False + + session.send( + "editor.viewport.camera.set", + [original["position"], original["rotation_deg"]], + ) + assert session.send("editor.history.can_undo") is False + assert close_enough(session.send("editor.viewport.camera.get"), original) + + session.send( + "editor.viewport.camera.set", + [[1e100, 0.0, 0.0], [0.0, 0.0, 0.0]], + expected_error=-32602, + ) + assert session.send("editor.history.can_undo") is False + assert close_enough(session.send("editor.viewport.camera.get"), original) + + clamped_rotation = [89.0, 90.0, 30.0] + session.send( + "editor.viewport.camera.set", + [original["position"], [120.0, 450.0, 30.0]], + ) + assert close_enough( + session.send("editor.viewport.camera.get")["rotation_deg"], + clamped_rotation, + ) + assert session.send("editor.history.undo") is True + assert close_enough(session.send("editor.viewport.camera.get"), original) + + delta = [0.25, -0.5, 0.75] + moved = [ + original["position"][index] + delta[index] + for index in range(3) + ] + session.send("editor.viewport.camera.move", [delta]) + assert close_enough( + session.send("editor.viewport.camera.get")["position"], + moved, + ) + assert session.send("editor.history.undo") is True + assert close_enough(session.send("editor.viewport.camera.get"), original) + + rolled_rotation = [10.0, 20.0, 25.0] + session.send( + "editor.viewport.camera.set", + [original["position"], rolled_rotation], + ) + current = session.send("editor.viewport.camera.get") + assert close_enough(current["rotation_deg"], rolled_rotation) + target = [ + current["position"][0] + 1.0, + current["position"][1] + 1.0, + current["position"][2] + 1.0, + ] + session.send("editor.viewport.camera.look_at", [target]) + aimed = session.send("editor.viewport.camera.get") + assert close_enough(aimed["rotation_deg"], [35.26439, 45.0, 0.0]) + assert session.send("editor.history.undo") is True + assert close_enough( + session.send("editor.viewport.camera.get")["rotation_deg"], + rolled_rotation, + ) + assert session.send("editor.history.undo") is True + assert close_enough(session.send("editor.viewport.camera.get"), original) + + session.send( + "editor.viewport.camera.look_at", + [original["position"]], + expected_error=-32603, + ) + assert session.send("editor.history.can_undo") is False + assert close_enough(session.send("editor.viewport.camera.get"), original) + + session.send("editor.app.request_exit") + exit_requested = True + assert process.wait(timeout=RESPONSE_TIMEOUT_SECONDS) == 0 + print( + "Editor CameraAuthoring contracts passed: legacy JSON, finite " + "validation, Pitch/Yaw/Roll clamp, move/look-at Roll, and Undo restore" + ) + return 0 + finally: + if process.poll() is None: + if not exit_requested: + try: + session.send("editor.app.request_exit") + except (AssertionError, BrokenPipeError, OSError): + pass + try: + process.wait(timeout=5.0) + except subprocess.TimeoutExpired: + process.terminate() + process.wait(timeout=10.0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tests/editor_domain_target_contracts.py b/Tests/editor_domain_target_contracts.py new file mode 100644 index 0000000..d957ca2 --- /dev/null +++ b/Tests/editor_domain_target_contracts.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +EXPECTED_SOURCES = { + "Tests/EditorDomainContractTests.h", + "Tests/EditorDomainContractTests.cpp", + "Tests/EditorDomainContractsMain.cpp", + "Engine/Source/Contract/ContractCore.h", + "Engine/Source/Contract/ContractCore.cpp", + "Engine/Source/Developer/Document/EditorDocumentLedger.h", + "Engine/Source/Developer/Document/EditorDocumentLedger.cpp", + "Engine/Source/Developer/Scene/EditorDomainContracts.h", + "Engine/Source/Developer/Scene/EditorDomainContracts.cpp", +} +FORBIDDEN_FRAGMENTS = ( + "EnginePCH", + "UI/", + "Mcp/", + "RHI/", + "Renderer/", + "RenderGraph/", + "Scene/Scene", + "Scene/Camera", + "Scene/Light", + "Window", + "Input/", + "ThirdParty/", + "imgui", + "nlohmann", +) + + +def fail(message: str) -> None: + raise RuntimeError(message) + + +def find_command_body(cmake: str, command: str, first_argument: str) -> str: + match = re.search( + rf"\b{re.escape(command)}\s*\(\s*{re.escape(first_argument)}\b(.*?)\)", + cmake, + re.IGNORECASE | re.DOTALL, + ) + if match is None: + fail(f"missing {command}({first_argument} ...)") + return match.group(1).strip() + + +def resolve_project_include( + repository_root: Path, + including_file: Path, + include: str, +) -> Path | None: + candidates = ( + including_file.parent / include, + repository_root / "Engine" / "Source" / include, + repository_root / "Tests" / include, + ) + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + return None + + +def validate_include_closure(repository_root: Path) -> int: + pending = [(repository_root / source).resolve() for source in EXPECTED_SOURCES] + visited: set[Path] = set() + while pending: + path = pending.pop() + if path in visited: + continue + visited.add(path) + relative = path.relative_to(repository_root).as_posix() + if relative not in EXPECTED_SOURCES: + fail(f"unexpected project include in editor-domain closure: {relative}") + text = path.read_text(encoding="utf-8") + for fragment in FORBIDDEN_FRAGMENTS: + if fragment.lower() in text.lower(): + fail(f"forbidden editor-domain dependency in {relative}: {fragment}") + for include in re.findall(r'^\s*#\s*include\s*"([^"]+)"', text, re.MULTILINE): + resolved = resolve_project_include(repository_root, path, include) + if resolved is not None and resolved not in visited: + pending.append(resolved) + return len(visited) + + +def validate_slice2_ownership(repository_root: Path) -> None: + scene_method_names = { + "editor.hierarchy.list", + "editor.hierarchy.select", + "editor.hierarchy.delete", + "editor.hierarchy.rename", + "editor.inspector.get", + "editor.inspector.set_field", + "editor.inspector.list_fields", + } + camera_method_names = { + "editor.viewport.camera.get", + "editor.viewport.camera.set", + "editor.viewport.camera.move", + "editor.viewport.camera.look_at", + } + render_method_names = { + "editor.lighting.get_main", + "editor.lighting.set_main", + "editor.renderer.get_settings", + "editor.renderer.set_setting", + "editor.viewport.set_debug_view", + } + method_names = scene_method_names | camera_method_names | render_method_names + adapter_path = ( + repository_root + / "Engine/Source/Developer/Scene/EditorDomainMcpAdapter.cpp" + ) + adapter = adapter_path.read_text(encoding="utf-8") + for method_name in method_names: + registration_count = len( + re.findall( + rf'Registry\.(?:AddReadOnly|Add)\s*\(\s*"{re.escape(method_name)}"', + adapter, + ) + ) + if registration_count != 1: + fail( + f"Scene domain MCP adapter must register {method_name} exactly once" + ) + descriptor_source = ( + repository_root + / "Engine/Source/Developer/Scene/EditorDomainContracts.cpp" + ).read_text(encoding="utf-8") + for method_name in method_names: + descriptor_count = len( + re.findall( + rf'Add(?:Query|Command)\s*\(\s*"{re.escape(method_name)}"', + descriptor_source, + ) + ) + if descriptor_count != 1: + fail( + f"typed v2 descriptor must cover {method_name} exactly once" + ) + for required in ( + "MakeEditorDomainCapabilityDescriptors", + "AddCapabilityVersion", + ): + if required not in adapter: + fail(f"Scene domain MCP adapter omitted typed v2 registration: {required}") + + for relative in ( + "Engine/Source/UI/Panels/HierarchyPanel.h", + "Engine/Source/UI/Panels/HierarchyPanel.cpp", + "Engine/Source/UI/Panels/InspectorPanel.h", + "Engine/Source/UI/Panels/InspectorPanel.cpp", + ): + text = (repository_root / relative).read_text(encoding="utf-8") + for forbidden in ("McpIdentity", "McpValidation", "RegisterMcpTools"): + if forbidden in text: + fail(f"{relative} retained migrated Scene ownership: {forbidden}") + if re.search(r"Registry\.(?:AddReadOnly|Add)\s*\(", text): + fail(f"{relative} retained migrated MCP registration") + + for relative in ( + "Engine/Source/Developer/Scene/SceneAuthoringService.h", + "Engine/Source/Developer/Scene/SceneAuthoringService.cpp", + "Engine/Source/Developer/Scene/CameraAuthoringService.h", + "Engine/Source/Developer/Scene/CameraAuthoringService.cpp", + "Engine/Source/Developer/Scene/LightingAuthoringService.h", + "Engine/Source/Developer/Scene/LightingAuthoringService.cpp", + "Engine/Source/Developer/Render/RenderSettingsAuthoringService.h", + "Engine/Source/Developer/Render/RenderSettingsAuthoringService.cpp", + ): + text = (repository_root / relative).read_text(encoding="utf-8") + for forbidden in ("UI/", "Mcp/", "FJson", "nlohmann", "imgui"): + if forbidden.lower() in text.lower(): + fail( + f"transport-neutral SceneAuthoring depends on {forbidden}: " + f"{relative}" + ) + + viewport = ( + repository_root / "Engine/Source/UI/Panels/ViewportPanel.cpp" + ).read_text(encoding="utf-8") + for forbidden in ("FViewportPanel::McpCamera", "McpValidation"): + if forbidden in viewport: + fail(f"ViewportPanel retained migrated Camera ownership: {forbidden}") + for method_name in camera_method_names: + if re.search( + rf'Registry\.(?:AddReadOnly|Add)\s*\(\s*"{re.escape(method_name)}"', + viewport, + ): + fail( + f"ViewportPanel retained migrated Camera registration: {method_name}" + ) + + inspector = ( + repository_root / "Engine/Source/UI/Panels/InspectorPanel.cpp" + ).read_text(encoding="utf-8") + for forbidden in ( + "Scene->Camera.Position =", + "Scene->Camera.SetRotation", + "EditorCommands::SetCameraTransform", + "MainLight->Color =", + "MainLight->Intensity =", + "MainLight->Direction =", + ): + if forbidden in inspector: + fail(f"InspectorPanel retained direct domain writer: {forbidden}") + + for relative in ( + "Engine/Source/UI/Panels/EngineSettingsPanel.h", + "Engine/Source/UI/Panels/EngineSettingsPanel.cpp", + "Engine/Source/UI/Panels/ViewportPanel.h", + "Engine/Source/UI/Panels/ViewportPanel.cpp", + ): + text = (repository_root / relative).read_text(encoding="utf-8") + for forbidden in ( + "McpLightingGetMain", + "McpLightingSetMain", + "McpRendererGetSettings", + "McpRendererSetSetting", + "McpSetDebugView", + "McpValidation", + ): + if forbidden in text: + fail( + f"{relative} retained migrated Render ownership: " + f"{forbidden}" + ) + for method_name in render_method_names: + if re.search( + rf'Registry\.(?:AddReadOnly|Add)\s*\(\s*"{re.escape(method_name)}"', + text, + ): + fail( + f"{relative} retained migrated Render registration: " + f"{method_name}" + ) + + direct_render_writers = { + "Engine/Source/UI/Panels/EngineSettingsPanel.cpp": ( + "Scene->RenderMode =", + "Scene->DebugViewMode =", + "Scene->LODErrorThreshold =", + "Scene->bAutoExposureEnabled =", + "Scene->PTMaxBounces =", + ), + "Engine/Source/UI/Panels/ViewportPanel.cpp": ( + "Scene->DebugViewMode =", + ), + "Engine/Source/UI/EditorShell.cpp": ( + "Scene->RenderMode =", + "Scene->bAutoExposureEnabled =", + ), + "Engine/Source/UI/ImGuiLayer.cpp": ( + "Scene->RenderMode =", + "Scene->DebugViewMode =", + "Scene->LODErrorThreshold =", + "Scene->MainLight->Color =", + "Scene->MainLight->Intensity =", + "Scene->MainLight->Direction =", + ), + } + for relative, forbidden_writers in direct_render_writers.items(): + text = (repository_root / relative).read_text(encoding="utf-8") + for forbidden in forbidden_writers: + if forbidden in text: + fail( + f"{relative} retained direct Render writer: {forbidden}" + ) + + imgui_layer = ( + repository_root / "Engine/Source/UI/ImGuiLayer.cpp" + ).read_text(encoding="utf-8") + for forbidden in ( + "bCameraSnapshotInitialized", + "LastCameraPosition", + "LastCameraRotation", + ): + if forbidden in imgui_layer: + fail(f"ImGuiLayer retained Camera observer state: {forbidden}") + if "ObserveExternalCameraChange" not in imgui_layer: + fail("ImGuiLayer does not route Camera navigation observation through service") + + for relative in ( + "Engine/Source/UI/EditorAssetLoader.cpp", + "Engine/Source/UI/Panels/AssetsPanel.cpp", + ): + text = (repository_root / relative).read_text(encoding="utf-8") + if "EditorCommands::AddInstance" in text: + fail(f"{relative} retained direct AddInstance promotion") + if "SceneAuthoring().AddInstance" not in text: + fail(f"{relative} does not promote through SceneAuthoring") + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: editor_domain_target_contracts.py ", file=sys.stderr) + return 2 + + repository_root = Path(sys.argv[1]).resolve() + cmake = (repository_root / "CMakeLists.txt").read_text(encoding="utf-8") + source_body = find_command_body( + cmake, + "set", + "WAVE_EDITOR_DOMAIN_CONTRACT_SOURCES", + ) + source_entries = { + entry.replace("\\", "/") + for entry in re.findall( + r'"\$\{PROJECT_SOURCE_DIR\}/([^"]+\.(?:cpp|h))"', + source_body, + re.IGNORECASE, + ) + } + if source_entries != EXPECTED_SOURCES: + fail( + "WaveEditorDomainContracts explicit source set mismatch: " + f"missing={sorted(EXPECTED_SOURCES - source_entries)!r}, " + f"extra={sorted(source_entries - EXPECTED_SOURCES)!r}" + ) + for source in source_entries: + if not (repository_root / source).is_file(): + fail(f"missing editor-domain contract source: {source}") + + target_body = find_command_body( + cmake, + "add_executable", + "WaveEditorDomainContracts", + ) + if target_body != "${WAVE_EDITOR_DOMAIN_CONTRACT_SOURCES}": + fail( + "WaveEditorDomainContracts must use only " + "WAVE_EDITOR_DOMAIN_CONTRACT_SOURCES" + ) + if re.search( + r"target_(?:sources|precompile_headers|link_libraries)\s*" + r"\(\s*WaveEditorDomainContracts\b", + cmake, + re.IGNORECASE, + ): + fail( + "WaveEditorDomainContracts must not add sources, PCH, or link dependencies" + ) + + include_count = validate_include_closure(repository_root) + validate_slice2_ownership(repository_root) + print( + "WaveEditorDomainContracts boundary passed: " + f"{len(source_entries)} explicit sources, {include_count} project files, " + "no Runtime/UI/MCP/RHI/ThirdParty dependency or PCH; " + "Slice 2 Scene, Slice 3 Camera, Slice 4 Render, and Slice 5 boundary ownership are centralized" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError) as error: + print(f"WaveEditorDomainContracts boundary failed: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/Tests/editor_render_authoring_contracts.py b/Tests/editor_render_authoring_contracts.py new file mode 100644 index 0000000..7a444a4 --- /dev/null +++ b/Tests/editor_render_authoring_contracts.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""Exercise migrated Lighting/Render Settings legacy compatibility.""" + +from __future__ import annotations + +import json +import math +import queue +import subprocess +import sys +import threading +from pathlib import Path + + +RESPONSE_TIMEOUT_SECONDS = 30.0 +RENDER_METHODS = { + "editor.lighting.get_main", + "editor.lighting.set_main", + "editor.renderer.get_settings", + "editor.renderer.set_setting", + "editor.viewport.set_debug_view", +} + + +class RpcSession: + def __init__(self, process: subprocess.Popen[str]): + self.process = process + self.responses: queue.Queue[str | None] = queue.Queue() + self.stderr_lines: list[str] = [] + self.next_id = 0 + threading.Thread(target=self._read_stdout, daemon=True).start() + threading.Thread(target=self._read_stderr, daemon=True).start() + + def _read_stdout(self) -> None: + assert self.process.stdout is not None + try: + for line in self.process.stdout: + self.responses.put(line) + finally: + self.responses.put(None) + + def _read_stderr(self) -> None: + assert self.process.stderr is not None + for line in self.process.stderr: + self.stderr_lines.append(line.rstrip()) + + def send(self, method: str, params=None, expected_error: int | None = None): + assert self.process.stdin is not None + self.next_id += 1 + request = { + "jsonrpc": "2.0", + "id": self.next_id, + "method": method, + "params": [] if params is None else params, + } + self.process.stdin.write( + json.dumps(request, separators=(",", ":")) + "\n" + ) + self.process.stdin.flush() + try: + line = self.responses.get(timeout=RESPONSE_TIMEOUT_SECONDS) + except queue.Empty as error: + raise AssertionError(f"timed out waiting for {method}") from error + if line is None: + raise AssertionError( + f"WaveEditor exited while waiting for {method}: " + + "\n".join(self.stderr_lines[-30:]) + ) + response = json.loads(line) + assert response.get("id") == self.next_id, response + if expected_error is None: + assert "error" not in response and "result" in response, response + return response["result"] + assert response.get("error", {}).get("code") == expected_error, response + return response["error"] + + +def close_enough(left, right, tolerance: float = 1.0e-5) -> bool: + if isinstance(left, dict) and isinstance(right, dict): + return left.keys() == right.keys() and all( + close_enough(left[key], right[key], tolerance) for key in left + ) + if isinstance(left, list) and isinstance(right, list): + return len(left) == len(right) and all( + close_enough(a, b, tolerance) for a, b in zip(left, right) + ) + if isinstance(left, (int, float)) and isinstance(right, (int, float)): + return math.isclose(float(left), float(right), abs_tol=tolerance) + return left == right + + +def configured_debug_mode(config_bytes: bytes | None) -> str: + if config_bytes is None: + return "none" + try: + for line in config_bytes.decode("utf-8").splitlines(): + parts = line.split() + if len(parts) == 2 and parts[0] == "DebugView": + return { + 0: "none", + 1: "meshlet_index", + 2: "group_id", + }.get(int(parts[1]), "none") + except (UnicodeDecodeError, ValueError): + pass + return "none" + + +def different_float(value: float, first: float, second: float) -> float: + return second if math.isclose(value, first, abs_tol=1.0e-6) else first + + +def main() -> int: + if len(sys.argv) != 2: + raise SystemExit("expected path to WaveEditor.exe") + executable = Path(sys.argv[1]).resolve() + if not executable.is_file(): + raise SystemExit(f"executable not found: {executable}") + + repository = Path(__file__).resolve().parents[1] + config_path = ( + repository / "Engine/Save/Project/Config/ui_config.cfg" + ) + original_config = config_path.read_bytes() if config_path.is_file() else None + original_debug = configured_debug_mode(original_config) + process = subprocess.Popen( + [str(executable)], + cwd=repository, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + ) + session = RpcSession(process) + exit_requested = False + try: + methods = session.send("rpc.discover") + assert len(methods) == 35, methods + assert RENDER_METHODS <= set(methods), methods + assert session.send("editor.history.can_undo") is False + + original_light = session.send("editor.lighting.get_main") + assert set(original_light) == {"color", "intensity", "direction"} + session.send( + "editor.lighting.set_main", + [ + original_light["color"], + original_light["intensity"], + original_light["direction"], + ], + ) + assert session.send("editor.history.can_undo") is False + assert close_enough( + session.send("editor.lighting.get_main"), + original_light, + ) + + session.send( + "editor.lighting.set_main", + [[1.1, 0.0, 0.0], 1.0, [1.0, 0.0, 0.0]], + expected_error=-32602, + ) + session.send( + "editor.lighting.set_main", + [[1.0, 1.0, 1.0], 1.0, [0.0, 0.0, 0.0]], + expected_error=-32602, + ) + assert close_enough( + session.send("editor.lighting.get_main"), + original_light, + ) + changed_color = [ + different_float(float(component), 0.25, 0.75) + for component in original_light["color"] + ] + changed_intensity = different_float( + float(original_light["intensity"]), + 2.0, + 3.0, + ) + session.send( + "editor.lighting.set_main", + [changed_color, changed_intensity, [1.0, 2.0, 3.0]], + ) + changed_light = session.send("editor.lighting.get_main") + direction_length = math.sqrt( + sum(component * component for component in changed_light["direction"]) + ) + assert close_enough(changed_light["color"], changed_color) + assert math.isclose(direction_length, 1.0, abs_tol=1.0e-5) + assert session.send("editor.history.can_undo") is False + session.send( + "editor.lighting.set_main", + [ + original_light["color"], + original_light["intensity"], + original_light["direction"], + ], + ) + assert close_enough( + session.send("editor.lighting.get_main"), + original_light, + ) + + original_settings = session.send("editor.renderer.get_settings") + assert set(original_settings) == { + "render_mode", + "auto_exposure_enabled", + "manual_exposure", + "pt_max_bounces", + "pt_samples_per_pixel", + "pt_accumulation_enabled", + "lod_error_threshold", + } + session.send( + "editor.renderer.set_setting", + ["render_mode", original_settings["render_mode"]], + ) + assert session.send("editor.history.can_undo") is False + assert close_enough( + session.send("editor.renderer.get_settings"), + original_settings, + ) + + changed_settings = { + "render_mode": ( + "path_tracing" + if original_settings["render_mode"] == "forward" + else "forward" + ), + "auto_exposure_enabled": not original_settings[ + "auto_exposure_enabled" + ], + "manual_exposure": different_float( + float(original_settings["manual_exposure"]), + 2.0, + 3.0, + ), + "pt_max_bounces": ( + 2 if original_settings["pt_max_bounces"] == 1 else 1 + ), + "pt_samples_per_pixel": ( + 2 if original_settings["pt_samples_per_pixel"] == 1 else 1 + ), + "pt_accumulation_enabled": not original_settings[ + "pt_accumulation_enabled" + ], + "lod_error_threshold": different_float( + float(original_settings["lod_error_threshold"]), + 0.001, + 0.002, + ), + } + for path, value in changed_settings.items(): + session.send("editor.renderer.set_setting", [path, value]) + assert close_enough( + session.send("editor.renderer.get_settings"), + changed_settings, + ) + assert session.send("editor.history.can_undo") is False + + for params in ( + ["render_mode", "invalid"], + ["manual_exposure", 0.0], + ["pt_max_bounces", 17], + ["pt_samples_per_pixel", 9], + ["lod_error_threshold", 0.0], + ["unknown", 1], + ): + session.send( + "editor.renderer.set_setting", + params, + expected_error=( + -32603 + if params[0] in {"render_mode", "unknown"} + else -32602 + ), + ) + assert close_enough( + session.send("editor.renderer.get_settings"), + changed_settings, + ) + + for path, value in original_settings.items(): + session.send("editor.renderer.set_setting", [path, value]) + assert close_enough( + session.send("editor.renderer.get_settings"), + original_settings, + ) + + for mode in ("meshlet_index", "group_id", "none"): + session.send("editor.viewport.set_debug_view", [mode]) + assert session.send("editor.history.can_undo") is False + session.send( + "editor.viewport.set_debug_view", + ["invalid"], + expected_error=-32603, + ) + session.send("editor.viewport.set_debug_view", [original_debug]) + assert session.send("editor.history.can_undo") is False + + session.send("editor.app.request_exit") + exit_requested = True + assert process.wait(timeout=RESPONSE_TIMEOUT_SECONDS) == 0 + print( + "Editor Render authoring contracts passed: five legacy methods, " + "exact JSON fields, validation, normalized light, no fake Undo, " + "and restored settings" + ) + return 0 + finally: + if process.poll() is None: + if not exit_requested: + try: + session.send("editor.app.request_exit") + except (AssertionError, BrokenPipeError, OSError): + pass + try: + process.wait(timeout=5.0) + except subprocess.TimeoutExpired: + process.terminate() + process.wait(timeout=10.0) + if original_config is None: + if config_path.is_file(): + config_path.unlink() + else: + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_bytes(original_config) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tests/editor_scene_authoring_contracts.py b/Tests/editor_scene_authoring_contracts.py new file mode 100644 index 0000000..220fee2 --- /dev/null +++ b/Tests/editor_scene_authoring_contracts.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Exercise the migrated SceneAuthoring compatibility and Undo path.""" + +from __future__ import annotations + +import json +import math +import queue +import subprocess +import sys +import threading +from pathlib import Path + + +RESPONSE_TIMEOUT_SECONDS = 30.0 +SCENE_METHODS = { + "editor.hierarchy.list", + "editor.hierarchy.select", + "editor.hierarchy.delete", + "editor.hierarchy.rename", + "editor.inspector.get", + "editor.inspector.set_field", + "editor.inspector.list_fields", +} + + +class RpcSession: + def __init__(self, process: subprocess.Popen[str]): + self.process = process + self.responses: queue.Queue[str | None] = queue.Queue() + self.stderr_lines: list[str] = [] + self.next_id = 0 + threading.Thread(target=self._read_stdout, daemon=True).start() + threading.Thread(target=self._read_stderr, daemon=True).start() + + def _read_stdout(self) -> None: + assert self.process.stdout is not None + try: + for line in self.process.stdout: + self.responses.put(line) + finally: + self.responses.put(None) + + def _read_stderr(self) -> None: + assert self.process.stderr is not None + for line in self.process.stderr: + self.stderr_lines.append(line.rstrip()) + + def send(self, method: str, params=None, expected_error: int | None = None): + assert self.process.stdin is not None + self.next_id += 1 + request = { + "jsonrpc": "2.0", + "id": self.next_id, + "method": method, + "params": [] if params is None else params, + } + self.process.stdin.write( + json.dumps(request, separators=(",", ":")) + "\n" + ) + self.process.stdin.flush() + try: + line = self.responses.get(timeout=RESPONSE_TIMEOUT_SECONDS) + except queue.Empty as error: + raise AssertionError(f"timed out waiting for {method}") from error + if line is None: + raise AssertionError( + f"WaveEditor exited while waiting for {method}: " + + "\n".join(self.stderr_lines[-30:]) + ) + response = json.loads(line) + assert response.get("id") == self.next_id, response + if expected_error is None: + assert "error" not in response and "result" in response, response + return response["result"] + assert response.get("error", {}).get("code") == expected_error, response + return response["error"] + + +def close_enough(left, right, tolerance: float = 1.0e-5) -> bool: + if isinstance(left, dict) and isinstance(right, dict): + return left.keys() == right.keys() and all( + close_enough(left[key], right[key], tolerance) for key in left + ) + if isinstance(left, list) and isinstance(right, list): + return len(left) == len(right) and all( + close_enough(a, b, tolerance) for a, b in zip(left, right) + ) + if isinstance(left, (int, float)) and isinstance(right, (int, float)): + return math.isclose(float(left), float(right), abs_tol=tolerance) + return left == right + + +def require_instance(session: RpcSession, instance_id: int) -> dict: + return session.send("editor.inspector.get", [instance_id]) + + +def main() -> int: + if len(sys.argv) != 2: + raise SystemExit("expected path to WaveEditor.exe") + executable = Path(sys.argv[1]).resolve() + if not executable.is_file(): + raise SystemExit(f"executable not found: {executable}") + + repository = Path(__file__).resolve().parents[1] + process = subprocess.Popen( + [str(executable)], + cwd=repository, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + ) + session = RpcSession(process) + exit_requested = False + try: + methods = session.send("rpc.discover") + assert len(methods) == 35, methods + assert SCENE_METHODS <= set(methods), methods + + hierarchy = session.send("editor.hierarchy.list") + assert hierarchy, "default scene hierarchy must not be empty" + assert all(set(item) == {"id", "name", "position"} for item in hierarchy) + instance_id = hierarchy[0]["id"] + original = require_instance(session, instance_id) + assert set(original) == { + "name", + "position", + "rotation", + "rotation_euler_deg", + "coordinate_system", + "scale", + } + assert original["coordinate_system"] == "LH_XForward_YRight_ZUp" + assert [field["path"] for field in session.send( + "editor.inspector.list_fields", [instance_id] + )] == [ + "transform.position", + "transform.rotation", + "transform.rotation_euler_deg", + "transform.scale", + ] + # Preserve the legacy quirk: list_fields accepts any uint32 id. + assert len(session.send( + "editor.inspector.list_fields", [4294967295] + )) == 4 + session.send("editor.hierarchy.select", [instance_id]) + + assert session.send("editor.history.can_undo") is False + session.send( + "editor.inspector.set_field", + [instance_id, "transform.position", original["position"]], + ) + assert session.send("editor.history.can_undo") is False + assert close_enough(require_instance(session, instance_id), original) + + session.send( + "editor.inspector.set_field", + [instance_id, "transform.scale", [0.0, 1.0, 1.0]], + expected_error=-32602, + ) + assert session.send("editor.history.can_undo") is False + assert close_enough(require_instance(session, instance_id), original) + + moved_position = list(original["position"]) + moved_position[0] += 0.25 + session.send( + "editor.inspector.set_field", + [instance_id, "transform.position", moved_position], + ) + assert close_enough( + require_instance(session, instance_id)["position"], + moved_position, + ) + assert session.send("editor.history.undo") is True + assert close_enough(require_instance(session, instance_id), original) + assert session.send("editor.history.redo") is True + assert close_enough( + require_instance(session, instance_id)["position"], + moved_position, + ) + assert session.send("editor.history.undo") is True + assert close_enough(require_instance(session, instance_id), original) + + renamed = "G0-04 Slice 2 Test" + session.send("editor.hierarchy.rename", [instance_id, renamed]) + assert require_instance(session, instance_id)["name"] == renamed + assert session.send("editor.history.undo") is True + assert require_instance(session, instance_id)["name"] == original["name"] + + session.send("editor.hierarchy.delete", [instance_id]) + assert instance_id not in { + item["id"] for item in session.send("editor.hierarchy.list") + } + assert session.send("editor.history.undo") is True + restored = require_instance(session, instance_id) + assert close_enough(restored, original), (restored, original) + + session.send("editor.app.request_exit") + exit_requested = True + assert process.wait(timeout=RESPONSE_TIMEOUT_SECONDS) == 0 + print( + "Editor SceneAuthoring contracts passed: 7 legacy methods, " + "no-op/invalid isolation, transform/rename/delete Undo/Redo restore" + ) + return 0 + finally: + if process.poll() is None: + if not exit_requested: + try: + session.send("editor.app.request_exit") + except (AssertionError, BrokenPipeError, OSError): + pass + try: + process.wait(timeout=5.0) + except subprocess.TimeoutExpired: + process.terminate() + process.wait(timeout=10.0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tests/editor_window_lifecycle_contracts.py b/Tests/editor_window_lifecycle_contracts.py index deffc76..11689b2 100644 --- a/Tests/editor_window_lifecycle_contracts.py +++ b/Tests/editor_window_lifecycle_contracts.py @@ -10,6 +10,7 @@ import queue import subprocess import sys +import tempfile import threading import time from pathlib import Path @@ -37,6 +38,19 @@ def wait_until(predicate, timeout: float, message: str) -> None: fail(message) +def cleanup_temporary_directory(directory: tempfile.TemporaryDirectory[str]) -> None: + last_error: PermissionError | None = None + for _ in range(50): + try: + directory.cleanup() + return + except PermissionError as error: + last_error = error + time.sleep(0.1) + if last_error is not None: + raise last_error + + class RpcSession: def __init__(self, process: subprocess.Popen[str]): self.process = process @@ -111,9 +125,12 @@ def main() -> int: if not executable.is_file(): raise SystemExit(f"executable not found: {executable}") + outside_directory = tempfile.TemporaryDirectory(prefix="wave-editor-non-cwd-") + outside_cwd = Path(outside_directory.name).resolve() + process_start_ns = time.time_ns() process = subprocess.Popen( [str(executable)], - cwd=repository, + cwd=outside_cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -202,6 +219,16 @@ def capture_window() -> bool: process.wait(timeout=RESPONSE_TIMEOUT_SECONDS) if process.returncode != 0: fail(f"WaveEditor returned {process.returncode} after normal request_exit") + if (outside_cwd / "WaveEngine.log").exists(): + fail("WaveEditor wrote WaveEngine.log to the process current directory") + user_log = ( + Path(os.environ["LOCALAPPDATA"]) + / "WaveEngine" + / "Logs" + / "WaveEngine.log" + ) + if not user_log.is_file() or user_log.stat().st_mtime_ns < process_start_ns: + fail("WaveEditor did not write the current session log to UserSaved/Logs") finally: if process.poll() is None: if not normal_exit_requested: @@ -218,10 +245,12 @@ def capture_window() -> bool: except subprocess.TimeoutExpired: process.kill() process.wait(timeout=5.0) + cleanup_temporary_directory(outside_directory) print( - "WaveEditor window lifecycle contracts passed: resize, minimize, " - "main-thread MCP drain, restore, and minimized request_exit" + "WaveEditor window lifecycle contracts passed from a non-repository cwd: " + "resize, minimize, main-thread MCP drain, restore, UserSaved logging, " + "and minimized request_exit" ) return 0 diff --git a/Tests/mcp_asset_input_contracts.py b/Tests/mcp_asset_input_contracts.py index ccb3939..adb5835 100644 --- a/Tests/mcp_asset_input_contracts.py +++ b/Tests/mcp_asset_input_contracts.py @@ -22,6 +22,24 @@ RESPONSE_TIMEOUT_SECONDS = 30.0 +TYPED_EDITOR_DOMAIN_METHODS = { + "editor.hierarchy.list", + "editor.hierarchy.select", + "editor.hierarchy.delete", + "editor.hierarchy.rename", + "editor.inspector.get", + "editor.inspector.set_field", + "editor.inspector.list_fields", + "editor.viewport.camera.get", + "editor.viewport.camera.set", + "editor.viewport.camera.move", + "editor.viewport.camera.look_at", + "editor.lighting.get_main", + "editor.lighting.set_main", + "editor.renderer.get_settings", + "editor.renderer.set_setting", + "editor.viewport.set_debug_view", +} def _reader(stream, output: queue.Queue[str | None]) -> None: @@ -127,10 +145,15 @@ def expect_stderr(fragment: str, start_index: int) -> None: }, None, ) + registration_roots = ( + repository / "Engine" / "Source" / "UI", + repository / "Engine" / "Source" / "Developer", + ) expected_names = sorted( { match.group(1) - for source_path in (repository / "Engine" / "Source" / "UI").rglob("*.cpp") + for source_root in registration_roots + for source_path in source_root.rglob("*.cpp") for match in re.finditer( r'Registry\.(?:AddReadOnly|Add)\s*\(\s*"([^"]+)"', source_path.read_text(encoding="utf-8"), @@ -138,6 +161,11 @@ def expect_stderr(fragment: str, start_index: int) -> None: } ) assert expected_names, "source capability registration catalog is empty" + assert TYPED_EDITOR_DOMAIN_METHODS <= set(expected_names) + expected_descriptor_keys = sorted( + [(name, 1, 0) for name in expected_names] + + [(name, 2, 0) for name in TYPED_EDITOR_DOMAIN_METHODS] + ) assert discovery_response["result"] == expected_names, ( discovery_response["result"], expected_names, @@ -153,14 +181,21 @@ def expect_stderr(fragment: str, start_index: int) -> None: "jsonrpc": "2.0", "id": 11, "method": "capability.list", - "params": {}, + "params": {"limit": 100}, }, None, )["result"] assert capability_list["api_version"] == {"major": 1, "minor": 0} - assert capability_list["total"] == len(expected_names) + assert capability_list["total"] == len(expected_descriptor_keys) assert capability_list["next_cursor"] is None - assert [item["name"] for item in capability_list["items"]] == expected_names + assert [ + ( + item["name"], + item["version"]["major"], + item["version"]["minor"], + ) + for item in capability_list["items"] + ] == expected_descriptor_keys assert all( set(item) == { @@ -185,10 +220,19 @@ def expect_stderr(fragment: str, start_index: int) -> None: }, None, )["result"] - expected_editor_names = [name for name in expected_names if name.startswith("editor.")] - assert [item["name"] for item in first_page["items"]] == expected_editor_names[:2] + expected_editor_descriptor_keys = [ + key for key in expected_descriptor_keys if key[0].startswith("editor.") + ] + assert [ + ( + item["name"], + item["version"]["major"], + item["version"]["minor"], + ) + for item in first_page["items"] + ] == expected_editor_descriptor_keys[:2] assert first_page["next_cursor"] == 2 - assert first_page["total"] == len(expected_editor_names) + assert first_page["total"] == len(expected_editor_descriptor_keys) available_only = request( { @@ -201,8 +245,8 @@ def expect_stderr(fragment: str, start_index: int) -> None: )["result"] available_names = {item["name"] for item in available_only["items"]} assert "editor.viewport.camera.set" in available_names - assert "editor.viewport.camera.get" not in available_names - assert 0 < available_only["total"] < len(expected_names) + assert "editor.viewport.camera.get" in available_names + assert 0 < available_only["total"] < len(expected_descriptor_keys) camera_set_descriptor = request( { @@ -246,16 +290,46 @@ def expect_stderr(fragment: str, start_index: int) -> None: assert camera_get_descriptor["available"] is False assert "legacy dynamic JSON shape" in camera_get_descriptor["availability_reason"] - missing_capability = request( + camera_get_typed_descriptor = request( { "jsonrpc": "2.0", "id": 16, "method": "capability.describe", "params": { - "name": "editor.viewport.camera.set", + "name": "editor.viewport.camera.get", "version": {"major": 2, "minor": 0}, }, }, + None, + )["result"]["descriptor"] + assert camera_get_typed_descriptor["version"] == { + "major": 2, + "minor": 0, + } + assert camera_get_typed_descriptor["available"] is True + assert camera_get_typed_descriptor["output_schema"]["kind"] == "object" + assert [ + field["name"] + for field in camera_get_typed_descriptor["output_schema"]["fields"] + ] == [ + "position", + "rotation_deg", + "coordinate_system", + "fov", + "near", + "far", + ] + + missing_capability = request( + { + "jsonrpc": "2.0", + "id": 160, + "method": "capability.describe", + "params": { + "name": "editor.viewport.camera.set", + "version": {"major": 3, "minor": 0}, + }, + }, -32001, ) assert missing_capability["error"]["data"]["code"] == "contract.capability.not_found" diff --git a/Tests/mcp_capability_registration_contracts.py b/Tests/mcp_capability_registration_contracts.py index e31fdd6..880c6bb 100644 --- a/Tests/mcp_capability_registration_contracts.py +++ b/Tests/mcp_capability_registration_contracts.py @@ -86,8 +86,15 @@ def main() -> int: return 2 repository_root = Path(sys.argv[1]).resolve() - ui_root = repository_root / "Engine" / "Source" / "UI" - source_files = sorted(ui_root.rglob("*.cpp")) + registration_roots = ( + repository_root / "Engine" / "Source" / "UI", + repository_root / "Engine" / "Source" / "Developer", + ) + source_files = sorted( + source_path + for source_root in registration_roots + for source_path in source_root.rglob("*.cpp") + ) registrations: list[tuple[str, list[str], Path]] = [] for source_path in source_files: source = source_path.read_text(encoding="utf-8") @@ -118,6 +125,8 @@ def main() -> int: if name in names: fail(f"duplicate MCP capability name: {name}") names.add(name) + if len(names) != 35: + fail(f"legacy MCP method catalog changed: expected 35, found {len(names)}") registry_header = ( repository_root / "Engine" / "Source" / "Mcp" / "McpRegistry.h" diff --git a/Tests/path_target_contracts.py b/Tests/path_target_contracts.py new file mode 100644 index 0000000..a4e2e87 --- /dev/null +++ b/Tests/path_target_contracts.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Static target/path closure contracts for G0-03.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +LEGACY_PATH_SYMBOLS = ( + "ENGINE_FOLDER", + "SHADERS_FOLDER", + "ASSETS_FOLDER", + "ENGINE_TEMP_FOLDER", + "GShaderFolder", + "GShaderTempFolder", +) + + +def fail(message: str) -> None: + raise RuntimeError(message) + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: path_target_contracts.py ", file=sys.stderr) + return 2 + + repository = Path(sys.argv[1]).resolve() + cmake = (repository / "CMakeLists.txt").read_text(encoding="utf-8") + source_roots = ( + repository / "Engine" / "Source", + repository / "Tools" / "TraceViewer", + ) + source_files = [ + path + for root in source_roots + for path in root.rglob("*") + if path.suffix.lower() in {".h", ".hpp", ".cpp", ".cxx"} + ] + for path in source_files: + text = path.read_text(encoding="utf-8", errors="ignore") + for symbol in LEGACY_PATH_SYMBOLS: + if symbol in text: + fail(f"legacy compile-time path symbol remains in {path}: {symbol}") + if "std::filesystem::current_path(" in text: + fail(f"runtime/editor path truth still depends on cwd: {path}") + + for symbol in LEGACY_PATH_SYMBOLS: + if symbol in cmake: + fail(f"CMake still defines legacy compile-time path symbol: {symbol}") + + for required in ( + "Core/Paths/PathService.cpp", + "Core/Paths/AtomicFileStore.cpp", + "Core/Paths/LegacyMigration.cpp", + "list(REMOVE_ITEM WAVE_RUNTIME_SOURCE", + "list(REMOVE_ITEM WAVE_RUNTIME_HEADER", + ): + if required not in cmake: + fail(f"path target closure is missing: {required}") + + editor_application = ( + repository / "Engine" / "Source" / "UI" / "EditorApplication.cpp" + ).read_text(encoding="utf-8") + for required in ( + 'EPathDomain::ProjectSaved', + 'EPathDomain::UserSaved', + '"Logs/WaveEngine.log"', + "GetModuleFileNameW", + ): + if required not in editor_application: + fail(f"Editor path composition is missing: {required}") + + print( + "Path target contracts passed: compile-time absolute path symbols and cwd " + "truth removed; PathService/atomic/migration CPU closure and Runtime " + "migration exclusion are explicit" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError) as error: + print(f"Path target contracts failed: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/Tests/runtime_target_contracts.py b/Tests/runtime_target_contracts.py index 2642706..3e4f143 100644 --- a/Tests/runtime_target_contracts.py +++ b/Tests/runtime_target_contracts.py @@ -25,6 +25,8 @@ "engine/source/ui/", ) FORBIDDEN_RUNTIME_FILES = { + "engine/source/core/paths/legacymigration.cpp", + "engine/source/core/paths/legacymigration.h", "engine/source/renderer/editorcompositepass.cpp", "engine/source/renderer/editorcompositepass.h", "engine/source/renderer/editorselectionpass.cpp", @@ -145,9 +147,11 @@ def main() -> int: 'list(FILTER WAVE_RUNTIME_THIRD_PARTY_FILES EXCLUDE REGEX ' '".*/Engine/ThirdParty/imgui/.*")', "list(REMOVE_ITEM WAVE_RUNTIME_HEADER", + "Core/Paths/LegacyMigration.h", "Renderer/EditorCompositePass.h", "Renderer/EditorSelectionPass.h", "list(REMOVE_ITEM WAVE_RUNTIME_SOURCE", + "Core/Paths/LegacyMigration.cpp", "Renderer/EditorCompositePass.cpp", "Renderer/EditorSelectionPass.cpp", "list(REMOVE_ITEM WAVE_RUNTIME_SHADERS ${WAVE_EDITOR_SHADERS})", diff --git a/Tests/wemesh_cache_contracts.py b/Tests/wemesh_cache_contracts.py index 8be84e5..3d49bfd 100644 --- a/Tests/wemesh_cache_contracts.py +++ b/Tests/wemesh_cache_contracts.py @@ -56,6 +56,15 @@ def restore_file(path: Path, snapshot: FileSnapshot) -> None: os.utime(path, ns=(snapshot.atime_ns, snapshot.mtime_ns)) +def derived_cache_path(repository: Path, source: Path) -> Path: + identity = source.resolve().as_posix().lower().encode("utf-8") + value = 1469598103934665603 + for byte in identity: + value ^= byte + value = (value * 1099511628211) & 0xFFFFFFFFFFFFFFFF + return repository / "Engine" / "Save" / "Derived" / "MeshCache" / f"{value:016x}.wemesh" + + def triangle_bytes(offset: float) -> bytes: positions = ( offset + 0.0, @@ -180,17 +189,19 @@ def main() -> int: raise SystemExit(f"executable not found: {executable}") persistent_paths = ( - repository / "Engine" / "Save" / "Scenes" / "default.wescn", - repository / "Engine" / "Save" / "ui_config.cfg", + repository / "Engine" / "Save" / "Project" / "Scenes" / "default.wescn", + repository / "Engine" / "Save" / "Project" / "Config" / "ui_config.cfg", ) snapshots = {path: snapshot_file(path) for path in persistent_paths} + generated_caches: set[Path] = set() process: subprocess.Popen[str] | None = None try: with tempfile.TemporaryDirectory(prefix="wemesh-contract-", dir=repository / "Tests") as temp_name: temp_root = Path(temp_name) source_a, dependency_a = write_source(temp_root / "asset-a") - cache_a = source_a.with_suffix(".wemesh") + cache_a = derived_cache_path(repository, source_a) + generated_caches.add(cache_a) process = subprocess.Popen( [str(executable)], @@ -313,9 +324,11 @@ def spawn_and_expect(path: Path, cache_state: str) -> None: directory_b.mkdir() source_b = directory_b / source_a.name dependency_b = directory_b / dependency_a.name - cache_b = directory_b / cache_a.name + cache_b = derived_cache_path(repository, source_b) + generated_caches.add(cache_b) shutil.copy2(source_a, source_b) shutil.copy2(dependency_a, dependency_b) + cache_b.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(cache_a, cache_b) spawn_and_expect(source_b, "miss") @@ -354,6 +367,8 @@ def spawn_and_expect(path: Path, cache_state: str) -> None: process.wait(timeout=10) for path, snapshot in snapshots.items(): restore_file(path, snapshot) + for path in generated_caches: + path.unlink(missing_ok=True) if __name__ == "__main__": diff --git a/Tools/TraceViewer/ViewerMain.cpp b/Tools/TraceViewer/ViewerMain.cpp index 22c17de..fa0f8cd 100644 --- a/Tools/TraceViewer/ViewerMain.cpp +++ b/Tools/TraceViewer/ViewerMain.cpp @@ -1,5 +1,6 @@ #include "ViewerApp.h" +#include "Core/Paths/PathService.h" #include "UI/EditorStyle.h" #include "imgui.h" #include "imgui_impl_dx11.h" @@ -21,6 +22,123 @@ namespace float GDpiScale = 1.0f; bool bDpiDirty = false; + std::filesystem::path GetExecutablePath() + { + std::vector Buffer(512); + for (;;) + { + const DWORD Length = GetModuleFileNameW( + nullptr, + Buffer.data(), + static_cast(Buffer.size())); + if (Length == 0) + { + return {}; + } + if (Length < Buffer.size() - 1) + { + return std::filesystem::path( + std::wstring_view(Buffer.data(), Length)).lexically_normal(); + } + Buffer.resize(Buffer.size() * 2); + } + } + + std::filesystem::path GetLocalAppDataPath() + { + std::vector Buffer(512); + for (;;) + { + const DWORD Length = GetEnvironmentVariableW( + L"LOCALAPPDATA", + Buffer.data(), + static_cast(Buffer.size())); + if (Length == 0) + { + return {}; + } + if (Length < Buffer.size()) + { + const std::filesystem::path Result( + std::wstring_view(Buffer.data(), Length)); + return Result.is_absolute() ? Result.lexically_normal() : std::filesystem::path{}; + } + Buffer.resize(static_cast(Length) + 1); + } + } + + bool ConfigurePathService(WavePaths::FPathService& Paths) + { + using namespace WavePaths; + + const std::filesystem::path ExecutablePath = GetExecutablePath(); + const std::filesystem::path UserRoot = GetLocalAppDataPath() / "WaveEngine"; + if (ExecutablePath.empty() || UserRoot.empty()) + { + return false; + } + + const std::filesystem::path ExecutableRoot = ExecutablePath.parent_path(); + const std::filesystem::path ProjectRoot = + ExecutableRoot.parent_path().parent_path(); + const std::filesystem::path EngineRoot = ProjectRoot / "Engine"; + if (!std::filesystem::is_directory( + EngineRoot / "ThirdParty/imgui/misc/fonts")) + { + return false; + } + + std::error_code Error; + std::filesystem::create_directories(UserRoot / "Config", Error); + if (Error) + { + return false; + } + + FPathServiceConfig Config; + Config.Revision = 1; + Config.Policy = EPathPolicyProfile::EditorSource; + Config.ProjectScope = {"wave-trace-viewer-source-session"}; + Config.Roots = { + {EPathDomain::Executable, ExecutableRoot, EPathAccess::ReadOnly, "executable", false, true}, + {EPathDomain::EngineContent, EngineRoot, EPathAccess::ReadOnly, "editor_source", false, true}, + {EPathDomain::UserSaved, UserRoot, EPathAccess::ReadWrite, "local_app_data", true, true}, + }; + const FPathConfigureResult Configured = Paths.Configure(std::move(Config)); + if (!Configured.bConfigured) + { + return false; + } + InstallPathService(Paths); + return true; + } + + class FScopedPathService final + { + public: + explicit FScopedPathService(WavePaths::FPathService& InPaths) + : Paths(InPaths) + { + } + + ~FScopedPathService() + { + if (bInstalled) + { + WavePaths::UninstallPathService(Paths); + } + } + + void MarkInstalled() + { + bInstalled = true; + } + + private: + WavePaths::FPathService& Paths; + bool bInstalled = false; + }; + void DestroyRenderTarget() { if (GRenderTarget) { GRenderTarget->Release(); GRenderTarget = nullptr; } @@ -121,6 +239,14 @@ namespace int WINAPI wWinMain(HINSTANCE Instance, HINSTANCE, PWSTR, int) { + WavePaths::FPathService Paths; + FScopedPathService PathScope(Paths); + if (!ConfigurePathService(Paths)) + { + return 1; + } + PathScope.MarkInstalled(); + SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); WNDCLASSEXW WindowClass{sizeof(WindowClass), CS_CLASSDC, WindowProc, 0, 0, Instance, nullptr, nullptr, nullptr, nullptr, L"WaveTraceViewer", nullptr}; RegisterClassExW(&WindowClass); @@ -137,7 +263,9 @@ int WINAPI wWinMain(HINSTANCE Instance, HINSTANCE, PWSTR, int) IMGUI_CHECKVERSION(); ImGui::CreateContext(); ImGuiIO& IO = ImGui::GetIO(); - static const std::string IniPath = std::string(ENGINE_TEMP_FOLDER) + "WaveTraceViewer.ini"; + const std::string IniPath = WavePaths::ResolveRequiredWritePath( + WavePaths::EPathDomain::UserSaved, + "Config/WaveTraceViewer.ini").string(); IO.IniFilename = IniPath.c_str(); IO.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard | ImGuiConfigFlags_DockingEnable; GDpiScale = static_cast(GetDpiForWindow(Window)) / 96.0f; diff --git a/docs/plans/2026-07-25-game-engine-production-roadmap.md b/docs/plans/2026-07-25-game-engine-production-roadmap.md index cb01c98..22fe777 100644 --- a/docs/plans/2026-07-25-game-engine-production-roadmap.md +++ b/docs/plans/2026-07-25-game-engine-production-roadmap.md @@ -289,9 +289,9 @@ A 系列只提供跨领域公共词汇、Developer host、Context/Evidence feder | [M0-01](../specs/2026-07-25-m0-01-cpu-contract-baseline.md) | CMake Presets、CPU CI、无窗口测试入口 | — | Verified | | [G0-01](../specs/2026-07-25-g0-01-runtime-editor-targets.md) | Runtime/Editor Target 图 | M0-01 | Verified | | [A0-01](../specs/2026-07-25-a0-01-contract-core.md) | AI-Native Contract Core 与领域能力注册 | Slice 1~3:M0-01;Slice 4:G0-01 | Verified | -| [G0-02](../specs/2026-07-25-g0-02-engine-loop-application-config.md) | Engine Loop、Application Config 与 Lifecycle | G0-01、M0-01 | Accepted | -| [G0-03](../specs/2026-07-25-g0-03-project-package-user-paths.md) | Project、Package、User Paths 与原子存储 | G0-02 | Accepted | -| [G0-04](../specs/2026-07-26-g0-04-existing-editor-domain-capabilities.md) | 现有 Scene/Camera/Lighting/Render Editor 能力 AI-Native 收口 | A0-01、G0-01、M0-01 | Draft | +| [G0-02](../specs/2026-07-25-g0-02-engine-loop-application-config.md) | Engine Loop、Application Config 与 Lifecycle | G0-01、M0-01 | Verified | +| [G0-03](../specs/2026-07-25-g0-03-project-package-user-paths.md) | Project、Package、User Paths 与原子存储 | G0-02 | Verified | +| [G0-04](../specs/2026-07-26-g0-04-existing-editor-domain-capabilities.md) | 现有 Scene/Camera/Lighting/Render Editor 能力 AI-Native 收口 | A0-01、G0-01、M0-01 | Verified | | [A0-02](../specs/2026-07-25-a0-02-developer-host.md) | Developer Host:Session、Policy、Operation 与 Audit | A0-01、G0-02、G0-03 | Accepted | | [A0-03](../specs/2026-07-25-a0-03-context-evidence-federation.md) | Context/Evidence/Artifact Provider Federation | A0-01、G0-03;Host integration:A0-02 | Accepted | | [A0-04](../specs/2026-07-25-a0-04-headless-agent-conformance.md) | Headless Developer Host、Adapter Conformance 与 Agent Eval | A0-02、A0-03、G0-01~G0-03 | Accepted | diff --git a/docs/specs/2026-07-25-g0-02-engine-loop-application-config.md b/docs/specs/2026-07-25-g0-02-engine-loop-application-config.md index 379ad34..30067c3 100644 --- a/docs/specs/2026-07-25-g0-02-engine-loop-application-config.md +++ b/docs/specs/2026-07-25-g0-02-engine-loop-application-config.md @@ -1,27 +1,27 @@ # G0-02:Engine Loop、Application Config 与 Lifecycle - Spec ID: `G0-02` -- Status: Accepted +- Status: Verified - Created: 2026-07-25 -- Updated: 2026-07-25 +- Updated: 2026-07-26 - Roadmap: [G0 产品边界与 Engine Loop](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) - Architecture: [AI-Native Engine Architecture](../architecture/ai-native-engine.md) - Depends on: [G0-01](./2026-07-25-g0-01-runtime-editor-targets.md)、[M0-01](./2026-07-25-m0-01-cpu-contract-baseline.md) - Owners: WaveEngine maintainers - Accepted by: User -- Accepted on: 2026-07-25 +- Accepted on: 2026-07-26 - Supersedes: None - Superseded by: None -> 本 Spec 已获用户明确接受;在 G0-01 target 边界上建立公共 runner、结构化 config、clock 与 lifecycle,并等待前置。 +> 本 Spec 最初于 2026-07-25 获用户接受。2026-07-26 用户明确要求只改造现有基础功能、不新增 Player 或产品功能,并在核对收窄范围后要求“完成 G0”;本次据此进入 Implementing,在 G0-01 target 边界上建立现有 Editor 与 CPU contract 共用的 runner、结构化 config、clock 与 lifecycle。 ## 1. Context -将 `WaveEngine.cpp` 中的启动、主循环、停止与异常处理提取为 Runtime-owned Application runner,使 Editor、Player 与未来 `WaveDevHost` 共享一套确定生命周期,同时通过 mode/config/services 组合各自能力。 +将 `WaveEditor.cpp` 中的启动、主循环、停止与异常处理提取为 Runtime-owned Application runner,使现有 Editor 与无窗口 CPU contract 共享一套确定生命周期。未来 Player 与 `WaveDevHost` 可复用该边界,但本 Spec 不创建对应 executable、产品模式或用户功能。 **Current facts** -- `Engine/Source/WaveEngine.cpp` 同时拥有 CPU contract 参数分支、日志、窗口、Input、RenderDoc、RHI/worker、ShaderMap、Scene、Renderer/ImGui、MCP、frame loop、异常和 shutdown。 +- `Engine/Source/WaveEditor.cpp` 同时拥有日志、窗口、Input、RenderDoc、RHI/worker、ShaderMap、Scene、Renderer/ImGui、MCP、frame loop、异常和 shutdown。 - application mode、窗口尺寸 `1600x900`、日志名 `WaveEngine.log`、是否创建 UI/MCP/RHI 和启动 Scene 都是代码隐式决定,没有结构化 `FApplicationConfig`。 - 主循环固定为 Input → MCP Dispatcher drain → Scene Tick → Renderer → FPS/frame index;MCP drain 已不依赖窗口是否可渲染。 - `DeltaTime` 使用上一帧 `GlobalContext.FrameTimeMs`,为 0 时回落约 1/60 秒;clock、首帧、最大 delta、fixed-step test mode 没有契约。 @@ -32,23 +32,24 @@ **Problem** -G0-01 如果只拆 target 而继续复制 `main()`,Editor、Player 与 WaveDevHost 会产生不同 init/tick/shutdown 语义;把 mode 判断散落成宏或 `if (Editor)` 又会重新耦合 Runtime 与 UI。需要由 Runtime 拥有通用状态机和调度顺序,由 mode-specific frontend/services 提供可选能力,并在副作用前验证 config。 +G0-01 如果只拆 target 而把现有 loop 永久留在 `main()`,后续任何宿主都会复制 init/tick/shutdown 语义;把 mode 判断散落成宏或 `if (Editor)` 又会重新耦合 Runtime 与 UI。需要由 Runtime 拥有通用状态机和调度顺序,由当前 Editor composition 与 CPU fake services 提供可选能力,并在副作用前验证 config。 ## 2. Goals and Non-Goals **Goals** -1. 定义 `EApplicationMode`、版本化 `FApplicationConfig`、config source/precedence、schema validation 和 availability diagnostic。 +1. 定义当前可用的 `Editor`/`ContractTest` application mode、版本化 `FApplicationConfig`、config source/precedence、schema validation 和 availability diagnostic。 2. 定义 `FApplicationLifecycle` 状态、revision、stop reason、exit code 与可查询 snapshot。 3. 提取公共 Application runner,明确 service 注册、依赖排序、init rollback、tick phases、drain 和 reverse shutdown。 4. 建立 monotonic real clock、game delta、first-frame/fixed-delta/test controls 和 completed frame 语义。 -5. Editor、Player 与未来 WaveDevHost 通过 frontend/service composition 复用 runner,不让 Runtime include Editor/MCP/ImGui 类型。 +5. 现有 Editor 与 CPU Contract composition 复用 runner,不让 Runtime include Editor/MCP/ImGui 类型;未来 Player/WaveDevHost 只保留可复用边界,不在本 Spec 中实现。 6. 保持现有 main-thread ownership、MCP drain、Present、GPU idle/quarantine、异常和正常退出顺序。 7. 为有限帧 smoke、无窗口 contract/headless、结构化启动诊断和 Evidence 提供稳定入口。 **Non-Goals** - 不在本 Spec 中拆 target、Renderer presenter 或 Editor UI;它们属于 G0-01。 +- 不新增 Player、WaveDevHost executable、应用模式入口、CLI、面板、MCP 方法或其他用户可见功能。 - 不定义 Project/Source/Derived/Saved/Package 根或 `.weproject`;路径策略属于 G0-03/G1-01。 - 不实现 A0 Developer Host、capability、session、policy、Context/Evidence 或 WaveDevHost executable。 - 不改变 Scene/Gameplay tick 模型、引入 ECS/Job System、固定物理步长或多 World;后续 G2/G3 负责。 @@ -61,8 +62,8 @@ G0-01 如果只拆 target 而继续复制 `main()`,Editor、Player 与 WaveDev | Scenario | Given / When | Expected | |---|---|---| | Editor start | mode=Editor,config 有效 | 公共 Runtime services 按序 init,Editor frontend 注册完成后才启动 Developer adapter | -| Player start | mode=Player | 不注册 Editor/MCP/RenderDoc service,使用相同 Runtime tick/shutdown | -| Headless start | mode=DeveloperHost/Contract,window/render disabled | 不创建 GLFW/RHI,仍处理 application executor/Host work 与 stop | +| Contract start | mode=ContractTest,window/render disabled | 不创建 GLFW/RHI,仍处理 fake/executor service、ticks 与 stop | +| Future mode | config 请求尚未交付的 Player/DeveloperHost profile | 副作用前返回稳定 unavailable diagnostic,不伪装成已实现产品 | | Invalid config | mode/size/delta/budget/feature 组合无效 | 创建窗口/RHI/线程/文件副作用前返回结构化 diagnostic | | Init failure | 任一 service 初始化抛错 | 只逆序销毁已成功 init 的 service;GPU 对象仍遵守 idle/quarantine | | Limited smoke | `max_completed_frames=N` | 仅在 N 个成功 frame/present 或 mode-defined completed ticks 后正常 request stop | @@ -71,7 +72,7 @@ G0-01 如果只拆 target 而继续复制 `main()`,Editor、Player 与 WaveDev ## 4. Constraints and Invariants -- **Mode**:`Editor`、`Player`、`DeveloperHost`、`ContractTest` 是配置/组合,不是 Runtime 中散落的 UI 宏。Shipping build 只编入产品允许的 mode/service。 +- **Mode**:本 Spec 只交付 `Editor` 与 `ContractTest`;Player/DeveloperHost profile 在对应后继 Spec 进入 Accepted 前必须返回 unavailable。mode 是配置/组合,不是 Runtime 中散落的 UI 宏。 - **Config precedence**:compiled safe defaults → mode profile → project/user config(G0-03 resolver)→ explicit CLI/test overrides。环境变量仅限文档化诊断/测试开关,不能静默覆盖安全 policy。 - **Validation**:unknown key、wrong type、non-finite、范围、互斥 feature、路径尚未解析和 unsupported hardware 在相应副作用前处理;unknown future minor key 可按 schema policy保留 warning,major mismatch 拒绝。 - **Lifecycle**:状态单调: @@ -92,7 +93,7 @@ Running → StopRequested → Draining → Stopped - **Threading**:Runtime/World 可变状态仍由主线程拥有;adapter/worker 通过 executor;runner 不让 service 在未声明 phase 并发触碰 Scene/RHI。 - **GPU lifetime**:Renderer/frontend 跨顶层 catch;stop adapters/new work → wait all queues/Present → destroy GPU-backed frontend/Renderer/cache → RHI → Window。Signal/fence failure 保持 quarantine。 - **Headless**:window/RHI disabled 时不能构造依赖它们的 service;availability 给出稳定 reason,不创建隐藏 UI 窗口。 -- **Deployment**:Runtime runner/config/lifecycle 可进入 Player;Developer Host/MCP/Editor service types 不进入 Runtime public header或 Player closure。 +- **Deployment**:Runtime runner/config/lifecycle 保持未来可由 Player 复用;Developer Host/MCP/Editor service types 不进入 Runtime public header。本 Spec 不创建或验证 Player target。 ### AI-Native Impact @@ -106,7 +107,7 @@ Running → StopRequested → Draining → Stopped | Validation/Evidence | Required | config/lifecycle/service/failure contracts;运行 Evidence 记录 mode/config hash、frames、stop、exit、GPU idle/quarantine | | Provenance/Migration | Required | config 记录 source/precedence;旧 `main()`、CLI flag、`GlobalContext` 迁移有移除 Slice | | Security/Budget | Required | input/config 长度/范围、service allowlist、max frames/time 和 headless feature budget 在副作用前检查 | -| Headless | Required | runner 支持 window/RHI/frontend 为 absent,requests/ticks/stop/diagnostic 仍工作 | +| Headless | Required | `ContractTest` composition 支持 window/RHI/frontend 为 absent,ticks/stop/diagnostic 仍工作;独立 Developer Host 仍属于 A0-04 | ## 5. Design @@ -145,7 +146,7 @@ Runner 先拓扑排序和 capability validation,再逐个 Init。初始化一 - render/RHI enabled、requested renderer mode、strict validation。 - fixed delta、max delta、max completed frames、max wall time。 - log/config/path refs(实际根由 G0-03 解析)。 -- enabled service IDs 与 mode-specific adapter profile refs。 +- enabled service IDs 与当前 mode-specific adapter profile refs。 序列化使用 A0-01 `FValueSchema` 后继;在 A0-01 未接入前,G0-02 可用显式 parser/validator,但字段名、单位、默认值、版本不得有第二份语义。CLI 只映射到同一 config object,不能直接修改 global。 @@ -153,7 +154,7 @@ Runner 先拓扑排序和 capability validation,再逐个 Init。初始化一 Runner 持有 application state、monotonic lifecycle revision、tick sequence、completed frame count、start/stop times、stop/fatal reasons、exit code、service states 和 GPU idle proof。状态查询复制 immutable snapshot,不在 Query 中 poll/drive service。 -Stop 请求可来自 Window、Editor、Player smoke、Developer Host、OS signal 或 fatal。普通 stop 首次写入;fatal 附加 failure diagnostic 并提升最终 exit code。Draining 阶段: +Stop 请求可来自 Window、Editor、Contract tick/frame limit、OS signal 或 fatal。普通 stop 首次写入;fatal 附加 failure diagnostic 并提升最终 exit code。Draining 阶段: 1. 禁止新外部 request/job。 2. 停 adapter 与 request executor intake。 @@ -168,13 +169,13 @@ Stop 请求可来自 Window、Editor、Player smoke、Developer Host、OS signal Clock 每 tick 采样一次 steady time。variable delta 使用当前两次 sample 差;首 tick 使用 1/60,负数/非 finite 为 fatal diagnostic,超 0.25 clamp 并记录 warning。fixed delta 配置必须 finite、在 `(0, 0.25]`,只改变 game delta,不伪造 real elapsed。 -`max_completed_frames` 对有 renderer 的 mode 统计成功完成 Render/Present contract 的 frame;CPU headless 使用显式 `max_ticks`,二者不能混淆。达到限制通过正常 stop request,不在循环内直接 `return` 或强杀。 +`max_completed_frames` 对 Editor 统计成功完成 Render/Present contract 的 frame;CPU Contract composition 使用显式 `max_ticks`,二者不能混淆。达到限制通过正常 stop request,不在循环内直接 `return` 或强杀。 ## 6. Alternatives | Alternative | Benefit | Cost / Rejection reason | |---|---|---| -| Editor/Player 各复制一个 `main()` | 局部直观 | init/tick/shutdown 很快漂移,GPU failure 风险高 | +| 为未来每个宿主复制一个 `main()` | 局部直观 | init/tick/shutdown 很快漂移,GPU failure 风险高 | | Runtime 中大量 `if (Editor)`/宏 | 改动快 | Runtime public closure 重新依赖 UI/Developer 概念 | | Service locator 自动发现所有 singleton | 接线少 | 隐式依赖、启动顺序和测试不可控 | | 所有 service 普通逆序析构 | 模型简单 | GPU/adapter/worker 有不同 drain/idle 边界,可能 use-after-free | @@ -185,10 +186,10 @@ Clock 每 tick 采样一次 steady time。variable delta 使用当前两次 samp | Slice | Scope | Invariant | Verification | Status | |---|---|---|---|---| -| 1 | config/mode/lifecycle/clock/diagnostic 纯 CPU 类型与 parser | 副作用前验证;无 UI/RHI;版本/单位/默认值唯一 | M0 Debug/Release config/clock/status contracts | Pending | -| 2 | service descriptor/DAG、runner init/rollback/tick/stop/drain | 稳定顺序、循环拒绝、只清理成功 init service | fake service ordering/failure/timeout contracts | Pending | -| 3 | 接入 G0-01 Editor/Player、迁移 `WaveEngine.cpp` loop 与有限帧 smoke | Editor/Player 行为和 GPU shutdown 不回退 | Debug/Release、CTest、Editor/Player raster、strict RG | Pending | -| 4 | 无窗口 mode、结构化 status/Evidence、移除 legacy main/global config 路径 | no-window 不构造 Window/RHI;旧入口无仓库调用者 | M0 headless contracts、`rg`、failure injection、boundary | Pending | +| 1 | config/mode/lifecycle/clock/diagnostic 纯 CPU 类型与 parser | 副作用前验证;无 UI/RHI;版本/单位/默认值唯一 | M0 Debug/Release config/clock/status contracts | Completed | +| 2 | service descriptor/DAG、runner init/rollback/tick/stop/drain | 稳定顺序、循环拒绝、只清理成功 init service | fake service ordering/failure/timeout contracts | Completed | +| 3 | 接入 G0-01 Editor、迁移 `WaveEditor.cpp` loop 与测试侧有限帧 adapter | Editor 行为和 GPU shutdown 不回退;不新增产品入口 | Debug/Release、CTest、Editor raster、strict RG | Completed | +| 4 | `ContractTest` 无窗口 composition、结构化 status/Evidence、移除 legacy loop/global config 路径 | no-window 不构造 Window/RHI;旧 loop 无第二调用路径 | M0 headless contracts、`rg`、failure injection、boundary | Completed | ## 8. Verification and Acceptance @@ -199,27 +200,27 @@ Clock 每 tick 采样一次 steady time。variable delta 使用当前两次 samp | Clock | first/variable/clamp/fixed/non-finite | unit/范围/real vs game time 正确 | Yes | | Headless | window/RHI disabled + requests/ticks/stop | 不创建 GPU/UI,有限 ticks 正常退出 | Yes | | Editor | real Editor + MCP request exit/save/dirty confirm | UI/MCP/Scene 行为与关闭保持 | Yes | -| Player | strict RG finite-frame raster smoke | 成功 Present 后正常 stop,退出 0 | Yes | +| Limited Editor | 测试侧有限 completed-frame raster smoke | 成功 Present 后正常 stop,退出 0;不新增产品 CLI | Yes | | Failure | service init、worker/RHI signal/fence/wait 注入 | reverse cleanup 或 quarantine,无不安全析构 | Yes | -| Boundary | target/header/source closure | Runtime runner无 Editor/MCP;Player无 Developer | Yes | -| Build | Debug/Release Editor/Player/Viewer + Debug CTest + Release Core | warning-as-error,必需测试通过 | Yes | +| Boundary | target/header/source closure | Runtime runner 无 Editor/MCP/Developer 类型 | Yes | +| Build | Debug/Release Editor/Runtime/Viewer + Debug CTest + Release Core | warning-as-error,必需测试通过 | Yes | ### Acceptance Criteria -- [ ] Editor、Player、DeveloperHost/Contract mode 复用同一 Runtime runner/lifecycle,不复制主循环。 -- [ ] config 版本、source/precedence、单位、默认值、范围和 availability 可机器读取,副作用前验证。 -- [ ] service dependency DAG、init rollback、tick phases、stop/drain/reverse shutdown 具有 deterministic contract。 -- [ ] Input/External requests/Simulation/Render/EndFrame 顺序明确;无窗口/最小化仍处理 requests。 -- [ ] real/game clock、首帧、clamp、fixed test delta 和 frame/tick identity 不依赖 UI FPS。 -- [ ] stop reason、fatal severity、exit code、completed frames、GPU idle/quarantine 可结构化查询并留 Evidence。 -- [ ] Editor/Player 现有渲染、MCP、保存、Present、resize/minimize 与安全退出不回退。 -- [ ] GPU idle 未证明时不执行 GPU-backed teardown,保持非零 quarantine。 -- [ ] Runtime/Player public/source/link closure 不包含 Editor/MCP/Developer service 类型。 -- [ ] 当前实现 Evidence、Spec 注册表、路线图和 README 同步。 +- [x] Editor 与 ContractTest composition 复用同一 Runtime runner/lifecycle,不复制主循环;未实现的 Player/DeveloperHost profile 明确 unavailable。 +- [x] config 版本、source/precedence、单位、默认值、范围和 availability 可机器读取,副作用前验证。 +- [x] service dependency DAG、init rollback、tick phases、stop/drain/reverse shutdown 具有 deterministic contract。 +- [x] Input/External requests/Simulation/Render/EndFrame 顺序明确;无窗口/最小化仍处理 requests。 +- [x] real/game clock、首帧、clamp、fixed test delta 和 frame/tick identity 不依赖 UI FPS。 +- [x] stop reason、fatal severity、exit code、completed frames、GPU idle/quarantine 可结构化查询并留 Evidence。 +- [x] Editor 现有渲染、MCP、保存、Present、resize/minimize 与安全退出不回退。 +- [x] GPU idle 未证明时不执行 GPU-backed teardown,保持非零 quarantine。 +- [x] Runtime public/source/link closure 不包含 Editor/MCP/Developer service 类型。 +- [x] 当前实现 Evidence、Spec 注册表、路线图和 README 同步。 ## 9. Compatibility, Risks, and Open Questions -- **Compatibility/Migration**:先让旧 `WaveEngine` entry 构造 config/runner,再由 G0-01 切换 WaveEditor/Player;现有 CLI/MCP/数据格式保持。`GlobalContext` 字段逐 consumer 迁移后移除。 +- **Compatibility/Migration**:现有 `WaveEditor` entry 构造 config/runner;现有 MCP/数据格式保持。`GlobalContext` 字段逐 consumer 迁移后移除,不建立第二个 executable。 - **Removal/Rollback**:Slice 可回退到上一可运行 runner;不能回退现有 MCP drain、GPU idle/quarantine 或 Release `check`。legacy entry 在 Verified 前移除。 | Risk | Impact | Mitigation | @@ -239,18 +240,30 @@ Clock 每 tick 采样一次 steady time。variable delta 使用当前两次 samp ### Current Progress -- 已完整核对 G0-01、Architecture、`WaveEngine.cpp`、`FWindow`、Input/MCP drain、Renderer/ImGui lifecycle、RHI idle/quarantine 与 current clock。 +- 已完整核对 G0-01、Architecture、`WaveEditor.cpp`、`FWindow`、Input/MCP drain、Renderer/ImGui lifecycle、RHI idle/quarantine 与 current clock。 - 已确认当前没有结构化 Application Config、service graph、lifecycle snapshot 或 no-window runner。 -- 尚未修改生产代码。 +- 已按用户“不新增功能、完成 G0”的明确指令移除实际 Player/WaveDevHost 交付与验证条款,并进入 Implementing。 +- Slice 1 已完成:Runtime-owned `Core/Application` 提供版本化 config/schema descriptor、原子 field layer/precedence、稳定 diagnostic、config hash、Editor/ContractTest mode profile、monotonic clock 与 lifecycle/status snapshot。 +- `WaveCoreContracts` 新增 Application contracts,覆盖 default/profile、unknown/wrong-type/range、无窗口 feature dependency、service budget、first/clamp/fixed/non-finite clock、stop precedence、fatal severity、revision、idle proof与 quarantine。 +- 新 public/source closure 不触达 Window/RHI/Renderer/Scene/Shader/UI/MCP/Developer/ThirdParty;完整 Debug Editor/Viewer 与既有 11 项 CTest 保持通过。 +- Slice 2 已完成:`FApplicationRunner` 提供稳定 ID 拓扑排序、mode/feature availability、依赖与 shutdown-class 校验、init rollback、固定 tick phase、stop/limit、pre-idle drain、idle proof、reverse shutdown 和 quarantine。 +- fake service contracts 覆盖 registration-order 独立排序、cycle/missing/unavailable、service stop、max tick、init failure rollback、shutdown phase 与 idle failure 时跳过 GPU-backed teardown。 +- Slice 3 已完成:`WaveEditor.cpp` 仅保留进程入口,EditorSupport composition 将既有 Log、Window/Input、RHI、Shader/Scene、Renderer/Frontend、MCP/Trace 适配为同一 Runtime runner 的 service graph。 +- 既有 Input → MCP drain → Scene → Render/Present → EndFrame 顺序、native close 确认、RenderDoc capture、GPU idle/quarantine 和反向 teardown 保持;`FEditorFrontend::Render()` 显式报告成功 Present,使 application tick 与 completed frame 分离。 +- Debug 全量 11 项 CTest、Release Editor/Core 构建与 CPU contracts 均通过;`WAVE_RHI_INJECT_FAILURE=1` 当前实现以 exit 1 进入 quarantine,未执行 GPU-backed teardown。 +- Slice 4 已完成:CPU contract composition 以 window/render disabled 的 `ContractTest` config 运行同一 runner,按 fixed delta 完成有限 ticks,并通过 no-GPU idle proof 正常停止。 +- `FApplicationRunEvidence` 记录 schema version、config hash/source、build/path profile、resolved service IDs 与完整 lifecycle snapshot;CPU contract 同时固定 Editor completed-frame limit 和 Contract tick limit。 +- `WaveEditor.cpp` 中不再存在 legacy loop/init/shutdown 调用路径;`GlobalContext` 仅作为 Renderer/UI 兼容 telemetry,Application config/clock/lifecycle/stop 由 runner 单独拥有。 ### Next Step -- 用户审查并明确接受 G0-02;实施等待 G0-01 target/frontend 边界和 M0 CPU contract 入口。 +- G0-02 已 Verified;按依赖顺序进入 G0-03 Project/Package/User Paths。 ### Changes and Deviations - G0-01 的“最小公共 runner”在本 Spec 中细化为 config + service DAG + lifecycle;不扩大到 Project/World/Gameplay。 - fixed delta 只用于 Test/Developer,GPU headless 不作为本 Spec 默认能力。 +- 2026-07-26 删除实际 Player/DeveloperHost mode、target 与 smoke 交付;只保留未来可复用 Runtime 边界和未实现 profile 的 unavailable diagnostic。 ### Evidence @@ -258,9 +271,16 @@ Clock 每 tick 采样一次 steady time。variable delta 使用当前两次 samp |---|---|---|---|---|---| | 2026-07-25 | `2bdaacab47d1736a0f7c2fbaf5dbca113dc115d5` + working tree | Source/design review | CMake、G0-01、WaveEngine main/loop/shutdown、Window、MCP Dispatcher、ImGui exit/save、RHI failure rules | Pass | sections 1、4、5 | | 2026-07-25 | working tree | Python 3.14.4;Debug CTest | `git diff --check`;`py -3 Tests/spec_contracts.py .`;`ctest --test-dir Build -C Debug -R SpecContracts --output-on-failure` | Pass | 8 concrete Specs;1/1 SpecContracts | +| 2026-07-26 | working tree | Windows 11 / VS 2022 / CMake 4.2.3 / Debug+Release CPU | configure;构建 Debug/Release `WaveCoreContracts`;运行 Spec/Core target/Core runtime contracts | Pass | Application config/clock/lifecycle contracts;Debug 3/3、Release 2/2 focused contracts | +| 2026-07-26 | working tree | Windows 11 / VS 2022 / Debug / DX12 validation | 完整 Debug build;`ctest --test-dir Build -C Debug --output-on-failure`;扫描 `WaveEngine.log` | Pass | Runtime/Editor/Viewer 构建;11/11 CTest;日志无 Error/Fatal/D3D12 validation 匹配 | +| 2026-07-26 | working tree | Windows 11 / VS 2022 / Debug+Release CPU | 构建两配置 `WaveCoreContracts`;focused Core/Boundary/Spec CTest | Pass | service DAG/order/rollback/tick/limit/idle/quarantine;Debug 3/3、Release 2/2 | +| 2026-07-26 | working tree | Windows 11 / VS 2022 / Debug / DX12 validation | Slice 2 后完整 Debug build/CTest 与日志扫描 | Pass | 11/11 CTest;日志无 Error/Fatal/D3D12 validation 匹配 | +| 2026-07-26 | working tree | Windows 11 / VS 2022 / Debug / DX12 validation | 构建 `WaveEditor`;完整 `ctest --test-dir Build -C Debug --output-on-failure`;扫描正常退出日志 | Pass | Editor 使用 Runtime runner;11/11 CTest;strict RG、MCP、WEMesh 与窗口退出均通过 | +| 2026-07-26 | working tree | Windows 11 / VS 2022 / Release | 构建 `WaveEditor WaveCoreContracts`;运行 Release Spec/Core boundary/Core runtime contracts | Pass | Release Editor 构建;3/3 focused contracts | +| 2026-07-26 | working tree | Windows 11 / VS 2022 / Debug / DX12 failure injection | `WAVE_RHI_INJECT_FAILURE=1` 启动 `WaveEditor.exe` 并等待退出;检查 `WaveEngine.log` | Pass | exit 1;tick/idle fatal diagnostic;Quarantined + `_Exit` | +| 2026-07-26 | working tree | Windows 11 / VS 2022 / Debug+Release CPU | `ContractTest` no-window composition;构建/运行两配置 CoreRuntimeContracts 与 target boundary | Pass | fixed 3 ticks、0 completed frames、structured stop/evidence、no Window/RHI closure | +| 2026-07-26 | working tree | Windows 11 / VS 2022 / Debug+Release | 完整 Debug build + 11 CTest;完整 Release build;legacy entry `rg`;normal log scan | Pass | Runtime/Editor/Viewer 两配置构建;11/11 Debug;legacy loop 无第二入口;日志无错误 | ### Remaining Work -- 用户明确接受 Draft。 -- 所有 Delivery Slices 与 Acceptance Criteria。 -- G0-03 path/config source integration,A0-02 Host service integration。 +- 无。本 Spec 已 Verified;G0-03 path/config source integration 与 A0-02 Host service integration 分属后继 Spec。 diff --git a/docs/specs/2026-07-25-g0-03-project-package-user-paths.md b/docs/specs/2026-07-25-g0-03-project-package-user-paths.md index fbc5470..3400a90 100644 --- a/docs/specs/2026-07-25-g0-03-project-package-user-paths.md +++ b/docs/specs/2026-07-25-g0-03-project-package-user-paths.md @@ -1,23 +1,23 @@ # G0-03:Project、Package、User Paths 与原子存储边界 - Spec ID: `G0-03` -- Status: Accepted +- Status: Verified - Created: 2026-07-25 -- Updated: 2026-07-25 +- Updated: 2026-07-26 - Roadmap: [G0 产品边界与 Engine Loop](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) - Architecture: [AI-Native Engine Architecture](../architecture/ai-native-engine.md) - Depends on: [G0-02](./2026-07-25-g0-02-engine-loop-application-config.md) - Owners: WaveEngine maintainers - Accepted by: User -- Accepted on: 2026-07-25 +- Accepted on: 2026-07-26 - Supersedes: None - Superseded by: None -> 本 Spec 已获用户明确接受;建立路径语义和存储策略,并等待 G0-02 前置,不引入 G1/G6 格式。 +> 本 Spec 最初于 2026-07-25 获用户接受。2026-07-26 用户明确要求只改造现有基础功能、不新增 Player 或产品功能,并在核对收窄范围后要求“完成 G0”;本次据此进入 Implementing,建立现有 Editor/Runtime 的路径语义、存储策略和未来 Package policy 前置,不引入 G1/G6 格式。 ## 1. Context -把 executable、engine runtime content、project source、derived cache、project saved、user saved、package content、staging/temp 与 external path 分成显式 domain。Editor、Player、WaveDevHost、Job、Artifact resolver 和测试通过同一 `FPathRef`/policy 解析,不再依赖编译机绝对宏、进程当前目录或裸路径身份。 +把 executable、engine runtime content、project source、derived cache、project saved、user saved、package content、staging/temp 与 external path 分成显式 domain。现有 Editor/Runtime consumer 与 CPU contract 通过同一 `FPathRef`/policy 解析,不再依赖编译机绝对宏、进程当前目录或裸路径身份;未来 Player/WaveDevHost 只复用该边界,本 Spec 不创建对应产品。 **Current facts** @@ -44,17 +44,18 @@ 3. 提供 Windows-aware canonicalization、containment、case/reparse/symlink、长度/字符和权限验证,防止 path traversal/escape。 4. 建立统一原子文件写入/替换 primitive,保留旧文件、dirty 和 staged artifact,失败可诊断。 5. 将 Application/Shader/Scene/WEMesh/UI/Trace/RenderDoc/Log/Viewer 逐步迁移到注入的 path service,不查询全局编译期路径宏。 -6. 支持 Editor/Developer source mode、Player development mode 与 packaged mode;packaged Player 不隐式回退 Project Source/checkout。 +6. 支持现有 Editor source mode,并以纯 CPU package-policy contract 固定 PackageContent 不回退 ProjectSource/checkout;不创建 Player development/package mode。 7. 定义 `Engine/Save` legacy 数据的显式发现、预览、复制/验证/promotion 和移除门,不自动删除或静默移动用户文件。 8. 为 A0-02 workspace/staging、A0-03 artifact resolver 和 A0-04 WaveDevHost 提供相同 path/policy。 **Non-Goals** - 不在本 Spec 中定义 `.weproject`、稳定 `FProjectId`、`FAssetId`、Asset Registry、Content Graph 或 metadata;属于 G1-01。 +- 不新增 Player、WaveDevHost、package executable、CLI、UI、MCP 方法或用户可见迁移功能。 - 不定义 Scene v4、World/Entity、Prefab、SaveGame、Cook manifest、package layout 完整格式;属于 G2/G5/G6。 - 不搬迁或重写 WEScene/WEMesh/ui config/Trace 的内部数据格式。 - 不引入 CAS/DDC、云存储、虚拟文件系统、pak/archive、文件 watcher 或跨平台路径支持。 -- 不允许 Player/Agent 通过本路径层任意访问操作系统文件;External path 仍需显式 policy/approval。 +- 不允许 runtime consumer/Agent 通过本路径层任意访问操作系统文件;External path 仍需显式 policy/approval。 - 不扫描磁盘猜测 Project root,不以目录名、显示名或 path hash 冒充未来持久 Project identity。 - 不在未经用户明确命令时删除 `Engine/Save`、source asset、cache 或外部文件。 @@ -63,8 +64,7 @@ | Scenario | Given / When | Expected | |---|---|---| | Editor source mode | 从任意 current directory 启动并显式指定 project root | Engine/Project/Derived/Saved/User roots 确定,加载与日志不依赖 cwd/编译机绝对路径 | -| Player development | Player 使用显式 project/runtime content roots | 只读 Runtime/Project content,用户写入 UserSaved;无 Editor/Developer paths | -| Packaged Player | package mode + manifest/root | 只读 PackageContent,写 UserSaved;缺失 cooked artifact 确定失败,不回退源码 Assets/Build | +| Package policy contract | CPU resolver 使用 package root + UserSaved root | 只读 PackageContent,写 UserSaved;ProjectSource/Derived/checkout root unavailable,不创建 Player | | Path traversal | relative path 含 `..`、absolute prefix、alternate stream、device/reparse escape | resolve/写入前结构化拒绝,不越过 domain root | | Atomic save failure | temp create/write/flush/replace 失败 | 旧目标保持,temp 清理或保留为诊断/staged,document dirty 不清 | | Job staging | import/build 产生候选 artifact | 只能写 isolated Staging/Derived;validator 通过后领域 Command promotion | @@ -99,7 +99,7 @@ - **Legacy migration**:只 copy + validate + explicit promotion;不 silent move/delete。原 `Engine/Save` 的删除属于独立确认的 External Action。 - **Sensitive paths**:公共 schema/audit/evidence 默认返回 PathRef/location policy,不返回用户绝对路径;diagnostic 可按权限显示受控 display path。 - **Limits**:限制相对路径 bytes/segments、文件 size、目录项、staging workspace 数/bytes、atomic temp 数与 retention。 -- **Deployment**:Player 不包含 External write、ProjectSource mutation 或 Developer workspace policy;只保留 Runtime/Package/UserSaved 所需 resolver。 +- **Deployment**:Runtime package-policy surface 不包含 External write、ProjectSource mutation 或 Developer workspace policy;未来 Player 只需 Runtime/Package/UserSaved resolver,但本 Spec 不创建 Player target。 ### AI-Native Impact @@ -113,7 +113,7 @@ | Validation/Evidence | Required | path/policy/atomic failure/migration/package isolation contracts;写入 Evidence 记录 PathRef/hash/old/new/revision,不只返回 bool | | Provenance/Migration | Required | legacy root/source、copy hash、validator、promotion/rollback 记录;不删除原数据,G1/G6 接管后续格式 | | Security/Budget | Required | traversal/reparse/TOCTOU、domain ACL、sensitive display、file/workspace/retention limits 全部进入验收 | -| Headless | Required | resolver/config/migration preview/atomic store 无 UI/RHI,Editor、WaveDevHost、CTest 共用;确认由 adapter/policy 提供 | +| Headless | Required | resolver/config/migration preview/atomic store 无 UI/RHI,Editor、CTest 与后继 WaveDevHost 可共用;确认由 adapter/policy 提供 | ## 5. Design @@ -131,7 +131,7 @@ G0-02 Application Config Path service 在任何 Scene/Shader/UI/Trace/Host service init 前完成配置。Root descriptor 包含 domain、canonical root、access、source、config revision、availability 和 sensitivity。Root 不可在 Running 状态静默改变;切换 Project 需要新的 application/project session。 -G0-03 不发明 `.weproject`。Developer mode 通过 G0-02 config 的显式 project root/engine content root;Player package mode由 executable/package manifest入口提供 package root。G1-01 接入后 `.weproject` 解析成相同 root config,调用者不改变。 +G0-03 不发明 `.weproject`。现有 Editor source mode 通过 G0-02 config 的显式 project root/engine content root;package policy 在 CPU contract 中通过显式 package/user root 配置。未来 G6 executable/package manifest 解析成相同 root config,调用者不改变。 ### Root layout and transition @@ -197,7 +197,7 @@ Discover → Preview plan → explicit approval ### Package isolation -Application mode/config 选择 Source 或 Package content policy。Package mode构造 resolver 时完全不注册 ProjectSource/Derived developer fallback;对这些 domain 的 query返回 unavailable。CI package smoke 在不提供 repo/source/build path 的新临时目录运行,以证明 absence 而非仅搜索代码字符串。 +Application config 选择 Source 或 Package content policy。Package policy 构造 resolver 时完全不注册 ProjectSource/Derived developer fallback;对这些 domain 的 query返回 unavailable。G0-03 使用不提供 repo/source/build path 的 CPU 临时目录 contract 证明 absence,而不是新增 Player 或把代码字符串搜索当作隔离证据;G6 再接入真实 package executable smoke。 ## 6. Alternatives @@ -215,10 +215,10 @@ Application mode/config 选择 Source 或 Package content policy。Package mode | Slice | Scope | Invariant | Verification | Status | |---|---|---|---|---| -| 1 | path domain/root/PathRef/config revision、Windows validation/containment | 纯 CPU、无 cwd/裸 path identity、root immutable | M0 path corpus、case/traversal/device/reparse/limit contracts | Pending | -| 2 | atomic store、staging、quota、legacy discovery/plan/copy/validate | 失败保旧/dirty;无 silent delete/move/promotion | temp/write/flush/replace/conflict/failure injection contracts | Pending | -| 3 | 迁移 Log/UI/Trace/Shader/Scene/WEMesh/RenderDoc/Viewer 到 injected paths | 数据格式不变;各 domain access 正确;compat warning有移除门 | Debug/Release、MCP/WEMesh、save/load、non-cwd launch | Pending | -| 4 | Source/Package mode isolation、移除编译期 runtime path fallback | packaged Player 不注册 source/build roots | clean-dir Player smoke、closure/`rg`、legacy migration Evidence | Pending | +| 1 | path domain/root/PathRef/config revision、Windows validation/containment | 纯 CPU、无 cwd/裸 path identity、root immutable | M0 path corpus、case/traversal/device/reparse/limit contracts | Completed | +| 2 | atomic store、staging、quota、legacy discovery/plan/copy/validate | 失败保旧/dirty;无 silent delete/move/promotion | temp/write/flush/replace/conflict/failure injection contracts | Completed | +| 3 | 迁移 Log/UI/Trace/Shader/Scene/WEMesh/RenderDoc/Viewer 到 injected paths | 数据格式不变;各 domain access 正确;compat warning有移除门 | Debug/Release、MCP/WEMesh、save/load、non-cwd launch | Completed | +| 4 | Source/Package policy isolation、移除普通 runtime 的编译期 path fallback | package policy 不注册 source/build roots;不创建 Player | clean-dir CPU resolver、non-cwd Editor、closure/`rg`、legacy migration Evidence | Completed | ## 8. Verification and Acceptance @@ -228,25 +228,25 @@ Application mode/config 选择 Source 或 Package content policy。Package mode | Security | `..`、absolute、UNC/device/ADS、reserved name、reparse/symlink、TOCTOU simulation | resolve/open 前拒绝或 containment 保持 | Yes | | Atomic storage | create/replace/flush/size/hash/validator/permission/disk failure injection | 旧文件保持、temp处理可解释、dirty不清 | Yes | | Legacy migration | empty/conflict/corrupt/absolute cross-drive/partial failure | preview、staging、validator、promotion/Evidence;源不删除 | Yes | -| Non-cwd | 从仓库外工作目录启动 Editor/Player/WaveDevHost | resource/log/save roots仍正确 | Yes | +| Non-cwd | 从仓库外工作目录启动 Editor | resource/log/save roots仍正确 | Yes | | Compatibility | Scene v3、WEMesh v2/v3、ui config、Trace、Shader cache contracts | 格式语义不回退;legacy有诊断 | Yes | -| Package | 新临时目录只含 Player/runtime/package/user root | 不读取 repo Assets/Engine/Build;缺失内容确定失败 | Yes | -| Boundary | Player source/link/path capability | 无 Developer External write/workspace/migration implementation | Yes | -| Build | Debug/Release Editor/Player/Viewer + Debug CTest + Release Core | warning-as-error,必需测试通过 | Yes | +| Package policy | 新临时目录只含 package/user root 的 CPU resolver | 不读取 repo Assets/Engine/Build;ProjectSource/Derived 确定 unavailable | Yes | +| Boundary | Runtime source/header/path capability | package resolver surface 无 Developer External write/workspace/migration implementation | Yes | +| Build | Debug/Release Editor/Runtime/Viewer + Debug CTest + Release Core | warning-as-error,必需测试通过 | Yes | ### Acceptance Criteria -- [ ] Runtime 资源、Project/Derived/Saved/User/Package/Staging/Temp/External path domain 明确且 access policy 可查询。 -- [ ] `FPathRef` 不使用 raw absolute path 作为身份,project scope/root revision变化可检测 stale。 -- [ ] Windows case、traversal、device/ADS、reparse/symlink、TOCTOU 和长度/配额有自动 contract。 -- [ ] runtime resource/log/save 行为不依赖 cwd 或编译机 checkout 绝对路径。 -- [ ] atomic write 失败不覆盖旧文件、不清 dirty、不 promotion,并返回结构化 Evidence。 -- [ ] Job staging 与领域 Command promotion 分离,path service 不拥有 Asset/Document 业务。 -- [ ] legacy `Engine/Save` 只有显式 preview/approval/copy/validate/promotion;不静默移动或删除。 -- [ ] Scene/WEMesh/ui config/Trace/Shader cache 格式和兼容读取不因路径迁移回退。 -- [ ] packaged Player 不注册或回退 ProjectSource/repo Assets/Engine/Build path。 -- [ ] Player closure 只包含 Runtime/Package/UserSaved resolver,不包含 Developer workspace/External migration。 -- [ ] 当前实现 Evidence、Spec 注册表、路线图和 README 同步。 +- [x] Runtime 资源、Project/Derived/Saved/User/Package/Staging/Temp/External path domain 明确且 access policy 可查询。 +- [x] `FPathRef` 不使用 raw absolute path 作为身份,project scope/root revision变化可检测 stale。 +- [x] Windows case、traversal、device/ADS、reparse/symlink、TOCTOU 和长度/配额有自动 contract。 +- [x] runtime resource/log/save 行为不依赖 cwd 或编译机 checkout 绝对路径。 +- [x] atomic write 失败不覆盖旧文件、不清 dirty、不 promotion,并返回结构化 Evidence。 +- [x] Job staging 与领域 Command promotion 分离,path service 不拥有 Asset/Document 业务。 +- [x] legacy `Engine/Save` 只有显式 preview/approval/copy/validate;promotion 仍是显式领域 Command,path layer 不执行、不静默移动或删除。 +- [x] Scene/WEMesh/ui config/Trace/Shader cache 格式和兼容读取不因路径迁移回退。 +- [x] Package policy 不注册或回退 ProjectSource/repo Assets/Engine/Build path,并由 clean-dir CPU resolver contract 证明。 +- [x] Runtime package resolver surface 只包含 Package/UserSaved 所需能力,不包含 Developer workspace/External migration;本 Spec 未创建 Player。 +- [x] 当前实现 Evidence、Spec 注册表、路线图和 README 同步。 ## 9. Compatibility, Risks, and Open Questions @@ -270,19 +270,26 @@ Application mode/config 选择 Source 或 Package content policy。Package mode ### Current Progress -- 已完整核对 CMake 路径宏、Core/Shader/Scene/WEMesh/UI/Trace/RenderDoc/Log/Viewer path consumers、Scene/UI/WEMesh 原子写与测试备份路径。 -- 已确认当前无 Project path service、Package mode、UserSaved split、PathRef/policy 或 legacy migration。 -- 尚未修改生产代码或移动任何文件。 +- 四个 Delivery Slices 全部完成;`FPathService`、`FAtomicStoreResult` 与 CPU-only legacy plan/staging contract 已实现。 +- Editor/Viewer 在任何资源 consumer 前安装 immutable root config;Log/UI/Trace/Shader/Scene/WEMesh/RenderDoc/Assets/RHI consumer 已迁移到逻辑 domain。 +- Scene document、UI config、shader cache 使用统一 PathRef atomic store;WEMesh 保留格式自己的流式 atomic writer,并通过相同 PathRef/hash Evidence adapter。 +- 普通源码和 CMake 已移除六个编译期 path symbol;Editor non-cwd 真实窗口/MCP/退出流程通过。 +- Source/Package policy、Windows reparse/TOCTOU、legacy approval/staging 和 Runtime migration exclusion 均有自动 CPU contract。 ### Next Step -- 用户审查并明确接受 G0-03;实施等待 G0-02 config/lifecycle,先从纯 CPU path/policy contract 开始。 +- G0-04 复用已验证的 Application/Path 边界迁移现有 Editor 领域能力;G1/G6 再接管稳定 Project/Asset/Package identity。 ### Changes and Deviations - 不提前创建 `.weproject`、Asset ID、VFS/pak/CAS;只固定 G0 路径与存储边界。 - `Engine/Save` 迁移使用显式 copy/validate/promotion,绝不自动 move/delete。 - persistent Project identity 延后 G1-01;G0 project scope 明确为 session identity。 +- 2026-07-26 将实际 Player/Package executable 和 clean Player smoke 移出 G0-03;G0 只交付可由 G6 复用的 package-policy isolation contract。 +- Editor source bootstrap 从 executable 的 source-build layout 构造显式 immutable root config;不扫描磁盘、不读取 cwd。G1/G6 的 manifest/config 再替换 bootstrap source。 +- 为避免自动移动既有数据,当前物理 `Derived/ProjectSaved/Staging` 根使用 `Engine/Save/{Derived,Project,Staging}` 过渡布局;domain/PathRef 不把该物理名称作为持久协议。 +- WEMesh 大 artifact 继续使用已有流式格式 validator + same-directory atomic replace;生产 call site 先经 Derived PathRef policy,并返回统一 hash Evidence,未改 WEMesh v3 wire format。 +- Legacy migration implementation 只进入 headless CPU contract closure并显式从 `WaveRuntime` 排除;本 Spec 不新增迁移 UI/CLI/MCP。 ### Evidence @@ -290,9 +297,12 @@ Application mode/config 选择 Source 或 Package content policy。Package mode |---|---|---|---|---|---| | 2026-07-25 | `2bdaacab47d1736a0f7c2fbaf5dbca113dc115d5` + working tree | Source/design review | CMake path macros、Core/Scene/Shader/UI/Trace/RenderDoc/Log/Viewer、Utils/WEScene/WEMesh atomic stores、Tests path assumptions | Pass | sections 1、4、5 | | 2026-07-25 | working tree | Python 3.14.4;Debug CTest | `git diff --check`;`py -3 Tests/spec_contracts.py .`;`ctest --test-dir Build -C Debug -R SpecContracts --output-on-failure` | Pass | 8 concrete Specs;1/1 SpecContracts | +| 2026-07-26 | working tree | Windows 11 x64;VS 2022/MSVC v143;Debug | `cmake --build Build --config Debug`;`ctest --test-dir Build -C Debug --output-on-failure` | Pass | Editor/Runtime/Viewer build;12/12 CTest | +| 2026-07-26 | working tree | Windows 11 x64;Debug;DX12 SM 6.7/DXR 1.1 | non-cwd `EditorWindowLifecycleContracts`;`McpAssetInputContracts`;`WEMeshCacheContracts` | Pass | real window/MCP/normal exit;legacy/new saves;WEMesh v2/v3/provenance | +| 2026-07-26 | working tree | Windows 11 x64;CPU | `WaveCoreContracts`;`PathTargetContracts`;`RuntimeTargetContracts`;real symlink/junction + TOCTOU injection | Pass | path schema/policy/atomic/legacy/package/closure Evidence | +| 2026-07-26 | working tree | Windows 11 x64;Release | `cmake --build Build --config Release`;`ctest --test-dir Build -C Release -L cpu --output-on-failure` | Pass | Release Editor/Runtime/Viewer;9/9 CPU tests | +| 2026-07-26 | working tree | Windows 11 x64;Debug failure path | `WAVE_RHI_INJECT_FAILURE=1 Build/Debug/WaveEditor.exe` | Pass | exit 1;tick/idle fatal;GPU teardown quarantine | ### Remaining Work -- 用户明确接受 Draft。 -- 所有 Delivery Slices 与 Acceptance Criteria。 - G1-01 Project/Asset identity、G2 Scene refs、G6 Package manifest 接管。 diff --git a/docs/specs/2026-07-25-m0-01-cpu-contract-baseline.md b/docs/specs/2026-07-25-m0-01-cpu-contract-baseline.md index 4a2599b..e9be6f1 100644 --- a/docs/specs/2026-07-25-m0-01-cpu-contract-baseline.md +++ b/docs/specs/2026-07-25-m0-01-cpu-contract-baseline.md @@ -226,6 +226,7 @@ M0-01 达到 `Verified` 后,A0-01 Slice 1~3 才能把 Contract Core 类型 - Slice 3 本地实现已完成:新增只读、固定 action SHA、20 分钟预算的 `windows-2022` CPU workflow,并由 `WaveEngine.CpuCiContracts` 检查 action、权限、工具链 Evidence 与 preset 顺序。 - Editor 已停止编译/链接共享 contract runner,旧参数分支及所有仓库调用者已移除;Debug/Release CPU lane 与完整 Editor/GPU contracts 继续通过。 - Slice 3 CI Evidence 已完成:GitHub 托管 `windows-2022` clean checkout 在无 Editor/GPU 启动的前提下通过 Debug/Release 各 5 项 CPU contracts,环境与结果同时写入日志和 Step Summary。 +- G0-04 按本 Spec 已允许的“CPU-only sibling target”规则新增 `WaveEditorDomainContracts`;`cpu-debug` / `cpu-release` preset 同时构建 Core 与该 sibling,`WaveCoreContracts` 的 source/include/link/PCH 闭包未扩大。 ### Next Step @@ -242,6 +243,7 @@ M0-01 达到 `Verified` 后,A0-01 Slice 1~3 才能把 Contract Core 类型 - `windows-2022` 镜像默认 CMake 可能低于项目要求;workflow 不联网安装工具,而是在 runner 预装 CMake 中选择满足 4.0.1 的最高版本,并把实际版本写入日志和 step summary。 - Slice 3 新增 `WaveEngine.CpuCiContracts`,把 pinned action、read-only token、预算、无下载/Editor 命令、可审计 Evidence 和 preset 复用变成可执行契约。 - 首次 clean CI 暴露 PowerShell native command 的 `$LASTEXITCODE` 在 runner 命令解析路径上不可靠;工具链探测改为直接解析完整版本输出,并由 PowerShell AST 与后续 clean run 验证。 +- 后继 G0-04 sibling target 进入同一 CPU lane,因此 preset target 列表兼容扩展为两个独立 executable;没有让 `WaveCoreContracts` 依赖 Developer,也没有改变 workflow 命令、label、timeout 或零测试失败门。 ### Evidence @@ -261,6 +263,7 @@ M0-01 达到 `Verified` 后,A0-01 Slice 1~3 才能把 Contract Core 类型 | 2026-07-25 | same working tree | Slice 3 local validation | `cmake --preset vs2022-x64`;完整 Debug build;Debug/Release CPU presets;完整 Debug CTest;legacy-flag `rg`;日志检查 | Pass locally | Debug 5/5 CPU;Release 5/5 CPU;full Debug 7/7;Editor 不再编译 runner;无 legacy flag 调用者;无 Error/Fatal/D3D12 validation 匹配 | | 2026-07-25 | same working tree source snapshot | 全新隔离目录;排除 `.git`、`Build`、IDE/cache/save/log;CMake 4.2.3;MSVC 19.44 | 从零 `cmake --preset vs2022-x64`;Debug/Release build/test presets | Pass locally | 新建 Build;Debug 5/5、Release 5/5;验证内容随后清空,仅保留被忽略的空临时目录 | | 2026-07-25 | source head `54bf748b3b0f9d4ac24706f714cef0ffa3d2ce2e`;PR merge `672d8d11892566abfefe2be5fde5f297425070a6` | GitHub `windows-2022`;runner `win22/20260720.249.2`;CMake 4.1.2;Python 3.12.10;Visual Studio 17.14.37502.11 | `cmake --preset vs2022-x64`;Debug/Release build presets;Debug/Release test presets | Pass | [CPU Contracts run 30155041110](https://github.com/kyriewxcode/WaveEngine/actions/runs/30155041110);Debug 5/5、Release 5/5;job 1m32s;environment/result 同时写入日志与 Step Summary | +| 2026-07-26 | working tree | Windows 11 / VS 2022 / Python 3 | `cmake --build --preset cpu-debug`;`ctest --preset test-cpu-debug`;Release 同链路;Core 与 Editor-domain target boundary contracts | Pass | 两个独立 CPU executables;Debug/Release 各 11/11;`WaveCoreContracts` 无 Developer/PCH/Editor/RHI 闭包 | ### Remaining Work diff --git a/docs/specs/2026-07-26-g0-04-existing-editor-domain-capabilities.md b/docs/specs/2026-07-26-g0-04-existing-editor-domain-capabilities.md index 3f5259e..67c422e 100644 --- a/docs/specs/2026-07-26-g0-04-existing-editor-domain-capabilities.md +++ b/docs/specs/2026-07-26-g0-04-existing-editor-domain-capabilities.md @@ -1,19 +1,19 @@ # G0-04:现有 Editor 领域能力 AI-Native 收口 - Spec ID: `G0-04` -- Status: Draft +- Status: Verified - Created: 2026-07-26 - Updated: 2026-07-26 - Roadmap: [G0:产品边界与 Engine Loop](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) - Architecture: [AI-Native Engine Architecture](../architecture/ai-native-engine.md) - Depends on: M0-01、G0-01、A0-01 - Owners: Scene、Camera、Lighting、Render Settings 领域与 Editor adapter -- Accepted by: — -- Accepted on: — +- Accepted by: User +- Accepted on: 2026-07-26 - Supersedes: None - Superseded by: None -> Draft 只允许调研、设计和审查;用户明确接受后才能开始生产实现。本 Spec 只改造现有基础能力,不新增 Player、Gameplay、资产类型、渲染算法、Editor 面板或 MCP 方法。 +> 用户在获知本 Spec 只改造现有能力、当前仍为 Draft 且尚无生产实现后,明确要求“完成 G0”;本 Spec 据此进入 Implementing。范围仍只改造现有基础能力,不新增 Player、Gameplay、资产类型、渲染算法、Editor 面板或 MCP 方法。 ## 1. Context @@ -246,11 +246,11 @@ FEditorDocumentLedger | Slice | Scope | Invariant | Verification | Status | |---|---|---|---|---| -| 1 | document ledger、领域 typed schema/request/result、revision/change/evidence pure rules;新增显式 `WaveEditorDomainContracts` target;定义 hydration baseline 与 adapter policy | 不接 UI/MCP/Scene service,不改变运行行为;`WaveCoreContracts`/Runtime closure 不变 | Editor-domain CPU target/boundary、A0-01 validators、audited behavior matrix fixtures | Pending | -| 2 | SceneAuthoring:hierarchy/inspector/instance refs;迁移对应 UI 与 7 个现有 MCP 方法 | 方法名、位置参数、legacy JSON、dirty/Undo 不变 | static ownership、CPU query/command/conflict/no-op、MCP before/after/undo | -| 3 | CameraAuthoring;迁移 UI 与 4 个现有 camera MCP 方法 | 坐标、Pitch/Yaw/Roll、Frame Selected、Undo 不变 | Core Runtime 坐标契约、finite/look-at/roll、真实窗口 | -| 4 | Lighting/RenderSettingsAuthoring;迁移 Panel、EditorShell toolbar、config hydration 与 5 个现有 MCP 方法 | setting 集合/范围、Debug View、PT reset、无伪 Undo | CPU schema/range、config baseline、MCP raster/PT/restore、`WAVE_RG_STRICT=1` | -| 5 | 迁移 Camera navigation observer 与 Asset AddInstance 交界;移除四个 Panel 的业务/MCP ownership,完成 typed v2 descriptor、feed/evidence 与兼容审计 | `rpc.discover` 仍为原 35 个名称;外部 Camera change 不重复;Runtime/Editor target closure 不回退 | full Debug/Release、writer ownership、registration/wire contracts、日志与 RHI failure | +| 1 | document ledger、领域 typed schema/request/result、revision/change/evidence pure rules;新增显式 `WaveEditorDomainContracts` target;定义 hydration baseline 与 adapter policy | 不接 UI/MCP/Scene service,不改变运行行为;`WaveCoreContracts`/Runtime closure 不变 | Editor-domain CPU target/boundary、A0-01 validators、audited behavior matrix fixtures | Completed | +| 2 | SceneAuthoring:hierarchy/inspector/instance refs;迁移对应 UI 与 7 个现有 MCP 方法 | 方法名、位置参数、legacy JSON、dirty/Undo 不变 | static ownership、CPU query/command/conflict/no-op、MCP before/after/undo | Completed | +| 3 | CameraAuthoring;迁移 UI 与 4 个现有 camera MCP 方法 | 坐标、Pitch/Yaw/Roll、Frame Selected、Undo 不变 | Core Runtime 坐标契约、finite/look-at/roll、真实窗口 | Completed | +| 4 | Lighting/RenderSettingsAuthoring;迁移 Panel、EditorShell toolbar、config hydration 与 5 个现有 MCP 方法 | setting 集合/范围、Debug View、PT reset、无伪 Undo | CPU schema/range、config baseline、MCP raster/PT/restore、`WAVE_RG_STRICT=1` | Completed | +| 5 | 迁移 Camera navigation observer 与 Asset AddInstance 交界;移除四个 Panel 的业务/MCP ownership,完成 typed v2 descriptor、feed/evidence 与兼容审计 | `rpc.discover` 仍为原 35 个名称;外部 Camera change 不重复;Runtime/Editor target closure 不回退 | full Debug/Release、writer ownership、registration/wire contracts、日志与 RHI failure | Completed | 每个 Slice 必须可独立验证并保持 Editor 可运行。Asset/Save/Trace 不在本 Spec 中顺带迁移。 @@ -269,15 +269,15 @@ FEditorDocumentLedger ### Acceptance Criteria -- [ ] 没有新增、删除或重命名 MCP 方法,现有 UI 功能、参数、legacy JSON 和视觉结果不回退。 -- [ ] Scene/Camera/Lighting/Render Settings 的 UI 与 MCP 都调用同一领域 service 和 validator。 -- [ ] 领域 service 不依赖 UI/MCP/JSON;Panel、EditorShell、ImGuiLayer 与 Asset promotion 不再绕过已定义的领域提交/观察边界。 -- [ ] 历史 UI/MCP/config 的不同窄化、clamp、hardware policy 与错误行为按 audited behavior matrix 保持并有 fixture。 -- [ ] session ref、typed schema、revisioned snapshot、expected-revision conflict、change set/event/evidence 有当前实现上的契约证据。 -- [ ] no-op/invalid/conflict 不修改状态、revision、dirty、Undo 或 PT reset。 -- [ ] 现有 Instance/Camera Undo 保持;Lighting/Render 不伪造 Undo;Undo/Redo 使用单调新 revision。 -- [ ] Debug/Release、CPU/MCP/真实窗口/GPU failure 和 target closure 的必需验证已记录 Evidence。 -- [ ] Spec 注册表与路线图状态已同步。 +- [x] 没有新增、删除或重命名 MCP 方法,现有 UI 功能、参数、legacy JSON 和视觉结果不回退。 +- [x] Scene/Camera/Lighting/Render Settings 的 UI 与 MCP 都调用同一领域 service 和 validator。 +- [x] 领域 service 不依赖 UI/MCP/JSON;Panel、EditorShell、ImGuiLayer 与 Asset promotion 不再绕过已定义的领域提交/观察边界。 +- [x] 历史 UI/MCP/config 的不同窄化、clamp、hardware policy 与错误行为按 audited behavior matrix 保持并有 fixture。 +- [x] session ref、typed schema、revisioned snapshot、expected-revision conflict、change set/event/evidence 有当前实现上的契约证据。 +- [x] no-op/invalid/conflict 不修改状态、revision、dirty、Undo 或 PT reset。 +- [x] 现有 Instance/Camera Undo 保持;Lighting/Render 不伪造 Undo;Undo/Redo 使用单调新 revision。 +- [x] Debug/Release、CPU/MCP/真实窗口/GPU failure 和 target closure 的必需验证已记录 Evidence。 +- [x] Spec 注册表与路线图状态已同步。 ## 9. Compatibility, Risks, and Open Questions @@ -306,11 +306,31 @@ FEditorDocumentLedger - 已确认 16 个目标方法分别由 4 个 Panel 注册,业务/校验同时存在于 Panel/MCP 和 ImGui 路径。 - 已审计 Panel 之外的 writer:EditorShell toolbar、首次 config hydration、Camera Tick observer、Asset AddInstance promotion。 - 已记录 Scale、Light direction、Path Tracing hardware policy 等 UI/MCP 不同处理,避免共用 service 时发生静默行为变化。 -- 尚未修改生产代码。 +- 用户已明确接受本 Spec 并要求完成整个 G0,状态进入 Implementing。 +- Slice 1 已完成:新增纯 `FEditorDocumentLedger`,分别维护 `editor.scene` 与 `editor.render_settings` hydrated baseline、expected revision、legacy base capture、external observation、单调 revision、change feed 和 Evidence。 +- ledger 的 no-op/conflict/invalid 路径不 commit;Applied 恰好递增一次 revision。事件 ring 固定 1024 项,Evidence ring 固定 256 项并显式报告历史截断。 +- 新增 transport-neutral instance/Transform/Camera/Main Light/Renderer Settings value、schema、validator 与 PT reset 纯规则;Scale 和 Light direction 的 UI/MCP 历史窄化使用独立 policy fixture 保留。 +- 新增 `WaveEditorDomainContracts`:9 个显式源码、无 PCH、无 link target;静态闭包只包含 Contract Core、纯 ledger、纯规则和测试,不触达 Runtime Scene/Renderer、UI、MCP、RHI 或 ThirdParty。 +- CPU Debug/Release presets 现在同时构建 `WaveCoreContracts` 与该 sibling target;完整 Debug build/14 CTest 验证现有窗口、MCP、WEMesh 与 GPU 路径未回退。 +- Slice 2 已完成:新增 `SceneAuthoringService`、共享 `EditorDomainServices` facade 与抽象 effect host;实例 ref resolution、stable list、name/Transform validator、add/remove/rename/transform command、live preview 与单次 Undo commit 都由 Scene 领域拥有。 +- Hierarchy/Inspector 已移除 7 个业务 MCP handler 以及 `McpIdentity`/`McpValidation` 依赖;同名 legacy v1 方法集中到 `FEditorDomainMcpAdapter`,位置参数、JSON、错误类别与 `list_fields` 的历史兼容行为保持。 +- `FUiContext` 仅实现 selection/dirty/既有 `EditorCommands` 的 effect bridge;UI drag 在 begin 捕获 typed expected revision,preview 不增加 revision/Undo,end 只提交一次。Shell Undo/Redo 经 facade 对前后 document snapshot 做 diff,并产生新的单调 revision。 +- `ui_config.cfg` hydration 已前移到 ledger baseline 与领域 MCP 注册之前;本 Slice 没有新增配置字段、UI 控件或 MCP 方法。 +- Slice 3 已完成:新增独立 `CameraAuthoringService`,拥有 Camera typed snapshot、position/rotation validator、set/move/look-at command、continuous preview 与 single Undo commit;共享 facade 仍只提供 ledger/session/effect composition。 +- Inspector Camera edit 与 Viewport Frame Selected 已调用 CameraAuthoring;四个 legacy camera handler 已从 ViewportPanel 移到集中 adapter。Viewport 的 Debug View 方法仍保留到 Slice 4,没有跨域提前迁移。 +- Camera legacy adapter 保留 position/rotation/projection JSON、float world-delta 加法、Pitch `[-89,89]`、Yaw/Roll normalize、look-at Roll=0 和 target==position 的历史错误类别;普通 set 与 Inspector edit 保留 Roll。 +- Slice 4 已完成:新增独立 `LightingAuthoringService` 与 `RenderSettingsAuthoringService`;范围、direction normalize、PT reset 与 no-op/invalid/expected-revision 规则由领域服务统一拥有,且服务不依赖 UI/MCP/JSON。 +- Inspector Main Light、Settings Panel、Viewport Debug View 与 EditorShell toolbar 已复用同一领域路径;slider/drag 保留即时 preview 并在释放时单次 commit。Lighting/Render Settings 保持无 Undo,不伪造 document transaction。 +- `ui_config.cfg` 通过领域 hydration 在 ledger baseline 与 MCP 注册之前应用,不产生 revision、dirty 或 PT reset;保存格式、字段和 clamp 规则均未改变。 +- 剩余 5 个 legacy 方法已从 Panel 移到集中 adapter;`editor.console.log_tail` 仍留在原 Panel 边界。`rpc.discover` 继续精确返回 35 个名称,legacy JSON、参数与错误类别不变。 +- Slice 5 已完成:Camera navigation 的外部变化由 `CameraAuthoringService` 观察并写入同一 revision/change/evidence feed;service 自身提交、preview、Undo/Redo 会同步 observer baseline,避免重复记录。 +- Asset loader 与 `editor.assets.spawn` 保留原有导入/加载行为,仅把最终 `AddInstance` promotion 交给 `SceneAuthoringService`;Asset/Save/Trace 其余能力未扩展、未迁移。 +- 16 个既有领域方法保留原 legacy v1 handler 与方法名,并在同一 capability 注册项下并存 typed v2 descriptor;`rpc.discover` 仍精确为 35 个名称,capability catalog 为 35 个 v1 加 16 个 v2。 +- 四个目标 Panel、EditorShell、ImGuiLayer observer 与 Asset promotion 的 writer ownership 已通过静态契约收口;领域 service 继续不依赖 UI/MCP/JSON,Runtime closure 未引入 Developer/Contract/UI/MCP。 ### Next Step -- 用户明确接受本 Draft 后,将状态改为 `Accepted`/`Implementing`,从 Slice 1 开始。 +- G0-04 已完成并 Verified;本 Spec 范围内没有后续 Delivery Slice。Asset/Save/Trace 如需同类改造,必须另立 Spec,不能作为本次范围的隐含延伸。 ### Changes and Deviations @@ -318,6 +338,8 @@ FEditorDocumentLedger - 现有 Asset/Save/Trace 暂不并入,避免跨领域大爆炸;它们使用后继 Spec 按同一模式迁移。 - 源码审计确认 UI/MCP 并非所有边界一致;改为“共同领域不变量 + 有测试的 legacy/UI adapter 窄化”,避免为追求表面统一而改变现有输入行为。 - `WaveCoreContracts` 明确不允许 Developer closure;Slice 1 使用独立显式 CPU target,不稀释 M0-01/A0-01 边界。 +- 为保证 clean CPU lane 能执行 sibling target,既有 `cpu-debug` / `cpu-release` build preset 从只构建 Core 扩展为同时构建 Core 与 Editor-domain contracts;workflow 命令、CPU label、零测试失败门和 `WaveCoreContracts` 自身闭包未改变。 +- 首次 retention contract 暴露 Evidence ring 无法报告已淘汰历史;实现增加有界累计元数据并保持只保留 256 个完整 bundle。 ### Evidence @@ -325,9 +347,14 @@ FEditorDocumentLedger |---|---|---|---|---|---| | 2026-07-26 | `8bf94fc` | Windows 11 / VS 2022 / Python 3 | `rg` 审计 Panel registrations、`FUiContext`、`EditorCommands`、`FScene`、MCP registry | Pass | 16 个目标方法;Panel-owned handler/validation;A0-01 公共 vocabulary 已 Verified | | 2026-07-26 | `89b1fe4` + working tree | Windows 11 / PowerShell / Python 3 | 审计全部 UI writer、Camera Tick/config hydration/Asset promotion、MCP version registry、CMake CPU/Runtime closure;运行 SpecContracts | Pass | 35 个 discover 名称;UI/MCP behavior matrix;typed v2 并存约束;独立 Editor-domain CPU target 设计;9 个 Spec 通过 | +| 2026-07-26 | working tree | Windows 11 / VS 2022 / MSVC v143 / Python 3 | `cmake --build --preset cpu-debug`;`ctest --preset test-cpu-debug`;Release 同链路;`py -3 Tests/editor_domain_target_contracts.py .` | Pass | Debug/Release CPU 各 11/11;9 个显式源码/项目 include;schema/ref/revision/conflict/no-op/event/evidence/retention/behavior-matrix fixtures | +| 2026-07-26 | working tree | Windows 11 / Debug / DX12 validation | `cmake --build Build --config Debug`;`ctest --test-dir Build -C Debug --output-on-failure`;UserSaved 日志扫描 | Pass | 14/14;Editor lifecycle、MCP、WEMesh、Core 与 Editor-domain contracts;日志无 Error/Fatal/D3D12 validation 匹配 | +| 2026-07-26 | working tree | Windows 11 / VS 2022 / Debug+Release / DX12 validation | full Debug/Release build;Debug full CTest;Release focused Editor-domain/MCP;`editor_scene_authoring_contracts.py`;UserSaved log scan | Pass | Debug 15/15;Release 4/4 focused;35 个 discover 名称;7 个方法 legacy JSON;no-op/invalid 无 Undo;Transform/Rename/Delete Undo/Redo 恢复;日志无 Error/Fatal/D3D12 validation 匹配 | +| 2026-07-26 | working tree | Windows 11 / VS 2022 / Debug+Release / DX12 validation | full Debug/Release build;Debug full CTest;Release focused Camera/Editor-domain/Core;`editor_camera_authoring_contracts.py`;UserSaved log scan | Pass | Debug 16/16;Release 5/5 focused;35 名称;Camera JSON/finite/Pitch/Yaw/Roll/move/look-at/Undo;Core coordinate contract;日志无 Error/Fatal/D3D12 validation 匹配 | +| 2026-07-26 | working tree | Windows 11 / VS 2022 / Debug+Release / DX12 validation | full Debug/Release build;Debug full CTest;Release focused Render/Editor-domain/Core;`editor_render_authoring_contracts.py`;ownership/spec/registration contracts;日志扫描 | Pass | Debug 17/17;Release 5/5 focused;35 名称;5 个 Lighting/Renderer/Debug legacy 方法的 JSON/range/normalize/PT restore/no-fake-Undo;config 备份恢复;日志无 Error/Fatal/D3D12 validation 匹配 | +| 2026-07-26 | working tree | Windows 11 / VS 2022 / Debug+Release / DX12 validation | full Debug/Release CTest;CPU Debug/Release presets;typed descriptor/writer/registration contracts;`WAVE_RHI_INJECT_FAILURE=1`;正常 lifecycle 与日志复检 | Pass | Debug 17/17;Release 17/17;CPU Debug/Release 各 11/11;35 个 discover 名称不变、16 个 typed v2 descriptor 并存;RHI 注入按预期 `exit 1` quarantine;随后正常窗口退出且日志无 Error/Fatal/D3D12 validation 匹配 | ### Remaining Work -- 用户接受 Draft。 -- Delivery Slice 1~5 的生产实现与验证。 -- 为现有 Asset/Save/Trace 建立独立、同样禁止新增业务功能的后继 Spec。 +- 本 Spec 与 G0 范围内无剩余工作。 +- Asset/Save/Trace 的领域化不属于 G0-04;若未来实施,需建立独立、同样禁止隐式新增业务功能的后继 Spec。 diff --git a/docs/specs/README.md b/docs/specs/README.md index 72a62ae..790e93e 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -22,9 +22,9 @@ Draft / Accepted / Implementing --被替代--> Superseded | [A0-03](./2026-07-25-a0-03-context-evidence-federation.md) | Accepted | Context、Evidence 与 Artifact Provider Federation | [AI-native track](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) | 2026-07-25 | | [A0-04](./2026-07-25-a0-04-headless-agent-conformance.md) | Accepted | Headless Developer Host、Adapter Conformance 与 Agent Eval | [AI-native track](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) | 2026-07-25 | | [G0-01](./2026-07-25-g0-01-runtime-editor-targets.md) | Verified | Runtime 与 Editor Target 边界 | [G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) | 2026-07-26 | -| [G0-02](./2026-07-25-g0-02-engine-loop-application-config.md) | Accepted | Engine Loop、Application Config 与 Lifecycle | [G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) | 2026-07-25 | -| [G0-03](./2026-07-25-g0-03-project-package-user-paths.md) | Accepted | Project、Package、User Paths 与原子存储边界 | [G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) | 2026-07-25 | -| [G0-04](./2026-07-26-g0-04-existing-editor-domain-capabilities.md) | Draft | 现有 Editor 领域能力 AI-Native 收口 | [G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) | 2026-07-26 | +| [G0-02](./2026-07-25-g0-02-engine-loop-application-config.md) | Verified | Engine Loop、Application Config 与 Lifecycle | [G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) | 2026-07-26 | +| [G0-03](./2026-07-25-g0-03-project-package-user-paths.md) | Verified | Project、Package、User Paths 与原子存储边界 | [G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) | 2026-07-26 | +| [G0-04](./2026-07-26-g0-04-existing-editor-domain-capabilities.md) | Verified | 现有 Editor 领域能力 AI-Native 收口 | [G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) | 2026-07-26 | ## Session 入口