From 6d2bee8b1c9993f092afee69afada0113366091b Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sat, 25 Jul 2026 18:39:44 +0800 Subject: [PATCH 01/16] establish AI-native foundation contracts --- .github/workflows/cpu-contracts.yml | 149 +++++++++ CMakeLists.txt | 82 ++++- CMakePresets.json | 80 +++++ Engine/Source/Core/Core.h | 5 + Engine/Source/Core/Engine.h | 2 + Engine/Source/Core/Math.h | 5 + Engine/Source/Misc/Log.cpp | 4 + Engine/Source/Misc/Log.h | 8 + .../RenderGraph/RenderGraphAllocator.cpp | 3 + .../Source/RenderGraph/RenderGraphAllocator.h | 7 + Engine/Source/Trace/TraceContracts.cpp | 3 +- Engine/Source/WaveEngine.cpp | 86 +---- README.md | 21 +- Tests/CoreRuntimeContracts.cpp | 92 ++++++ Tests/CoreRuntimeContracts.h | 6 + Tests/CoreRuntimeContractsMain.cpp | 6 + Tests/cmake_preset_contracts.py | 122 +++++++ Tests/core_contract_target_contracts.py | 201 ++++++++++++ Tests/cpu_ci_contracts.py | 114 +++++++ ...26-07-25-game-engine-production-roadmap.md | 18 +- docs/specs/2026-07-25-a0-01-contract-core.md | 26 +- docs/specs/2026-07-25-a0-02-developer-host.md | 279 ++++++++++++++++ ...07-25-a0-03-context-evidence-federation.md | 274 ++++++++++++++++ ...-07-25-a0-04-headless-agent-conformance.md | 280 ++++++++++++++++ ...-25-g0-01-runtime-editor-player-targets.md | 13 +- ...25-g0-02-engine-loop-application-config.md | 266 ++++++++++++++++ ...-07-25-g0-03-project-package-user-paths.md | 298 ++++++++++++++++++ .../2026-07-25-m0-01-cpu-contract-baseline.md | 265 ++++++++++++++++ docs/specs/README.md | 10 +- 29 files changed, 2608 insertions(+), 117 deletions(-) create mode 100644 .github/workflows/cpu-contracts.yml create mode 100644 CMakePresets.json create mode 100644 Tests/CoreRuntimeContracts.cpp create mode 100644 Tests/CoreRuntimeContracts.h create mode 100644 Tests/CoreRuntimeContractsMain.cpp create mode 100644 Tests/cmake_preset_contracts.py create mode 100644 Tests/core_contract_target_contracts.py create mode 100644 Tests/cpu_ci_contracts.py create mode 100644 docs/specs/2026-07-25-a0-02-developer-host.md create mode 100644 docs/specs/2026-07-25-a0-03-context-evidence-federation.md create mode 100644 docs/specs/2026-07-25-a0-04-headless-agent-conformance.md create mode 100644 docs/specs/2026-07-25-g0-02-engine-loop-application-config.md create mode 100644 docs/specs/2026-07-25-g0-03-project-package-user-paths.md create mode 100644 docs/specs/2026-07-25-m0-01-cpu-contract-baseline.md diff --git a/.github/workflows/cpu-contracts.yml b/.github/workflows/cpu-contracts.yml new file mode 100644 index 0000000..36f0f43 --- /dev/null +++ b/.github/workflows/cpu-contracts.yml @@ -0,0 +1,149 @@ +name: CPU Contracts + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: cpu-contracts-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + cpu-contracts: + name: Windows x64 CPU contracts + runs-on: windows-2022 + timeout-minutes: 20 + + defaults: + run: + shell: pwsh + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Select and report toolchain + run: | + $ErrorActionPreference = "Stop" + $MinimumCMakeVersion = [Version]"4.0.1" + + function Get-CMakeVersion([string]$Executable) + { + $VersionLine = & $Executable --version | Select-Object -First 1 + if ($LASTEXITCODE -ne 0 -or $VersionLine -notmatch "^cmake version ([0-9.]+)") + { + throw "Unable to determine CMake version from $Executable" + } + return [Version]$Matches[1] + } + + $SelectedCMakeVersion = Get-CMakeVersion "cmake" + if ($SelectedCMakeVersion -lt $MinimumCMakeVersion) + { + $AndroidCMakeRoot = Join-Path $env:ANDROID_HOME "cmake" + $Candidates = @( + Get-ChildItem -LiteralPath $AndroidCMakeRoot -Directory -ErrorAction SilentlyContinue | + ForEach-Object { + $CandidateExecutable = Join-Path $_.FullName "bin\cmake.exe" + if (Test-Path -LiteralPath $CandidateExecutable) + { + $CandidateVersion = Get-CMakeVersion $CandidateExecutable + if ($CandidateVersion -ge $MinimumCMakeVersion) + { + [PSCustomObject]@{ + Bin = Split-Path -Parent $CandidateExecutable + Version = $CandidateVersion + } + } + } + } | + Sort-Object Version -Descending + ) + if ($Candidates.Count -eq 0) + { + throw "CMake $MinimumCMakeVersion or newer is not installed on the runner" + } + + $Selected = $Candidates[0] + $Selected.Bin | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + $env:Path = "$($Selected.Bin);$env:Path" + $SelectedCMakeVersion = Get-CMakeVersion "cmake" + } + + $PythonVersion = & python --version 2>&1 | Select-Object -First 1 + if ($LASTEXITCODE -ne 0 -or $PythonVersion -notmatch "^Python 3\.") + { + throw "Python 3 is required for the complete CPU contract lane" + } + + $VsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $VisualStudioVersion = & $VsWhere -latest -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationVersion + if ($LASTEXITCODE -ne 0 -or -not $VisualStudioVersion) + { + throw "Visual Studio 2022 C++ tools are required" + } + if ($VisualStudioVersion -notmatch "^17\.") + { + throw "Expected Visual Studio 2022, found $VisualStudioVersion" + } + + $Commit = git rev-parse HEAD + @( + "## CPU Contract Environment" + "" + "- Commit: ``$Commit``" + "- Runner: ``$env:ImageOS`` / ``$env:ImageVersion``" + "- CMake: ``$SelectedCMakeVersion``" + "- Python: ``$PythonVersion``" + "- Visual Studio: ``$VisualStudioVersion``" + "- Configure preset: ``vs2022-x64``" + "- Build presets: ``cpu-debug``, ``cpu-release``" + "- Test presets: ``test-cpu-debug``, ``test-cpu-release``" + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Encoding utf8 + + - name: Configure + run: cmake --preset vs2022-x64 + + - name: Build CPU contracts (Debug) + run: cmake --build --preset cpu-debug + + - name: Test CPU contracts (Debug) + run: ctest --preset test-cpu-debug + + - name: Build CPU contracts (Release) + run: cmake --build --preset cpu-release + + - name: Test CPU contracts (Release) + run: ctest --preset test-cpu-release + + - name: Report CPU contract result + run: | + $ErrorActionPreference = "Stop" + $DebugDiscovery = ctest --preset test-cpu-debug --show-only=json-v1 | + ConvertFrom-Json + if ($LASTEXITCODE -ne 0) + { + throw "Unable to discover Debug CPU tests" + } + $ReleaseDiscovery = ctest --preset test-cpu-release --show-only=json-v1 | + ConvertFrom-Json + if ($LASTEXITCODE -ne 0) + { + throw "Unable to discover Release CPU tests" + } + @( + "" + "## CPU Contract Result" + "" + "- Debug: **Pass** ($($DebugDiscovery.tests.Count) tests)" + "- Release: **Pass** ($($ReleaseDiscovery.tests.Count) tests)" + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Encoding utf8 diff --git a/CMakeLists.txt b/CMakeLists.txt index 151efc7..d1293a8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,9 +74,45 @@ if (WIN32) endif() file(GLOB_RECURSE ENGINE_SHADERS CONFIGURE_DEPENDS "${SHADERS_FOLDER}/*.hlsl" "${SHADERS_FOLDER}/*.hlsli" "${SHADERS_FOLDER}/*.h") -add_executable(WaveEngine ${ENGINE_HEADER} ${ENGINE_SOURCE} ${THIRD_PARTY_FILES} ${ENGINE_SHADERS}) +set(CORE_RUNTIME_CONTRACT_SUITE + "${PROJECT_SOURCE_DIR}/Tests/CoreRuntimeContracts.h" + "${PROJECT_SOURCE_DIR}/Tests/CoreRuntimeContracts.cpp") + +add_executable(WaveEngine + ${ENGINE_HEADER} + ${ENGINE_SOURCE} + ${THIRD_PARTY_FILES} + ${ENGINE_SHADERS}) set_property(TARGET WaveEngine PROPERTY COMPILE_WARNING_AS_ERROR ON) +# ---------------- WaveCoreContracts ---------------- +# Explicit CPU-only source closure. This target intentionally does not use +# EnginePCH and must not grow Window/RHI/Renderer/Scene/Shader/UI/MCP/Developer +# or third-party source dependencies. +set(WAVE_CORE_CONTRACT_SOURCES + ${CORE_RUNTIME_CONTRACT_SUITE} + "${PROJECT_SOURCE_DIR}/Tests/CoreRuntimeContractsMain.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" + "${PROJECT_SOURCE_DIR}/Engine/Source/RenderGraph/RenderGraphAllocator.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Trace/TraceAnalysis.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Trace/TraceCodec.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Trace/TraceContracts.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Trace/TraceLiveSink.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Trace/TraceRecorder.cpp") +add_executable(WaveCoreContracts ${WAVE_CORE_CONTRACT_SOURCES}) +set_property(TARGET WaveCoreContracts PROPERTY COMPILE_WARNING_AS_ERROR ON) +target_include_directories(WaveCoreContracts PRIVATE + "${PROJECT_SOURCE_DIR}/Engine/Source" + "${PROJECT_SOURCE_DIR}/Tests") +if(WIN32) + target_link_libraries(WaveCoreContracts PRIVATE ws2_32) +endif() +if(MSVC) + target_compile_options(WaveCoreContracts PRIVATE /bigobj /Zc:preprocessor) +endif() + # ---------------- WaveTraceViewer ---------------- file(GLOB TRACE_VIEWER_SOURCES CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/Tools/TraceViewer/*.h" @@ -209,6 +245,34 @@ endif() if(BUILD_TESTING) find_package(Python3 COMPONENTS Interpreter QUIET) if(Python3_Interpreter_FOUND) + add_test( + NAME WaveEngine.CMakePresetContracts + COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/Tests/cmake_preset_contracts.py" + "${PROJECT_SOURCE_DIR}" + ) + set_tests_properties( + WaveEngine.CMakePresetContracts + PROPERTIES + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + TIMEOUT 10 + LABELS "build;contract;cpu" + ) + + add_test( + NAME WaveEngine.CpuCiContracts + COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/Tests/cpu_ci_contracts.py" + "${PROJECT_SOURCE_DIR}" + ) + set_tests_properties( + WaveEngine.CpuCiContracts + PROPERTIES + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + TIMEOUT 10 + LABELS "build;contract;cpu" + ) + add_test( NAME WaveEngine.SpecContracts COMMAND "${Python3_EXECUTABLE}" @@ -223,6 +287,20 @@ if(BUILD_TESTING) LABELS "contract;spec;cpu" ) + add_test( + NAME WaveEngine.CoreContractTargetBoundary + COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/Tests/core_contract_target_contracts.py" + "${PROJECT_SOURCE_DIR}" + ) + set_tests_properties( + WaveEngine.CoreContractTargetBoundary + PROPERTIES + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + TIMEOUT 10 + LABELS "contract;core;cpu" + ) + add_test( NAME WaveEngine.McpAssetInputContracts COMMAND "${Python3_EXECUTABLE}" @@ -260,7 +338,7 @@ if(BUILD_TESTING) add_test( NAME WaveEngine.CoreRuntimeContracts - COMMAND "$" --run-core-runtime-contracts + COMMAND "$" ) set_tests_properties( WaveEngine.CoreRuntimeContracts diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..f59fa57 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,80 @@ +{ + "version": 6, + "cmakeMinimumRequired": { + "major": 4, + "minor": 0, + "patch": 1 + }, + "configurePresets": [ + { + "name": "vs2022-x64", + "displayName": "Visual Studio 2022 x64", + "description": "Configure the canonical Windows x64 build with contract tests enabled.", + "generator": "Visual Studio 17 2022", + "architecture": "x64", + "binaryDir": "${sourceDir}/Build", + "cacheVariables": { + "BUILD_TESTING": { + "type": "BOOL", + "value": "ON" + } + } + } + ], + "buildPresets": [ + { + "name": "cpu-debug", + "displayName": "CPU Contracts (Debug)", + "configurePreset": "vs2022-x64", + "configuration": "Debug", + "targets": [ + "WaveCoreContracts" + ] + }, + { + "name": "cpu-release", + "displayName": "CPU Contracts (Release)", + "configurePreset": "vs2022-x64", + "configuration": "Release", + "targets": [ + "WaveCoreContracts" + ] + } + ], + "testPresets": [ + { + "name": "test-cpu-debug", + "displayName": "CPU Contracts (Debug)", + "configurePreset": "vs2022-x64", + "configuration": "Debug", + "filter": { + "include": { + "label": "cpu" + } + }, + "output": { + "outputOnFailure": true + }, + "execution": { + "noTestsAction": "error" + } + }, + { + "name": "test-cpu-release", + "displayName": "CPU Contracts (Release)", + "configurePreset": "vs2022-x64", + "configuration": "Release", + "filter": { + "include": { + "label": "cpu" + } + }, + "output": { + "outputOnFailure": true + }, + "execution": { + "noTestsAction": "error" + } + } + ] +} diff --git a/Engine/Source/Core/Core.h b/Engine/Source/Core/Core.h index 5e1bc1c..760e566 100644 --- a/Engine/Source/Core/Core.h +++ b/Engine/Source/Core/Core.h @@ -1,5 +1,10 @@ #pragma once +#include +#include +#include +#include + #if defined(_DEBUG) && !defined(NDEBUG) #define WAVE_DEBUG 1 #else diff --git a/Engine/Source/Core/Engine.h b/Engine/Source/Core/Engine.h index ca74d1b..acae720 100644 --- a/Engine/Source/Core/Engine.h +++ b/Engine/Source/Core/Engine.h @@ -1,5 +1,7 @@ #pragma once +#include "Core.h" + struct FGlobalEngineContext { uint64 FrameIndex = 0; diff --git a/Engine/Source/Core/Math.h b/Engine/Source/Core/Math.h index 64047a0..f5265b1 100644 --- a/Engine/Source/Core/Math.h +++ b/Engine/Source/Core/Math.h @@ -1,5 +1,10 @@ #pragma once +#include +#include +#include +#include + constexpr float PI = 3.1415926535f; namespace WaveMath diff --git a/Engine/Source/Misc/Log.cpp b/Engine/Source/Misc/Log.cpp index b3dc741..6d66a61 100644 --- a/Engine/Source/Misc/Log.cpp +++ b/Engine/Source/Misc/Log.cpp @@ -1,5 +1,9 @@ #include "Log.h" +#include "Core/Profiler.h" + +#include + #if defined(_WIN32) #define WIN32_LEAN_AND_MEAN #include diff --git a/Engine/Source/Misc/Log.h b/Engine/Source/Misc/Log.h index 6095d1b..c423be9 100644 --- a/Engine/Source/Misc/Log.h +++ b/Engine/Source/Misc/Log.h @@ -1,7 +1,15 @@ #pragma once +#include +#include +#include +#include #include #include +#include +#include +#include +#include class Log { diff --git a/Engine/Source/RenderGraph/RenderGraphAllocator.cpp b/Engine/Source/RenderGraph/RenderGraphAllocator.cpp index f8c7f4a..0b5cd88 100644 --- a/Engine/Source/RenderGraph/RenderGraphAllocator.cpp +++ b/Engine/Source/RenderGraph/RenderGraphAllocator.cpp @@ -1,5 +1,8 @@ #include "RenderGraphAllocator.h" +#include +#include + FRenderGraphAllocator::FRenderGraphAllocator(uint64 Size) : DefaultBlockSize(Size) { diff --git a/Engine/Source/RenderGraph/RenderGraphAllocator.h b/Engine/Source/RenderGraph/RenderGraphAllocator.h index 510db0f..7a70b73 100644 --- a/Engine/Source/RenderGraph/RenderGraphAllocator.h +++ b/Engine/Source/RenderGraph/RenderGraphAllocator.h @@ -1,5 +1,12 @@ #pragma once +#include "Core/Core.h" + +#include +#include +#include +#include + class FRenderGraphAllocator { struct FAllocatedObject diff --git a/Engine/Source/Trace/TraceContracts.cpp b/Engine/Source/Trace/TraceContracts.cpp index 6f436cf..eb42aa1 100644 --- a/Engine/Source/Trace/TraceContracts.cpp +++ b/Engine/Source/Trace/TraceContracts.cpp @@ -6,6 +6,7 @@ #include "Trace/TraceRecorder.h" #include "Core/Core.h" #include "Core/Profiler.h" +#include "Misc/Log.h" #include #include @@ -522,7 +523,7 @@ namespace WaveTrace // --------------------------------------------------------------- // Recording end-to-end contracts (FTraceRecorder + SCOPED_CPU_EVENT // hot path + collector thread + file sink, decoded with the Task 1 - // codec). Runs in --run-core-runtime-contracts: no window, no RHI. + // codec). Runs in WaveCoreContracts: no window, no RHI. // --------------------------------------------------------------- std::vector ReadAllFileBytes(const std::filesystem::path& Path) diff --git a/Engine/Source/WaveEngine.cpp b/Engine/Source/WaveEngine.cpp index 537b603..a3c12cd 100644 --- a/Engine/Source/WaveEngine.cpp +++ b/Engine/Source/WaveEngine.cpp @@ -9,9 +9,7 @@ #include "Misc/Log.h" #include "Mcp/McpServer.h" #include "Mcp/MainThreadDispatcher.h" -#include "RenderGraph/RenderGraphAllocator.h" #include "RenderGraph/RenderGraphResourcePool.h" -#include "Trace/TraceContracts.h" #include "Trace/TraceRecorder.h" #include "Developer/RenderDocIntegration.h" @@ -36,92 +34,10 @@ namespace } #endif } - - int RunCoreRuntimeContracts() - { - try - { - const auto NearlyEqual = [](float A, float B) - { - return std::abs(A - B) <= 1e-4f; - }; - const auto VectorNearlyEqual = [&](const FVector3& A, const FVector3& B) - { - return NearlyEqual(A.x, B.x) - && NearlyEqual(A.y, B.y) - && NearlyEqual(A.z, B.z); - }; - - check(VectorNearlyEqual(WaveMath::WorldForward, FVector3(1.0f, 0.0f, 0.0f)), - "UE forward-axis contract"); - check(VectorNearlyEqual(WaveMath::WorldRight, FVector3(0.0f, 1.0f, 0.0f)), - "UE right-axis contract"); - check(VectorNearlyEqual(WaveMath::WorldUp, FVector3(0.0f, 0.0f, 1.0f)), - "UE up-axis contract"); - check(VectorNearlyEqual( - Normalize(Cross(WaveMath::WorldUp, WaveMath::WorldForward)), - WaveMath::WorldRight), "UE camera right-axis contract"); - check(VectorNearlyEqual(FRotator(0.0f, 0.0f, 0.0f).Vector(), WaveMath::WorldForward), - "UE zero-rotator contract"); - check(VectorNearlyEqual(FRotator(0.0f, 90.0f, 0.0f).Vector(), WaveMath::WorldRight), - "UE positive-yaw contract"); - check(VectorNearlyEqual(FRotator(90.0f, 0.0f, 0.0f).Vector(), WaveMath::WorldUp), - "UE positive-pitch contract"); - - const FRotator SourceRotator(23.0f, 117.0f, -31.0f); - const FQuat SourceQuat = Normalize(SourceRotator.Quaternion()); - const FQuat RoundTripQuat = Normalize(FRotator::FromQuaternion(SourceQuat).Quaternion()); - check(std::abs(Dot(SourceQuat, RoundTripQuat)) >= 0.9999f, - "UE rotator/quaternion round-trip contract"); - - const FMatrix4x4 View = FMatrix4x4::LookAt( - FVector3(0.0f), WaveMath::WorldForward, WaveMath::WorldUp); - check(VectorNearlyEqual( - FVector3(View.M[0][0], View.M[1][0], View.M[2][0]), - WaveMath::WorldRight), "UE look-at right-axis contract"); - check(VectorNearlyEqual( - WaveMath::ConvertLegacyWorldVectorToUE(FVector3(1.0f, 2.0f, 3.0f)), - FVector3(1.0f, -3.0f, 2.0f)), "legacy world migration contract"); - check(VectorNearlyEqual( - WaveMath::ConvertYUpAssetVectorToUE(FVector3(1.0f, 2.0f, 3.0f)), - FVector3(1.0f, 3.0f, 2.0f)), "Y-up asset to UE conversion contract"); - - { - FRenderGraphAllocator Allocator(1024); - void* FirstAllocation = Allocator.Allocate(256, 64); - const uint64 InitialCapacity = Allocator.GetCapacity(); - check(FirstAllocation != nullptr && Allocator.GetSize() >= 256, - "RenderGraph allocator initial allocation contract"); - Allocator.Reset(); - check(Allocator.GetSize() == 0 && Allocator.GetCapacity() == InitialCapacity, - "RenderGraph allocator reset must retain arena capacity"); - void* ReusedAllocation = Allocator.Allocate(256, 64); - check(ReusedAllocation == FirstAllocation, - "RenderGraph allocator reset must reuse the retained arena"); - } - - check(WaveTrace::RunTraceContracts() == 0, "trace codec contracts"); - - check(false, "runtime check contract"); - } - catch (const std::runtime_error& Error) - { - return std::string_view(Error.what()).find("runtime check contract") != std::string_view::npos ? 0 : 2; - } - catch (...) - { - return 3; - } - return 4; - } } -int main(int ArgC, char** ArgV) +int main() { - if (ArgC == 2 && std::string_view(ArgV[1]) == "--run-core-runtime-contracts") - { - return RunCoreRuntimeContracts(); - } HidePrivateConsoleWindow(); if (!Log::OpenLog("WaveEngine.log")) diff --git a/README.md b/README.md index 0d34096..ad78d6a 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Shipping Player → Runtime-only closure UI、MCP 和未来 headless host 只是同一领域能力的客户端。Query、Document Command、异步 Job 和 VCS/发布类 External Action 具有不同失败边界;修改必须用 change set、validator、artifact provenance、测试、截图或 trace 证明。未来运行时模型只产生受 Gameplay 校验的高层 Intent,不直接控制逐帧状态。 -整体设计见 [AI-Native Engine Architecture](docs/architecture/ai-native-engine.md);通用设计输入已作为[仓库内参考快照](docs/references/AI时代自研游戏引擎设计指南.md)保存;首个基础实现见 [A0-01 Draft](docs/specs/2026-07-25-a0-01-contract-core.md)。 +整体设计见 [AI-Native Engine Architecture](docs/architecture/ai-native-engine.md);通用设计输入已作为[仓库内参考快照](docs/references/AI时代自研游戏引擎设计指南.md)保存;首个基础实现契约见 [A0-01 Accepted Spec](docs/specs/2026-07-25-a0-01-contract-core.md)。 ## 系统要求 @@ -82,6 +82,24 @@ live 端口以编辑器 Trace Capture 面板中的配置为准。 ## 验证 +无窗口 CPU 契约使用仓库 preset,Debug 与 Release 都只构建 `WaveCoreContracts`,不会构建或启动 Editor: + +```powershell +cmake --preset vs2022-x64 + +cmake --build --preset cpu-debug +ctest --preset test-cpu-debug + +cmake --build --preset cpu-release +ctest --preset test-cpu-release +``` + +测试 preset 只运行带 `cpu` label 的测试、显示失败输出,并在没有匹配测试时返回失败。`CMakeSettings.json` 保留给 Visual Studio 兼容使用,自动化以 `CMakePresets.json` 为准。 + +`.github/workflows/cpu-contracts.yml` 在 Windows x64 runner 上复用完全相同的 preset 链路;该 workflow 不构建或启动 Editor。 + +完整 Debug 验证: + ```powershell cmake --build Build --config Debug ctest --test-dir Build -C Debug --output-on-failure @@ -89,6 +107,7 @@ ctest --test-dir Build -C Debug --output-on-failure 当前 CTest 覆盖: +- CMake preset 与 CPU target/source/include 边界。 - Spec 元数据、状态和链接。 - Core Runtime 坐标、旋转、RenderGraph allocator 和 trace codec。 - MCP framing、数值边界和恶意资产输入。 diff --git a/Tests/CoreRuntimeContracts.cpp b/Tests/CoreRuntimeContracts.cpp new file mode 100644 index 0000000..f2857db --- /dev/null +++ b/Tests/CoreRuntimeContracts.cpp @@ -0,0 +1,92 @@ +#include "CoreRuntimeContracts.h" + +#include "Core/Core.h" +#include "Core/Math.h" +#include "Misc/Log.h" +#include "RenderGraph/RenderGraphAllocator.h" +#include "Trace/TraceContracts.h" + +#include +#include +#include + +namespace WaveContracts +{ + int RunCoreRuntimeContracts() + { + try + { + const auto NearlyEqual = [](float A, float B) + { + return std::abs(A - B) <= 1e-4f; + }; + const auto VectorNearlyEqual = [&](const FVector3& A, const FVector3& B) + { + return NearlyEqual(A.x, B.x) + && NearlyEqual(A.y, B.y) + && NearlyEqual(A.z, B.z); + }; + + check(VectorNearlyEqual(WaveMath::WorldForward, FVector3(1.0f, 0.0f, 0.0f)), + "UE forward-axis contract"); + check(VectorNearlyEqual(WaveMath::WorldRight, FVector3(0.0f, 1.0f, 0.0f)), + "UE right-axis contract"); + check(VectorNearlyEqual(WaveMath::WorldUp, FVector3(0.0f, 0.0f, 1.0f)), + "UE up-axis contract"); + check(VectorNearlyEqual( + Normalize(Cross(WaveMath::WorldUp, WaveMath::WorldForward)), + WaveMath::WorldRight), "UE camera right-axis contract"); + check(VectorNearlyEqual(FRotator(0.0f, 0.0f, 0.0f).Vector(), WaveMath::WorldForward), + "UE zero-rotator contract"); + check(VectorNearlyEqual(FRotator(0.0f, 90.0f, 0.0f).Vector(), WaveMath::WorldRight), + "UE positive-yaw contract"); + check(VectorNearlyEqual(FRotator(90.0f, 0.0f, 0.0f).Vector(), WaveMath::WorldUp), + "UE positive-pitch contract"); + + const FRotator SourceRotator(23.0f, 117.0f, -31.0f); + const FQuat SourceQuat = Normalize(SourceRotator.Quaternion()); + const FQuat RoundTripQuat = Normalize(FRotator::FromQuaternion(SourceQuat).Quaternion()); + check(std::abs(Dot(SourceQuat, RoundTripQuat)) >= 0.9999f, + "UE rotator/quaternion round-trip contract"); + + const FMatrix4x4 View = FMatrix4x4::LookAt( + FVector3(0.0f), WaveMath::WorldForward, WaveMath::WorldUp); + check(VectorNearlyEqual( + FVector3(View.M[0][0], View.M[1][0], View.M[2][0]), + WaveMath::WorldRight), "UE look-at right-axis contract"); + check(VectorNearlyEqual( + WaveMath::ConvertLegacyWorldVectorToUE(FVector3(1.0f, 2.0f, 3.0f)), + FVector3(1.0f, -3.0f, 2.0f)), "legacy world migration contract"); + check(VectorNearlyEqual( + WaveMath::ConvertYUpAssetVectorToUE(FVector3(1.0f, 2.0f, 3.0f)), + FVector3(1.0f, 3.0f, 2.0f)), "Y-up asset to UE conversion contract"); + + { + FRenderGraphAllocator Allocator(1024); + void* FirstAllocation = Allocator.Allocate(256, 64); + const uint64 InitialCapacity = Allocator.GetCapacity(); + check(FirstAllocation != nullptr && Allocator.GetSize() >= 256, + "RenderGraph allocator initial allocation contract"); + Allocator.Reset(); + check(Allocator.GetSize() == 0 && Allocator.GetCapacity() == InitialCapacity, + "RenderGraph allocator reset must retain arena capacity"); + void* ReusedAllocation = Allocator.Allocate(256, 64); + check(ReusedAllocation == FirstAllocation, + "RenderGraph allocator reset must reuse the retained arena"); + } + + check(WaveTrace::RunTraceContracts() == 0, "trace codec contracts"); + + check(false, "runtime check contract"); + } + catch (const std::runtime_error& Error) + { + return std::string_view(Error.what()).find("runtime check contract") != std::string_view::npos ? 0 : 2; + } + catch (...) + { + return 3; + } + return 4; + } +} diff --git a/Tests/CoreRuntimeContracts.h b/Tests/CoreRuntimeContracts.h new file mode 100644 index 0000000..f8328a0 --- /dev/null +++ b/Tests/CoreRuntimeContracts.h @@ -0,0 +1,6 @@ +#pragma once + +namespace WaveContracts +{ + int RunCoreRuntimeContracts(); +} diff --git a/Tests/CoreRuntimeContractsMain.cpp b/Tests/CoreRuntimeContractsMain.cpp new file mode 100644 index 0000000..bfd822c --- /dev/null +++ b/Tests/CoreRuntimeContractsMain.cpp @@ -0,0 +1,6 @@ +#include "CoreRuntimeContracts.h" + +int main() +{ + return WaveContracts::RunCoreRuntimeContracts(); +} diff --git a/Tests/cmake_preset_contracts.py b/Tests/cmake_preset_contracts.py new file mode 100644 index 0000000..9086267 --- /dev/null +++ b/Tests/cmake_preset_contracts.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + + +def fail(message: str) -> None: + raise RuntimeError(message) + + +def require_object(value: Any, path: str) -> dict[str, Any]: + if not isinstance(value, dict): + fail(f"{path} must be an object") + return value + + +def find_named_preset(document: dict[str, Any], group: str, name: str) -> dict[str, Any]: + presets = document.get(group) + if not isinstance(presets, list): + fail(f"{group} must be an array") + for preset in presets: + if isinstance(preset, dict) and preset.get("name") == name: + return preset + fail(f"missing {group} preset: {name}") + + +def require_equal(actual: Any, expected: Any, path: str) -> None: + if actual != expected: + fail(f"{path} must be {expected!r}, got {actual!r}") + + +def validate_build_preset( + document: dict[str, Any], + name: str, + configuration: str, +) -> None: + 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") + + +def validate_test_preset( + document: dict[str, Any], + name: str, + configuration: str, +) -> None: + preset = find_named_preset(document, "testPresets", name) + require_equal(preset.get("configurePreset"), "vs2022-x64", f"{name}.configurePreset") + require_equal(preset.get("configuration"), configuration, f"{name}.configuration") + + include = require_object( + require_object(preset.get("filter"), f"{name}.filter").get("include"), + f"{name}.filter.include", + ) + require_equal(include.get("label"), "cpu", f"{name}.filter.include.label") + + output = require_object(preset.get("output"), f"{name}.output") + require_equal(output.get("outputOnFailure"), True, f"{name}.output.outputOnFailure") + + execution = require_object(preset.get("execution"), f"{name}.execution") + require_equal( + execution.get("noTestsAction"), + "error", + f"{name}.execution.noTestsAction", + ) + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: cmake_preset_contracts.py ", file=sys.stderr) + return 2 + + repository_root = Path(sys.argv[1]).resolve() + preset_path = repository_root / "CMakePresets.json" + document = require_object( + json.loads(preset_path.read_text(encoding="utf-8")), + "CMakePresets.json", + ) + + require_equal(document.get("version"), 6, "version") + require_equal( + document.get("cmakeMinimumRequired"), + {"major": 4, "minor": 0, "patch": 1}, + "cmakeMinimumRequired", + ) + + configure = find_named_preset(document, "configurePresets", "vs2022-x64") + require_equal(configure.get("generator"), "Visual Studio 17 2022", "generator") + require_equal(configure.get("architecture"), "x64", "architecture") + require_equal(configure.get("binaryDir"), "${sourceDir}/Build", "binaryDir") + build_testing = require_object( + require_object(configure.get("cacheVariables"), "cacheVariables").get( + "BUILD_TESTING" + ), + "cacheVariables.BUILD_TESTING", + ) + require_equal(build_testing.get("type"), "BOOL", "BUILD_TESTING.type") + require_equal(build_testing.get("value"), "ON", "BUILD_TESTING.value") + + validate_build_preset(document, "cpu-debug", "Debug") + validate_build_preset(document, "cpu-release", "Release") + validate_test_preset(document, "test-cpu-debug", "Debug") + validate_test_preset(document, "test-cpu-release", "Release") + + print( + "CMake preset contracts passed: canonical VS 2022 x64 configure, " + "Debug/Release CPU-only build and non-empty CPU test gates" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (json.JSONDecodeError, OSError, RuntimeError) as error: + print(f"CMake preset contracts failed: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/Tests/core_contract_target_contracts.py b/Tests/core_contract_target_contracts.py new file mode 100644 index 0000000..85b559d --- /dev/null +++ b/Tests/core_contract_target_contracts.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +FORBIDDEN_SOURCE_PREFIXES = ( + "engine/source/rhi/", + "engine/source/renderer/", + "engine/source/scene/", + "engine/source/shader/", + "engine/source/ui/", + "engine/source/mcp/", + "engine/source/developer/", + "engine/thirdparty/", +) +FORBIDDEN_SOURCE_FILES = { + "engine/source/window.cpp", + "engine/source/window.h", +} +ALLOWED_SOURCE_VARIABLES = {"CORE_RUNTIME_CONTRACT_SUITE", "PROJECT_SOURCE_DIR"} +ALLOWED_LINK_ITEMS = {"PRIVATE", "ws2_32"} + + +def fail(message: str) -> None: + raise RuntimeError(message) + + +def find_command_body(cmake: str, command: str, first_argument: str) -> str: + pattern = re.compile( + rf"{re.escape(command)}\s*\(\s*{re.escape(first_argument)}\b(.*?)\)", + re.IGNORECASE | re.DOTALL, + ) + match = pattern.search(cmake) + if not match: + fail(f"missing {command}({first_argument} ...)") + return match.group(1) + + +def validate_repository_path(repository_root: Path, path: Path) -> str: + resolved = path.resolve() + try: + repository_path = resolved.relative_to(repository_root).as_posix() + except ValueError: + fail(f"WaveCoreContracts dependency escapes repository: {resolved}") + + normalized = repository_path.lower() + if normalized in FORBIDDEN_SOURCE_FILES or normalized.startswith( + FORBIDDEN_SOURCE_PREFIXES + ): + fail(f"forbidden WaveCoreContracts source/include: {repository_path}") + return repository_path + + +def validate_include_closure(repository_root: Path, source_paths: list[Path]) -> int: + include_roots = ( + repository_root / "Engine" / "Source", + repository_root / "Tests", + ) + include_pattern = re.compile( + r'^\s*#\s*include\s*[<"]([^">]+)[">]', + re.MULTILINE, + ) + pending = list(source_paths) + visited: set[Path] = set() + + while pending: + dependency = pending.pop().resolve() + if dependency in visited: + continue + validate_repository_path(repository_root, dependency) + visited.add(dependency) + + text = dependency.read_text(encoding="utf-8", errors="ignore") + for include_name in include_pattern.findall(text): + candidates = [dependency.parent / include_name] + candidates.extend(root / include_name for root in include_roots) + included_path = next( + (candidate for candidate in candidates if candidate.is_file()), + None, + ) + if included_path is not None: + validate_repository_path(repository_root, included_path) + pending.append(included_path) + + return len(visited) + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: core_contract_target_contracts.py ", file=sys.stderr) + return 2 + + repository_root = Path(sys.argv[1]).resolve() + cmake_path = repository_root / "CMakeLists.txt" + cmake = cmake_path.read_text(encoding="utf-8") + + source_body = find_command_body(cmake, "set", "WAVE_CORE_CONTRACT_SOURCES") + variables = set(re.findall(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", source_body)) + unexpected_variables = sorted(variables - ALLOWED_SOURCE_VARIABLES) + if unexpected_variables: + fail( + "WaveCoreContracts source closure uses unapproved variables: " + + ", ".join(unexpected_variables) + ) + + source_bodies = [source_body] + for source_variable in sorted(variables - {"PROJECT_SOURCE_DIR"}): + variable_body = find_command_body(cmake, "set", source_variable) + nested_variables = set( + re.findall(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", variable_body) + ) + if nested_variables - {"PROJECT_SOURCE_DIR"}: + fail(f"{source_variable} must contain only explicit repository sources") + source_bodies.append(variable_body) + + combined_source_body = "\n".join(source_bodies) + if re.search( + r"\b(?:file|aux_source_directory)\s*\(", + combined_source_body, + re.IGNORECASE, + ): + fail("WaveCoreContracts source closure must not use source discovery") + + source_entries = re.findall( + r'"\$\{PROJECT_SOURCE_DIR\}/([^"]+\.(?:c|cc|cpp|cxx|h|hpp))"', + combined_source_body, + re.IGNORECASE, + ) + if not source_entries: + fail("WaveCoreContracts source closure has no explicit repository sources") + + source_paths = [] + for source_entry in source_entries: + source_path = repository_root / source_entry + if not source_path.is_file(): + fail(f"WaveCoreContracts source does not exist: {source_entry}") + validate_repository_path(repository_root, source_path) + source_paths.append(source_path) + + include_count = validate_include_closure(repository_root, source_paths) + + target_body = find_command_body(cmake, "add_executable", "WaveCoreContracts") + if target_body.strip() != "${WAVE_CORE_CONTRACT_SOURCES}": + fail("WaveCoreContracts executable must use only WAVE_CORE_CONTRACT_SOURCES") + + editor_target_body = find_command_body(cmake, "add_executable", "WaveEngine") + if "CORE_RUNTIME_CONTRACT_SUITE" in editor_target_body: + fail("WaveEngine must not compile the standalone contract runner") + + if re.search(r"target_sources\s*\(\s*WaveCoreContracts\b", cmake, re.IGNORECASE): + fail("WaveCoreContracts sources must remain in WAVE_CORE_CONTRACT_SOURCES") + + if re.search( + r"target_precompile_headers\s*\(\s*WaveCoreContracts\b", + cmake, + re.IGNORECASE, + ): + fail("WaveCoreContracts must remain independent from precompiled headers") + + link_bodies = re.findall( + r"target_link_libraries\s*\(\s*WaveCoreContracts\b(.*?)\)", + cmake, + re.IGNORECASE | re.DOTALL, + ) + link_items = { + item + for link_body in link_bodies + for item in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", link_body) + } + unexpected_link_items = sorted(link_items - ALLOWED_LINK_ITEMS) + if unexpected_link_items: + fail( + "WaveCoreContracts links unapproved libraries/targets: " + + ", ".join(unexpected_link_items) + ) + + editor_main = ( + repository_root / "Engine" / "Source" / "WaveEngine.cpp" + ).read_text(encoding="utf-8") + for legacy_fragment in ("CoreRuntimeContracts", "RunCoreRuntimeContracts"): + if legacy_fragment in editor_main: + fail(f"WaveEngine.cpp retains legacy contract runner usage: {legacy_fragment}") + + print( + "WaveCoreContracts boundary passed: " + f"{len(source_entries)} explicit sources, {include_count} project files in " + "the include closure, no forbidden domain dependency or PCH" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError) as error: + print(f"WaveCoreContracts boundary failed: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/Tests/cpu_ci_contracts.py b/Tests/cpu_ci_contracts.py new file mode 100644 index 0000000..c3dee40 --- /dev/null +++ b/Tests/cpu_ci_contracts.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +WORKFLOW_PATH = Path(".github/workflows/cpu-contracts.yml") +EXPECTED_ACTIONS = { + "actions/checkout": "3d3c42e5aac5ba805825da76410c181273ba90b1", +} +EXPECTED_COMMANDS = ( + "cmake --preset vs2022-x64", + "cmake --build --preset cpu-debug", + "ctest --preset test-cpu-debug", + "cmake --build --preset cpu-release", + "ctest --preset test-cpu-release", +) +FORBIDDEN_FRAGMENTS = ( + "pull_request_target", + "invoke-webrequest", + "start-bitstransfer", + "curl ", + "wget ", + "choco install", + "pip install", + "--target waveengine", + "waveengine.exe", +) + + +def fail(message: str) -> None: + raise RuntimeError(message) + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: cpu_ci_contracts.py ", file=sys.stderr) + return 2 + + repository_root = Path(sys.argv[1]).resolve() + workflow_path = repository_root / WORKFLOW_PATH + workflow = workflow_path.read_text(encoding="utf-8") + workflow_lower = workflow.lower() + + required_fragments = ( + "runs-on: windows-2022", + "timeout-minutes: 20", + "contents: read", + "persist-credentials: false", + "push:", + "pull_request:", + "workflow_dispatch:", + "$env:ImageOS", + "$env:ImageVersion", + "$env:GITHUB_STEP_SUMMARY", + "git rev-parse HEAD", + 'Get-CMakeVersion "cmake"', + "python --version", + "CPU Contract Result", + "--show-only=json-v1", + '[Version]"4.0.1"', + ) + for fragment in required_fragments: + if fragment not in workflow: + fail(f"missing required workflow fragment: {fragment}") + + for fragment in FORBIDDEN_FRAGMENTS: + if fragment in workflow_lower: + fail(f"forbidden CPU workflow fragment: {fragment}") + + action_refs = re.findall( + r"^\s*uses:\s*([^@\s]+)@([0-9a-fA-F]+)(?:\s+#.*)?$", + workflow, + re.MULTILINE, + ) + if not action_refs: + fail("CPU workflow must use at least the pinned checkout action") + + actual_actions = dict(action_refs) + if actual_actions != EXPECTED_ACTIONS: + fail(f"unexpected action refs: {actual_actions!r}") + for action_name, action_revision in action_refs: + if len(action_revision) != 40: + fail(f"{action_name} must be pinned to a full 40-character commit SHA") + + command_positions = [] + for command in EXPECTED_COMMANDS: + execution_pattern = re.compile( + rf"^\s*run:\s*{re.escape(command)}\s*$", + re.MULTILINE, + ) + execution_matches = list(execution_pattern.finditer(workflow)) + if len(execution_matches) != 1: + fail(f"workflow must execute command exactly once: {command}") + command_positions.append(execution_matches[0].start()) + if command_positions != sorted(command_positions): + fail("CPU workflow commands are not in configure/debug/release order") + + print( + "CPU CI contracts passed: read-only pinned checkout, bounded Windows job, " + "toolchain evidence and canonical Debug/Release presets" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError) as error: + print(f"CPU CI contracts failed: {error}", file=sys.stderr) + raise SystemExit(1) 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 cefc508..72e7fd1 100644 --- a/docs/plans/2026-07-25-game-engine-production-roadmap.md +++ b/docs/plans/2026-07-25-game-engine-production-roadmap.md @@ -87,7 +87,7 @@ G6 Cook / Package / SampleGame / Release | 阶段 | 状态 | 核心产物 | 阶段结束时可运行结果 | |---|---|---|---| -| M0 | 待实施 | CMake Presets、CPU CI、无窗口测试入口 | 干净 checkout 可复现基础构建和测试 | +| M0 | 实施中 | CMake Presets、CPU CI、无窗口测试入口 | 干净 checkout 可复现基础构建和测试 | | G0 | 待实施 | Runtime/Editor/Player targets、Engine Loop、路径层 | 独立 Player 显示现有场景 | | G1 | 待实施 | `.weproject`、Asset ID、Registry、共享 Handle | 移动资产后 Scene 仍可加载 | | G2 | 待实施 | World、Entity、Component、层级、Scene v4 | World 场景可编辑、保存和渲染 | @@ -286,14 +286,14 @@ A 系列只提供跨领域公共词汇、Developer host、Context/Evidence feder | ID | 内容 | 前置 | 状态 | |---|---|---|---| -| M0-01 | CMake Presets、CPU CI、无窗口测试入口 | — | 待建 Spec | -| [G0-01](../specs/2026-07-25-g0-01-runtime-editor-player-targets.md) | Runtime/Editor/Player Target 图 | 可与 M0-01 并行 | Draft | -| [A0-01](../specs/2026-07-25-a0-01-contract-core.md) | AI-Native Contract Core 与领域能力注册 | Slice 1~3:M0-01;Slice 4:G0-01 | Draft | -| G0-02 | Engine Loop 与 Application Config | G0-01 | 待建 Spec | -| G0-03 | Project、Package 与 User 路径 | G0-02 | 待建 Spec | -| A0-02 | Developer Host:Session、Policy 与 Operation | A0-01、G0-02、G0-03 | 待建 Spec | -| A0-03 | Context/Evidence Federation | A0-01、G0-03 | 待建 Spec | -| A0-04 | Headless Adapter 与 Agent Eval | A0-02、A0-03 | 待建 Spec | +| [M0-01](../specs/2026-07-25-m0-01-cpu-contract-baseline.md) | CMake Presets、CPU CI、无窗口测试入口 | — | Implementing | +| [G0-01](../specs/2026-07-25-g0-01-runtime-editor-player-targets.md) | Runtime/Editor/Player Target 图 | 可与 M0-01 并行 | Accepted | +| [A0-01](../specs/2026-07-25-a0-01-contract-core.md) | AI-Native Contract Core 与领域能力注册 | Slice 1~3:M0-01;Slice 4:G0-01 | Accepted | +| [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 | +| [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 | | G1-01 | Asset ID、`.meta` 与 Registry | G0-03 | 待建 Spec | | G1-02 | 共享 Asset Handle 与 Mesh 迁移 | G1-01 | 待建 Spec | | G2-01 | World/Entity 与延迟 Spawn/Destroy | G1-01 | 待建 Spec | diff --git a/docs/specs/2026-07-25-a0-01-contract-core.md b/docs/specs/2026-07-25-a0-01-contract-core.md index 47c3831..09b8cf3 100644 --- a/docs/specs/2026-07-25-a0-01-contract-core.md +++ b/docs/specs/2026-07-25-a0-01-contract-core.md @@ -1,19 +1,19 @@ # A0-01:AI-Native Contract Core 与领域能力注册 - Spec ID: `A0-01` -- Status: Draft +- Status: Accepted - Created: 2026-07-25 - Updated: 2026-07-25 - Roadmap: [AI-native 横向设计](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) - Architecture: [AI-Native Engine Architecture](../architecture/ai-native-engine.md) -- Depends on: 设计可独立审查;实施 Slice 1~3 依赖 `M0-01`,Slice 4 依赖 `G0-01` +- Depends on: 设计可独立审查;实施 Slice 1~3 依赖 [M0-01](./2026-07-25-m0-01-cpu-contract-baseline.md),Slice 4 依赖 [G0-01](./2026-07-25-g0-01-runtime-editor-player-targets.md) - Owners: WaveEngine maintainers -- Accepted by: — -- Accepted on: — +- Accepted by: User +- Accepted on: 2026-07-25 - Supersedes: None - Superseded by: None -> 本 Spec 是 Draft,只实现全引擎 AI-native 架构的公共词汇和领域注册基础;未获用户明确接受前不修改生产代码。 +> 本 Spec 已获用户明确接受;实施按依赖顺序等待 M0-01 与 G0-01 的对应前置完成。 ## 1. Context @@ -29,6 +29,7 @@ **Current facts** - 当前只有单体 `WaveEngine` Editor executable;`WavePlayer`、Headless Developer host、Contract Core 和领域 capability federation 尚不存在。 +- 当前 `CoreRuntimeContracts` 虽在窗口/RHI 初始化前返回,但仍编译和链接完整 `WaveEngine`、Engine PCH、DX12/GLFW 与 Editor 闭包;尚无独立 CPU contract target 或仓库 CMake preset/CI。 - 已有 stdio JSON-RPC 2.0 server、`FMcpRegistry`、主线程 Dispatcher 和输入上限。 - MCP 工具由 `FEditorShell` 和 Panel 注册,handler 直接调用 UI owner;没有 transport-neutral domain service。 - `rpc.discover` 只返回方法名。Registry 的 read-only 标记不控制并发或权限;所有调用仍同步切到主线程。 @@ -212,9 +213,9 @@ Start Job → Produce Staged Artifact → Validate → Promote with Command | Issue | Responsibility | Earliest dependency | |---|---|---| | A0-01 | Contract Core、effect/schema/error/change-feed、领域注册与 adapter 边界 | Slice 1~3:M0-01;Slice 4:G0-01 | -| A0-02 | Developer Host:session、policy、Operation、audit | A0-01、G0-02、G0-03 | -| A0-03 | Context/Evidence provider federation 与通用 artifact 引用 | A0-01、G0-03 | -| A0-04 | Headless adapter、Agent Eval 与 conformance tooling | A0-02、A0-03 | +| [A0-02](./2026-07-25-a0-02-developer-host.md) | Developer Host:session、policy、Operation、audit | A0-01、G0-02、G0-03 | +| [A0-03](./2026-07-25-a0-03-context-evidence-federation.md) | Context/Evidence provider federation 与通用 artifact 引用 | A0-01、G0-03;Host integration:A0-02 | +| [A0-04](./2026-07-25-a0-04-headless-agent-conformance.md) | Headless adapter、Agent Eval 与 conformance tooling | A0-02、A0-03、G0-01~G0-03 | ## 6. Alternatives @@ -297,8 +298,8 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac ### Next Step -- 用户审查并明确接受 A0-01;Slice 1~3 等待 M0-01,target closure 的 Slice 4 等待 G0-01。 -- 分别建立 A0-02、A0-03、A0-04 Draft;领域能力进入对应 G Spec,不在 A0 中集中实现。 +- 用户审查并明确接受 A0-01;Slice 1~3 等待 M0-01 Verified,target closure 的 Slice 4 等待 G0-01。 +- A0-02~A0-04 Draft 已建立;继续按前置关系审查,领域能力进入对应 G Spec,不在 A0 中集中实现。 ### Changes and Deviations @@ -307,18 +308,21 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac - Headless、Context、Agent eval 和运行时 Model Runtime 仍保持独立交付范围。 - 采用 staging + artifact promotion,而非默认通用 Layer 或全局 transaction。 - 增加 schema/capability/格式独立演进、revisioned change feed 和多 session 冲突边界。 +- 已把 Developer Host、Context/Evidence federation、独立 WaveDevHost 与 Agent Eval 的后继边界固化到 A0-02~A0-04。 ### Evidence | Date | Commit | Environment | Command/Steps | Result | Artifacts | |---|---|---|---|---|---| | 2026-07-25 | `4e23a32` + working tree | Source review | CMake、MCP、EditorCommands、WEMesh PROV、WaveTrace | Pass | 本 Spec Current Facts | +| 2026-07-25 | `2bdaacab47d1736a0f7c2fbaf5dbca113dc115d5` + clean working tree | Source/build review | `CMakeLists.txt`、`WaveEngine.cpp`、Engine PCH、MCP Registry/Server、CTest discovery、Core contract baseline | Pass | M0-01 Draft;补充 Current Facts 与依赖链接 | | 2026-07-25 | working tree | Reference snapshot | 下载文件与仓库文件逐字节比较;SHA-256 | Pass | [881 行与完整校验值](../references/README.md) | | 2026-07-25 | working tree | Design review | 对照仓库内 881 行参考快照,逐项判断采用、改造或延期 | Pass | Architecture 取舍表;本 Spec 第 1、5、6 节 | | 2026-07-25 | working tree | Python 3 / Debug CTest | `py -3 Tests/spec_contracts.py .`;`ctest --test-dir Build -C Debug --output-on-failure` | Pass | 2 Specs;4/4 CTest | +| 2026-07-25 | working tree | Downstream design review | A0-02 Session/Policy/Operation/Audit;A0-03 Context/Evidence/Artifact;A0-04 Headless/Conformance/Eval | Pass | 3 downstream Drafts 与依赖矩阵 | ### Remaining Work - 用户审查并明确接受 Draft。 - 所有 Delivery Slices 与 Acceptance Criteria。 -- A0-02~A0-04 具体 Spec 及其产品依赖。 +- A0-02~A0-04 的接受、产品依赖与实现。 diff --git a/docs/specs/2026-07-25-a0-02-developer-host.md b/docs/specs/2026-07-25-a0-02-developer-host.md new file mode 100644 index 0000000..3542e67 --- /dev/null +++ b/docs/specs/2026-07-25-a0-02-developer-host.md @@ -0,0 +1,279 @@ +# A0-02:Developer Host Session、Policy、Operation 与 Audit + +- Spec ID: `A0-02` +- Status: Accepted +- Created: 2026-07-25 +- Updated: 2026-07-25 +- Roadmap: [AI-native 横向设计](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) +- Architecture: [AI-Native Engine Architecture](../architecture/ai-native-engine.md) +- Depends on: [A0-01](./2026-07-25-a0-01-contract-core.md)、[G0-02](./2026-07-25-g0-02-engine-loop-application-config.md)、[G0-03](./2026-07-25-g0-03-project-package-user-paths.md) +- Owners: WaveEngine maintainers +- Accepted by: User +- Accepted on: 2026-07-25 +- Supersedes: None +- Superseded by: None + +> 本 Spec 已获用户明确接受;只建设 Developer Host 通用宿主服务,并等待 A0-01、G0-02、G0-03 前置。 + +## 1. Context + +在 A0-01 Contract Core 的 capability/effect/schema vocabulary 之上,建立 transport-neutral Developer Host:负责 session、policy、approval、Operation 生命周期和 audit,但把 Asset、World、Render、Build、Trace 等业务继续留在所属领域。 + +**Current facts** + +- 当前只有进程级 `FMcpRegistry` singleton、stdio `FMcpServer` 与 `FMainThreadDispatcher`;没有 Developer Host 类型或独立 library/target。 +- MCP server 启动后,除 `rpc.discover` 外的调用统一提交主线程并同步等待 future;没有 actor/session、project、workspace、request correlation、总预算或并发 Operation。 +- `FMcpTool::bReadOnly` 仅作自描述,不参与权限或并发控制;Registry 没有 version、risk、availability、permission、preview、cancel 或 retry。 +- JSON-RPC transport 只区分成功与标准错误;没有结构化业务 diagnostic、approval challenge 或 revision conflict。 +- `FEditorCommandStack` 由 `FUiContext` 持有,只覆盖部分 Scene/Camera 操作;不能代表所有领域 Command,更不能为 Job/VCS/网络提供全局 Undo。 +- 当前日志是文本输出;没有 request/capability/actor/correlation 关联,也没有有界、可查询的结构化 audit。 +- Engine shutdown 会先关闭 Dispatcher/MCP,再等待 GPU idle;无法证明 idle 时进入 quarantine。任何新 Host/Operation 生命周期都必须保持该顺序。 + +**Problem** + +如果 MCP、CLI、测试或未来 Agent 各自实现权限、异步任务和审计,保证会漂移;如果中央 Host 接管领域 handler/validator,则会形成新的 God Service。需要一个只拥有调用治理和生命周期的宿主层,同时确保领域状态、revision、transaction、staging、validator 和 Evidence 仍由领域拥有。 + +## 2. Goals and Non-Goals + +**Goals** + +1. 定义 session identity、actor、project/workspace、capability allowlist、base revisions、预算、过期和关闭状态。 +2. 建立默认拒绝的 policy evaluator,并支持 Host 签发、绑定 request/revision/scope/budget、短时且单次使用的 approval。 +3. 建立通用 Operation manager,描述 Job 的排队、运行、进度、取消请求、终态、artifact、diagnostic 和保留期。 +4. 建立有界、结构化、按 session/request/correlation 查询的 audit;敏感字段按 schema/policy 脱敏。 +5. 为 Query、Command、Job、External Action 提供 transport-neutral invocation pipeline,同时保持各 effect 的真实失败边界。 +6. 保持主线程 ownership、Engine shutdown、GPU idle/quarantine、MCP framing 和 Shipping Player 闭包不回退。 +7. 让 Editor、未来 `WaveDevHost`、CLI/Test adapter 复用同一 Host 服务,而不依赖 ImGui 或某个 transport。 + +**Non-Goals** + +- 不在本 Spec 中实现 Asset/World/Render/Build/Trace 领域 handler、revision、transaction、validator 或 artifact producer。 +- 不建设 Context/Evidence federation;它属于 A0-03。 +- 不创建 headless executable、Agent Eval 或模型集成;它属于 A0-04。 +- 不把 `FEditorCommandStack` 提升成全引擎 Command Bus,也不承诺跨领域或跨外部系统的原子事务。 +- 不持久化可跨进程恢复的 session/Operation;首版均为 process/session scope,终态仅在有界保留期内可查询。 +- 不实现多用户网络服务、账号系统、云权限、远程监听端口或公开插件 ABI。 +- 不改变现有 MCP 方法语义、stdio framing、Scene/WEMesh/Trace 格式、坐标或 GPU 生命周期契约。 + +## 3. Scenarios + +| Scenario | Given / When | Expected | +|---|---|---| +| Inspect session | Host 创建 Inspect session | session 只能执行允许的 Query,并携带 actor/project/workspace/预算/expiry | +| Command conflict | Command 的 expected revision 不是领域当前 revision | invoke 前或领域 precondition 返回结构化 conflict,不执行且不 silent overwrite | +| Approval | Operator session 请求高风险 Command/External Action | policy 返回 approval challenge;只有 Host 签发且仍匹配 request/revision/scope/budget 的 token 可继续 | +| Long Job | client 启动 import/compile/capture | 立即返回 Operation;领域后台执行,状态/进度可查,成功 artifact 仍需 validator/promotion | +| Cancel | client 请求取消可取消 Job | Operation 进入 cancel requested;领域确认后终止,失败/取消不 promotion;不支持取消时明确拒绝 | +| Budget exhausted | 请求超过单次或 session 总预算 | 副作用前拒绝,diagnostic 指明 budget dimension 与允许值 | +| Adapter parity | 同一 request 经 Editor、MCP 或 Test adapter | policy、effect、diagnostic、Operation 和 audit 语义一致 | +| Shutdown | Editor/Host 正在接受请求或运行 Job | 停止新 session/request,关闭 adapter,通知可取消 Operation,等待领域定义边界,再进入现有 GPU idle/quarantine 路径 | + +## 4. Constraints and Invariants + +- **Domain ownership**:Host 只查 descriptor、调用领域 handler/validator、持有 session/policy/Operation/audit;不读取或直接修改 Asset/World/Render 等内部状态。 +- **Session scope**:`FDeveloperSessionId` 与 approval/Operation 默认 process scope;重启后失效。Session 明确 actor、project、workspace、base provider revisions、allowlist、预算、created/expiry 和 state。 +- **Policy**:默认拒绝;允许结果绑定 capability name+major、effect、target refs、expected revisions、参数摘要、预算和当前 availability。client 不能声明自己已获批准。 +- **Approval**:由 Host 签发,使用不可猜测 nonce,单次消费,短时过期;request、scope、revision 或 capability 变化即失效。approval 不提升 session allowlist。 +- **Effect honesty**:Query 不产生领域副作用;Command 由领域提供 expected revision/preview/change set;Job 使用 Operation/staging;External Action 单独 approval/audit,不进入引擎 Undo。 +- **Threading**:Host 不改变领域线程 ownership。需要主线程的 handler 经 application executor 调度;transport 线程不直接访问 Scene/Camera/Light/ImGui/RHI。 +- **Operation**:状态转换单调且有稳定 sequence;取消是请求,不是已取消事实;终态 immutable。Host 不伪造 progress、rollback 或 artifact promotion。 +- **Audit**:append-only、稳定序号、有界保留;它记录治理事件,不替代领域 change feed、当前状态或永久安全日志。 +- **Shutdown**:先拒绝新 session/request,再停 adapter,再处理 Operation,之后才进入 RHI/GPU idle;Host 不在 idle 未证明时触发 GPU-backed cleanup。 +- **Deployment**:Host services 只能进入 Developer/Editor/Test target,不进入 Shipping Player。核心类型不依赖 MCP、ImGui、RHI 或模型 SDK。 +- **Security**:参数、diagnostic、audit 和 approval 摘要遵循 schema sensitive 标记;不记录密钥、认证头、无限 Prompt 或原始大 payload。 +- **Budget**:限制 request bytes、output bytes、rows、time、Operation 数、CPU/GPU/内存/磁盘与 audit retention;缺失预算不能解释为 unlimited。 + +### AI-Native Impact + +| Dimension | Status | Decision / Verification | +|---|---|---| +| Identity | Required | session/request/approval/Operation/correlation 使用独立 process-scope ID;领域对象继续使用 A0-01 `FObjectRef` | +| Schema | Required | session、policy decision、approval、Operation、audit 与 invocation envelope 都有独立 major/minor schema 和规范 JSON | +| Query | Required | session/Operation/audit 提供稳定排序、分页、sequence cursor、freshness 与 retention gap;过期返回 resync/expired | +| Effect | Required | invocation 强制 Query/Command/Job/External Action;approval、preview、cancel、retry、undo/compensation 逐 capability 决定 | +| Determinism | Required | policy 输入规范化、稳定排序、预算记账、状态转换和错误码由 CPU contract 固定 | +| Validation/Evidence | Required | 每次 invocation 关联 policy/approval、handler/validator、change/artifact 和 A0-03 Evidence ref;调用成功不等于目标验证通过 | +| Provenance/Migration | Required | 旧 MCP 作为 legacy session adapter 渐进迁移;audit 记录 adapter、actor、engine build 与 capability version | +| Security/Budget | Required | 默认拒绝、Host 签发 approval、敏感字段脱敏、路径/workspace 隔离和多维预算均为验收项 | +| Headless | Required | Host core 无 ImGui/RHI/MCP 依赖,可由 Editor 与 A0-04 `WaveDevHost` 共同承载 | + +## 5. Design + +### Ownership and target boundary + +```text +Editor UI MCP adapter CLI/Test adapter + │ │ │ + └──────────────┴─────────────────┘ + ▼ + Developer Host Services + session / policy / approval / Operation / audit + │ + A0-01 Registry + │ + Domain-owned handler/validator +``` + +实现放入无 UI 的 Developer support library;具体 target 名由 G0-01/G0-02 落地,但依赖方向固定为 Developer Host → Contract Core → domain public contract。MCP adapter 依赖 Host,Host 不依赖 MCP。Editor UI 只提交 invocation/request,不绕过 policy 直接调用同一对外 capability。 + +### Session and invocation + +Session record 至少包含: + +```text +session_id / actor / actor_type +project_ref / workspace_ref +capability allowlist + accepted major range +base revisions by provider +per-request and total budget +created / expires / state +``` + +Invocation envelope 至少包含 request/correlation/session、capability+version、typed input、target refs、expected revisions、preview flag、budget 和可选 approval。流程固定为: + +```text +Resolve session + → resolve descriptor/version/availability + → validate schema/reference/budget + → evaluate policy + → return approval challenge or dispatch + → domain precondition/handler + → domain validator + → audit + Evidence association +``` + +任何阶段失败都返回 `FStructuredDiagnostic`;Host 只补治理类错误,不把领域错误压成通用字符串。Query/Command 可返回同步结果,但仍受 application executor 与 time budget;超出同步预算的能力必须建模为 Job。 + +### Policy and approval + +Policy 输入只使用结构化字段:session actor/type、effect、permission/risk、capability version、target scope、expected revisions、preview、budget、workspace/path class 和 availability。输出为: + +- `allow`:绑定当前规范化 request。 +- `deny`:稳定 code 与可解释 reason。 +- `approval_required`:返回 Host 创建的 challenge ID、摘要、影响范围、预算、expiry 和需要的人类 policy channel。 + +Approval token 只由 Host 根据 challenge 签发;它不能由 MCP/CLI 参数自行构造,不能复用到不同 revision/target/request,也不能把 External Action 藏进已批准的多步 goal。 + +### Operation lifecycle + +首版状态: + +```text +Queued → Running → Succeeded + → Failed +Queued/Running → CancelRequested → Cancelled + → Succeeded/Failed +``` + +`CancelRequested` 后仍可能成功或失败,因为外部工具/GPU/文件系统可能已经越过可取消点。Operation record 包含 capability/request/session、progress 0~1、stage、budget used、diagnostics、staged artifact refs、validator refs、timestamps、terminal reason 与 retention expiry。progress 必须 finite、单调不下降;未知进度显式标记 unknown,不能伪造百分比。 + +Operation manager 不拥有 worker pool 或领域 staging。领域返回 execution object/callback,Host 只协调状态、取消信号、保留和查询。promotion 永远是所属领域的独立 Command,并重新检查 expected revision。 + +### Audit and lifecycle + +Audit event 至少记录 sequence、time、actor/session/request/correlation、adapter、capability/version/effect、target摘要、policy/approval decision、before/after provider revisions(若有)、Operation/change/artifact/Evidence refs、result code 与 budget used。输入输出只记录 schema-guided 摘要/hash;敏感字段永不落入普通 audit。 + +Host lifecycle: + +```text +Construct → Register providers → Accepting + → Draining (reject new work, stop adapters, request cancellation) + → Stopped +``` + +Draining 不能自行销毁领域或 GPU 对象;application runner 仍负责 RHI idle/quarantine 顺序。超出关闭预算的 Operation 按领域能力返回 abandoned/unknown 诊断,并保留 OS/process 终止边界,不能伪装安全回滚。 + +## 6. Alternatives + +| Alternative | Benefit | Cost / Rejection reason | +|---|---|---| +| 直接扩展 `FMcpRegistry` | 改动集中 | transport、治理与领域业务继续耦合,无法供 UI/CLI/Test 复用 | +| 建立中央 Command Bus 管理所有业务 | API 统一 | 形成 God Service,并伪造跨 Job/外部系统事务 | +| 所有请求都同步主线程执行 | 实现简单 | 长任务阻塞 frame/transport,无法取消、预算或保留进度 | +| client 自报 permission/approval | 无 Host 状态 | 权限可伪造,approval 无法绑定 revision/scope/request | +| audit 保存完整参数/响应 | 调试方便 | 泄露密钥、Prompt 与资产数据,且保留无界 | +| Operation 持久化并支持进程恢复 | 体验完整 | 首版缺少 Project/Artifact 稳定身份与恢复协议;先固定 process scope | + +## 7. Delivery Slices + +| Slice | Scope | Invariant | Verification | Status | +|---|---|---|---|---| +| 1 | session/request ID、budget、policy decision、approval 与 audit 基础类型 | 纯 CPU、默认拒绝、规范序列化、无领域依赖 | A0/M0 schema round-trip、policy/expiry/replay/redaction contracts | Pending | +| 2 | Operation manager、状态机、取消、进度、保留与 gap | 终态 immutable;取消不伪造;无 worker/domain ownership | transition/race/budget/retention/resync CPU contracts | Pending | +| 3 | invocation pipeline 与 A0-01 Registry/application executor 接线 | effect/validator/domain ownership 保持;transport 不直达领域状态 | fake domain conformance、revision conflict、approval binding | Pending | +| 4 | Editor/MCP legacy session adapter 与 shutdown/audit integration | 旧 framing/方法不回退;Player closure无 Host | MCP contracts、normal exit、failure injection、target boundary | Pending | + +## 8. Verification and Acceptance + +| Layer | Command/Steps | Expected | Required | +|---|---|---|---| +| Schema | session/policy/approval/Operation/audit JSON round-trip 与 major mismatch | 规范、有界、破坏性版本拒绝 | Yes | +| Policy | allow/deny/approval、expiry、single-use、revision/target/request mismatch | 默认拒绝且 token 不可跨请求复用 | Yes | +| Budget | bytes/rows/time/Operation/audit retention 边界与 overflow | 副作用前确定拒绝,无无限分配 | Yes | +| Operation | 全部合法/非法 transition、cancel race、progress、retention gap | 状态单调,终态 immutable,过期要求 resync | Yes | +| Invocation | fake Query/Command/Job/External Action provider | effect、expected revision、validator、promotion 边界正确 | Yes | +| Compatibility | 现有 MCP contracts 与 smoke | framing、`id:null`、notification、位置参数与方法行为不回退 | Yes | +| Lifecycle | normal drain、queued/running Operation、dispatcher/RHI failure | adapter 先停;GPU idle/quarantine 不回退 | Yes | +| Boundary | target/header/source closure | Host core 无 UI/MCP/RHI;Shipping Player 无 Developer Host | Yes | +| Build | M0 Debug/Release CPU + Editor Debug/Release + CTest | warning-as-error,范围内必需测试通过 | Yes | + +### Acceptance Criteria + +- [ ] Session 明确 actor/project/workspace/allowlist/base revisions/budget/expiry,process scope 不冒充持久身份。 +- [ ] Policy 默认拒绝,approval 由 Host 签发并绑定 request/capability/target/revision/budget/expiry/单次消费。 +- [ ] Query、Command、Job、External Action 走同一治理 pipeline,但保持不同失败、Undo、cancel 和 compensation 边界。 +- [ ] Operation 状态、进度、取消、终态、artifact/validator refs 和 retention 可结构化查询。 +- [ ] audit 有界、可分页、可检测 gap,并对 sensitive 字段脱敏;不替代领域状态/change feed。 +- [ ] Host 不拥有领域业务、worker/staging/promotion,也不绕过领域 handler/validator。 +- [ ] Editor/MCP/Test adapter 语义一致,旧 MCP framing 和工具行为在兼容期不回退。 +- [ ] Host core 无 ImGui/MCP/RHI/模型 SDK 依赖,Shipping Player closure 无 Developer Host。 +- [ ] shutdown 顺序、main-thread ownership、GPU idle 与 quarantine 保持。 +- [ ] 当前实现 Evidence、Spec 注册表、路线图和 README 同步。 + +## 9. Compatibility, Risks, and Open Questions + +- **Compatibility/Migration**:A0-01 先给旧 MCP handler descriptor;本 Spec 用隐式 legacy session 包装旧 client。旧方法参数/返回保持,新增治理 API 使用具名 object。 +- **Removal/Rollback**:只有仓库 client 和领域 handler 均迁移后,后继 Spec 才可移除 legacy session。Host pipeline 可独立回退,但不能回退既有 MCP input/shutdown safety。 + +| Risk | Impact | Mitigation | +|---|---|---| +| Host 演变为业务层 | 领域耦合与重复逻辑 | public API 禁止领域具体类型;handler/validator/provider 由领域注册 | +| approval 变成形式化弹窗 | 高风险请求绕过 | token 绑定规范 request hash、revision、scope、budget、expiry 和单次消费 | +| Operation 与真实 Job 状态漂移 | 错误取消/成功声明 | 领域驱动状态;Host 只接受合法 transition,终态关联 validator/Evidence | +| audit 泄密或无界 | 安全/磁盘问题 | schema-guided redaction、hash 摘要、分页、配额和 retention | +| shutdown 等待无限 Job | 编辑器无法退出 | Draining budget、领域取消边界、超时诊断;GPU teardown 仍由 application 决定 | +| legacy client 无 session 概念 | 迁移回退 | Host 创建最小权限、短生命周期 legacy session,并记录 adapter | + +### Open Questions + +无。session scope、policy/approval 绑定、Operation 状态、audit 保留、领域 ownership、legacy adapter 与 shutdown 边界已固定;实质变化必须先更新本 Spec。 + +## 10. Implementation Record + +### Current Progress + +- 已完整核对 A0-01、Architecture、`FMcpRegistry`、`FMcpServer`、`FMainThreadDispatcher`、`FEditorCommandStack` 与 Engine shutdown。 +- 已确认当前不存在 session、policy、approval、Operation manager 或结构化 audit。 +- 尚未修改生产代码。 + +### Next Step + +- 用户审查并明确接受 A0-02;实施等待 A0-01、G0-02、G0-03 的前置边界成立;对应 Draft 已建立。 + +### Changes and Deviations + +- 没有建立中央 Command Bus;Host 只承载治理与生命周期。 +- Operation 不承诺进程恢复,approval 不提升 session 权限,audit 不保存完整输入输出。 + +### Evidence + +| Date | Commit | Environment | Command/Steps | Result | Artifacts | +|---|---|---|---|---|---| +| 2026-07-25 | `2bdaacab47d1736a0f7c2fbaf5dbca113dc115d5` + working tree | Source/design review | A0-01、Architecture、MCP Registry/Server、Dispatcher、EditorCommands、WaveEngine lifecycle | 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 | 6 concrete Specs;1/1 SpecContracts | + +### Remaining Work + +- 用户明确接受 Draft。 +- 所有 Delivery Slices 与 Acceptance Criteria。 +- A0-03 Evidence association 与 A0-04 headless adapter integration。 diff --git a/docs/specs/2026-07-25-a0-03-context-evidence-federation.md b/docs/specs/2026-07-25-a0-03-context-evidence-federation.md new file mode 100644 index 0000000..4f422bc --- /dev/null +++ b/docs/specs/2026-07-25-a0-03-context-evidence-federation.md @@ -0,0 +1,274 @@ +# A0-03:Context、Evidence 与 Artifact Provider Federation + +- Spec ID: `A0-03` +- Status: Accepted +- Created: 2026-07-25 +- Updated: 2026-07-25 +- Roadmap: [AI-native 横向设计](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) +- Architecture: [AI-Native Engine Architecture](../architecture/ai-native-engine.md) +- Depends on: [A0-01](./2026-07-25-a0-01-contract-core.md)、[G0-03](./2026-07-25-g0-03-project-package-user-paths.md);Host integration 依赖 [A0-02](./2026-07-25-a0-02-developer-host.md) +- Owners: WaveEngine maintainers +- Accepted by: User +- Accepted on: 2026-07-25 +- Supersedes: None +- Superseded by: None + +> 本 Spec 已获用户明确接受;只建设 provider federation 与通用 Context/Evidence/Artifact 引用,并等待前置依赖。 + +## 1. Context + +为 Human UI、Agent、脚本、测试和 CI 提供可规划的结构化 Context,并把 change、validator、log、trace、screenshot、test 和 artifact 关联成可验证 Evidence。每个领域仍是事实与 revision 的 owner;联邦层只做发现、预算、组合、引用和一致性说明。 + +**Current facts** + +- 当前 `rpc.discover` 只返回 MCP 方法名;没有 Context provider、字段选择、source revision、freshness、分页或 omission metadata。 +- Scene/Camera/Light/Panel handler 直接从 `FUiContext` 或 singleton 读取状态;没有跨领域 snapshot bundle,也没有 provider ownership contract。 +- `McpId` 只在进程内稳定;路径、显示名和 UI selection 仍出现在现有工具结果中,不能充当未来持久领域引用。 +- WaveTrace 可生成 `.wetrace` 并输出录制状态;WEMesh v3 `PROV` 记录规范源身份、依赖指纹和导入设置。它们是局部产物/来源,不是统一 `FArtifactRef` 或 `FEvidenceBundle`。 +- 日志是文本与 UI line buffer;当前没有稳定 diagnostic/event schema、correlation 查询、保留策略或 Evidence 引用。 +- RenderGraph、Shader、Scene、Build/Cook、Project/Asset 的正式 query/change feed 大多尚不存在;A0-03 不能替它们创建真值。 +- G0-03 尚未建立 Project/Source/Derived/Saved/Package 路径层,通用 artifact location policy 不能继续使用编译期绝对路径。 + +**Problem** + +直接抓取整个进程状态会得到无界、陈旧且不可复现的 Context;把所有状态复制到中央数据库会制造第二事实源;只使用向量检索会丢失 revision、关系和精确性。另一方面,单个工具返回 success、文本日志或文件路径不能证明目标完成。需要 provider-owned、revision-aware、budgeted federation,以及只引用可验证事实与内容寻址产物的 Evidence。 + +## 2. Goals and Non-Goals + +**Goals** + +1. 定义 Context provider descriptor、request、slice 和 bundle,保留每个 provider 独立 schema/revision/freshness/scope。 +2. 支持 provider snapshot、diff/change cursor、稳定分页、字段选择、预算、截断/遗漏摘要和 resync。 +3. 定义内容寻址 `FArtifactRef` 的类型、hash、location policy、lifecycle、access、size、producer 与 retention。 +4. 定义 `FEvidenceBundle`,关联 request/actor/capability、before/after revisions、change set、validator result、artifact/log/trace/screenshot/test 和 approval/promotion。 +5. 建立 Context/Evidence provider registry 与 federation;descriptor/provider 同注册,领域查询/验证实现仍由领域拥有。 +6. 让 Editor、A0-02 Host、A0-04 headless/Test adapter 使用同一联邦接口,并保持 Shipping Player 无开发期 federation。 +7. 为后续 G1~G6 领域 Spec 提供一致词汇与 conformance contract,而不提前实现不存在的领域模型。 + +**Non-Goals** + +- 不建立复制所有 Asset/World/Render/Trace/Build 状态的中央数据库、Event Store 或永久审计日志。 +- 不在本 Spec 中实现 Asset Registry、World/Entity snapshot、RenderGraph introspection、Shader reflection、Build DAG 或 VCS/code index。 +- 不把向量数据库或语义索引作为资产、场景、依赖、Trace 或构建事实来源。 +- 不执行 screenshot/trace/import/compile/cook;这些是领域 Job,A0-03 只引用其已完成 artifact/validator。 +- 不定义 AI Prompt、tokenization、embedding 模型、LLM API 或云存储。 +- 不迁移 WEMesh/Trace 持久格式;通用 adapter 只能暴露引用和摘要,格式变化由所属领域 Spec 决定。 +- 不把 Evidence bundle 当作全局事务、真实性签名或永久合规记录。 + +## 3. Scenarios + +| Scenario | Given / When | Expected | +|---|---|---| +| Build context | client 请求 Project + World + Render Context | 返回分 provider slice;每项带 schema、source revision、freshness、scope 和独立 omitted 信息 | +| Bounded query | 结果超过 row/byte/time budget | 稳定截断或分页,bundle 明确 omitted count/reason;不能静默丢数据 | +| Revision changes | 分页或 diff 期间 provider revision 改变 | cursor 返回 stale/conflict/resync_required,不在新 revision 上静默续页 | +| Provider unavailable | GPU/Project/World 未加载 | slice 返回结构化 availability diagnostic;其他 provider 可继续并标记 bundle partial | +| Change gap | change cursor 过期或 schema major 变化 | 明确 `resync_required`,client 重新取得 snapshot | +| Evidence assembly | Command/Job 完成并运行 validators | bundle 引用 exact request、revisions、change/artifact、validator 与环境,不把 handler success 当作验证 | +| Artifact access | client 无权限或 artifact 已过期 | 返回 denied/expired,不能泄露 raw path 或内容 | +| Cross-domain mismatch | screenshot 观察 revision 与请求目标 revision 不同 | Evidence 明确 requested/observed revisions 和 mismatch,不伪装同一状态 | + +## 4. Constraints and Invariants + +- **Provider ownership**:provider 由所属领域实现并声明 authoritative scope、schema、revision semantics、query modes、availability 和 budget;federation 不读取领域内部 singleton。 +- **No global clock**:Context bundle 记录每个 provider 的独立 revision/freshness;不虚构跨领域原子 revision。 +- **Purity**:Context query 是 Query effect,不触发 import、compile、capture、save、index rebuild 或 lazy mutation;需要生成数据时返回 availability/operation suggestion。 +- **Stable output**:集合按 schema 指定稳定 key 排序;分页 cursor 绑定 provider、query hash、schema major、source revision 和 expiry。 +- **Gap handling**:cursor 过期、事件丢失、provider reset 或 schema major mismatch 必须返回 resync;diff/change feed 不替代 snapshot/当前状态。 +- **Budget**:request 强制指定或继承 row/byte/time/provider/token-hint budget;bundle 返回 consumed 与 omitted summary。token 只是估算,不是事实裁剪唯一尺度。 +- **Artifact identity**:artifact 用内容 hash + type + scope 引用;raw absolute path 不作为公共 identity。location 由 G0-03 path policy 解析。 +- **Artifact lifecycle**:Source、Derived、Staged、Promoted、Packaged 与 Ephemeral 分离;Evidence 不自动 promotion,过期 artifact 引用可检测。 +- **Evidence immutability**:bundle 创建后内容/引用集合 immutable;新增验证产生新 bundle 或 superseding link,不原地改写历史结果。 +- **Validation honesty**:validator result 包含 validator ID/version、input revisions/artifacts、status、diagnostics、metrics/tolerance 和 environment;Not Run/Skipped 不能等同 Pass。 +- **Security**:provider schema 标记 sensitive 字段;federation 在组合前执行 allowlist/redaction。Artifact access 经过 A0-02 session/policy,不暴露未授权路径。 +- **Deployment**:federation/开发期 artifact resolver 不进入 Shipping Player;Runtime 可使用自己的产品 artifact handle,但不依赖 A0-03 Host 服务。 + +### AI-Native Impact + +| Dimension | Status | Decision / Verification | +|---|---|---| +| Identity | Required | provider/context request/bundle/artifact/evidence/validator 使用独立引用;领域对象继续使用带 scope/project 的 `FObjectRef` | +| Schema | Required | provider descriptor、request/slice/bundle、artifact/evidence/validator result 使用独立 major/minor schema 与规范 JSON | +| Query | Required | snapshot/diff/change feed、revision/freshness、稳定分页、cursor expiry/gap/resync 和 partial bundle 全部进入 contract | +| Effect | Required | Context/Evidence 读取是 Query;capture/import/compile 等仍是领域 Job,promotion 是领域 Command,不由 federation 隐式触发 | +| Determinism | Required | provider 排序、query hash、分页、预算裁剪、bundle canonicalization 与内容 hash 由 CPU contract 固定 | +| Validation/Evidence | Required | Evidence 关联 exact request/revision/change/artifact/validator/environment;Pass/Fail/Skipped/Not Run 语义可机器判断 | +| Provenance/Migration | Required | artifact 记录 producer/version/input/parameter摘要/seed/license;WEMesh/Trace 通过 adapter 渐进映射,不改原格式 | +| Security/Budget | Required | provider/字段 allowlist、敏感字段 redaction、artifact access、row/byte/time/token-hint/retention 预算均为验收项 | +| Headless | Required | federation core 无 UI/MCP/RHI 依赖,Editor、Test 与 A0-04 `WaveDevHost` 共用 | + +## 5. Design + +### Provider federation + +```text +Context Request + → Federation planner (provider selection + budgets) + ├─ Project/Asset provider + ├─ World provider + ├─ Render/Shader provider + ├─ Trace/Log/Build provider + └─ Docs/Code/VCS adapter + → Context Bundle (independent slices) +``` + +Provider descriptor 至少包含 provider ID/version/stability、domain、authoritative scopes、query modes、schema refs、revision/cursor semantics、availability、default/max budget、sensitive fields 和 deterministic sort key。注册重复 ID+major、缺失 revision 语义或无界结果必须失败。 + +Context request 至少包含 request/session、provider allowlist、object refs/scope、field selection、filters、snapshot/diff/change cursor、per-provider/global budgets 和 freshness requirement。Federation 只在预算内并行/顺序调用 provider,并以稳定 provider ID 排序 bundle;它不合并不同 provider revision 成一个“当前版本”。 + +Context slice 至少包含: + +```text +provider + schema version +source identity + source revision +observed_at + freshness +scope + stable ordering +items/page cursor or diff/change events +consumed budget +truncated/omitted/gap/availability diagnostics +``` + +### Artifact reference + +`FArtifactRef` 的公共字段: + +```text +artifact_id / content_hash / media_or_schema_type +scope / project / lifecycle +size / created / retention_expiry +producer + producer_version +location_policy (not raw public path) +access_class / sensitivity +``` + +内容 hash 使用明确算法与规范字节;首版算法由 A0-01 schema 固定,不能把 `std::hash` 或 mtime 当内容身份。Location resolver 根据 G0-03 Project/Derived/Saved/Package policy 返回受权限控制的 stream/handle;公共 JSON 默认不返回绝对磁盘路径。 + +WEMesh `PROV` 继续作为 importer cache 有效性事实;A0-03 adapter 可以生成引用该文件 hash/type/producer 的 artifact ref,但不能声称其中已有 AI producer/license/review。WaveTrace 只有在 Stop/flush 完成后才能成为 immutable artifact;live stream 不是完成 Evidence。 + +### Evidence bundle + +Evidence bundle 至少关联: + +```text +request / actor / session / correlation +capability + version + effect +target refs +before/after provider revisions +policy/approval refs +change set or staged/promoted artifact refs +validator results +log/trace/screenshot/test/build refs +engine/project/platform/GPU identity +created / completeness / supersedes +``` + +Evidence assembly 不执行 validator;它验证引用、revision、权限与完整性后 canonicalize bundle。领域 validator 负责目标正确性,例如资产结构、World invariant、画面 tolerance、GPU fence completion、Build manifest 或 gameplay scenario。 + +Completeness 由请求时声明的 evidence plan 决定:每个 required validator/artifact 标记 Pass/Fail/Skipped/Not Run/Missing。只有全部 required 项 Pass,且 promotion/revision 匹配时,Host 才能把 goal result 标记 validated;这仍不自动改变 Spec 或发布状态。 + +### Evidence and Context queries + +Evidence store 首版是 Developer Host 进程/项目 Saved 范围内的有界索引,存 bundle metadata 与 artifact refs,不复制大 artifact。查询按 bundle ID、request/correlation、capability、target、revision、validator、time 和 result,稳定分页并受 session policy。 + +Audit 与 Evidence 分离: + +- Audit:谁请求了什么、policy/operation/result,短期治理事件。 +- Evidence:为什么目标被判断正确/失败,引用不可变验证产物。 +- Domain change feed:领域 revision 之间发生了什么。 + +三者可以互相引用,但不能互相替代。 + +## 6. Alternatives + +| Alternative | Benefit | Cost / Rejection reason | +|---|---|---| +| 中央数据库复制所有领域状态 | 查询统一 | 产生第二事实源、revision 漂移和高迁移成本 | +| 只提供全文/向量检索 | 接入模型快 | 无法保证精确关系、revision、完整性和重同步 | +| Context query 自动触发缺失数据构建 | 使用方便 | Query 产生隐藏 Job/磁盘/GPU 副作用,破坏 effect 语义 | +| Evidence 保存全部原始日志/Prompt/文件 | 信息完整 | 无界、泄密、难以保留;应保存受控引用与摘要 | +| 使用文件路径作为 artifact ID | 实现简单 | 移动/重命名即失效,也无法证明内容一致 | +| 由 Host 实现所有 validator | 集中展示 | 领域知识复制并漂移;validator 必须由领域拥有 | + +## 7. Delivery Slices + +| Slice | Scope | Invariant | Verification | Status | +|---|---|---|---|---| +| 1 | provider descriptor、Context request/slice/bundle、budget/cursor 基础类型 | 纯 CPU、无全局 revision、Query 无副作用 | schema round-trip、ordering/paging/gap/resync/budget contracts | Pending | +| 2 | `FArtifactRef`、resolver policy、content hash 与 lifecycle | raw path 非身份;staged/promoted 分离;access 有界 | hash/canonicalization/path denial/expiry/migration contracts | Pending | +| 3 | validator result、Evidence plan/bundle/store/query | handler success 不等于 Pass;bundle immutable | completeness/status/supersede/redaction/retention contracts | Pending | +| 4 | federation + A0-02 Host integration;Trace/WEMesh 只读 adapter pilot | 不复制领域业务/数据,不改变现有格式 | fake providers、Host policy、Trace flush/WEMesh hash、target boundary | Pending | + +## 8. Verification and Acceptance + +| Layer | Command/Steps | Expected | Required | +|---|---|---|---| +| Schema | Context/Artifact/Evidence canonical JSON round-trip 与 major mismatch | 稳定、有界、破坏性版本拒绝 | Yes | +| Query | multi-provider snapshot、diff、change feed、paging、revision change | 独立 revision/freshness;gap 明确 resync | Yes | +| Budget | row/byte/time/provider/token-hint 与 partial results | 稳定裁剪,omitted/consumed 完整,无无限分配 | Yes | +| Artifact | content hash、location/access/lifecycle/expiry | raw path 不泄露;staged 不冒充 promoted | Yes | +| Evidence | required validator matrix、revision mismatch、GPU/trace completion | completeness 可机器判断,Not Run/Skipped 不等 Pass | Yes | +| Federation | fake providers + unavailable/slow/failing provider | partial bundle 可解释,其他 provider 不被伪造成功 | Yes | +| Adapters | WEMesh PROV 与 stopped WaveTrace artifact pilot | 原格式不改,语义不夸大,内容引用稳定 | Yes | +| Boundary | target/header/source closure | core 无 UI/MCP/RHI;Player 无 development federation | Yes | +| Build | M0 Debug/Release CPU + Editor Debug/Release + CTest | warning-as-error,范围内测试通过 | Yes | + +### Acceptance Criteria + +- [ ] 每个 Context slice 明确 provider/schema/source revision/freshness/scope/order/budget/omitted/availability。 +- [ ] federation 不虚构全局 revision,不复制领域真值,也不在 Query 中触发 Job/Command。 +- [ ] snapshot/diff/change feed 与分页 cursor 在 revision/gap/schema 变化时明确要求 resync。 +- [ ] artifact 使用内容 hash/type/scope/lifecycle/access 引用,raw path/mtime/显示名不充当身份。 +- [ ] Source/Derived/Staged/Promoted/Packaged/Ephemeral 生命周期与 promotion 边界可区分。 +- [ ] Evidence bundle immutable,关联 exact request/revision/change/artifact/validator/environment 和 approval/promotion。 +- [ ] Required validator 的 Fail/Skipped/Not Run/Missing 不会被报告为 validated success。 +- [ ] audit、Evidence、domain change feed 职责分离但可相互引用。 +- [ ] sensitive 字段、artifact access、预算、retention 和 Shipping Player closure 满足安全边界。 +- [ ] 当前实现 Evidence、Spec 注册表、路线图和 README 同步。 + +## 9. Compatibility, Risks, and Open Questions + +- **Compatibility/Migration**:现有 MCP、WEMesh v3、WaveTrace 与日志格式不变;先增加只读 adapter/ref,领域格式和持久 registry 由对应 G Spec 迁移。 +- **Removal/Rollback**:provider/federation 可按 Slice 回退;不能删除现有 WEMesh provenance 或 Trace contract。语义索引始终可删重建,不能成为回滚前置。 + +| Risk | Impact | Mitigation | +|---|---|---| +| federation 演变为中央状态库 | 双真值与同步 bug | provider 只返回请求结果;事实/revision/change feed 留在领域 | +| Context 结果无界 | 内存/延迟/模型成本失控 | 强制 budget、分页、字段选择、omitted summary 与超时 | +| 跨 provider revision 被误认为一致 | 错误规划/验证 | 每 slice 独立 revision/freshness,禁止全局 transaction 宣称 | +| artifact ref 泄露路径或敏感内容 | 安全问题 | location policy + access class,公共 JSON 默认不含绝对路径 | +| Evidence 只记录“调用成功” | 假完成 | required evidence plan、领域 validator、revision/promotion 匹配 | +| 旧 provenance 被过度解释 | 来源错误 | adapter 明确字段缺失/unknown,不把 WEMesh PROV 冒充 AI 来源 | + +### Open Questions + +无。provider ownership、独立 revision、budget/cursor、artifact identity/lifecycle、Evidence completeness、adapter 兼容和部署边界已固定;实质变化必须先更新本 Spec。 + +## 10. Implementation Record + +### Current Progress + +- 已核对 A0-01/Architecture、现有 MCP discover/handler、WEMesh PROV、WaveTrace recorder/artifact、Log 与 G0-03 路径前置。 +- 已确认当前没有 Context/Evidence provider registry、统一 artifact ref、revisioned bundle 或 evidence completeness。 +- 尚未修改生产代码。 + +### Next Step + +- 用户审查并明确接受 A0-03;实施等待 A0-01/G0-03,Host integration slice 等待 A0-02。 + +### Changes and Deviations + +- 没有采用中央数据库或向量索引作为事实源。 +- Evidence store 只索引 bundle metadata/refs,不复制大 artifact;WEMesh/Trace 首期只做语义保守的只读 adapter。 + +### Evidence + +| Date | Commit | Environment | Command/Steps | Result | Artifacts | +|---|---|---|---|---|---| +| 2026-07-25 | `2bdaacab47d1736a0f7c2fbaf5dbca113dc115d5` + working tree | Source/design review | A0-01、Architecture、MCP、WEMesh PROV tests、Trace recorder/contracts、Log、G0-03 roadmap | 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 | 6 concrete Specs;1/1 SpecContracts | + +### Remaining Work + +- 用户明确接受 Draft。 +- 所有 Delivery Slices 与 Acceptance Criteria。 +- 各 G 系列领域 provider/validator/evidence 实现。 diff --git a/docs/specs/2026-07-25-a0-04-headless-agent-conformance.md b/docs/specs/2026-07-25-a0-04-headless-agent-conformance.md new file mode 100644 index 0000000..bd80f3b --- /dev/null +++ b/docs/specs/2026-07-25-a0-04-headless-agent-conformance.md @@ -0,0 +1,280 @@ +# A0-04:Headless Developer Host、Adapter Conformance 与 Agent Eval + +- Spec ID: `A0-04` +- Status: Accepted +- Created: 2026-07-25 +- Updated: 2026-07-25 +- Roadmap: [AI-native 横向设计](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) +- Architecture: [AI-Native Engine Architecture](../architecture/ai-native-engine.md) +- Depends on: [A0-02](./2026-07-25-a0-02-developer-host.md)、[A0-03](./2026-07-25-a0-03-context-evidence-federation.md)、[G0-01](./2026-07-25-g0-01-runtime-editor-player-targets.md)、[G0-02](./2026-07-25-g0-02-engine-loop-application-config.md)、[G0-03](./2026-07-25-g0-03-project-package-user-paths.md) +- Owners: WaveEngine maintainers +- Accepted by: User +- Accepted on: 2026-07-25 +- Supersedes: None +- Superseded by: None + +> 本 Spec 已获用户明确接受;建立独立无 ImGui 的 Developer Host 与 conformance/eval 工具,并等待 A0/G0 前置。 + +## 1. Context + +把 A0-01 capability、A0-02 Developer Host 和 A0-03 Context/Evidence 通过独立 `WaveDevHost` executable 暴露给 MCP、CLI、CTest 和外部 Agent。Headless 不是另一套业务实现;它加载同一 Project/领域服务、执行同一 handler/validator,并用 adapter conformance 与 Agent Eval 证明 Human/Agent parity。 + +**Current facts** + +- 当前只有单体 `WaveEngine.exe` Editor 与 `WaveTraceViewer.exe`;没有 Runtime/Editor/Player target,也没有 headless Developer executable。 +- M0-01 的独立 `WaveCoreContracts` 只运行 CPU tests,不加载 Project/Scene,不承载 capability/Host,因此不等同 headless Developer Host。 +- MCP server 只能由 Editor 在 Renderer/ImGui/Panel 完成工具注册后启动;所有业务工具仍由 Panel owner 注册。 +- stdio JSON-RPC 是当前唯一自动化 transport;`rpc.discover` 只返回方法名,参数主要是位置数组。 +- 现有 Python smoke/contract 会启动 GPU Editor,并通过 `editor.app.request_exit` 正常退出;脚本会修改 Scene/Camera/Debug View,不是通用无副作用 conformance。 +- 没有 adapter parity test、capability schema corpus、Eval manifest/result、外部 Agent session recorder 或固定 goal validator。 +- Shipping Player 尚不存在;G0-01 已规定 Player/Runtime closure 不包含 UI、MCP、Developer 或 RenderDoc。 + +**Problem** + +只在 Editor UI/Panel 中开放能力,CI 和 Agent 必须依赖 GPU、窗口与 UI 生命周期;另写一套 headless handler 又会产生业务漂移。没有 conformance,direct/MCP/CLI/Test adapter 可能对参数、error、effect、revision、approval 和 Evidence 给出不同语义。没有版本化 Eval,模型或 tool schema 变化也无法量化成功率、成本与误修改。 + +## 2. Goals and Non-Goals + +**Goals** + +1. 新增独立 `WaveDevHost` console executable,不创建 ImGui context,不链接 Editor Panel/RenderDoc/Shipping Player。 +2. 复用 G0 application/config/path、A0-02 Host 和领域服务,支持无窗口加载 Project、注册 capability、执行 Query/Command/Job/External Action 治理和正常关闭。 +3. 提供 stdio MCP、CLI/Test direct adapter;所有 adapter 复用同一 descriptor、schema、Host policy、handler、diagnostic、Operation 和 Evidence。 +4. 建立 adapter conformance corpus,自动验证 discovery、参数、错误、effect、revision、approval、budget、notification/framing 与 lifecycle parity。 +5. 定义版本化 Agent Eval manifest/result,固定初始 workspace/revisions、允许 capability、预算、成功 validator、停止条件和 Evidence。 +6. Agent/模型保持外部可替换;引擎只记录 session/request/audit/Evidence 并验证 goal,不集成 LLM SDK、账号或 Prompt 业务。 +7. 对 GPU/窗口/音频等不可用 capability 返回结构化 availability;不运行的硬件验证显式 Not Run/Skipped。 +8. 以 target/source/link contract 证明 `WavePlayer` 不包含 WaveDevHost、MCP、Developer Host、eval 或模型依赖。 + +**Non-Goals** + +- 不在本 Spec 中实现 A0-01~A0-03、G0 application/path、G1 Asset、G2 World 或后续领域 capability。 +- 不在引擎内运行 LLM、embedding、云 API、Prompt orchestration、Agent planner 或模型训练。 +- 不把 headless Developer Host 作为 Shipping dedicated server、Game server 或 Runtime Model Service。 +- 不保证所有 Renderer/RHI capability 在无 GPU 环境可用;必须诚实报告 availability 并由硬件 lane 验证。 +- 不使用 UI 像素、Panel 文本、鼠标坐标或磁盘直改作为 conformance/eval 成功判据。 +- 不为 Agent 自动授予更高权限,也不让 Eval manifest 自签 approval 或隐藏 External Action。 +- 不用概率性 Agent Eval 替代 deterministic Contract Test。 + +## 3. Scenarios + +| Scenario | Given / When | Expected | +|---|---|---| +| Start headless | 使用 project/config 参数启动 `WaveDevHost` | 不创建窗口/ImGui/RenderDoc,加载同一 Project/领域服务并发布 capability availability | +| Inspect | stdio client discovery/query | descriptor/schema/version/effect 与 direct Test adapter 一致,Query 无副作用 | +| Authoring command | session 对已加载 document 提交 expected revision Command | 经 A0-02 policy/preview/handler/validator/Evidence;冲突不 silent overwrite | +| GPU unavailable | CPU runner 请求 Render capture | discovery/describe 保留能力但 availability=false+reason;调用确定拒绝,不伪造截图 | +| Adapter conformance | 同一规范 request 经 direct/MCP/CLI/Test | 规范结果/diagnostic/effect/revision/Evidence 等价;transport envelope 差异被规范化 | +| Agent Eval | 外部 Agent 在受限 workspace 完成固定 goal | Host 记录模型/tool schema/engine build/预算/步骤/误修改,validator 判定 success/failure | +| Malicious input | 超限 JSON、路径逃逸、敏感字段或 manifest 注入 | 副作用前拒绝;数据内容不改变 capability/permission | +| Shutdown | request exit、stdin EOF、signal 或 fatal domain failure | 停新请求、drain Host/Operation、保存允许的 Evidence,再走 application/RHI 安全关闭或 quarantine | + +## 4. Constraints and Invariants + +- **Single business path**:WaveDevHost、Editor UI、MCP、CLI 与 Test adapter 调用同一领域 capability/validator;headless 不复制 Panel handler。 +- **Product boundary**:`WaveDevHost` 链接 Runtime + Developer support + adapters;不链接 ImGui/Editor Panel/RenderDoc。`WavePlayer` 只链接 Runtime product closure。 +- **Startup**:Log/config/path/project → Runtime/domain state → Contract/Host/provider registration → adapter;不得在领域注册完成前接受请求。 +- **Shutdown**:拒绝新 request → stop adapter → A0-02 Host draining/Operation → domain/app stop → GPU idle/quarantine(若使用 GPU)→ destroy。stdin EOF 与显式 request exit 都走正常路径。 +- **Headless**:默认不创建 native window/ImGui;需要 GPU 的能力必须由显式 runtime mode 和 platform capability 决定,不能隐式创建隐藏 UI 窗口冒充 headless。 +- **Adapter parity**:adapter 只能处理 framing、transport identity 与 canonical JSON 映射;不能增加权限、业务默认值、validator 或不同 effect。 +- **MCP compatibility**:1 MiB line、128 depth、`id:null`、missing-id notification、stdout purity、旧 `rpc.discover` 兼容期保持。 +- **Eval isolation**:每次 Eval 使用独立 workspace/staging、固定初始 revision/fixture、capability allowlist 和总预算;promotion/External Action 默认禁止。 +- **Eval honesty**:成功只由 manifest 指定的 deterministic validators/Evidence 判断;Agent 文本声明、tool return success 或进程 exit 0 不足以成功。 +- **Reproducibility**:结果记录 eval/tool schema/engine/project/fixture/model/policy versions、seed(若有)、步骤、预算、耗时、人工接管和 final revisions。 +- **Security**:Project 内容、文档、资产名、日志与 model output 都是不可信数据;不能改变 policy。外部模型数据发送由调用方/项目 policy 负责,Host 默认不联网。 +- **Budget**:限制 wall time、step/request/Operation 数、bytes/rows、CPU/GPU/内存/磁盘和 artifact retention;达到上限停止 session 并产出诊断。 + +### AI-Native Impact + +| Dimension | Status | Decision / Verification | +|---|---|---| +| Identity | Required | host instance/session/request/eval run/workspace/result 使用独立 ID;领域对象、Operation、Artifact/Evidence 使用 A0/G Spec 引用 | +| Schema | Required | CLI/config、adapter canonical envelope、Eval manifest/result 与 conformance corpus 独立版本化并规范 JSON | +| Query | Required | capability/context/Operation/Evidence discovery 带 revision/freshness/paging;adapter 不改变 resync/gap 语义 | +| Effect | Required | direct/MCP/CLI/Test 均保持 Query/Command/Job/External Action 分类、approval、preview、cancel、undo/compensation 边界 | +| Determinism | Required | conformance corpus、fixture、排序、canonical result、validator、stop condition 与 test adapter 无模型可重复 | +| Validation/Evidence | Required | deterministic contract 与概率性 Eval 分离;Eval success 只由 required validator/Evidence 判断 | +| Provenance/Migration | Required | 记录 engine/tool schema/eval/model/policy/fixture versions、seed/步骤/预算;旧 Editor MCP 通过兼容 corpus 迁移 | +| Security/Budget | Required | workspace 隔离、默认不联网、allowlist、Host approval、路径限制、敏感数据和多维预算进入验收 | +| Headless | Required | 独立 WaveDevHost 无 ImGui/Panel/RenderDoc,CPU-only 启动/关闭和可选 GPU availability 均有自动验证 | + +## 5. Design + +### Target topology + +```text +WaveRuntime + ├─ WavePlayer + ├─ WaveEditorSupport → WaveEditor + └─ WaveDeveloperSupport + ├─ WaveDevHost + └─ Developer/Test adapters + +WavePlayer ─X─ DeveloperSupport / MCP / Eval / ImGui +``` + +`WaveDeveloperSupport` 由 A0-01~A0-03 core、Developer Host 和 transport-neutral provider adapter 组成,不包含领域业务。MCP stdio、CLI/Test adapter 作为 Developer-only 边缘组件。Editor 可以链接 DeveloperSupport;WaveDevHost 不链接 EditorSupport。 + +### WaveDevHost application + +首版 CLI 使用具名参数,至少包括 project/config、workspace、stdio enable、read-only/allowed policy profile、budget profile 和 deterministic exit controls;具体拼写在实现前由 G0-02 config schema注册,但不能继续依赖 UI config 或编译期绝对 Project 路径。 + +运行模式: + +- CPU headless:默认;不创建 window/RHI,只有 CPU-available capability。 +- GPU headless:Deferred 到具体 Render/Build capability Spec;必须显式启用并满足平台 surface/device 生命周期,不属于本 Spec 默认成功条件。 + +WaveDevHost 主循环持续处理 adapter input、A0-02 Host/application executor、领域 CPU ticks 和 Operation completion。没有可渲染窗口时仍 drain request;显式 `developer.app.request_exit`、stdin EOF 或 test stop condition 触发正常关闭。 + +### Canonical adapter contract + +Conformance 使用 canonical invocation/result,忽略 JSON-RPC id 等 transport-only 字段,但比较: + +```text +capability + version/effect +typed input/output canonical JSON +structured diagnostic code/path/retryable/details +target/before/after revisions +policy/approval decision +Operation/change/artifact/validator/Evidence refs +budget consumed +``` + +Corpus 包含 valid、invalid type/range/reference、duplicate/version mismatch、unavailable、revision conflict、approval required/expired/replay、budget exceeded、cancel race、notification、oversize/depth、shutdown 等案例。Direct adapter 是 deterministic oracle 的一个调用路径,不得绕过 Host policy。 + +旧 `rpc.discover` 只验证兼容输出;新 capability list/describe/invoke schema 由 A0-01 descriptor 驱动。CLI 可提供人类可读输出,但 test 模式必须输出 canonical JSON 到指定 stream,日志不能污染协议 stdout。 + +### Agent Eval format + +Eval manifest v1 至少包含: + +```text +eval_id / version / description +fixture + fixture hash / initial provider revisions +allowed capability versions +policy profile / workspace/staging +budgets / stop conditions +required validators + evidence plan +forbidden targets/effects +``` + +模型/Agent 不由 manifest 在引擎内启动。外部 orchestrator 创建 A0-02 session,声明 agent/model/prompt-policy/tool-schema identity,并通过公开 adapter 操作。Host 记录步骤和 Evidence;Eval finalizer 在 session 结束或 stop condition 后运行 manifest validators并生成 result: + +```text +success/failure/incomplete +final provider revisions +required validator matrix +steps / retries / invalid calls +wall time / resource and cost fields +modified and forbidden targets +manual interventions +audit/evidence/artifact refs +``` + +cost 可由外部 orchestrator提供并标记 source;Host 不猜测模型费用。统计报告按同一 eval+fixture+tool schema major 聚合 success rate、步骤/时间/费用、误修改率、人工接管率和可复现率,不把一次成功当作能力保证。 + +### Failure and shutdown + +- manifest/schema/hash 不匹配:不启动 Eval。 +- provider/capability unavailable:按 manifest 决定 Fail/Skipped/Not Run;默认 required 项 Fail。 +- Agent 超预算/超时/重复违规:停止接受其 request,请求取消可取消 Operation,生成 incomplete/failure Evidence。 +- stdin断开:停止 stdio adapter;若没有其他 owner,按 config 正常退出。 +- domain/RHI fatal:记录可安全取得的 diagnostic;继续遵守 quarantine,不能为写 Eval result 执行不安全 teardown。 + +## 6. Alternatives + +| Alternative | Benefit | Cost / Rejection reason | +|---|---|---| +| `WaveEditor --no-ui` | executable 少 | 仍链接/初始化 Editor 依赖,无法证明 Player/Headless 边界 | +| 隐藏窗口运行现有 Editor MCP | 改动小 | 依赖 GPU/ImGui/Panel,CI 脆弱且不是业务 parity | +| Headless 复制一套 handler | 快速接入 | schema、effect、validator 和行为必然漂移 | +| 在引擎内嵌 LLM/Agent loop | Demo 完整 | 模型/账号/Prompt 侵入 Host,无法替换且扩大安全面 | +| 只用 scripted smoke 作为 Agent Eval | 确定稳定 | 只能做 adapter contract,不能衡量真实 Agent;两类测试必须分离 | +| Eval 允许直接改 fixture 目录 | 实现简单 | 污染基线、并发冲突且难以复现;必须 workspace/staging 隔离 | + +## 7. Delivery Slices + +| Slice | Scope | Invariant | Verification | Status | +|---|---|---|---|---| +| 1 | `WaveDeveloperSupport`/`WaveDevHost` target、CPU headless startup/config/exit | 无 ImGui/Panel/RenderDoc/RHI,Player closure 不变 | target/source/link contract;start/EOF/request-exit smoke | Pending | +| 2 | MCP stdio + CLI/Test adapter 接入 A0-02 Host/A0-03 federation | 单业务路径、stdout/framing/availability 诚实 | existing MCP contracts + headless direct/stdio parity | Pending | +| 3 | versioned conformance corpus/runner 与 adapter canonical comparison | 无模型、确定性、覆盖 effect/policy/revision/lifecycle | Debug/Release CPU CTest;invalid/failure matrix | Pending | +| 4 | Eval manifest/result、workspace isolation、external Agent session recorder/aggregator | 不内置模型;validator/Evidence 决定成功;External Action 独立审批 | fixture/hash/budget/forbidden-target/result contracts;pilot eval | Pending | + +## 8. Verification and Acceptance + +| Layer | Command/Steps | Expected | Required | +|---|---|---|---| +| Boundary | target/source/link/header closure | WaveDevHost 无 UI/Panel/RenderDoc;Player 无 Developer/MCP/Eval | Yes | +| CPU headless | start、discover、query、stdin EOF、request exit | 无窗口/RHI,正常退出且无后台线程泄漏 | Yes | +| Compatibility | 当前 MCP asset/framing contracts + old discover | 1 MiB/128 depth、`id:null`、notification、stdout 语义不回退 | Yes | +| Conformance | direct/MCP/CLI/Test canonical corpus | descriptor/input/output/diagnostic/effect/revision/policy/Evidence 等价 | Yes | +| Availability | GPU/World/Project/provider absent matrix | discover 可解释,调用确定拒绝,Not Run/Skipped 不冒充 Pass | Yes | +| Security | path escape、sensitive data、manifest injection、approval replay、budget | 副作用前拒绝,workspace/权限不扩大 | Yes | +| Eval | fixed fixture + deterministic mock agent + external-agent pilot | manifest/result 可复现,validator 判定,预算/误修改可报告 | Yes | +| Lifecycle | running Operation、EOF/exit/failure、可选 GPU failure | Host drain;GPU idle/quarantine 不回退 | Yes | +| Build | M0 Debug/Release CPU、WaveDevHost Debug/Release、Debug CTest | warning-as-error,范围内必需测试通过 | Yes | + +### Acceptance Criteria + +- [ ] 独立 `WaveDevHost` 不创建 ImGui/Panel/RenderDoc,CPU default 不初始化 Window/RHI。 +- [ ] WaveDevHost、Editor、MCP、CLI/Test 复用同一 capability/Host/provider/validator,不存在 headless 业务副本。 +- [ ] capability 注册完成前不接受请求;EOF、request exit、failure 都走确定 shutdown。 +- [ ] direct/MCP/CLI/Test conformance 覆盖 schema、effect、revision、policy、Operation、Evidence、budget 和 framing。 +- [ ] GPU/Project/World 等 unavailable 能力有稳定 reason;required Not Run/Skipped 不被报告为通过。 +- [ ] Eval manifest/result 版本化并固定 fixture hash、初始 revisions、allowlist、budget、stop、validator 和 forbidden scope。 +- [ ] 引擎不内置模型/Prompt/云账号;外部 Agent identity、步骤、费用来源和人工接管可记录。 +- [ ] Eval success 只由 deterministic validators/Evidence 决定,不依赖 Agent 声明、调用成功或 exit 0。 +- [ ] workspace/staging、Host approval、External Action、敏感数据和多维预算边界成立。 +- [ ] WavePlayer 的 source/link closure 无 Developer Host、MCP、Eval、代码索引或模型 SDK。 +- [ ] 当前实现 Evidence、Spec 注册表、路线图和 README 同步。 + +## 9. Compatibility, Risks, and Open Questions + +- **Compatibility/Migration**:保留旧 Editor stdio MCP 与方法;先用同一 corpus 同跑 Editor/WaveDevHost,领域迁移后才移除 Panel owner。Eval v1 破坏性变化使用新 major。 +- **Removal/Rollback**:WaveDevHost/adapter/eval target 可独立回退;不能回退 A0-01 schema、MCP safety、G0 Player closure或领域 validator。旧 compatibility adapter 的移除由后继 Spec 单独批准。 + +| Risk | Impact | Mitigation | +|---|---|---| +| Headless 成为第二 Editor | 业务/状态漂移 | target 只组合共享领域服务,conformance 阻止 adapter 特有逻辑 | +| 无窗口但仍隐式依赖 GPU/UI | CI 与部署脆弱 | CPU default target/link/runtime contract,availability matrix | +| conformance 只比较 JSON 外形 | 语义仍漂移 | 比较 effect/revision/policy/Operation/Evidence 并检查状态前后 | +| Eval 被当 deterministic test | 模型波动掩盖回退 | Contract Test 与 Eval 分 lane,mock agent 只验证 harness | +| Eval manifest 提升权限 | 安全绕过 | manifest 只能收窄 allowlist;approval 仍由 A0-02 Host 签发 | +| workspace 污染真实项目 | 数据损坏 | fixture hash、隔离 staging、expected revision、默认禁止 promotion | +| Shipping Player 误链接开发服务 | 体积/攻击面 | CMake source/link/header closure contract in every config | + +### Open Questions + +无。独立 executable、CPU default、target closure、adapter parity、Eval 外部模型边界、manifest/result、成功判据和 shutdown 已固定;实质变化必须先更新本 Spec。 + +## 10. Implementation Record + +### Current Progress + +- 已核对 Architecture、A0-01~A0-03、G0-01、WaveEngine startup/shutdown、MCP server、Editor Panel 注册、Python MCP smoke/contracts 与现有 CMake target。 +- 已确认当前没有 headless Developer Host、adapter conformance 或 Agent Eval schema/runner。 +- 尚未修改生产代码。 + +### Next Step + +- 用户审查并明确接受 A0-04;实施等待 A0-02/A0-03 与 G0-01~G0-03 的产品边界。 + +### Changes and Deviations + +- 选择独立 `WaveDevHost`,不采用 `WaveEditor --no-ui` 或隐藏窗口。 +- Agent/模型保持外部;A0-04 只交付 session recorder、manifest/result、validator 和统计聚合。 +- GPU headless mode 留给具体 Render/Build capability Spec;A0-04 默认 CPU-only。 + +### Evidence + +| Date | Commit | Environment | Command/Steps | Result | Artifacts | +|---|---|---|---|---|---| +| 2026-07-25 | `2bdaacab47d1736a0f7c2fbaf5dbca113dc115d5` + working tree | Source/design review | Architecture、A0/G0 Specs、CMake targets、WaveEngine lifecycle、MCP server/registry、Python smoke/contracts | 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 | 6 concrete Specs;1/1 SpecContracts | + +### Remaining Work + +- 用户明确接受 Draft。 +- 所有 Delivery Slices 与 Acceptance Criteria。 +- 具体领域的 headless capability 与 fixed Agent Eval cases。 diff --git a/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md b/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md index 8af7e95..3672c5e 100644 --- a/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md +++ b/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md @@ -1,19 +1,19 @@ # G0-01:Runtime、Editor 与 Player Target 边界 - Spec ID: `G0-01` -- Status: Draft +- Status: Accepted - Created: 2026-07-25 - Updated: 2026-07-25 - Roadmap: [游戏产品化路线 G0](../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` 可并行;最终验证依赖其稳定构建/测试入口 +- Depends on: [M0-01](./2026-07-25-m0-01-cpu-contract-baseline.md) 可并行;最终验证依赖其稳定构建/测试入口 - Owners: WaveEngine maintainers -- Accepted by: — -- Accepted on: — +- Accepted by: User +- Accepted on: 2026-07-25 - Supersedes: None - Superseded by: None -> 本 Spec 仍是 Draft,只允许审查与设计,不修改生产代码。 +> 本 Spec 已获用户明确接受;实施按依赖顺序等待 M0-01,并从 Delivery Slice 1 开始。 ## 1. Context @@ -230,7 +230,7 @@ Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。Player ### Next Step -- 用户审查并明确接受 Draft,或继续修改契约。 +- 用户审查并明确接受 Draft;后继 G0-02 Application runner/config 与 G0-03 path/storage Draft 已建立。 ### Changes and Deviations @@ -249,3 +249,4 @@ Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。Player - 用户审查并明确接受 Draft。 - 所有 Delivery Slices 与 Acceptance Criteria。 +- G0-02/G0-03 的接受与后续实现。 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 new file mode 100644 index 0000000..4983f19 --- /dev/null +++ b/docs/specs/2026-07-25-g0-02-engine-loop-application-config.md @@ -0,0 +1,266 @@ +# G0-02:Engine Loop、Application Config 与 Lifecycle + +- Spec ID: `G0-02` +- Status: Accepted +- Created: 2026-07-25 +- Updated: 2026-07-25 +- 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-player-targets.md)、[M0-01](./2026-07-25-m0-01-cpu-contract-baseline.md) +- Owners: WaveEngine maintainers +- Accepted by: User +- Accepted on: 2026-07-25 +- Supersedes: None +- Superseded by: None + +> 本 Spec 已获用户明确接受;在 G0-01 target 边界上建立公共 runner、结构化 config、clock 与 lifecycle,并等待前置。 + +## 1. Context + +将 `WaveEngine.cpp` 中的启动、主循环、停止与异常处理提取为 Runtime-owned Application runner,使 Editor、Player 与未来 `WaveDevHost` 共享一套确定生命周期,同时通过 mode/config/services 组合各自能力。 + +**Current facts** + +- `Engine/Source/WaveEngine.cpp` 同时拥有 CPU contract 参数分支、日志、窗口、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 没有契约。 +- `FWindow` 将 native close 转成 Editor 确认流程,程序化 close 直接结束 loop;无窗口 mode 没有共同 stop source。 +- MCP 必须在 Renderer/ImGui/Panel 注册完成后启动;这是 UI owner 注册业务的现状,不是长期 Host 边界。 +- 顶层异常后 Renderer 保持存活;先关闭 Dispatcher/MCP,再停止 Trace,等待 GPU idle,之后才销毁 Renderer/Scene/RHI/Window;idle 不可证明时 `_Exit` quarantine。 +- 当前只记录文本 Fatal;没有 application mode、lifecycle state/revision、stop reason、config source 或 completed frame count 的结构化状态。 + +**Problem** + +G0-01 如果只拆 target 而继续复制 `main()`,Editor、Player 与 WaveDevHost 会产生不同 init/tick/shutdown 语义;把 mode 判断散落成宏或 `if (Editor)` 又会重新耦合 Runtime 与 UI。需要由 Runtime 拥有通用状态机和调度顺序,由 mode-specific frontend/services 提供可选能力,并在副作用前验证 config。 + +## 2. Goals and Non-Goals + +**Goals** + +1. 定义 `EApplicationMode`、版本化 `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 类型。 +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。 +- 不定义 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 负责。 +- 不修改 Renderer/RHI/RenderGraph/Shader 算法、数据格式、MCP framing 或坐标语义。 +- 不以公共 ABI、DLL/plugin interface 或热重载为目标;frontend/service interface 仅仓库内部静态链接。 +- 不通过本 Spec 自动保存 dirty document;保存/确认由 Editor domain/frontend 决定。 + +## 3. Scenarios + +| 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 | +| 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 | +| Native/user exit | Window close、Editor confirm、CLI/Host request | 统一写入 first stop reason,进入 StopRequested/Draining,不被后续原因静默覆盖 | +| RHI failure | worker/submit/signal/wait 失败 | lifecycle=Failed;停止新工作;idle 未证明则 Quarantined + 非零 `_Exit` | + +## 4. Constraints and Invariants + +- **Mode**:`Editor`、`Player`、`DeveloperHost`、`ContractTest` 是配置/组合,不是 Runtime 中散落的 UI 宏。Shipping build 只编入产品允许的 mode/service。 +- **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**:状态单调: + +```text +Constructed → Configured → Initializing → Running + ↘ Failed +Running → StopRequested → Draining → Stopped + ↘ Failed → Draining → Stopped + ↘ Quarantined +``` + +- **Stop reason**:第一个成功 stop request 绑定 source/time/frame/reason;更严重的 fatal 可提升 exit severity,但不抹去原始 reason。 +- **Services**:每个 service 声明 ID、dependencies、mode availability、Init/TickPhase/RequestStop/Drain/Shutdown;依赖图稳定拓扑排序,循环/重复/缺失在 Init 前拒绝。 +- **Tick order**:Input/Event → External/Main-thread requests → Simulation/Scene → Render/Present → EndFrame/Evidence。无窗口/最小化仍执行 requests 与允许的 simulation;不可渲染时不计成功 Present。 +- **Clock**:real time 使用 monotonic steady clock;首个 game delta 为 1/60 秒;variable delta 必须 finite 并 clamp 到 `[0, 0.25]` 秒。`fixed_delta_seconds` 只用于 Test/Developer 明确配置并记录 Evidence。 +- **Frame identity**:application tick sequence 与 completed render frame 分离;`GlobalContext.FrameIndex` 的后继语义是完成全部 configured frame phases 后递增,不能用 UI FPS 作为时间真值。 +- **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。 + +### AI-Native Impact + +| Dimension | Status | Decision / Verification | +|---|---|---| +| Identity | Required | application instance、service、tick/stop request 使用 session-scope ID/sequence;不冒充 Project/World 持久身份 | +| Schema | Required | Application Config、mode profile、service descriptor、lifecycle/status/diagnostic 独立 major/minor 版本化 | +| Query | Required | application status 是带 lifecycle revision/freshness 的纯 snapshot;event cursor gap 返回 resync,不用日志文本解析 | +| Effect | Required | status 是 Query;request stop 是不可 Undo 的有界 Command;外部进程/发布不由 runner 隐式执行 | +| Determinism | Required | service DAG、tick phase、clock/fixed delta、stop precedence、frame count 和 exit code由 CPU contract 固定 | +| 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 仍工作 | + +## 5. Design + +### Application composition + +```text +Entry point + → Parse + validate FApplicationConfig + → Build mode-specific service graph + → FApplicationRunner + Configure + Initialize (topological) + Run phases + RequestStop / Drain + Shutdown (reverse topological) +``` + +Runtime 定义最小 `IApplicationService`/frontend 接口,但不暴露稳定 ABI。Service descriptor: + +```text +service_id / version +dependencies +allowed modes / required features +tick phases +shutdown class (CPU / GPU-backed / adapter) +``` + +Runner 先拓扑排序和 capability validation,再逐个 Init。初始化一半失败时,只对成功 service 调 RequestStop/Drain/Shutdown;GPU-backed service 仍通过 RHI idle coordinator,不能普通逆序析构绕过 fence。 + +### Structured config + +`FApplicationConfig` 至少包含: + +- schema version、mode、build/config identity。 +- window enabled/title/width/height。 +- 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。 + +序列化使用 A0-01 `FValueSchema` 后继;在 A0-01 未接入前,G0-02 可用显式 parser/validator,但字段名、单位、默认值、版本不得有第二份语义。CLI 只映射到同一 config object,不能直接修改 global。 + +### Lifecycle and status + +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 阶段: + +1. 禁止新外部 request/job。 +2. 停 adapter 与 request executor intake。 +3. 通知 service stop/cancel。 +4. 等 CPU worker/Operation 的领域边界。 +5. 等 queued Present 与 Direct/Compute/Copy GPU idle。 +6. 销毁 GPU-backed frontend/Renderer/cache、Runtime state、RHI、Window。 + +无法完成第 5 步时进入 Quarantined,记录可安全输出的 Evidence 后 `_Exit`。 + +### Clock and test controls + +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` 或强杀。 + +## 6. Alternatives + +| Alternative | Benefit | Cost / Rejection reason | +|---|---|---| +| Editor/Player 各复制一个 `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 | +| 使用 wall clock/FPS 直接驱动 gameplay | 现状延伸 | 首帧/暂停/跳变不确定,难以 replay/eval | +| Headless 继续创建隐藏窗口/RHI | 复用渲染路径 | 无 GPU CI 失败,也无法证明真正无 UI/GPU | + +## 7. Delivery Slices + +| 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 | + +## 8. Verification and Acceptance + +| Layer | Command/Steps | Expected | Required | +|---|---|---|---| +| Config | valid/unknown/wrong type/non-finite/range/major/precedence corpus | 副作用前稳定接受或 diagnostic | Yes | +| Lifecycle | fake services 全状态、init rollback、stop precedence、drain timeout | 单调 revision/状态,顺序确定 | Yes | +| 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 | +| 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 | + +### 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 同步。 + +## 9. Compatibility, Risks, and Open Questions + +- **Compatibility/Migration**:先让旧 `WaveEngine` entry 构造 config/runner,再由 G0-01 切换 WaveEditor/Player;现有 CLI/MCP/数据格式保持。`GlobalContext` 字段逐 consumer 迁移后移除。 +- **Removal/Rollback**:Slice 可回退到上一可运行 runner;不能回退现有 MCP drain、GPU idle/quarantine 或 Release `check`。legacy entry 在 Verified 前移除。 + +| Risk | Impact | Mitigation | +|---|---|---| +| 通用 runner 过度抽象 | 小引擎复杂化 | 只抽当前三个 mode 共同 lifecycle;接口内部静态链接 | +| config 与 UI settings 混淆 | 单位/默认值漂移 | Application config 只管宿主;Render/Scene settings 留领域 schema | +| service graph 隐藏 GPU 顺序 | teardown use-after-free | shutdown class + 显式 idle coordinator + failure injection | +| fixed delta 误用于产品默认 | gameplay/性能失真 | 仅 Test/Developer profile允许,Evidence记录 | +| stop/fatal race 覆盖原因 | 诊断不可信 | first stop immutable,fatal 追加 severity/correlation | +| headless path 偷建窗口 | CI 依赖 GPU | constructor/target contract 与 runtime create counters | + +### Open Questions + +无。mode、config precedence、lifecycle、service DAG、tick phases、clock、stop/quarantine 和 headless 边界已固定;实质变化必须先更新本 Spec。 + +## 10. Implementation Record + +### Current Progress + +- 已完整核对 G0-01、Architecture、`WaveEngine.cpp`、`FWindow`、Input/MCP drain、Renderer/ImGui lifecycle、RHI idle/quarantine 与 current clock。 +- 已确认当前没有结构化 Application Config、service graph、lifecycle snapshot 或 no-window runner。 +- 尚未修改生产代码。 + +### Next Step + +- 用户审查并明确接受 G0-02;实施等待 G0-01 target/frontend 边界和 M0 CPU contract 入口。 + +### Changes and Deviations + +- G0-01 的“最小公共 runner”在本 Spec 中细化为 config + service DAG + lifecycle;不扩大到 Project/World/Gameplay。 +- fixed delta 只用于 Test/Developer,GPU headless 不作为本 Spec 默认能力。 + +### Evidence + +| Date | Commit | Environment | Command/Steps | Result | Artifacts | +|---|---|---|---|---|---| +| 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 | + +### Remaining Work + +- 用户明确接受 Draft。 +- 所有 Delivery Slices 与 Acceptance Criteria。 +- G0-03 path/config source integration,A0-02 Host service integration。 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 new file mode 100644 index 0000000..fbc5470 --- /dev/null +++ b/docs/specs/2026-07-25-g0-03-project-package-user-paths.md @@ -0,0 +1,298 @@ +# G0-03:Project、Package、User Paths 与原子存储边界 + +- Spec ID: `G0-03` +- Status: Accepted +- Created: 2026-07-25 +- Updated: 2026-07-25 +- 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 +- Supersedes: None +- Superseded by: None + +> 本 Spec 已获用户明确接受;建立路径语义和存储策略,并等待 G0-02 前置,不引入 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 解析,不再依赖编译机绝对宏、进程当前目录或裸路径身份。 + +**Current facts** + +- CMake 将 `ENGINE_FOLDER`、`SHADERS_FOLDER`、`ASSETS_FOLDER`、`ENGINE_TEMP_FOLDER` 作为带尾斜杠的编译期绝对路径注入全部源码。 +- `Core/Core.h` 全局 `GShaderFolder/GShaderTempFolder`、Scene skybox、Scene/mesh cache、Shader cache、UI/ImGui、Trace、RenderDoc 与 Viewer 都直接使用这些宏。 +- 默认 Scene 为 `Engine/Save/Scenes/default.wescn`,mesh cache 为 `Engine/Save/Meshes`,UI config/ImGui/Trace/Shader cache 也共享 `Engine/Save`。 +- `WaveEngine.log` 写进进程当前目录;从其他目录启动会改变日志位置,但资源仍指向编译机 checkout。 +- Scene v3 对绝对 mesh path 尝试相对 `Engine/Save`;跨盘时保留绝对路径。它当前兼容但不可移植,也不能作为未来 Asset identity。 +- Scene、WEMesh 与 `Utils::StoreFile` 已使用同目录临时文件和原子替换;失败不覆盖旧文件。各实现重复 temp/commit 逻辑且未统一 path policy。 +- `ui_config.cfg` 是不可信输入并已有迁移/校验要求;现有保存位置混合项目 renderer settings 与 Editor user/layout state。 +- 测试直接备份/恢复 `Engine/Save/Scenes/default.wescn` 与 `ui_config.cfg`;MCP demo 使用 `Engine/Save/Meshes` 相对路径。 +- 没有 `.weproject`、Project ID、package manifest 或 cooked content;这些分别属于 G1-01/G6。 + +**Problem** + +编译期绝对路径使产物不能从非 checkout 目录运行;把 source/cache/document/user settings 混在 `Engine/Save` 会造成误提交、覆盖与权限混乱;裸 `std::filesystem::path` 让 Agent/Job 难以表达 scope、权限和 provenance。需要先固定逻辑路径与安全解析,再由 G1/G6 在其上建立 Project/Asset/Package 格式。 + +## 2. Goals and Non-Goals + +**Goals** + +1. 定义 Project session scope 与 `FPathRef`,用 `{domain, project_scope, relative_path}` 表达逻辑位置,raw path 不再作为公共 identity。 +2. 明确 Executable、EngineContent、ProjectSource、Derived、ProjectSaved、UserSaved、PackageContent、Staging、Temp、External 的 root、读写与发布策略。 +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。 +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。 +- 不定义 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。 +- 不扫描磁盘猜测 Project root,不以目录名、显示名或 path hash 冒充未来持久 Project identity。 +- 不在未经用户明确命令时删除 `Engine/Save`、source asset、cache 或外部文件。 + +## 3. Scenarios + +| 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 | +| 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 | +| Legacy save | 新 root 为空且检测到 `Engine/Save` | 报告 migration plan/diff;仅显式确认后复制并验证,原目录不删除 | +| External source import | 用户选择 project 外模型 | 作为 External path 经 session policy读取;生成 artifact 不原地覆盖 source | + +## 4. Constraints and Invariants + +- **Logical domains**: + +| Domain | Default access | Purpose | +|---|---|---| +| Executable | Read | binaries/runtime DLL identity | +| EngineContent | Read | shaders/built-in runtime content | +| ProjectSource | Read by jobs; write only explicit document/asset Command | project-owned source/assets | +| Derived | Read/Write, rebuildable | caches/imported/compiled outputs | +| ProjectSaved | Read/Write, project-local | authoring documents/settings/evidence selected by project | +| UserSaved | Read/Write, user-local | logs/layout/preferences/crash/SaveGame | +| PackageContent | Read only | cooked/shipping content | +| Staging | Isolated Read/Write | unpromoted Job outputs/workspaces | +| Temp | Ephemeral Read/Write | same-operation scratch, no durable identity | +| External | Deny by default | user-selected outside inputs/outputs | + +- **Project scope**:G0-03 使用 process/session `FProjectScopeId` 绑定 canonical project root/config;它不是持久 Project ID。G1-01 引入持久 ID 后显式迁移。 +- **PathRef**:relative path 使用 UTF-8 generic separators、禁止 absolute/root name、空 segment、`.`/`..`、NUL、Windows device names、alternate streams 和超限;domain/project scope 必填。 +- **Canonical containment**:解析后必须在 canonical root 内;对已存在父链检查 reparse/symlink target,创建前后重新验证。Windows 比较遵循 filesystem case-insensitive 语义,不能只做字符串前缀。 +- **TOCTOU**:security-sensitive write 使用已验证 parent/handle 或 replace 前重检;无法证明 containment 时失败,不接受“通常在 root 内”。 +- **No cwd truth**:current working directory 只用于解析用户显式 CLI 相对输入,并立即 canonicalize/记录 source;runtime resource、log、save 不从 cwd 隐式推导。 +- **No source fallback**:packaged mode 禁止查 ProjectSource、repo `Assets/`、`Engine/Shaders` 或 Build cache;缺失为稳定 diagnostic。 +- **Writes**:文档/配置/manifest 用同目录 unique temp → write/flush/size/hash validation → atomic replace。失败不覆盖旧文件、不清 dirty、不 promotion。 +- **Staging/promotion**:Job 只能写允许 Staging/Derived;promotion 是领域 Command,绑定 expected revision、source/target refs、validator/Evidence,不能由 path service 自行完成。 +- **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。 + +### AI-Native Impact + +| Dimension | Status | Decision / Verification | +|---|---|---| +| Identity | Required | `FPathRef` 使用 domain/project_scope/relative_path;persistent `FProjectId` 明确 Deferred 到 G1-01,不以 path hash 冒充 | +| Schema | Required | path domains、root config、PathRef、access/promotion/migration diagnostic 独立 major/minor 版本化 | +| Query | Required | path roots/capability/usage/migration plan 提供带 config revision/freshness 的 snapshot;root change 使 cursor/ref stale | +| Effect | Required | resolve/inspect 是 Query;document promotion 是领域 Command;import/build 是 Job;External path/VCS/delete 需 External Action approval | +| Determinism | Required | UTF-8 normalization、generic path、Windows case/containment、root precedence、atomic temp/replace 结果有 CPU contract | +| 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 提供 | + +## 5. Design + +### Path service + +```text +G0-02 Application Config + → FPathService::Configure(roots + mode + policy) + ├─ ResolveRead(FPathRef) + ├─ ResolveWrite(FPathRef, intent) + ├─ Open/AtomicStore + ├─ Describe roots/capabilities + └─ BuildLegacyMigrationPlan +``` + +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,调用者不改变。 + +### Root layout and transition + +首版建议逻辑布局,不把物理名称当持久协议: + +```text +ProjectRoot/ + Assets/ ProjectSource compatibility + Derived/ rebuildable artifacts + Saved/ project-local documents/evidence + +UserSavedRoot/ + Logs/ + Config/ + Traces/ + Crashes/ + SaveGames/ + +PackageRoot/ + Content/ read-only packaged content + +StagingRoot/ + session-or-operation-id/ +``` + +EngineContent 在 developer build 可由显式 source/build config 指向 shader/runtime content,在 packaged build 指向 package Runtime content。CMake 编译期宏只在迁移期作为 compatibility fallback,并记录 warning;G0-03 Verified 前生产路径调用者全部改为 injected `FPathRef`,宏从 public `Core.h` 和普通 runtime source 移除。 + +职责迁移: + +- Scene document → ProjectSaved(G2/G5 最终由 Scene/Level Asset identity接管)。 +- imported WEMesh/cache → Derived。 +- renderer/project settings → ProjectSaved;ImGui layout/user preference → UserSaved。 +- shader compiled cache → Derived;shader source/runtime content → EngineContent/PackageContent。 +- logs/traces/crash/RenderDoc captures → UserSaved 或显式 External output。 +- user SaveGame → UserSaved;不与 Editor Scene/UI config 混放。 + +### Safe resolution and storage + +Resolve 流程: + +1. 验证 PathRef schema/domain/project scope/relative bytes。 +2. 取得 immutable root snapshot 与 access policy。 +3. lexical normalize,拒绝 absolute/root/parent/device/stream。 +4. canonicalize existing root/parent chain并检查 containment/reparse。 +5. 按 operation intent 检查 read/write/create/replace/delete/execute 与 session policy。 +6. 打开/创建后重新验证需要的 handle/parent identity。 +7. 返回短生命周期 resolved handle,不把 raw path作为跨 request reference。 + +原子写 primitive 输入 target PathRef、expected existing hash/revision(可选)、max size、writer 和 validator;使用同目录唯一 temp,flush/close,验证 size/hash/format,再 `MoveFileExW(REPLACE_EXISTING|WRITE_THROUGH)`。冲突/失败返回 diagnostic,旧文件保持。删除/目录递归不属于 generic atomic store。 + +### Legacy migration + +Legacy discovery 只检查明确的旧 `Engine/Save` root,不扫描整个磁盘。Migration plan 列出源/目标 PathRef、类型、size/hash、冲突、unsupported/absolute reference 和 validator。流程: + +```text +Discover → Preview plan → explicit approval + → copy to Staging → validate formats/hashes + → promote each document/artifact by policy + → report Evidence +``` + +原目录保留。若目标已有不同内容,返回 conflict,不 last-writer-wins。旧 Scene 中跨 root absolute mesh path 保持兼容读取并标记 external/legacy;G1/G2 通过 Asset ID/Scene migration最终消除,不由 G0-03 猜测重写。 + +### 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 而非仅搜索代码字符串。 + +## 6. Alternatives + +| Alternative | Benefit | Cost / Rejection reason | +|---|---|---| +| 保留编译期绝对路径宏 | 简单、当前可跑 | 构建不可移动,污染 Core header,无法 package/headless | +| 以 cwd 为 Project root | 命令短 | 启动位置改变行为,脚本/Agent 易写错目录 | +| 自动向上搜索 `.git`/`Assets` | 用户方便 | 模糊、多 checkout 不确定,可能选错 Project | +| 用裸绝对 path 作为公共 API | 实现直接 | 泄露机器信息、不可移植、无法表达 domain/permission | +| 自动移动 `Engine/Save` | 迁移无交互 | 可能覆盖/丢失用户数据,缺乏验证和 rollback | +| 所有数据放 User AppData | 权限简单 | Project source/document 不可协作,Derived/Package 生命周期混淆 | +| 立刻引入 VFS/pak/CAS | 长期完整 | 超出 G0,缺少 Asset ID/Cook manifest 前置 | + +## 7. Delivery Slices + +| 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 | + +## 8. Verification and Acceptance + +| Layer | Command/Steps | Expected | Required | +|---|---|---|---| +| Path schema | domain/scope/UTF-8/segments/length/case corpus | canonical PathRef 稳定,无歧义 | Yes | +| 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 | +| 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 | + +### 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 同步。 + +## 9. Compatibility, Risks, and Open Questions + +- **Compatibility/Migration**:G0-03 保留旧格式读取和显式 legacy root adapter;写入新 root。G1/G2/G6 分别接管 Project/Asset ID、Scene refs 和 Package manifest 后才能移除 path-based兼容。 +- **Removal/Rollback**:compatibility fallback 在 Slice 4 前保留并告警;回退不得删除新/旧用户数据。任何迁移删除需要独立 External Action 确认。 + +| Risk | Impact | Mitigation | +|---|---|---| +| 路径迁移找不到现有数据 | 用户内容似乎丢失 | legacy discovery/preview、只 copy、冲突报告、源保留 | +| canonicalization 被 reparse/TOCTOU 绕过 | root escape/覆盖 | parent/handle recheck、无法证明即失败、安全 corpus | +| ProjectSaved/UserSaved 划分错误 | 协作或隐私问题 | 明确内容类别;G1/G5 可版本化迁移,不混用默认 root | +| compatibility fallback 永久存在 | package 偷读源码 | warning、usage telemetry/test、Slice 4 clean-dir removal gate | +| 绝对路径藏在数据/日志 | 不可移植/泄密 | PathRef/location policy、受控 display、migration diagnostic | +| 原子替换在磁盘/权限失败 | dirty/旧数据损坏 | same-dir temp、flush/validate、replace、旧文件和 dirty 保持 | + +### Open Questions + +无。path domains、scope、root source、Windows containment、atomic store、legacy migration、package isolation 和 G1/G6 交接已固定;实质变化必须先更新本 Spec。 + +## 10. Implementation Record + +### 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。 +- 尚未修改生产代码或移动任何文件。 + +### Next Step + +- 用户审查并明确接受 G0-03;实施等待 G0-02 config/lifecycle,先从纯 CPU path/policy contract 开始。 + +### 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。 + +### Evidence + +| Date | Commit | Environment | Command/Steps | Result | Artifacts | +|---|---|---|---|---|---| +| 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 | + +### 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 new file mode 100644 index 0000000..29ecb6d --- /dev/null +++ b/docs/specs/2026-07-25-m0-01-cpu-contract-baseline.md @@ -0,0 +1,265 @@ +# M0-01:可复现 CPU Contract 与无窗口测试基线 + +- Spec ID: `M0-01` +- Status: Implementing +- Created: 2026-07-25 +- Updated: 2026-07-25 +- Roadmap: [M0 自动化基线](../plans/2026-07-25-game-engine-production-roadmap.md#4-路线与依赖) +- Architecture: [AI-Native Engine Architecture](../architecture/ai-native-engine.md) +- Depends on: None +- Owners: WaveEngine maintainers +- Accepted by: User +- Accepted on: 2026-07-25 +- Supersedes: None +- Superseded by: None + +> 本 Spec 已获用户明确接受,当前按 Delivery Slices 实施;生产修改必须保持独立 CPU closure 与现有 Editor 行为。 + +## 1. Context + +建立一个不创建窗口、不初始化 RHI、不启动 Editor/MCP、也不要求 GPU 的 Windows x64 CPU contract lane。它既承载现有坐标、RenderGraph allocator 和 WaveTrace 契约,也作为 A0-01 Contract Core 及后续领域纯 CPU contract 的稳定宿主。 + +**Acceptance baseline (2026-07-25, before implementation)** + +- 接受时的 `CMakeLists.txt` 把全部 `Engine/Source` 与绝大多数 ThirdParty 源编入单一 `WaveEngine` executable;只有 `WaveTraceViewer` 是独立 target。 +- `WaveEngine.CoreRuntimeContracts` 通过完整 Editor executable 的旧参数分支执行。参数分支发生在窗口和 RHI 初始化前,但被测 executable 仍编译、链接并部署完整 Editor/RHI/MCP 闭包。 +- `RunCoreRuntimeContracts()` 位于 `Engine/Source/WaveEngine.cpp`,与 application `main()` 同一翻译单元;契约代码不能由独立 CPU target 复用。 +- `WaveEngine` 强制使用 `EnginePCH.h`,其中包含 DX12、GLFW、Windows native GLFW、RHI 与 D3D12MA;现有 CPU contract 因而不能证明公共契约头的直接 include 闭包。 +- `WaveEngine.SpecContracts` 是当前唯一不依赖引擎 executable 的 CTest;其余 MCP/WEMesh tests 会启动完整 GPU Editor。 +- 仓库只有 Visual Studio `CMakeSettings.json`,没有 `CMakePresets.json`、已跟踪 CI workflow 或只构建 CPU contract target 的标准命令。 +- 2026-07-25 接受时的 working tree 上,旧 Editor core-contract 参数返回 0;这是接受基线,不是 M0-01 实现证据。 + +**Problem at acceptance** + +A0-01 要求 Contract Core 无 UI、MCP 与 RHI 依赖并可纯 CPU 测试,但当前唯一 C++ contract 入口只有“运行时不初始化 GPU”,没有独立的 build/link/source closure。干净 checkout、CI 或无兼容 GPU 环境无法只构建和验证契约层,也无法阻止新的公共 contract 头继续依赖 PCH 或 Editor/RHI。 + +## 2. Goals and Non-Goals + +**Goals** + +1. 新增独立 `WaveCoreContracts` console executable,只编译 contract runner 与明确列出的 CPU 源,不使用 Engine PCH。 +2. 将现有 Core Runtime contract 从 `WaveEngine.cpp` 迁移到测试 target,并保持坐标、Rotator、RenderGraph allocator、WaveTrace 与 Release `check` 语义覆盖。 +3. 新增版本化 CMake configure/build/test presets,为 Debug/Release CPU lane 提供仓库唯一标准命令。 +4. 新增 Windows x64 CPU CI,在干净 checkout 上只构建 contract target 并运行带 `cpu` label 的 CTest。 +5. 为失败结果记录 commit、工具链、配置、命令、退出码和 CTest/编译日志,使后续 Spec Evidence 可追溯。 +6. 保持完整 `WaveEngine`、MCP/WEMesh GPU tests、`WaveTraceViewer`、渲染与数据格式行为不变。 + +**Non-Goals** + +- 不在本 Spec 中实现 A0-01 Contract Core、capability schema、Developer host 或任何领域业务。 +- 不拆 Runtime/Editor/Player target;该工作属于 G0-01。 +- 不建设 GPU/DXR、真实 Editor、package、性能或 Agent Eval CI lane。 +- 不承诺 Linux/macOS 支持,也不引入测试框架、包管理器或第三方依赖。 +- 不修改 Shader、RHI、RenderGraph 资源语义、Trace 格式、MCP framing、资产格式或坐标契约。 +- 不把整个 `WaveEngine` 构建改造成 hermetic build;M0-01 只固定 CPU contract lane 的输入和命令。 + +## 3. Scenarios + +| Scenario | Given / When | Expected | +|---|---|---| +| Clean checkout | Windows x64 + VS 2022 + CMake 4.0.1+ + Python 3 | 使用仓库 preset 只构建 `WaveCoreContracts` 并运行 CPU tests,不构建或启动 Editor | +| No compatible GPU | 机器没有 Mesh Shader/DXR 或没有图形会话 | CPU lane 结果不受 GPU、显示器、DXC runtime 或窗口系统可用性影响 | +| Debug/Release | 分别运行 CPU Debug 与 Release preset | 两种配置都通过;Release 仍验证 `check` 会抛出并由 runner 转成确定退出码 | +| Header regression | Contract/public header 偶然依赖 Engine PCH、UI、MCP 或 RHI | 独立 target 编译失败并在 CI 中给出具体 translation unit | +| Contract failure | 任一断言、解析或 round-trip 不成立 | executable 非零退出,CTest/CI 失败并保留诊断 | +| Missing Python | 本地未安装 Python 3 | C++ core contract 仍可构建;CMake 明确报告 Python Spec test 不可用,CI 必须提供 Python 并失败而非跳过 | + +## 4. Constraints and Invariants + +- **Functional**:迁移前后的 contract 断言集合与 CTest 测试名保持;新增 A0/G 系列纯 CPU contract 进入同一 target 或明确的 CPU-only sibling target。 +- **Build closure**:`WaveCoreContracts` 不链接 `WaveEngine`,不编译 Window、Input、RHI、Renderer、Scene、Shader、UI、MCP、Developer 或 ThirdParty 源;允许现有 Trace CPU contract 所需的 Windows/WinSock 系统库。 +- **Headers**:测试 target 不使用 `EnginePCH.h`;纳入 target 的公共头必须显式包含直接依赖。为现有 CPU 源保留的路径宏只能作为兼容编译定义,新的 Contract Core 不能依赖 Shader/Asset 绝对路径。 +- **Runtime**:CPU lane 不创建窗口、不初始化 RHI、不编译 Shader、不访问 GPU,不启动 MCP/stdin server,不写项目 Source/Assets;临时 trace 只写系统 temp 并按现有 contract 清理。 +- **Determinism**:测试集合、排序、退出码、超时与 preset 名固定;时间只用于不会进入断言真值的临时 artifact 名称/metadata。 +- **Compatibility**:`WaveEngine.SpecContracts` 与 `WaveEngine.CoreRuntimeContracts` CTest 名和 `cpu` label 保持。MCP/WEMesh test 仍指向现有 Editor executable,直到 G0-01 迁移。 +- **Security/Input**:CI 不执行仓库外脚本,不在 test phase 访问网络,不上传源资产或日志中的敏感内容;单个 CPU test 默认 10 秒超时。 +- **Failure**:缺少编译器、Python、预期 target 或 contract test 被过滤为空都必须可见失败;不能以“0 tests”冒充通过。 + +### AI-Native Impact + +| Dimension | Status | Decision / Verification | +|---|---|---| +| Identity | N/A | 本 Spec 不创建领域对象;build、commit 与 test 名只作为 Evidence identity | +| Schema | N/A | 不新增引擎数据或 capability schema;CMake preset/CTest metadata 使用其原生版本化格式 | +| Query | N/A | 不新增运行时 snapshot、diff 或 change feed;CTest discovery 不是领域状态查询 | +| Effect | N/A | 不注册引擎 capability;configure/build/test 是开发宿主显式发起的外部进程,后续 A0-02 才定义 Operation/Policy | +| Determinism | Required | 固定 preset、target、label、排序、timeout 和退出码;Debug/Release 在干净 checkout 重复执行 | +| Validation/Evidence | Required | 编译闭包、SpecContracts、CoreRuntimeContracts 与 CTest/CI 日志共同形成机器证据 | +| Provenance/Migration | Required | Evidence 记录 commit、VS/CMake/Python 与配置;旧 `WaveEngine.exe` contract flag 在迁移完成后显式移除 | +| Security/Budget | Required | CPU lane 无 GPU/网络/外部脚本,测试有 10 秒 timeout,日志/artifact 有界 | +| Headless | Required | build/test 全程不创建窗口、不初始化 RHI/UI/MCP,并由独立 target closure 自动证明 | + +## 5. Design + +### Target and test topology + +```text +CMake configure + ├─ WaveEngine existing full Editor + ├─ WaveTraceViewer existing viewer + └─ WaveCoreContracts new, explicit CPU-only source list + └─ CTest: WaveEngine.CoreRuntimeContracts + +CTest label=cpu + ├─ CMakePresetContracts Python 3 + ├─ CpuCiContracts Python 3 + ├─ SpecContracts Python 3 + ├─ CoreContractTargetBoundary Python 3 + └─ CoreRuntimeContracts WaveCoreContracts +``` + +`WaveCoreContracts` 使用显式 source list,不能复用 `ENGINE_SOURCE` glob,也不能链接完整 `WaveEngine`。首个列表只包含 contract main、现有 Core/Math contract 所需实现、`RenderGraphAllocator`、WaveTrace contract/codec/analysis/recorder 及其必要 CPU 支持源。每个新增源都必须通过 target review 证明没有 Window/RHI/Renderer/Scene/Shader/UI/MCP/Developer/ThirdParty 依赖。 + +Contract runner 从 `WaveEngine.cpp` 移到 `Tests/`,返回稳定退出码: + +```text +0 = all contracts passed +2 = expected runtime-check sentinel mismatched +3 = unexpected non-std exception +4 = sentinel was not raised +``` + +普通 contract 失败继续通过 `check` 抛出 `std::runtime_error`,由 runner 区分预期的 Release sentinel 与真实失败。CTest 测试名保持 `WaveEngine.CoreRuntimeContracts`,但 command 改为 CMake 解析得到的 `WaveCoreContracts` target file。迁移完成并经 `rg` 确认没有仓库调用者后,删除旧 Editor core-contract 参数分支,避免维护两套 runner。 + +### Presets + +跟踪根 `CMakePresets.json`,至少提供: + +- configure preset `vs2022-x64`:Visual Studio 17 2022、x64、`BUILD_TESTING=ON`。 +- build presets `cpu-debug`、`cpu-release`:只构建 `WaveCoreContracts`,分别使用 Debug/Release。 +- test presets `test-cpu-debug`、`test-cpu-release`:按 `cpu` label 运行、启用 `--output-on-failure`,且在没有匹配 test 时失败。 + +Preset 是本地与 CI 的共同入口;`CMakeSettings.json` 可保留为 Visual Studio 兼容入口,但文档和 Evidence 不再把它作为自动化真值。所有路径基于 source/build preset 展开,不写用户绝对路径。 + +### CI and evidence + +新增 Windows x64 workflow,按以下顺序执行: + +```text +checkout pinned revision + → report VS/CMake/Python versions + → configure preset + → build cpu-debug + → test-cpu-debug + → build cpu-release + → test-cpu-release +``` + +第三方 GitHub Actions 使用固定 commit SHA;workflow 不下载项目依赖、不启动 Editor、不访问 GPU。失败时保留 CMake configure/build 与 CTest 输出;成功 Evidence 至少包含 repository commit、runner image、工具版本、preset、test count 与结果。 + +### Dependency rule for A0-01 + +M0-01 达到 `Verified` 后,A0-01 Slice 1~3 才能把 Contract Core 类型、registry contract 和 adapter contract 加入 `WaveCoreContracts`。A0-01 不得反向让 CPU target 链接 MCP/UI/RHI;新增依赖闭包由 M0 target 的无 PCH 编译与静态 source-list contract 阻止。 + +## 6. Alternatives + +| Alternative | Benefit | Cost / Rejection reason | +|---|---|---| +| 继续使用 Editor executable 承载 core contract | 零 CMake 迁移 | 仍构建/链接完整 GPU Editor,不能证明 Contract Core closure | +| 只保留 Python SpecContracts | 最轻量 | 只能验证文档,不能编译 C++ schema/ref/effect 或 Release `check` 语义 | +| 为 CPU tests 链接未来 `WaveRuntime` | 复用 target | G0-01 尚未实施,且 Runtime 仍会包含窗口/RHI;M0 必须独立成为前置 | +| 在 CI 构建完整 Debug Editor | 覆盖更多 | 需要 GPU/runtime DLL,耗时且无法隔离 CPU contract;完整构建仍由后续 lane 承担 | +| 引入 Catch2/GoogleTest | 断言与报告更丰富 | 增加第三方升级、许可证和构建面;当前 `check` + CTest 足以建立基线 | + +## 7. Delivery Slices + +| Slice | Scope | Invariant | Verification | Status | +|---|---|---|---|---| +| 1 | 提取 contract runner,新增无 PCH `WaveCoreContracts` 与显式 CPU source list | 现有断言集合保持,target 无 Editor/RHI/MCP closure | Debug/Release target build;CoreRuntimeContracts;source/include closure | Completed | +| 2 | 新增 CMake presets、CPU labels、零测试失败门与文档命令 | 本地和 CI 复用同一命令,现有 GPU tests 不变 | 1 个 configure 与 4 个 build/test preset;`ctest -N` | Completed | +| 3 | 新增 pinned Windows CPU CI,移除旧 executable flag 和重复 runner | 干净 checkout 无 GPU/窗口通过,仓库无旧 flag 调用者 | CI run;`rg`;Debug/Release CPU lane | In Progress (CI Evidence pending) | + +## 8. Verification and Acceptance + +| Layer | Command/Steps | Expected | Required | +|---|---|---|---| +| Static | `git diff --check`;target source/include closure script/contract | 无格式错误;无 Window/RHI/Renderer/Scene/Shader/UI/MCP/Developer/ThirdParty 闭包 | Yes | +| Configure | `cmake --preset vs2022-x64` | 干净 build tree 配置成功,注册 CPU tests | Yes | +| Debug build | `cmake --build --preset cpu-debug` | 只要求 `WaveCoreContracts` 及其明确 CPU 依赖成功 | Yes | +| Debug test | `ctest --preset test-cpu-debug` | Spec + Core contracts 均运行并通过,test count 非零 | Yes | +| Release build | `cmake --build --preset cpu-release` | warning-as-error,无 Editor/GPU target 前置 | Yes | +| Release test | `ctest --preset test-cpu-release` | Release `check` 与所有 CPU contracts 通过 | Yes | +| Compatibility | `ctest --test-dir Build -C Debug -N`;现有 CTest 名/label 核对 | 两个现有 CPU test 名保持;MCP/WEMesh 注册不变 | Yes | +| CI | 新 Windows workflow on clean checkout | Debug/Release CPU lane 通过并记录工具/commit/test 证据 | Yes | + +### Acceptance Criteria + +- [x] `WaveCoreContracts` 可独立构建,不使用 Engine PCH,也不要求构建或链接完整 `WaveEngine`。 +- [x] CPU target 的 source/include closure 不包含 Window、RHI、Renderer、Scene、Shader、UI、MCP、Developer 或 ThirdParty。 +- [x] 现有 Core Runtime contract 断言迁移后保持,Debug/Release 均实际执行。 +- [x] `WaveEngine.SpecContracts` 与 `WaveEngine.CoreRuntimeContracts` 名称、`cpu` label 和 10 秒 timeout 保持。 +- [x] 仓库 preset 是本地与 CI 的共同入口,零匹配测试不能返回成功。 +- [ ] 干净 Windows x64 runner 无 GPU/窗口运行 CPU lane,Evidence 含 commit、环境、命令和结果。 +- [x] 旧 Editor core-contract 参数分支及仓库调用者在迁移完成后移除。 +- [x] 完整 Editor、MCP/WEMesh GPU tests、Trace 格式、坐标和渲染行为未被本 Spec 改变。 +- [x] A0-01 可在不扩大 target closure 的前提下接入纯 CPU contract。 +- [ ] Spec、注册表、路线图、README 与当前实现 Evidence 同步。 + +## 9. Compatibility, Risks, and Open Questions + +- **Compatibility/Migration**:CTest 测试名和 label 保持,只替换 command target。旧 Editor 参数只在 Slice 1 迁移期短暂并存,已在 Slice 3 移除。 +- **Removal/Rollback**:独立 target/preset/CI 可逐 Slice 回退;回退不能删除或弱化现有 Core contract。若 source closure 无法保持 CPU-only,停止 A0-01 并先拆分违规依赖。 + +| Risk | Impact | Mitigation | +|---|---|---| +| CPU target 复制生产实现 | 测试与真实代码漂移 | 只复制 runner/断言;被测实现使用同一生产 `.cpp` 或后续 Runtime library | +| PCH 依赖暴露大量缺失 include | Slice 1 扩大 | 仅修复 target 直接触达的公共头,保持邻近格式并由无 PCH build 验证 | +| Trace contract 引入平台服务 | “CPU-only”含义不清 | 允许 Windows/WinSock 系统库,但禁止窗口、GPU、RHI、网络连接和外部服务 | +| CI 与本地命令漂移 | 证据不可复现 | workflow 只能调用已跟踪 preset,不复制 CMake 参数 | +| `cpu` label 漏标导致零覆盖 | CI 假通过 | test preset 和 workflow 显式要求非零 test count | +| Action 或工具链漂移 | 供应链与复现风险 | Action 固定 commit SHA,Evidence 打印 VS/CMake/Python 版本 | + +### Open Questions + +无。target 名、允许的 CPU closure、preset 名、CTest 兼容、CI 平台、旧 flag 移除门和 A0-01 依赖已在本 Draft 固定;改变这些契约必须先更新本 Spec。 + +## 10. Implementation Record + +### Current Progress + +- Slice 1 已完成:Core Runtime runner 已从 `WaveEngine.cpp` 提取到共享测试源,CTest 改由独立 `WaveCoreContracts` 执行。 +- `WaveCoreContracts` 使用 12 个显式仓库源(含共享 runner 源集)和 WinSock 系统库,不启用 Engine PCH;无 PCH 编译同时补齐了 Core、Math、Log、RenderGraph allocator 与 Trace contract 的直接 include。 +- 新增 `WaveEngine.CoreContractTargetBoundary`,递归检查项目 include closure,并拒绝禁用领域源码/include、未批准 source-list 变量/链接目标和 PCH 回退。 +- Slice 1 迁移期曾验证完整 Editor、Debug/Release 独立 target、全部既有 CTest 与临时保留的旧 Editor 参数。 +- Slice 2 已完成:根 `CMakePresets.json` 提供 `vs2022-x64` 与 Debug/Release CPU build/test presets,test preset 固定 `cpu` label、失败输出和零测试失败策略。 +- 新增 `WaveEngine.CMakePresetContracts`,固定 generator、architecture、build target、configuration、label 和 `noTestsAction=error`;README 已切换为 preset 标准入口。 +- Slice 3 本地实现已完成:新增只读、固定 action SHA、20 分钟预算的 `windows-2022` CPU workflow,并由 `WaveEngine.CpuCiContracts` 检查 action、权限、工具链 Evidence 与 preset 顺序。 +- Editor 已停止编译/链接共享 contract runner,旧参数分支及所有仓库调用者已移除;Debug/Release CPU lane 与完整 Editor/GPU contracts 继续通过。 + +### Next Step + +- 经用户确认后 commit/push,使 GitHub Actions 在干净 checkout 上运行新 workflow;记录 run URL、runner image、commit、工具版本和 5/5 Debug/Release 结果后完成 Slice 3 并将 M0-01 标记为 `Verified`。 + +### Changes and Deviations + +- 相对路线图“M0 自动化基线”,本 Spec 只交付 A0/G0 所需的 CPU contract/preset/CI 闭环;完整 Editor/GPU/package CI 保留给后续阶段。 +- 允许 WaveTrace contract 使用 Windows/WinSock 系统库,但明确禁止窗口、RHI、GPU 和外部网络。 +- Slice 1 为验证迁移兼容性,曾临时让 `WaveEngine` 与独立 target 共享同一 runner;Slice 3 已按设计移除 Editor 分支与链接。 +- 相比只做一次 source-list 审查,新增可重复执行的 `WaveEngine.CoreContractTargetBoundary` CTest,以持续阻止边界回退;不改变既有测试名。 +- Slice 2 新增一项 `WaveEngine.CMakePresetContracts` CPU CTest,而非只依赖人工查看 JSON;这是对 Determinism 与 Validation/Evidence 的强化,不改变任何既有测试名。 +- `vs2022-x64` 继续使用仓库既有 `Build/`,使 preset 与 Visual Studio `CMakeSettings.json` 共存;后者保留为 IDE 兼容入口,不作为自动化真值。 +- `windows-2022` 镜像默认 CMake 可能低于项目要求;workflow 不联网安装工具,而是在 runner 预装 CMake 中选择满足 4.0.1 的最高版本,并把实际版本写入 step summary。 +- Slice 3 新增 `WaveEngine.CpuCiContracts`,把 pinned action、read-only token、预算、无下载/Editor 命令和 preset 复用变成可执行契约;GitHub 托管环境的真实执行仍需 push 后留证。 + +### Evidence + +| Date | Commit | Environment | Command/Steps | Result | Artifacts | +|---|---|---|---|---|---| +| 2026-07-25 | `2bdaacab47d1736a0f7c2fbaf5dbca113dc115d5` + clean working tree | Windows x64;CMake 4.2.3;Python 3.14.4;existing Debug build | `git status --short`;`ctest --test-dir Build -C Debug -N -V`;旧 Editor core-contract 参数 | Pass (baseline only) | 4 registered CTests;Core contract exit 0;本 Spec acceptance baseline | +| 2026-07-25 | working tree | Source review | `CMakeLists.txt`、`WaveEngine.cpp`、`EnginePCH.h`、MCP Registry/Server、Trace contract 与 SDD/A0/G0 | Pass | target/source/dependency audit in sections 1 and 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 | 3 concrete Specs;1/1 SpecContracts | +| 2026-07-25 | `2bdaacab47d1736a0f7c2fbaf5dbca113dc115d5` + working tree | Windows x64;MSBuild 17.14.23;CMake 4.2.3;Python 3.14.4 | `cmake -S . -B Build`;`cmake --build Build --config Debug`;`cmake --build Build --config Release --target WaveCoreContracts` | Pass | 完整 Debug Editor/Viewer 与 Debug/Release `WaveCoreContracts.exe` | +| 2026-07-25 | same working tree | Debug/Release CTest | `ctest --test-dir Build -C Debug -R "SpecContracts\|CoreRuntimeContracts\|CoreContractTargetBoundary" --output-on-failure`;Release 同范围(不重复 Spec) | Pass | Debug 3/3;Release 2/2;原测试名、`cpu` label、10 秒 timeout 保持 | +| 2026-07-25 | same working tree | Debug runtime/GPU contract environment | `ctest --test-dir Build -C Debug --output-on-failure`;旧 Editor core-contract 参数;检查 `WaveEngine.log` | Pass | 5/5 CTest;迁移期旧参数 exit 0;无 Error/Fatal/D3D12 validation 匹配 | +| 2026-07-25 | same working tree | Static boundary | `py -3 Tests/core_contract_target_contracts.py .`;`git diff --check` | Pass | 12 个显式仓库源、25 个 project include closure 文件;无禁用领域依赖或 PCH;仅换行策略 warning | +| 2026-07-25 | `2bdaacab47d1736a0f7c2fbaf5dbca113dc115d5` + working tree | Windows x64;MSBuild 17.14.23;CMake 4.2.3;Python 3.14.4 | `cmake --list-presets=all`;`cmake --preset vs2022-x64`;`cmake --build --preset cpu-debug`;`ctest --preset test-cpu-debug`;Release 同链路 | Pass | 1 configure、2 build、2 test presets;Debug 4/4、Release 4/4 CPU tests | +| 2026-07-25 | same working tree | Negative zero-test gate | `ctest --preset test-cpu-debug -L __wave_no_such_cpu_label__` | Expected failure | exit 8;`No tests were found`,未把空测试集视为成功 | +| 2026-07-25 | same working tree | CTest compatibility | `ctest --test-dir Build -C Debug -N -V`;`ctest --test-dir Build -C Debug --output-on-failure`;检查 `WaveEngine.log` | Pass | 6 tests registered;6/6 passed;MCP/WEMesh 保持;无 Error/Fatal/D3D12 validation 匹配 | +| 2026-07-25 | same working tree | GitHub Actions source/security review | official release pages;`git ls-remote` for pinned tags;runner image manifest;`py -3 Tests/cpu_ci_contracts.py .` | Pass (source contract) | checkout v7.0.1 SHA `3d3c42e5aac5ba805825da76410c181273ba90b1`;read-only token;20-minute job;no install/download/Editor commands | +| 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;验证内容随后清空,仅保留被忽略的空临时目录 | + +### Remaining Work + +- Slice 3:在 GitHub 托管的干净 `windows-2022` checkout 上取得真实 CI run Evidence。 +- 完成剩余 Acceptance Criteria 后将 M0-01 标记为 `Verified`,再进入 A0-01 实施。 diff --git a/docs/specs/README.md b/docs/specs/README.md index 0925fe4..bf84531 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -16,8 +16,14 @@ Draft / Accepted / Implementing --被替代--> Superseded | Spec ID | Status | Title | Roadmap | Updated | |---|---|---|---|---| -| [A0-01](./2026-07-25-a0-01-contract-core.md) | Draft | AI-Native Contract Core 与领域能力注册 | [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-player-targets.md) | Draft | Runtime、Editor 与 Player Target 边界 | [G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) | 2026-07-25 | +| [M0-01](./2026-07-25-m0-01-cpu-contract-baseline.md) | Implementing | 可复现 CPU Contract 与无窗口测试基线 | [M0](../plans/2026-07-25-game-engine-production-roadmap.md#4-路线与依赖) | 2026-07-25 | +| [A0-01](./2026-07-25-a0-01-contract-core.md) | Accepted | AI-Native Contract Core 与领域能力注册 | [AI-native track](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) | 2026-07-25 | +| [A0-02](./2026-07-25-a0-02-developer-host.md) | Accepted | Developer Host Session、Policy、Operation 与 Audit | [AI-native track](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) | 2026-07-25 | +| [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-player-targets.md) | Accepted | Runtime、Editor 与 Player Target 边界 | [G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) | 2026-07-25 | +| [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 | ## Session 入口 From 97f3579f8e06bf0860d97db029f28e724e677549 Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sat, 25 Jul 2026 18:42:33 +0800 Subject: [PATCH 02/16] harden CI toolchain detection --- .github/workflows/cpu-contracts.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cpu-contracts.yml b/.github/workflows/cpu-contracts.yml index 36f0f43..b4fcc0a 100644 --- a/.github/workflows/cpu-contracts.yml +++ b/.github/workflows/cpu-contracts.yml @@ -36,12 +36,16 @@ jobs: function Get-CMakeVersion([string]$Executable) { - $VersionLine = & $Executable --version | Select-Object -First 1 - if ($LASTEXITCODE -ne 0 -or $VersionLine -notmatch "^cmake version ([0-9.]+)") + $VersionOutput = @(& $Executable --version 2>&1) + $VersionMatch = [regex]::Match( + [string]::Join("`n", $VersionOutput), + "(?m)^cmake version ([0-9]+(?:\.[0-9]+)+)" + ) + if (-not $VersionMatch.Success) { throw "Unable to determine CMake version from $Executable" } - return [Version]$Matches[1] + return [Version]($VersionMatch.Groups[1].Value) } $SelectedCMakeVersion = Get-CMakeVersion "cmake" @@ -78,7 +82,7 @@ jobs: } $PythonVersion = & python --version 2>&1 | Select-Object -First 1 - if ($LASTEXITCODE -ne 0 -or $PythonVersion -notmatch "^Python 3\.") + if ($PythonVersion -notmatch "^Python 3\.") { throw "Python 3 is required for the complete CPU contract lane" } @@ -87,7 +91,7 @@ jobs: $VisualStudioVersion = & $VsWhere -latest -products * ` -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` -property installationVersion - if ($LASTEXITCODE -ne 0 -or -not $VisualStudioVersion) + if (-not $VisualStudioVersion) { throw "Visual Studio 2022 C++ tools are required" } From 54bf748b3b0f9d4ac24706f714cef0ffa3d2ce2e Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sat, 25 Jul 2026 18:45:33 +0800 Subject: [PATCH 03/16] publish auditable CI evidence --- .github/workflows/cpu-contracts.yml | 14 ++++++++++---- Tests/cpu_ci_contracts.py | 2 ++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cpu-contracts.yml b/.github/workflows/cpu-contracts.yml index b4fcc0a..ef82a13 100644 --- a/.github/workflows/cpu-contracts.yml +++ b/.github/workflows/cpu-contracts.yml @@ -101,7 +101,7 @@ jobs: } $Commit = git rev-parse HEAD - @( + $EnvironmentEvidence = @( "## CPU Contract Environment" "" "- Commit: ``$Commit``" @@ -112,7 +112,10 @@ jobs: "- Configure preset: ``vs2022-x64``" "- Build presets: ``cpu-debug``, ``cpu-release``" "- Test presets: ``test-cpu-debug``, ``test-cpu-release``" - ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Encoding utf8 + ) + $EnvironmentEvidence | Write-Output + $EnvironmentEvidence | + Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Encoding utf8 - name: Configure run: cmake --preset vs2022-x64 @@ -144,10 +147,13 @@ jobs: { throw "Unable to discover Release CPU tests" } - @( + $ResultEvidence = @( "" "## CPU Contract Result" "" "- Debug: **Pass** ($($DebugDiscovery.tests.Count) tests)" "- Release: **Pass** ($($ReleaseDiscovery.tests.Count) tests)" - ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Encoding utf8 + ) + $ResultEvidence | Write-Output + $ResultEvidence | + Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Encoding utf8 diff --git a/Tests/cpu_ci_contracts.py b/Tests/cpu_ci_contracts.py index c3dee40..228c4d0 100644 --- a/Tests/cpu_ci_contracts.py +++ b/Tests/cpu_ci_contracts.py @@ -59,6 +59,8 @@ def main() -> int: "git rev-parse HEAD", 'Get-CMakeVersion "cmake"', "python --version", + "$EnvironmentEvidence | Write-Output", + "$ResultEvidence | Write-Output", "CPU Contract Result", "--show-only=json-v1", '[Version]"4.0.1"', From 96f953506376a8d7382ebbcbb9070e75c0b17ba6 Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sat, 25 Jul 2026 18:49:00 +0800 Subject: [PATCH 04/16] verify M0 CPU contract baseline --- ...26-07-25-game-engine-production-roadmap.md | 4 ++-- .../2026-07-25-m0-01-cpu-contract-baseline.md | 20 ++++++++++--------- docs/specs/README.md | 2 +- 3 files changed, 14 insertions(+), 12 deletions(-) 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 72e7fd1..890e82e 100644 --- a/docs/plans/2026-07-25-game-engine-production-roadmap.md +++ b/docs/plans/2026-07-25-game-engine-production-roadmap.md @@ -87,7 +87,7 @@ G6 Cook / Package / SampleGame / Release | 阶段 | 状态 | 核心产物 | 阶段结束时可运行结果 | |---|---|---|---| -| M0 | 实施中 | CMake Presets、CPU CI、无窗口测试入口 | 干净 checkout 可复现基础构建和测试 | +| M0 | 已完成 | CMake Presets、CPU CI、无窗口测试入口 | 干净 checkout 可复现基础构建和测试 | | G0 | 待实施 | Runtime/Editor/Player targets、Engine Loop、路径层 | 独立 Player 显示现有场景 | | G1 | 待实施 | `.weproject`、Asset ID、Registry、共享 Handle | 移动资产后 Scene 仍可加载 | | G2 | 待实施 | World、Entity、Component、层级、Scene v4 | World 场景可编辑、保存和渲染 | @@ -286,7 +286,7 @@ A 系列只提供跨领域公共词汇、Developer host、Context/Evidence feder | ID | 内容 | 前置 | 状态 | |---|---|---|---| -| [M0-01](../specs/2026-07-25-m0-01-cpu-contract-baseline.md) | CMake Presets、CPU CI、无窗口测试入口 | — | Implementing | +| [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-player-targets.md) | Runtime/Editor/Player Target 图 | 可与 M0-01 并行 | Accepted | | [A0-01](../specs/2026-07-25-a0-01-contract-core.md) | AI-Native Contract Core 与领域能力注册 | Slice 1~3:M0-01;Slice 4:G0-01 | Accepted | | [G0-02](../specs/2026-07-25-g0-02-engine-loop-application-config.md) | Engine Loop、Application Config 与 Lifecycle | G0-01、M0-01 | Accepted | 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 29ecb6d..4a2599b 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 @@ -1,7 +1,7 @@ # M0-01:可复现 CPU Contract 与无窗口测试基线 - Spec ID: `M0-01` -- Status: Implementing +- Status: Verified - Created: 2026-07-25 - Updated: 2026-07-25 - Roadmap: [M0 自动化基线](../plans/2026-07-25-game-engine-production-roadmap.md#4-路线与依赖) @@ -167,7 +167,7 @@ M0-01 达到 `Verified` 后,A0-01 Slice 1~3 才能把 Contract Core 类型 |---|---|---|---|---| | 1 | 提取 contract runner,新增无 PCH `WaveCoreContracts` 与显式 CPU source list | 现有断言集合保持,target 无 Editor/RHI/MCP closure | Debug/Release target build;CoreRuntimeContracts;source/include closure | Completed | | 2 | 新增 CMake presets、CPU labels、零测试失败门与文档命令 | 本地和 CI 复用同一命令,现有 GPU tests 不变 | 1 个 configure 与 4 个 build/test preset;`ctest -N` | Completed | -| 3 | 新增 pinned Windows CPU CI,移除旧 executable flag 和重复 runner | 干净 checkout 无 GPU/窗口通过,仓库无旧 flag 调用者 | CI run;`rg`;Debug/Release CPU lane | In Progress (CI Evidence pending) | +| 3 | 新增 pinned Windows CPU CI,移除旧 executable flag 和重复 runner | 干净 checkout 无 GPU/窗口通过,仓库无旧 flag 调用者 | CI run;`rg`;Debug/Release CPU lane | Completed | ## 8. Verification and Acceptance @@ -189,11 +189,11 @@ M0-01 达到 `Verified` 后,A0-01 Slice 1~3 才能把 Contract Core 类型 - [x] 现有 Core Runtime contract 断言迁移后保持,Debug/Release 均实际执行。 - [x] `WaveEngine.SpecContracts` 与 `WaveEngine.CoreRuntimeContracts` 名称、`cpu` label 和 10 秒 timeout 保持。 - [x] 仓库 preset 是本地与 CI 的共同入口,零匹配测试不能返回成功。 -- [ ] 干净 Windows x64 runner 无 GPU/窗口运行 CPU lane,Evidence 含 commit、环境、命令和结果。 +- [x] 干净 Windows x64 runner 无 GPU/窗口运行 CPU lane,Evidence 含 commit、环境、命令和结果。 - [x] 旧 Editor core-contract 参数分支及仓库调用者在迁移完成后移除。 - [x] 完整 Editor、MCP/WEMesh GPU tests、Trace 格式、坐标和渲染行为未被本 Spec 改变。 - [x] A0-01 可在不扩大 target closure 的前提下接入纯 CPU contract。 -- [ ] Spec、注册表、路线图、README 与当前实现 Evidence 同步。 +- [x] Spec、注册表、路线图、README 与当前实现 Evidence 同步。 ## 9. Compatibility, Risks, and Open Questions @@ -225,10 +225,11 @@ M0-01 达到 `Verified` 后,A0-01 Slice 1~3 才能把 Contract Core 类型 - 新增 `WaveEngine.CMakePresetContracts`,固定 generator、architecture、build target、configuration、label 和 `noTestsAction=error`;README 已切换为 preset 标准入口。 - 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。 ### Next Step -- 经用户确认后 commit/push,使 GitHub Actions 在干净 checkout 上运行新 workflow;记录 run URL、runner image、commit、工具版本和 5/5 Debug/Release 结果后完成 Slice 3 并将 M0-01 标记为 `Verified`。 +- M0-01 已满足全部 Acceptance Criteria 并标记为 `Verified`;下一步可按依赖门禁把 A0-01 改为 `Implementing`,开始 Contract Core Slice 1。 ### Changes and Deviations @@ -238,8 +239,9 @@ M0-01 达到 `Verified` 后,A0-01 Slice 1~3 才能把 Contract Core 类型 - 相比只做一次 source-list 审查,新增可重复执行的 `WaveEngine.CoreContractTargetBoundary` CTest,以持续阻止边界回退;不改变既有测试名。 - Slice 2 新增一项 `WaveEngine.CMakePresetContracts` CPU CTest,而非只依赖人工查看 JSON;这是对 Determinism 与 Validation/Evidence 的强化,不改变任何既有测试名。 - `vs2022-x64` 继续使用仓库既有 `Build/`,使 preset 与 Visual Studio `CMakeSettings.json` 共存;后者保留为 IDE 兼容入口,不作为自动化真值。 -- `windows-2022` 镜像默认 CMake 可能低于项目要求;workflow 不联网安装工具,而是在 runner 预装 CMake 中选择满足 4.0.1 的最高版本,并把实际版本写入 step summary。 -- Slice 3 新增 `WaveEngine.CpuCiContracts`,把 pinned action、read-only token、预算、无下载/Editor 命令和 preset 复用变成可执行契约;GitHub 托管环境的真实执行仍需 push 后留证。 +- `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 验证。 ### Evidence @@ -258,8 +260,8 @@ M0-01 达到 `Verified` 后,A0-01 Slice 1~3 才能把 Contract Core 类型 | 2026-07-25 | same working tree | GitHub Actions source/security review | official release pages;`git ls-remote` for pinned tags;runner image manifest;`py -3 Tests/cpu_ci_contracts.py .` | Pass (source contract) | checkout v7.0.1 SHA `3d3c42e5aac5ba805825da76410c181273ba90b1`;read-only token;20-minute job;no install/download/Editor commands | | 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 | ### Remaining Work -- Slice 3:在 GitHub 托管的干净 `windows-2022` checkout 上取得真实 CI run Evidence。 -- 完成剩余 Acceptance Criteria 后将 M0-01 标记为 `Verified`,再进入 A0-01 实施。 +- M0-01 无剩余工作;后续若 CPU target closure、preset 或 CI Evidence 契约变化,必须更新本 Spec 并重新验证。 diff --git a/docs/specs/README.md b/docs/specs/README.md index bf84531..12e7e66 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -16,7 +16,7 @@ Draft / Accepted / Implementing --被替代--> Superseded | Spec ID | Status | Title | Roadmap | Updated | |---|---|---|---|---| -| [M0-01](./2026-07-25-m0-01-cpu-contract-baseline.md) | Implementing | 可复现 CPU Contract 与无窗口测试基线 | [M0](../plans/2026-07-25-game-engine-production-roadmap.md#4-路线与依赖) | 2026-07-25 | +| [M0-01](./2026-07-25-m0-01-cpu-contract-baseline.md) | Verified | 可复现 CPU Contract 与无窗口测试基线 | [M0](../plans/2026-07-25-game-engine-production-roadmap.md#4-路线与依赖) | 2026-07-25 | | [A0-01](./2026-07-25-a0-01-contract-core.md) | Accepted | AI-Native Contract Core 与领域能力注册 | [AI-native track](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) | 2026-07-25 | | [A0-02](./2026-07-25-a0-02-developer-host.md) | Accepted | Developer Host Session、Policy、Operation 与 Audit | [AI-native track](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) | 2026-07-25 | | [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 | From a72fbe47b3d680e11172f6f293f55921554e6ebe Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sun, 26 Jul 2026 11:57:56 +0800 Subject: [PATCH 05/16] implement Contract Core slice 1 --- CMakeLists.txt | 4 + Engine/Source/Contract/ContractCore.cpp | 1642 +++++++++++++++++ Engine/Source/Contract/ContractCore.h | 243 +++ Tests/ContractCoreContracts.cpp | 228 +++ Tests/ContractCoreContracts.h | 6 + Tests/CoreRuntimeContracts.cpp | 3 + ...26-07-25-game-engine-production-roadmap.md | 2 +- docs/specs/2026-07-25-a0-01-contract-core.md | 27 +- docs/specs/README.md | 2 +- 9 files changed, 2144 insertions(+), 13 deletions(-) create mode 100644 Engine/Source/Contract/ContractCore.cpp create mode 100644 Engine/Source/Contract/ContractCore.h create mode 100644 Tests/ContractCoreContracts.cpp create mode 100644 Tests/ContractCoreContracts.h diff --git a/CMakeLists.txt b/CMakeLists.txt index d1293a8..6cc944e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -91,7 +91,11 @@ set_property(TARGET WaveEngine PROPERTY COMPILE_WARNING_AS_ERROR ON) # or third-party source dependencies. set(WAVE_CORE_CONTRACT_SOURCES ${CORE_RUNTIME_CONTRACT_SUITE} + "${PROJECT_SOURCE_DIR}/Tests/ContractCoreContracts.h" + "${PROJECT_SOURCE_DIR}/Tests/ContractCoreContracts.cpp" "${PROJECT_SOURCE_DIR}/Tests/CoreRuntimeContractsMain.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Contract/ContractCore.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Contract/ContractCore.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" diff --git a/Engine/Source/Contract/ContractCore.cpp b/Engine/Source/Contract/ContractCore.cpp new file mode 100644 index 0000000..0c0b42e --- /dev/null +++ b/Engine/Source/Contract/ContractCore.cpp @@ -0,0 +1,1642 @@ +#include "Contract/ContractCore.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace WaveContract +{ + namespace + { + constexpr size_t MaxJsonDepth = MaxSchemaDepth * 4 + 8; + constexpr uint64_t MaxDescriptorPayloadBytes = 1024ull * 1024ull * 1024ull; + constexpr uint64_t MaxDescriptorTimeoutMilliseconds = 24ull * 60ull * 60ull * 1000ull; + constexpr uint32_t MaxDescriptorConcurrency = 1024; + + 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; + } + + bool EqualSchemaPointer( + const std::shared_ptr& Left, + const std::shared_ptr& Right) + { + if (Left == Right) + { + return true; + } + return Left != nullptr && Right != nullptr && *Left == *Right; + } + + std::optional ValidateSchemaRecursive( + const FValueSchema& Schema, + std::string Path, + size_t Depth, + size_t& NodeCount, + std::unordered_set& ActiveSchemas, + bool bRoot) + { + if (Depth > MaxSchemaDepth) + { + return MakeDiagnostic( + "contract.schema.depth_exceeded", + std::move(Path), + "schema nesting exceeds the supported depth"); + } + if (++NodeCount > MaxSchemaNodes) + { + return MakeDiagnostic( + "contract.schema.nodes_exceeded", + std::move(Path), + "schema node count exceeds the supported limit"); + } + if (!ActiveSchemas.insert(&Schema).second) + { + return MakeDiagnostic( + "contract.schema.cycle", + std::move(Path), + "schema graph must not contain a cycle"); + } + + struct FActiveSchemaGuard + { + std::unordered_set& Active; + const FValueSchema* Schema; + + ~FActiveSchemaGuard() + { + Active.erase(Schema); + } + }; + const FActiveSchemaGuard Guard{ ActiveSchemas, &Schema }; + + if ((bRoot || !Schema.Id.Name.empty()) && Schema.Id.Name.empty()) + { + return MakeDiagnostic( + "contract.schema.id_required", + Path + ".id.name", + "root schema id name is required"); + } + if (!Schema.Id.Name.empty() && Schema.Id.Version.Major == 0) + { + return MakeDiagnostic( + "contract.schema.version_invalid", + Path + ".id.version.major", + "schema major version must be greater than zero"); + } + if ((Schema.Minimum.has_value() && !std::isfinite(*Schema.Minimum)) + || (Schema.Maximum.has_value() && !std::isfinite(*Schema.Maximum))) + { + return MakeDiagnostic( + "contract.schema.range_non_finite", + Path, + "numeric bounds must be finite"); + } + if (Schema.Minimum.has_value() + && Schema.Maximum.has_value() + && *Schema.Minimum > *Schema.Maximum) + { + return MakeDiagnostic( + "contract.schema.range_invalid", + Path, + "minimum must not exceed maximum"); + } + + const bool bNumeric = Schema.Kind == EValueKind::Integer || Schema.Kind == EValueKind::Number; + if (!bNumeric && (Schema.Minimum.has_value() || Schema.Maximum.has_value())) + { + return MakeDiagnostic( + "contract.schema.constraint_mismatch", + Path, + "numeric bounds are valid only for integer and number schemas"); + } + if (Schema.Kind != EValueKind::String && Schema.MaxLength.has_value()) + { + return MakeDiagnostic( + "contract.schema.constraint_mismatch", + Path + ".maxLength", + "maxLength is valid only for string schemas"); + } + if (Schema.Kind != EValueKind::Array + && (Schema.MinItems.has_value() || Schema.MaxItems.has_value())) + { + return MakeDiagnostic( + "contract.schema.constraint_mismatch", + Path, + "item bounds are valid only for array schemas"); + } + if (Schema.MinItems.has_value() + && Schema.MaxItems.has_value() + && *Schema.MinItems > *Schema.MaxItems) + { + return MakeDiagnostic( + "contract.schema.item_range_invalid", + Path, + "minItems must not exceed maxItems"); + } + if (Schema.Kind != EValueKind::Reference && Schema.ReferenceScope.has_value()) + { + return MakeDiagnostic( + "contract.schema.constraint_mismatch", + Path + ".referenceScope", + "referenceScope is valid only for reference schemas"); + } + if (Schema.Kind == EValueKind::Reference && !Schema.ReferenceScope.has_value()) + { + return MakeDiagnostic( + "contract.schema.reference_scope_required", + Path + ".referenceScope", + "reference schema must declare a scope"); + } + + if (Schema.Kind == EValueKind::Object) + { + if (Schema.Items != nullptr || !Schema.EnumValues.empty()) + { + return MakeDiagnostic( + "contract.schema.shape_invalid", + Path, + "object schema cannot declare items or enum values"); + } + + std::unordered_set FieldNames; + for (size_t FieldIndex = 0; FieldIndex < Schema.Fields.size(); ++FieldIndex) + { + const FSchemaField& Field = Schema.Fields[FieldIndex]; + const std::string FieldPath = Path + ".fields[" + std::to_string(FieldIndex) + "]"; + if (Field.Name.empty()) + { + return MakeDiagnostic( + "contract.schema.field_name_required", + FieldPath + ".name", + "schema field name is required"); + } + if (!FieldNames.insert(Field.Name).second) + { + return MakeDiagnostic( + "contract.schema.field_duplicate", + FieldPath + ".name", + "schema field names must be unique"); + } + if (Field.Schema == nullptr) + { + return MakeDiagnostic( + "contract.schema.field_schema_required", + FieldPath + ".schema", + "schema field must reference a value schema"); + } + if (auto Diagnostic = ValidateSchemaRecursive( + *Field.Schema, + FieldPath + ".schema", + Depth + 1, + NodeCount, + ActiveSchemas, + false)) + { + return Diagnostic; + } + } + } + else if (!Schema.Fields.empty()) + { + return MakeDiagnostic( + "contract.schema.shape_invalid", + Path + ".fields", + "fields are valid only for object schemas"); + } + + if (Schema.Kind == EValueKind::Array) + { + if (Schema.Items == nullptr) + { + return MakeDiagnostic( + "contract.schema.items_required", + Path + ".items", + "array schema must declare an item schema"); + } + if (!Schema.EnumValues.empty()) + { + return MakeDiagnostic( + "contract.schema.shape_invalid", + Path + ".enumValues", + "array schema cannot declare enum values"); + } + if (auto Diagnostic = ValidateSchemaRecursive( + *Schema.Items, + Path + ".items", + Depth + 1, + NodeCount, + ActiveSchemas, + false)) + { + return Diagnostic; + } + } + else if (Schema.Items != nullptr) + { + return MakeDiagnostic( + "contract.schema.shape_invalid", + Path + ".items", + "items are valid only for array schemas"); + } + + if (Schema.Kind == EValueKind::Enum) + { + if (Schema.EnumValues.empty()) + { + return MakeDiagnostic( + "contract.schema.enum_values_required", + Path + ".enumValues", + "enum schema must declare at least one value"); + } + std::unordered_set Values; + for (size_t ValueIndex = 0; ValueIndex < Schema.EnumValues.size(); ++ValueIndex) + { + const std::string& Value = Schema.EnumValues[ValueIndex]; + if (Value.empty() || !Values.insert(Value).second) + { + return MakeDiagnostic( + "contract.schema.enum_value_invalid", + Path + ".enumValues[" + std::to_string(ValueIndex) + "]", + "enum values must be non-empty and unique"); + } + } + } + else if (!Schema.EnumValues.empty()) + { + return MakeDiagnostic( + "contract.schema.shape_invalid", + Path + ".enumValues", + "enumValues are valid only for enum schemas"); + } + + return std::nullopt; + } + + void AppendEscapedString(std::string& Output, std::string_view Value) + { + constexpr char HexDigits[] = "0123456789abcdef"; + Output.push_back('"'); + for (const unsigned char Character : Value) + { + switch (Character) + { + case '"': + Output += "\\\""; + break; + case '\\': + Output += "\\\\"; + break; + case '\b': + Output += "\\b"; + break; + case '\f': + Output += "\\f"; + break; + case '\n': + Output += "\\n"; + break; + case '\r': + Output += "\\r"; + break; + case '\t': + Output += "\\t"; + break; + default: + if (Character < 0x20) + { + Output += "\\u00"; + Output.push_back(HexDigits[(Character >> 4) & 0x0f]); + Output.push_back(HexDigits[Character & 0x0f]); + } + else + { + Output.push_back(static_cast(Character)); + } + break; + } + } + Output.push_back('"'); + } + + void AppendUnsigned(std::string& Output, uint64_t Value) + { + char Buffer[32]; + const auto Result = std::to_chars(std::begin(Buffer), std::end(Buffer), Value); + Output.append(Buffer, Result.ptr); + } + + bool AppendNumber(std::string& Output, double Value) + { + if (!std::isfinite(Value)) + { + return false; + } + char Buffer[64]; + const auto Result = std::to_chars( + std::begin(Buffer), + std::end(Buffer), + Value, + std::chars_format::general, + std::numeric_limits::max_digits10); + if (Result.ec != std::errc()) + { + return false; + } + Output.append(Buffer, Result.ptr); + return true; + } + + void AppendOptionalUnsigned(std::string& Output, const std::optional& Value) + { + if (!Value.has_value()) + { + Output += "null"; + return; + } + AppendUnsigned(Output, *Value); + } + + bool AppendOptionalNumber(std::string& Output, const std::optional& Value) + { + if (!Value.has_value()) + { + Output += "null"; + return true; + } + return AppendNumber(Output, *Value); + } + + bool AppendSchemaJson(std::string& Output, const FValueSchema& Schema) + { + Output += "{\"id\":{\"name\":"; + AppendEscapedString(Output, Schema.Id.Name); + Output += ",\"version\":{\"major\":"; + AppendUnsigned(Output, Schema.Id.Version.Major); + Output += ",\"minor\":"; + AppendUnsigned(Output, Schema.Id.Version.Minor); + Output += "}},\"kind\":"; + AppendEscapedString(Output, ToString(Schema.Kind)); + Output += ",\"description\":"; + AppendEscapedString(Output, Schema.Description); + Output += ",\"nullable\":"; + Output += Schema.bNullable ? "true" : "false"; + Output += ",\"minimum\":"; + if (!AppendOptionalNumber(Output, Schema.Minimum)) + { + return false; + } + Output += ",\"maximum\":"; + if (!AppendOptionalNumber(Output, Schema.Maximum)) + { + return false; + } + Output += ",\"minItems\":"; + AppendOptionalUnsigned(Output, Schema.MinItems); + Output += ",\"maxItems\":"; + AppendOptionalUnsigned(Output, Schema.MaxItems); + Output += ",\"maxLength\":"; + AppendOptionalUnsigned(Output, Schema.MaxLength); + Output += ",\"unit\":"; + AppendEscapedString(Output, Schema.Unit); + Output += ",\"coordinateSystem\":"; + AppendEscapedString(Output, Schema.CoordinateSystem); + Output += ",\"referenceScope\":"; + if (Schema.ReferenceScope.has_value()) + { + AppendEscapedString(Output, ToString(*Schema.ReferenceScope)); + } + else + { + Output += "null"; + } + Output += ",\"enumValues\":["; + for (size_t ValueIndex = 0; ValueIndex < Schema.EnumValues.size(); ++ValueIndex) + { + if (ValueIndex != 0) + { + Output.push_back(','); + } + AppendEscapedString(Output, Schema.EnumValues[ValueIndex]); + } + Output += "],\"fields\":["; + for (size_t FieldIndex = 0; FieldIndex < Schema.Fields.size(); ++FieldIndex) + { + if (FieldIndex != 0) + { + Output.push_back(','); + } + const FSchemaField& Field = Schema.Fields[FieldIndex]; + Output += "{\"name\":"; + AppendEscapedString(Output, Field.Name); + Output += ",\"required\":"; + Output += Field.bRequired ? "true" : "false"; + Output += ",\"schema\":"; + if (Field.Schema == nullptr || !AppendSchemaJson(Output, *Field.Schema)) + { + return false; + } + Output.push_back('}'); + } + Output += "],\"items\":"; + if (Schema.Items != nullptr) + { + if (!AppendSchemaJson(Output, *Schema.Items)) + { + return false; + } + } + else + { + Output += "null"; + } + Output.push_back('}'); + return Output.size() <= MaxSchemaJsonBytes; + } + + enum class EJsonKind : uint8_t + { + Null, + Boolean, + Number, + String, + Array, + Object + }; + + struct FJsonValue + { + EJsonKind Kind = EJsonKind::Null; + bool Boolean = false; + std::string Text; + std::vector Array; + std::vector> Object; + }; + + class FBoundedJsonParser + { + public: + explicit FBoundedJsonParser(std::string_view InJson) + : Json(InJson) + { + } + + bool Parse(FJsonValue& OutValue, FStructuredDiagnostic& OutDiagnostic) + { + if (Json.size() > MaxSchemaJsonBytes) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema_json.size_exceeded", + "$", + "schema JSON exceeds the supported byte limit"); + return false; + } + if (!ParseValue(OutValue, 1)) + { + OutDiagnostic = Diagnostic; + return false; + } + SkipWhitespace(); + if (Position != Json.size()) + { + Fail("contract.schema_json.trailing_data", "unexpected data after the root value"); + OutDiagnostic = Diagnostic; + return false; + } + return true; + } + + private: + void SkipWhitespace() + { + while (Position < Json.size()) + { + const char Character = Json[Position]; + if (Character != ' ' && Character != '\t' && Character != '\r' && Character != '\n') + { + break; + } + ++Position; + } + } + + bool Fail(std::string Code, std::string Message) + { + if (Diagnostic.Code.empty()) + { + Diagnostic = MakeDiagnostic( + std::move(Code), + "$@" + std::to_string(Position), + std::move(Message)); + } + return false; + } + + bool ParseValue(FJsonValue& OutValue, size_t Depth) + { + SkipWhitespace(); + if (Depth > MaxJsonDepth) + { + return Fail( + "contract.schema_json.depth_exceeded", + "schema JSON nesting exceeds the supported depth"); + } + if (++NodeCount > MaxSchemaNodes * 8) + { + return Fail( + "contract.schema_json.nodes_exceeded", + "schema JSON node count exceeds the supported limit"); + } + if (Position >= Json.size()) + { + return Fail("contract.schema_json.unexpected_end", "unexpected end of schema JSON"); + } + + switch (Json[Position]) + { + case 'n': + return ParseLiteral("null", EJsonKind::Null, OutValue); + case 't': + OutValue.Boolean = true; + return ParseLiteral("true", EJsonKind::Boolean, OutValue); + case 'f': + OutValue.Boolean = false; + return ParseLiteral("false", EJsonKind::Boolean, OutValue); + case '"': + OutValue.Kind = EJsonKind::String; + return ParseString(OutValue.Text); + case '[': + return ParseArray(OutValue, Depth); + case '{': + return ParseObject(OutValue, Depth); + default: + if (Json[Position] == '-' || (Json[Position] >= '0' && Json[Position] <= '9')) + { + return ParseNumber(OutValue); + } + return Fail("contract.schema_json.invalid_token", "invalid schema JSON token"); + } + } + + bool ParseLiteral(std::string_view Literal, EJsonKind Kind, FJsonValue& OutValue) + { + if (Json.substr(Position, Literal.size()) != Literal) + { + return Fail("contract.schema_json.invalid_token", "invalid schema JSON literal"); + } + Position += Literal.size(); + OutValue.Kind = Kind; + return true; + } + + static int HexValue(char Character) + { + if (Character >= '0' && Character <= '9') + { + return Character - '0'; + } + if (Character >= 'a' && Character <= 'f') + { + return Character - 'a' + 10; + } + if (Character >= 'A' && Character <= 'F') + { + return Character - 'A' + 10; + } + return -1; + } + + static void AppendUtf8(std::string& Output, uint32_t CodePoint) + { + if (CodePoint <= 0x7f) + { + Output.push_back(static_cast(CodePoint)); + } + else if (CodePoint <= 0x7ff) + { + Output.push_back(static_cast(0xc0 | (CodePoint >> 6))); + Output.push_back(static_cast(0x80 | (CodePoint & 0x3f))); + } + else + { + Output.push_back(static_cast(0xe0 | (CodePoint >> 12))); + Output.push_back(static_cast(0x80 | ((CodePoint >> 6) & 0x3f))); + Output.push_back(static_cast(0x80 | (CodePoint & 0x3f))); + } + } + + bool ParseString(std::string& OutString) + { + if (Json[Position++] != '"') + { + return Fail("contract.schema_json.string_expected", "expected a JSON string"); + } + OutString.clear(); + while (Position < Json.size()) + { + const unsigned char Character = static_cast(Json[Position++]); + if (Character == '"') + { + return true; + } + if (Character < 0x20) + { + return Fail( + "contract.schema_json.control_character", + "unescaped control character in JSON string"); + } + if (Character != '\\') + { + OutString.push_back(static_cast(Character)); + continue; + } + if (Position >= Json.size()) + { + return Fail("contract.schema_json.unexpected_end", "unfinished JSON escape"); + } + const char Escape = Json[Position++]; + switch (Escape) + { + case '"': + case '\\': + case '/': + OutString.push_back(Escape); + break; + case 'b': + OutString.push_back('\b'); + break; + case 'f': + OutString.push_back('\f'); + break; + case 'n': + OutString.push_back('\n'); + break; + case 'r': + OutString.push_back('\r'); + break; + case 't': + OutString.push_back('\t'); + break; + case 'u': + { + if (Position + 4 > Json.size()) + { + return Fail("contract.schema_json.unexpected_end", "unfinished unicode escape"); + } + uint32_t CodePoint = 0; + for (size_t DigitIndex = 0; DigitIndex < 4; ++DigitIndex) + { + const int Digit = HexValue(Json[Position++]); + if (Digit < 0) + { + return Fail( + "contract.schema_json.unicode_escape_invalid", + "invalid unicode escape"); + } + CodePoint = (CodePoint << 4) | static_cast(Digit); + } + if (CodePoint >= 0xd800 && CodePoint <= 0xdfff) + { + return Fail( + "contract.schema_json.unicode_escape_invalid", + "surrogate unicode escapes are not supported"); + } + AppendUtf8(OutString, CodePoint); + break; + } + default: + return Fail("contract.schema_json.escape_invalid", "invalid JSON escape"); + } + } + return Fail("contract.schema_json.unexpected_end", "unterminated JSON string"); + } + + bool ParseNumber(FJsonValue& OutValue) + { + const size_t Start = Position; + if (Json[Position] == '-') + { + ++Position; + } + if (Position >= Json.size()) + { + return Fail("contract.schema_json.number_invalid", "invalid JSON number"); + } + if (Json[Position] == '0') + { + ++Position; + if (Position < Json.size() && Json[Position] >= '0' && Json[Position] <= '9') + { + return Fail("contract.schema_json.number_invalid", "JSON number has a leading zero"); + } + } + else + { + if (Json[Position] < '1' || Json[Position] > '9') + { + return Fail("contract.schema_json.number_invalid", "invalid JSON number"); + } + while (Position < Json.size() && Json[Position] >= '0' && Json[Position] <= '9') + { + ++Position; + } + } + if (Position < Json.size() && Json[Position] == '.') + { + ++Position; + const size_t FractionStart = Position; + while (Position < Json.size() && Json[Position] >= '0' && Json[Position] <= '9') + { + ++Position; + } + if (Position == FractionStart) + { + return Fail("contract.schema_json.number_invalid", "JSON fraction requires digits"); + } + } + if (Position < Json.size() && (Json[Position] == 'e' || Json[Position] == 'E')) + { + ++Position; + if (Position < Json.size() && (Json[Position] == '+' || Json[Position] == '-')) + { + ++Position; + } + const size_t ExponentStart = Position; + while (Position < Json.size() && Json[Position] >= '0' && Json[Position] <= '9') + { + ++Position; + } + if (Position == ExponentStart) + { + return Fail("contract.schema_json.number_invalid", "JSON exponent requires digits"); + } + } + + OutValue.Kind = EJsonKind::Number; + OutValue.Text.assign(Json.substr(Start, Position - Start)); + return true; + } + + bool ParseArray(FJsonValue& OutValue, size_t Depth) + { + ++Position; + OutValue.Kind = EJsonKind::Array; + SkipWhitespace(); + if (Position < Json.size() && Json[Position] == ']') + { + ++Position; + return true; + } + while (true) + { + FJsonValue Element; + if (!ParseValue(Element, Depth + 1)) + { + return false; + } + OutValue.Array.push_back(std::move(Element)); + SkipWhitespace(); + if (Position >= Json.size()) + { + return Fail("contract.schema_json.unexpected_end", "unterminated JSON array"); + } + const char Delimiter = Json[Position++]; + if (Delimiter == ']') + { + return true; + } + if (Delimiter != ',') + { + return Fail("contract.schema_json.array_invalid", "expected comma or array end"); + } + } + } + + bool ParseObject(FJsonValue& OutValue, size_t Depth) + { + ++Position; + OutValue.Kind = EJsonKind::Object; + SkipWhitespace(); + if (Position < Json.size() && Json[Position] == '}') + { + ++Position; + return true; + } + std::unordered_set PropertyNames; + while (true) + { + SkipWhitespace(); + if (Position >= Json.size() || Json[Position] != '"') + { + return Fail("contract.schema_json.property_expected", "expected JSON property name"); + } + std::string Name; + if (!ParseString(Name)) + { + return false; + } + if (!PropertyNames.insert(Name).second) + { + return Fail( + "contract.schema_json.property_duplicate", + "JSON property names must be unique"); + } + SkipWhitespace(); + if (Position >= Json.size() || Json[Position++] != ':') + { + return Fail("contract.schema_json.colon_expected", "expected colon after property name"); + } + FJsonValue Value; + if (!ParseValue(Value, Depth + 1)) + { + return false; + } + OutValue.Object.emplace_back(std::move(Name), std::move(Value)); + SkipWhitespace(); + if (Position >= Json.size()) + { + return Fail("contract.schema_json.unexpected_end", "unterminated JSON object"); + } + const char Delimiter = Json[Position++]; + if (Delimiter == '}') + { + return true; + } + if (Delimiter != ',') + { + return Fail("contract.schema_json.object_invalid", "expected comma or object end"); + } + } + } + + std::string_view Json; + size_t Position = 0; + size_t NodeCount = 0; + FStructuredDiagnostic Diagnostic; + }; + + const FJsonValue* FindProperty(const FJsonValue& Object, std::string_view Name) + { + if (Object.Kind != EJsonKind::Object) + { + return nullptr; + } + const auto Match = std::find_if( + Object.Object.begin(), + Object.Object.end(), + [Name](const auto& Property) + { + return Property.first == Name; + }); + return Match == Object.Object.end() ? nullptr : &Match->second; + } + + bool RequireProperties( + const FJsonValue& Object, + std::initializer_list Names, + std::string_view Path, + FStructuredDiagnostic& OutDiagnostic) + { + if (Object.Kind != EJsonKind::Object) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema_json.type_mismatch", + std::string(Path), + "expected a JSON object"); + return false; + } + if (Object.Object.size() != Names.size()) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema_json.properties_invalid", + std::string(Path), + "schema JSON contains missing or unknown properties"); + return false; + } + for (const std::string_view Name : Names) + { + if (FindProperty(Object, Name) == nullptr) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema_json.property_missing", + std::string(Path) + "." + std::string(Name), + "required schema JSON property is missing"); + return false; + } + } + return true; + } + + bool ReadString( + const FJsonValue& Value, + std::string& OutString, + std::string Path, + FStructuredDiagnostic& OutDiagnostic) + { + if (Value.Kind != EJsonKind::String) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema_json.type_mismatch", + std::move(Path), + "expected a JSON string"); + return false; + } + OutString = Value.Text; + return true; + } + + bool ReadBoolean( + const FJsonValue& Value, + bool& OutBoolean, + std::string Path, + FStructuredDiagnostic& OutDiagnostic) + { + if (Value.Kind != EJsonKind::Boolean) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema_json.type_mismatch", + std::move(Path), + "expected a JSON boolean"); + return false; + } + OutBoolean = Value.Boolean; + return true; + } + + bool ReadUnsigned( + const FJsonValue& Value, + uint64_t& OutUnsigned, + std::string Path, + FStructuredDiagnostic& OutDiagnostic) + { + if (Value.Kind != EJsonKind::Number + || Value.Text.empty() + || Value.Text[0] == '-' + || Value.Text.find_first_of(".eE") != std::string::npos) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema_json.unsigned_expected", + std::move(Path), + "expected an unsigned integer"); + return false; + } + const char* Begin = Value.Text.data(); + const char* End = Begin + Value.Text.size(); + const auto Result = std::from_chars(Begin, End, OutUnsigned); + if (Result.ec != std::errc() || Result.ptr != End) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema_json.unsigned_invalid", + std::move(Path), + "unsigned integer is outside the supported range"); + return false; + } + return true; + } + + bool ReadOptionalUnsigned( + const FJsonValue& Value, + std::optional& OutUnsigned, + std::string Path, + FStructuredDiagnostic& OutDiagnostic) + { + if (Value.Kind == EJsonKind::Null) + { + OutUnsigned.reset(); + return true; + } + uint64_t Parsed = 0; + if (!ReadUnsigned(Value, Parsed, std::move(Path), OutDiagnostic)) + { + return false; + } + OutUnsigned = Parsed; + return true; + } + + bool ReadOptionalNumber( + const FJsonValue& Value, + std::optional& OutNumber, + std::string Path, + FStructuredDiagnostic& OutDiagnostic) + { + if (Value.Kind == EJsonKind::Null) + { + OutNumber.reset(); + return true; + } + if (Value.Kind != EJsonKind::Number) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema_json.number_expected", + std::move(Path), + "expected a JSON number or null"); + return false; + } + double Parsed = 0.0; + const char* Begin = Value.Text.data(); + const char* End = Begin + Value.Text.size(); + const auto Result = std::from_chars(Begin, End, Parsed, std::chars_format::general); + if (Result.ec != std::errc() || Result.ptr != End || !std::isfinite(Parsed)) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema_json.number_invalid", + std::move(Path), + "number is outside the supported finite range"); + return false; + } + OutNumber = Parsed; + return true; + } + + bool ReadVersion( + const FJsonValue& Value, + FContractVersion& OutVersion, + std::string Path, + FStructuredDiagnostic& OutDiagnostic) + { + if (!RequireProperties(Value, { "major", "minor" }, Path, OutDiagnostic)) + { + return false; + } + uint64_t Major = 0; + uint64_t Minor = 0; + if (!ReadUnsigned(*FindProperty(Value, "major"), Major, Path + ".major", OutDiagnostic) + || !ReadUnsigned(*FindProperty(Value, "minor"), Minor, Path + ".minor", OutDiagnostic)) + { + return false; + } + if (Major > std::numeric_limits::max() + || Minor > std::numeric_limits::max()) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema_json.version_invalid", + std::move(Path), + "schema version component exceeds uint16 range"); + return false; + } + OutVersion.Major = static_cast(Major); + OutVersion.Minor = static_cast(Minor); + return true; + } + + std::optional ParseValueKind(std::string_view Value) + { + for (const EValueKind Kind : { + EValueKind::Boolean, + EValueKind::Integer, + EValueKind::Number, + EValueKind::String, + EValueKind::Object, + EValueKind::Array, + EValueKind::Enum, + EValueKind::Reference }) + { + if (ToString(Kind) == Value) + { + return Kind; + } + } + return std::nullopt; + } + + std::optional ParseReferenceScope(std::string_view Value) + { + for (const EReferenceScope Scope : { + EReferenceScope::Persistent, + EReferenceScope::Document, + EReferenceScope::Session }) + { + if (ToString(Scope) == Value) + { + return Scope; + } + } + return std::nullopt; + } + + bool ReadSchema( + const FJsonValue& Value, + FValueSchema& OutSchema, + const std::string& Path, + size_t Depth, + size_t& NodeCount, + FStructuredDiagnostic& OutDiagnostic) + { + if (Depth > MaxSchemaDepth) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema.depth_exceeded", + Path, + "schema nesting exceeds the supported depth"); + return false; + } + if (++NodeCount > MaxSchemaNodes) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema.nodes_exceeded", + Path, + "schema node count exceeds the supported limit"); + return false; + } + if (!RequireProperties( + Value, + { + "id", + "kind", + "description", + "nullable", + "minimum", + "maximum", + "minItems", + "maxItems", + "maxLength", + "unit", + "coordinateSystem", + "referenceScope", + "enumValues", + "fields", + "items" + }, + Path, + OutDiagnostic)) + { + return false; + } + + const FJsonValue& Id = *FindProperty(Value, "id"); + if (!RequireProperties(Id, { "name", "version" }, Path + ".id", OutDiagnostic) + || !ReadString( + *FindProperty(Id, "name"), + OutSchema.Id.Name, + Path + ".id.name", + OutDiagnostic) + || !ReadVersion( + *FindProperty(Id, "version"), + OutSchema.Id.Version, + Path + ".id.version", + OutDiagnostic)) + { + return false; + } + + std::string KindName; + if (!ReadString( + *FindProperty(Value, "kind"), + KindName, + Path + ".kind", + OutDiagnostic)) + { + return false; + } + const std::optional Kind = ParseValueKind(KindName); + if (!Kind.has_value()) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema_json.kind_invalid", + Path + ".kind", + "unknown value schema kind"); + return false; + } + OutSchema.Kind = *Kind; + + if (!ReadString( + *FindProperty(Value, "description"), + OutSchema.Description, + Path + ".description", + OutDiagnostic) + || !ReadBoolean( + *FindProperty(Value, "nullable"), + OutSchema.bNullable, + Path + ".nullable", + OutDiagnostic) + || !ReadOptionalNumber( + *FindProperty(Value, "minimum"), + OutSchema.Minimum, + Path + ".minimum", + OutDiagnostic) + || !ReadOptionalNumber( + *FindProperty(Value, "maximum"), + OutSchema.Maximum, + Path + ".maximum", + OutDiagnostic) + || !ReadOptionalUnsigned( + *FindProperty(Value, "minItems"), + OutSchema.MinItems, + Path + ".minItems", + OutDiagnostic) + || !ReadOptionalUnsigned( + *FindProperty(Value, "maxItems"), + OutSchema.MaxItems, + Path + ".maxItems", + OutDiagnostic) + || !ReadOptionalUnsigned( + *FindProperty(Value, "maxLength"), + OutSchema.MaxLength, + Path + ".maxLength", + OutDiagnostic) + || !ReadString( + *FindProperty(Value, "unit"), + OutSchema.Unit, + Path + ".unit", + OutDiagnostic) + || !ReadString( + *FindProperty(Value, "coordinateSystem"), + OutSchema.CoordinateSystem, + Path + ".coordinateSystem", + OutDiagnostic)) + { + return false; + } + + const FJsonValue& ReferenceScope = *FindProperty(Value, "referenceScope"); + if (ReferenceScope.Kind == EJsonKind::Null) + { + OutSchema.ReferenceScope.reset(); + } + else + { + std::string ScopeName; + if (!ReadString( + ReferenceScope, + ScopeName, + Path + ".referenceScope", + OutDiagnostic)) + { + return false; + } + const std::optional Scope = ParseReferenceScope(ScopeName); + if (!Scope.has_value()) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema_json.reference_scope_invalid", + Path + ".referenceScope", + "unknown reference scope"); + return false; + } + OutSchema.ReferenceScope = *Scope; + } + + const FJsonValue& EnumValues = *FindProperty(Value, "enumValues"); + if (EnumValues.Kind != EJsonKind::Array) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema_json.type_mismatch", + Path + ".enumValues", + "expected a JSON array"); + return false; + } + OutSchema.EnumValues.clear(); + OutSchema.EnumValues.reserve(EnumValues.Array.size()); + for (size_t ValueIndex = 0; ValueIndex < EnumValues.Array.size(); ++ValueIndex) + { + std::string EnumValue; + if (!ReadString( + EnumValues.Array[ValueIndex], + EnumValue, + Path + ".enumValues[" + std::to_string(ValueIndex) + "]", + OutDiagnostic)) + { + return false; + } + OutSchema.EnumValues.push_back(std::move(EnumValue)); + } + + const FJsonValue& Fields = *FindProperty(Value, "fields"); + if (Fields.Kind != EJsonKind::Array) + { + OutDiagnostic = MakeDiagnostic( + "contract.schema_json.type_mismatch", + Path + ".fields", + "expected a JSON array"); + return false; + } + OutSchema.Fields.clear(); + OutSchema.Fields.reserve(Fields.Array.size()); + for (size_t FieldIndex = 0; FieldIndex < Fields.Array.size(); ++FieldIndex) + { + const std::string FieldPath = Path + ".fields[" + std::to_string(FieldIndex) + "]"; + const FJsonValue& FieldValue = Fields.Array[FieldIndex]; + if (!RequireProperties( + FieldValue, + { "name", "required", "schema" }, + FieldPath, + OutDiagnostic)) + { + return false; + } + FSchemaField Field; + if (!ReadString( + *FindProperty(FieldValue, "name"), + Field.Name, + FieldPath + ".name", + OutDiagnostic) + || !ReadBoolean( + *FindProperty(FieldValue, "required"), + Field.bRequired, + FieldPath + ".required", + OutDiagnostic)) + { + return false; + } + Field.Schema = std::make_shared(); + if (!ReadSchema( + *FindProperty(FieldValue, "schema"), + *Field.Schema, + FieldPath + ".schema", + Depth + 1, + NodeCount, + OutDiagnostic)) + { + return false; + } + OutSchema.Fields.push_back(std::move(Field)); + } + + const FJsonValue& Items = *FindProperty(Value, "items"); + if (Items.Kind == EJsonKind::Null) + { + OutSchema.Items.reset(); + } + else + { + OutSchema.Items = std::make_shared(); + if (!ReadSchema( + Items, + *OutSchema.Items, + Path + ".items", + Depth + 1, + NodeCount, + OutDiagnostic)) + { + return false; + } + } + return true; + } + } + + bool FSchemaField::operator==(const FSchemaField& Other) const + { + return Name == Other.Name + && bRequired == Other.bRequired + && EqualSchemaPointer(Schema, Other.Schema); + } + + bool FValueSchema::operator==(const FValueSchema& Other) const + { + return Id == Other.Id + && Kind == Other.Kind + && Description == Other.Description + && bNullable == Other.bNullable + && Minimum == Other.Minimum + && Maximum == Other.Maximum + && MinItems == Other.MinItems + && MaxItems == Other.MaxItems + && MaxLength == Other.MaxLength + && Unit == Other.Unit + && CoordinateSystem == Other.CoordinateSystem + && ReferenceScope == Other.ReferenceScope + && EnumValues == Other.EnumValues + && Fields == Other.Fields + && EqualSchemaPointer(Items, Other.Items); + } + + std::string_view ToString(EReferenceScope Scope) + { + switch (Scope) + { + case EReferenceScope::Persistent: + return "persistent"; + case EReferenceScope::Document: + return "document"; + case EReferenceScope::Session: + return "session"; + } + return "unknown"; + } + + std::string_view ToString(ECapabilityEffect Effect) + { + switch (Effect) + { + case ECapabilityEffect::Query: + return "query"; + case ECapabilityEffect::Command: + return "command"; + case ECapabilityEffect::Job: + return "job"; + case ECapabilityEffect::ExternalAction: + return "external_action"; + } + return "unknown"; + } + + std::string_view ToString(EValueKind Kind) + { + switch (Kind) + { + case EValueKind::Boolean: + return "boolean"; + case EValueKind::Integer: + return "integer"; + case EValueKind::Number: + return "number"; + case EValueKind::String: + return "string"; + case EValueKind::Object: + return "object"; + case EValueKind::Array: + return "array"; + case EValueKind::Enum: + return "enum"; + case EValueKind::Reference: + return "reference"; + } + return "unknown"; + } + + EChangeCursorStatus EvaluateChangeCursor( + const FChangeCursor& Cursor, + std::string_view ExpectedProvider, + uint16_t ExpectedSchemaMajor, + uint64_t FirstAvailableSequence) + { + if (Cursor.bExpired + || Cursor.bGapDetected + || Cursor.Provider != ExpectedProvider + || Cursor.SchemaVersion.Major != ExpectedSchemaMajor + || Cursor.NextSequence < FirstAvailableSequence) + { + return EChangeCursorStatus::ResyncRequired; + } + return EChangeCursorStatus::Current; + } + + std::optional ValidateSchema(const FValueSchema& Schema) + { + size_t NodeCount = 0; + std::unordered_set ActiveSchemas; + return ValidateSchemaRecursive(Schema, "$", 1, NodeCount, ActiveSchemas, true); + } + + std::optional ValidateDescriptor( + const FCapabilityDescriptor& Descriptor) + { + if (Descriptor.Name.empty()) + { + return MakeDiagnostic( + "contract.descriptor.name_required", + "$.name", + "capability name is required"); + } + if (Descriptor.Version.Major == 0) + { + return MakeDiagnostic( + "contract.descriptor.version_invalid", + "$.version.major", + "capability major version must be greater than zero"); + } + if (Descriptor.Stability.empty() + || Descriptor.Summary.empty() + || Descriptor.Permission.empty() + || Descriptor.Risk.empty()) + { + return MakeDiagnostic( + "contract.descriptor.metadata_required", + "$", + "stability, summary, permission, and risk are required"); + } + if (Descriptor.MinimumReaderVersion.Major == 0 + || Descriptor.MinimumWriterVersion.Major == 0) + { + return MakeDiagnostic( + "contract.descriptor.compatibility_version_invalid", + "$", + "minimum reader and writer major versions must be greater than zero"); + } + if (!Descriptor.bAvailable && Descriptor.AvailabilityReason.empty()) + { + return MakeDiagnostic( + "contract.descriptor.availability_reason_required", + "$.availabilityReason", + "unavailable capability must provide a reason"); + } + if (auto Diagnostic = ValidateSchema(Descriptor.InputSchema)) + { + Diagnostic->Path = "$.inputSchema" + Diagnostic->Path.substr(1); + return Diagnostic; + } + if (auto Diagnostic = ValidateSchema(Descriptor.OutputSchema)) + { + Diagnostic->Path = "$.outputSchema" + Diagnostic->Path.substr(1); + return Diagnostic; + } + if (Descriptor.Budget.MaxInputBytes == 0 + || Descriptor.Budget.MaxOutputBytes == 0 + || Descriptor.Budget.TimeoutMilliseconds == 0 + || Descriptor.Budget.MaxConcurrency == 0 + || Descriptor.Budget.MaxInputBytes > MaxDescriptorPayloadBytes + || Descriptor.Budget.MaxOutputBytes > MaxDescriptorPayloadBytes + || Descriptor.Budget.TimeoutMilliseconds > MaxDescriptorTimeoutMilliseconds + || Descriptor.Budget.MaxConcurrency > MaxDescriptorConcurrency) + { + return MakeDiagnostic( + "contract.descriptor.budget_invalid", + "$.budget", + "capability resource budget is missing or outside supported bounds"); + } + if (Descriptor.bSupportsUndo + && (Descriptor.Effect != ECapabilityEffect::Command + || Descriptor.ReferenceScope != EReferenceScope::Document)) + { + return MakeDiagnostic( + "contract.descriptor.undo_invalid", + "$.supportsUndo", + "undo is valid only for document commands"); + } + if (Descriptor.bSupportsUndo && Descriptor.bSupportsCompensation) + { + return MakeDiagnostic( + "contract.descriptor.recovery_ambiguous", + "$", + "capability cannot claim both undo and compensation"); + } + if (Descriptor.Effect == ECapabilityEffect::Query + && (Descriptor.bSupportsPreview + || Descriptor.bSupportsUndo + || Descriptor.bSupportsCompensation + || Descriptor.bSupportsCancel)) + { + return MakeDiagnostic( + "contract.descriptor.effect_options_invalid", + "$.effect", + "query cannot claim mutation, recovery, or cancellation controls"); + } + if ((Descriptor.Effect == ECapabilityEffect::Job + || Descriptor.Effect == ECapabilityEffect::ExternalAction) + && Descriptor.bSupportsUndo) + { + return MakeDiagnostic( + "contract.descriptor.effect_options_invalid", + "$.supportsUndo", + "job and external action cannot claim transaction undo"); + } + return std::nullopt; + } + + std::string SerializeSchemaCanonicalJson(const FValueSchema& Schema) + { + if (ValidateSchema(Schema).has_value()) + { + return {}; + } + std::string Output; + Output.reserve(1024); + if (!AppendSchemaJson(Output, Schema) || Output.size() > MaxSchemaJsonBytes) + { + return {}; + } + return Output; + } + + bool DeserializeSchemaCanonicalJson( + std::string_view Json, + FValueSchema& OutSchema, + FStructuredDiagnostic& OutDiagnostic) + { + FJsonValue Root; + FBoundedJsonParser Parser(Json); + if (!Parser.Parse(Root, OutDiagnostic)) + { + return false; + } + + FValueSchema ParsedSchema; + size_t NodeCount = 0; + if (!ReadSchema(Root, ParsedSchema, "$", 1, NodeCount, OutDiagnostic)) + { + return false; + } + if (auto Diagnostic = ValidateSchema(ParsedSchema)) + { + OutDiagnostic = std::move(*Diagnostic); + return false; + } + OutSchema = std::move(ParsedSchema); + OutDiagnostic = {}; + return true; + } +} diff --git a/Engine/Source/Contract/ContractCore.h b/Engine/Source/Contract/ContractCore.h new file mode 100644 index 0000000..933c32d --- /dev/null +++ b/Engine/Source/Contract/ContractCore.h @@ -0,0 +1,243 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace WaveContract +{ + inline constexpr size_t MaxSchemaJsonBytes = 256 * 1024; + inline constexpr size_t MaxSchemaDepth = 32; + inline constexpr size_t MaxSchemaNodes = 4096; + + struct FContractVersion + { + uint16_t Major = 1; + uint16_t Minor = 0; + + auto operator<=>(const FContractVersion&) const = default; + }; + + struct FSchemaId + { + std::string Name; + FContractVersion Version; + + bool operator==(const FSchemaId&) const = default; + }; + + enum class EReferenceScope : uint8_t + { + Persistent, + Document, + Session + }; + + struct FObjectRef + { + std::string Kind; + std::string Id; + EReferenceScope Scope = EReferenceScope::Session; + std::string Project; + + bool operator==(const FObjectRef&) const = default; + }; + + struct FRevision + { + std::string Domain; + uint64_t Value = 0; + FContractVersion SchemaVersion; + + bool operator==(const FRevision&) const = default; + }; + + struct FSnapshotMeta + { + FRevision SourceRevision; + uint64_t FreshnessMilliseconds = 0; + bool bTruncated = false; + std::string OmittedSummary; + }; + + struct FChangeCursor + { + std::string Provider; + FContractVersion SchemaVersion; + uint64_t NextSequence = 0; + FRevision SourceRevision; + bool bExpired = false; + bool bGapDetected = false; + }; + + enum class EChangeCursorStatus : uint8_t + { + Current, + ResyncRequired + }; + + enum class EDiagnosticSeverity : uint8_t + { + Info, + Warning, + Error + }; + + struct FStructuredDiagnostic + { + std::string Code; + std::string Path; + std::string Message; + EDiagnosticSeverity Severity = EDiagnosticSeverity::Error; + bool bRetryable = false; + std::string Details; + std::string CorrelationId; + + bool operator==(const FStructuredDiagnostic&) const = default; + }; + + enum class ECapabilityEffect : uint8_t + { + Query, + Command, + Job, + ExternalAction + }; + + enum class EValueKind : uint8_t + { + Boolean, + Integer, + Number, + String, + Object, + Array, + Enum, + Reference + }; + + struct FValueSchema; + + struct FSchemaField + { + std::string Name; + bool bRequired = false; + std::shared_ptr Schema; + + bool operator==(const FSchemaField&) const; + }; + + struct FValueSchema + { + FSchemaId Id; + EValueKind Kind = EValueKind::Object; + std::string Description; + bool bNullable = false; + std::optional Minimum; + std::optional Maximum; + std::optional MinItems; + std::optional MaxItems; + std::optional MaxLength; + std::string Unit; + std::string CoordinateSystem; + std::optional ReferenceScope; + std::vector EnumValues; + std::vector Fields; + std::shared_ptr Items; + + bool operator==(const FValueSchema&) const; + }; + + struct FResourceBudget + { + uint64_t MaxInputBytes = 0; + uint64_t MaxOutputBytes = 0; + uint64_t TimeoutMilliseconds = 0; + uint32_t MaxConcurrency = 0; + + bool operator==(const FResourceBudget&) const = default; + }; + + struct FCapabilityDescriptor + { + std::string Name; + FContractVersion Version; + std::string Stability; + std::string DeprecatedReplacement; + FContractVersion MinimumReaderVersion; + FContractVersion MinimumWriterVersion; + std::string Summary; + ECapabilityEffect Effect = ECapabilityEffect::Query; + FValueSchema InputSchema; + FValueSchema OutputSchema; + std::string Permission; + std::string Risk; + bool bAvailable = true; + std::string AvailabilityReason; + bool bSupportsPreview = false; + bool bSupportsUndo = false; + bool bSupportsCompensation = false; + bool bIdempotent = false; + bool bSupportsCancel = false; + bool bSupportsRetry = false; + FResourceBudget Budget; + std::string CoordinateSystem; + std::string Units; + std::optional ReferenceScope; + }; + + struct FArtifactRef + { + std::string ContentHash; + std::string Type; + std::string LocationPolicy; + std::string Lifecycle; + std::string Access; + }; + + struct FProvenance + { + std::string Producer; + std::string ProducerVersion; + std::vector Inputs; + std::string ParameterDigest; + std::optional Seed; + std::string License; + bool bHumanReviewed = false; + }; + + struct FEvidenceBundle + { + std::string RequestId; + std::string Capability; + FRevision BeforeRevision; + FRevision AfterRevision; + std::vector ValidatorResults; + std::vector Artifacts; + }; + + [[nodiscard]] std::string_view ToString(EReferenceScope Scope); + [[nodiscard]] std::string_view ToString(ECapabilityEffect Effect); + [[nodiscard]] std::string_view ToString(EValueKind Kind); + + [[nodiscard]] EChangeCursorStatus EvaluateChangeCursor( + const FChangeCursor& Cursor, + std::string_view ExpectedProvider, + uint16_t ExpectedSchemaMajor, + uint64_t FirstAvailableSequence); + + [[nodiscard]] std::optional ValidateSchema(const FValueSchema& Schema); + [[nodiscard]] std::optional ValidateDescriptor( + const FCapabilityDescriptor& Descriptor); + + [[nodiscard]] std::string SerializeSchemaCanonicalJson(const FValueSchema& Schema); + [[nodiscard]] bool DeserializeSchemaCanonicalJson( + std::string_view Json, + FValueSchema& OutSchema, + FStructuredDiagnostic& OutDiagnostic); +} diff --git a/Tests/ContractCoreContracts.cpp b/Tests/ContractCoreContracts.cpp new file mode 100644 index 0000000..f8eb58f --- /dev/null +++ b/Tests/ContractCoreContracts.cpp @@ -0,0 +1,228 @@ +#include "ContractCoreContracts.h" + +#include "Contract/ContractCore.h" + +#include +#include +#include + +namespace WaveContracts +{ + namespace + { + using namespace WaveContract; + + void Require(bool bCondition, std::string_view Message) + { + if (!bCondition) + { + throw std::runtime_error(std::string(Message)); + } + } + + std::shared_ptr MakeNumberSchema( + std::string Description, + double Minimum, + double Maximum) + { + auto Schema = std::make_shared(); + Schema->Kind = EValueKind::Number; + Schema->Description = std::move(Description); + Schema->Minimum = Minimum; + Schema->Maximum = Maximum; + return Schema; + } + + FValueSchema MakeInputSchema() + { + FValueSchema Schema; + Schema.Id = { "wave.contract.tests.transform_input", { 1, 2 } }; + Schema.Kind = EValueKind::Object; + Schema.Description = "Named transform-edit input"; + Schema.CoordinateSystem = "wave-left-handed-z-up"; + + auto TargetSchema = std::make_shared(); + TargetSchema->Kind = EValueKind::Reference; + TargetSchema->Description = "Document object target"; + TargetSchema->ReferenceScope = EReferenceScope::Document; + + auto WeightsSchema = std::make_shared(); + WeightsSchema->Kind = EValueKind::Array; + WeightsSchema->Description = "Normalized weights"; + WeightsSchema->MinItems = 1; + WeightsSchema->MaxItems = 4; + WeightsSchema->Items = MakeNumberSchema("Normalized scalar", 0.0, 1.0); + + auto ModeSchema = std::make_shared(); + ModeSchema->Kind = EValueKind::Enum; + ModeSchema->Description = "Edit mode"; + ModeSchema->EnumValues = { "replace", "add" }; + + Schema.Fields = { + { "target", true, std::move(TargetSchema) }, + { "weights", true, std::move(WeightsSchema) }, + { "mode", false, std::move(ModeSchema) } + }; + return Schema; + } + + FValueSchema MakeOutputSchema() + { + FValueSchema Schema; + Schema.Id = { "wave.contract.tests.transform_output", { 1, 0 } }; + Schema.Kind = EValueKind::Object; + Schema.Description = "Transform-edit result"; + + auto RevisionSchema = std::make_shared(); + RevisionSchema->Kind = EValueKind::Integer; + RevisionSchema->Description = "Resulting document revision"; + RevisionSchema->Minimum = 0.0; + Schema.Fields = { + { "revision", true, std::move(RevisionSchema) } + }; + return Schema; + } + + FCapabilityDescriptor MakeValidDescriptor() + { + FCapabilityDescriptor Descriptor; + Descriptor.Name = "world.transform.describe"; + Descriptor.Version = { 1, 0 }; + Descriptor.Stability = "experimental"; + Descriptor.MinimumReaderVersion = { 1, 0 }; + Descriptor.MinimumWriterVersion = { 1, 0 }; + Descriptor.Summary = "Describe a transform edit without mutating the world"; + Descriptor.Effect = ECapabilityEffect::Query; + Descriptor.InputSchema = MakeInputSchema(); + Descriptor.OutputSchema = MakeOutputSchema(); + Descriptor.Permission = "world.read"; + Descriptor.Risk = "low"; + Descriptor.bIdempotent = true; + Descriptor.bSupportsRetry = true; + Descriptor.Budget = { + 64 * 1024, + 256 * 1024, + 1000, + 8 + }; + Descriptor.CoordinateSystem = "wave-left-handed-z-up"; + Descriptor.Units = "engine-units"; + Descriptor.ReferenceScope = EReferenceScope::Document; + return Descriptor; + } + } + + int RunContractCoreContracts() + { + using namespace WaveContract; + + const FContractVersion Version10{ 1, 0 }; + const FContractVersion Version11{ 1, 1 }; + const FContractVersion Version20{ 2, 0 }; + Require(Version10 < Version11 && Version11 < Version20, "contract version ordering"); + Require(ToString(EReferenceScope::Document) == "document", "reference scope spelling"); + Require(ToString(ECapabilityEffect::ExternalAction) == "external_action", + "effect spelling"); + + const FValueSchema InputSchema = MakeInputSchema(); + Require(!ValidateSchema(InputSchema).has_value(), "valid schema rejected"); + + const std::string CanonicalJson = SerializeSchemaCanonicalJson(InputSchema); + Require(!CanonicalJson.empty(), "valid schema serialization failed"); + Require(CanonicalJson.size() <= MaxSchemaJsonBytes, "schema JSON byte bound"); + + FValueSchema RoundTrippedSchema; + FStructuredDiagnostic Diagnostic; + Require( + DeserializeSchemaCanonicalJson(CanonicalJson, RoundTrippedSchema, Diagnostic), + "canonical schema JSON did not deserialize"); + Require(RoundTrippedSchema == InputSchema, "schema JSON round-trip changed the schema"); + Require( + SerializeSchemaCanonicalJson(RoundTrippedSchema) == CanonicalJson, + "schema JSON serialization is not canonical"); + + FValueSchema IgnoredSchema; + Require( + !DeserializeSchemaCanonicalJson("{\"id\":", IgnoredSchema, Diagnostic), + "malformed schema JSON was accepted"); + Require( + Diagnostic.Code.starts_with("contract.schema_json."), + "malformed schema JSON diagnostic code is unstable"); + + const std::string OversizedJson(MaxSchemaJsonBytes + 1, ' '); + Require( + !DeserializeSchemaCanonicalJson(OversizedJson, IgnoredSchema, Diagnostic), + "oversized schema JSON was accepted"); + Require( + Diagnostic.Code == "contract.schema_json.size_exceeded", + "oversized schema JSON diagnostic code"); + + FValueSchema DuplicateFieldSchema = MakeInputSchema(); + DuplicateFieldSchema.Fields.push_back(DuplicateFieldSchema.Fields.front()); + const std::optional DuplicateDiagnostic = + ValidateSchema(DuplicateFieldSchema); + Require(DuplicateDiagnostic.has_value(), "duplicate schema field was accepted"); + Require( + DuplicateDiagnostic->Code == "contract.schema.field_duplicate", + "duplicate schema field diagnostic code"); + Require( + SerializeSchemaCanonicalJson(DuplicateFieldSchema).empty(), + "invalid schema was serialized"); + + FCapabilityDescriptor Descriptor = MakeValidDescriptor(); + Require(!ValidateDescriptor(Descriptor).has_value(), "valid descriptor rejected"); + + Descriptor.bSupportsUndo = true; + const std::optional UndoDiagnostic = ValidateDescriptor(Descriptor); + Require(UndoDiagnostic.has_value(), "query descriptor with undo was accepted"); + Require( + UndoDiagnostic->Code == "contract.descriptor.effect_options_invalid" + || UndoDiagnostic->Code == "contract.descriptor.undo_invalid", + "invalid query descriptor diagnostic code"); + + Descriptor = MakeValidDescriptor(); + Descriptor.bAvailable = false; + Descriptor.AvailabilityReason.clear(); + const std::optional AvailabilityDiagnostic = + ValidateDescriptor(Descriptor); + Require(AvailabilityDiagnostic.has_value(), "unavailable descriptor without reason accepted"); + Require( + AvailabilityDiagnostic->Code == "contract.descriptor.availability_reason_required", + "availability descriptor diagnostic code"); + + FChangeCursor Cursor; + Cursor.Provider = "world.change_feed"; + Cursor.SchemaVersion = { 1, 3 }; + Cursor.NextSequence = 42; + Cursor.SourceRevision = { "world", 17, { 4, 0 } }; + Require( + EvaluateChangeCursor(Cursor, "world.change_feed", 1, 40) + == EChangeCursorStatus::Current, + "current change cursor requires resync"); + Require( + EvaluateChangeCursor(Cursor, "asset.change_feed", 1, 40) + == EChangeCursorStatus::ResyncRequired, + "provider mismatch did not require resync"); + Require( + EvaluateChangeCursor(Cursor, "world.change_feed", 2, 40) + == EChangeCursorStatus::ResyncRequired, + "schema major mismatch did not require resync"); + Require( + EvaluateChangeCursor(Cursor, "world.change_feed", 1, 43) + == EChangeCursorStatus::ResyncRequired, + "expired sequence window did not require resync"); + Cursor.bGapDetected = true; + Require( + EvaluateChangeCursor(Cursor, "world.change_feed", 1, 40) + == EChangeCursorStatus::ResyncRequired, + "cursor gap did not require resync"); + Cursor.bGapDetected = false; + Cursor.bExpired = true; + Require( + EvaluateChangeCursor(Cursor, "world.change_feed", 1, 40) + == EChangeCursorStatus::ResyncRequired, + "expired cursor did not require resync"); + + return 0; + } +} diff --git a/Tests/ContractCoreContracts.h b/Tests/ContractCoreContracts.h new file mode 100644 index 0000000..73c471e --- /dev/null +++ b/Tests/ContractCoreContracts.h @@ -0,0 +1,6 @@ +#pragma once + +namespace WaveContracts +{ + int RunContractCoreContracts(); +} diff --git a/Tests/CoreRuntimeContracts.cpp b/Tests/CoreRuntimeContracts.cpp index f2857db..c0289d8 100644 --- a/Tests/CoreRuntimeContracts.cpp +++ b/Tests/CoreRuntimeContracts.cpp @@ -1,5 +1,7 @@ #include "CoreRuntimeContracts.h" +#include "ContractCoreContracts.h" + #include "Core/Core.h" #include "Core/Math.h" #include "Misc/Log.h" @@ -76,6 +78,7 @@ namespace WaveContracts } check(WaveTrace::RunTraceContracts() == 0, "trace codec contracts"); + check(RunContractCoreContracts() == 0, "Contract Core contracts"); check(false, "runtime check contract"); } 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 890e82e..ec28fcf 100644 --- a/docs/plans/2026-07-25-game-engine-production-roadmap.md +++ b/docs/plans/2026-07-25-game-engine-production-roadmap.md @@ -288,7 +288,7 @@ 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-player-targets.md) | Runtime/Editor/Player Target 图 | 可与 M0-01 并行 | Accepted | -| [A0-01](../specs/2026-07-25-a0-01-contract-core.md) | AI-Native Contract Core 与领域能力注册 | Slice 1~3:M0-01;Slice 4:G0-01 | Accepted | +| [A0-01](../specs/2026-07-25-a0-01-contract-core.md) | AI-Native Contract Core 与领域能力注册 | Slice 1~3:M0-01;Slice 4:G0-01 | Implementing | | [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 | | [A0-02](../specs/2026-07-25-a0-02-developer-host.md) | Developer Host:Session、Policy、Operation 与 Audit | A0-01、G0-02、G0-03 | Accepted | diff --git a/docs/specs/2026-07-25-a0-01-contract-core.md b/docs/specs/2026-07-25-a0-01-contract-core.md index 09b8cf3..77e68d9 100644 --- a/docs/specs/2026-07-25-a0-01-contract-core.md +++ b/docs/specs/2026-07-25-a0-01-contract-core.md @@ -1,9 +1,9 @@ # A0-01:AI-Native Contract Core 与领域能力注册 - Spec ID: `A0-01` -- Status: Accepted +- Status: Implementing - Created: 2026-07-25 -- Updated: 2026-07-25 +- Updated: 2026-07-26 - Roadmap: [AI-native 横向设计](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) - Architecture: [AI-Native Engine Architecture](../architecture/ai-native-engine.md) - Depends on: 设计可独立审查;实施 Slice 1~3 依赖 [M0-01](./2026-07-25-m0-01-cpu-contract-baseline.md),Slice 4 依赖 [G0-01](./2026-07-25-g0-01-runtime-editor-player-targets.md) @@ -13,7 +13,7 @@ - Supersedes: None - Superseded by: None -> 本 Spec 已获用户明确接受;实施按依赖顺序等待 M0-01 与 G0-01 的对应前置完成。 +> 本 Spec 已获用户明确接受;M0-01 已 Verified,Slice 1 已通过独立 Debug/Release CPU 契约,当前准备实施 Slice 2。Slice 4 仍等待 G0-01。 ## 1. Context @@ -28,8 +28,9 @@ **Current facts** -- 当前只有单体 `WaveEngine` Editor executable;`WavePlayer`、Headless Developer host、Contract Core 和领域 capability federation 尚不存在。 -- 当前 `CoreRuntimeContracts` 虽在窗口/RHI 初始化前返回,但仍编译和链接完整 `WaveEngine`、Engine PCH、DX12/GLFW 与 Editor 闭包;尚无独立 CPU contract target 或仓库 CMake preset/CI。 +- 当前产品仍只有单体 `WaveEngine` Editor executable;`WavePlayer`、Headless Developer host 和领域 capability federation 尚不存在。 +- M0-01 已提供无 Engine PCH、窗口、RHI、Editor 或第三方依赖的 `WaveCoreContracts`,并由 CMake preset 与 Windows CPU CI 固定。 +- Contract Core Slice 1 已提供 schema、descriptor、effect/error/ref/revision/change cursor、artifact/provenance/evidence 公共词汇,以及有界规范 schema JSON 与基础校验;尚无领域 registry/handler。 - 已有 stdio JSON-RPC 2.0 server、`FMcpRegistry`、主线程 Dispatcher 和输入上限。 - MCP 工具由 `FEditorShell` 和 Panel 注册,handler 直接调用 UI owner;没有 transport-neutral domain service。 - `rpc.discover` 只返回方法名。Registry 的 read-only 标记不控制并发或权限;所有调用仍同步切到主线程。 @@ -234,7 +235,7 @@ Start Job → Produce Staged Artifact → Validate → Promote with Command | Slice | Scope | Invariant | Verification | Status | |---|---|---|---|---| -| 1 | `FValueSchema`、descriptor、effect/error/ref/revision/change-feed 基础类型 | 不依赖 MCP、UI 或模型 SDK | schema round-trip、version/cursor/resync、invalid descriptor contract | Pending | +| 1 | `FValueSchema`、descriptor、effect/error/ref/revision/change-feed 基础类型 | 不依赖 MCP、UI 或模型 SDK | schema round-trip、version/cursor/resync、invalid descriptor contract | Verified | | 2 | Registry 绑定 descriptor/handler;描述现有工具 | descriptor 与实际参数/效果不漂移 | registration、duplicate/version/effect tests | Pending | | 3 | 新 capability list/describe API 与结构化错误 adapter | 旧 `rpc.discover`、位置参数和 framing 不回退 | existing MCP contracts + new schema/error contracts | Pending | | 4 | target/header closure 与文档迁移 | Player/Runtime 无 Developer host/MCP closure | G0 boundary contract、Debug/Release | Pending | @@ -294,12 +295,14 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac - 已核对 CMake target、MCP Registry/Server、Editor Command Stack、WEMesh provenance 和 WaveTrace 能力。 - 已进一步否定“单一 AI/Automation 模块”模型,改为领域自带契约、公共 Core 只提供 vocabulary/federation。 - 已建立跨所有 Full SDD Spec 的 AI-Native Impact 门禁。 -- 尚未修改生产代码。 +- M0-01 已 Verified;A0-01 进入 Implementing。 +- Slice 1 已实现 `ContractCore` 公共类型、schema/descriptor 校验、有界规范 schema JSON、cursor resync 语义,并接入无 PCH 的 `WaveCoreContracts`。 +- Slice 1 在 Debug/Release CPU presets 下各通过 5/5 tests,静态闭包确认无 MCP/UI/RHI/第三方依赖。 ### Next Step -- 用户审查并明确接受 A0-01;Slice 1~3 等待 M0-01 Verified,target closure 的 Slice 4 等待 G0-01。 -- A0-02~A0-04 Draft 已建立;继续按前置关系审查,领域能力进入对应 G Spec,不在 A0 中集中实现。 +- 实施 Slice 2:以领域 ownership 为边界绑定 descriptor/handler/validator,并增加 registration、duplicate/version/effect contracts。 +- Slice 3 在 Slice 2 验证后继续;target closure 的 Slice 4 等待 G0-01。 ### Changes and Deviations @@ -309,6 +312,7 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac - 采用 staging + artifact promotion,而非默认通用 Layer 或全局 transaction。 - 增加 schema/capability/格式独立演进、revisioned change feed 和多 session 冲突边界。 - 已把 Developer Host、Context/Evidence federation、独立 WaveDevHost 与 Agent Eval 的后继边界固化到 A0-02~A0-04。 +- Slice 1 仅提供 transport-neutral vocabulary 与确定性校验;未引入 registry、MCP adapter、领域 handler 或任何模型 SDK。 ### Evidence @@ -320,9 +324,10 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac | 2026-07-25 | working tree | Design review | 对照仓库内 881 行参考快照,逐项判断采用、改造或延期 | Pass | Architecture 取舍表;本 Spec 第 1、5、6 节 | | 2026-07-25 | working tree | Python 3 / Debug CTest | `py -3 Tests/spec_contracts.py .`;`ctest --test-dir Build -C Debug --output-on-failure` | Pass | 2 Specs;4/4 CTest | | 2026-07-25 | working tree | Downstream design review | A0-02 Session/Policy/Operation/Audit;A0-03 Context/Evidence/Artifact;A0-04 Headless/Conformance/Eval | Pass | 3 downstream Drafts 与依赖矩阵 | +| 2026-07-26 | working tree | VS 2022 / Windows SDK 10.0.26100.0 / C++20 | `cmake --build Build --config Debug`;`cmake --build Build --config Release`;`ctest --preset test-cpu-debug`;`ctest --preset test-cpu-release` | Pass | 完整 Debug/Release build;CPU Debug 5/5、Release 5/5;schema canonical round-trip、invalid descriptor、version/cursor/resync contracts | +| 2026-07-26 | working tree | Python 3 / static source closure | `py -3 Tests/spec_contracts.py .`;`py -3 Tests/core_contract_target_contracts.py .` | Pass | 8 Specs;16 explicit sources / 29 project files;无 forbidden dependency/PCH | ### Remaining Work -- 用户审查并明确接受 Draft。 -- 所有 Delivery Slices 与 Acceptance Criteria。 +- Slice 2~4 与 Acceptance Criteria;其中 Slice 4 等待 G0-01。 - A0-02~A0-04 的接受、产品依赖与实现。 diff --git a/docs/specs/README.md b/docs/specs/README.md index 12e7e66..85dba9f 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -17,7 +17,7 @@ Draft / Accepted / Implementing --被替代--> Superseded | Spec ID | Status | Title | Roadmap | Updated | |---|---|---|---|---| | [M0-01](./2026-07-25-m0-01-cpu-contract-baseline.md) | Verified | 可复现 CPU Contract 与无窗口测试基线 | [M0](../plans/2026-07-25-game-engine-production-roadmap.md#4-路线与依赖) | 2026-07-25 | -| [A0-01](./2026-07-25-a0-01-contract-core.md) | Accepted | AI-Native Contract Core 与领域能力注册 | [AI-native track](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) | 2026-07-25 | +| [A0-01](./2026-07-25-a0-01-contract-core.md) | Implementing | AI-Native Contract Core 与领域能力注册 | [AI-native track](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) | 2026-07-26 | | [A0-02](./2026-07-25-a0-02-developer-host.md) | Accepted | Developer Host Session、Policy、Operation 与 Audit | [AI-native track](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) | 2026-07-25 | | [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 | From 26438ffff1eb6c4c006badc30f1bf8a7e483be48 Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sun, 26 Jul 2026 12:20:15 +0800 Subject: [PATCH 06/16] bind capability registry contracts --- CMakeLists.txt | 15 + Engine/Source/Contract/CapabilityRegistry.h | 340 ++++++++++++++++++ Engine/Source/Contract/ContractCore.cpp | 50 +++ Engine/Source/Contract/ContractCore.h | 3 + Engine/Source/Mcp/McpRegistry.cpp | 128 +++++++ Engine/Source/Mcp/McpRegistry.h | 311 +++++++++++++++- Engine/Source/UI/EditorShell.cpp | 57 ++- Engine/Source/UI/Panels/AssetsPanel.cpp | 15 +- .../Source/UI/Panels/EngineSettingsPanel.cpp | 35 +- Engine/Source/UI/Panels/HierarchyPanel.cpp | 28 +- Engine/Source/UI/Panels/InspectorPanel.cpp | 21 +- Engine/Source/UI/Panels/ProfilerPanel.cpp | 56 ++- Engine/Source/UI/Panels/ViewportPanel.cpp | 35 +- Tests/ContractCoreContracts.cpp | 251 +++++++++++++ Tests/mcp_asset_input_contracts.py | 18 +- .../mcp_capability_registration_contracts.py | 167 +++++++++ docs/specs/2026-07-25-a0-01-contract-core.md | 22 +- 17 files changed, 1501 insertions(+), 51 deletions(-) create mode 100644 Engine/Source/Contract/CapabilityRegistry.h create mode 100644 Tests/mcp_capability_registration_contracts.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 6cc944e..23effd7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -94,6 +94,7 @@ set(WAVE_CORE_CONTRACT_SOURCES "${PROJECT_SOURCE_DIR}/Tests/ContractCoreContracts.h" "${PROJECT_SOURCE_DIR}/Tests/ContractCoreContracts.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/Engine.cpp" @@ -305,6 +306,20 @@ if(BUILD_TESTING) LABELS "contract;core;cpu" ) + add_test( + NAME WaveEngine.McpCapabilityRegistrationContracts + COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/Tests/mcp_capability_registration_contracts.py" + "${PROJECT_SOURCE_DIR}" + ) + set_tests_properties( + WaveEngine.McpCapabilityRegistrationContracts + PROPERTIES + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + TIMEOUT 10 + LABELS "contract;mcp;cpu" + ) + add_test( NAME WaveEngine.McpAssetInputContracts COMMAND "${Python3_EXECUTABLE}" diff --git a/Engine/Source/Contract/CapabilityRegistry.h b/Engine/Source/Contract/CapabilityRegistry.h new file mode 100644 index 0000000..953fc8d --- /dev/null +++ b/Engine/Source/Contract/CapabilityRegistry.h @@ -0,0 +1,340 @@ +#pragma once + +#include "Contract/ContractCore.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace WaveContract +{ + struct FCapabilityKey + { + std::string Name; + FContractVersion Version; + + auto operator<=>(const FCapabilityKey&) const = default; + }; + + template + struct TCapabilityRegistration + { + using FHandler = std::function; + using FValidator = + std::function(const TRequest&)>; + + std::string Provider; + FCapabilityDescriptor Descriptor; + FHandler Handler; + FValidator Validator; + std::shared_ptr Lifetime; + }; + + template + struct TCapabilityInvocationResult + { + std::optional Value; + std::vector Diagnostics; + + [[nodiscard]] bool Succeeded() const + { + return Value.has_value(); + } + }; + + template + class TCapabilityRegistry + { + public: + using FRegistration = TCapabilityRegistration; + using FInvocationResult = TCapabilityInvocationResult; + + [[nodiscard]] std::optional Register( + FRegistration Registration) + { + if (Registration.Provider.empty()) + { + return MakeRegistryDiagnostic( + "contract.registry.provider_required", + "$.provider", + "capability provider is required"); + } + if (!Registration.Handler) + { + return MakeRegistryDiagnostic( + "contract.registry.handler_required", + "$.handler", + "capability handler must be registered with its descriptor"); + } + if (!Registration.Validator) + { + return MakeRegistryDiagnostic( + "contract.registry.validator_required", + "$.validator", + "capability validator must be registered with its descriptor"); + } + if (auto Diagnostic = ValidateDescriptor(Registration.Descriptor)) + { + Diagnostic->Path = "$.descriptor" + Diagnostic->Path.substr(1); + return Diagnostic; + } + Registration.Descriptor = CloneDescriptor(Registration.Descriptor); + + const FCapabilityKey Key{ + Registration.Descriptor.Name, + Registration.Descriptor.Version + }; + std::lock_guard Lock(Mutex); + if (Entries.contains(Key)) + { + return MakeRegistryDiagnostic( + "contract.registry.duplicate", + "$.descriptor.version", + "capability name and version are already registered"); + } + + for (const auto& [ExistingKey, ExistingEntry] : Entries) + { + if (ExistingKey.Name != Key.Name + || ExistingKey.Version.Major != Key.Version.Major) + { + continue; + } + const FCapabilityDescriptor& Existing = ExistingEntry.Registration.Descriptor; + if (Existing.Effect != Registration.Descriptor.Effect) + { + return MakeRegistryDiagnostic( + "contract.registry.effect_major_mismatch", + "$.descriptor.effect", + "effect changes require a new capability major version"); + } + if (!SchemasShareMajorIdentity( + Existing.InputSchema, + Registration.Descriptor.InputSchema) + || !SchemasShareMajorIdentity( + Existing.OutputSchema, + Registration.Descriptor.OutputSchema)) + { + return MakeRegistryDiagnostic( + "contract.registry.schema_major_mismatch", + "$.descriptor", + "schema identity changes require a new capability major version"); + } + if (ExistingEntry.Registration.Provider != Registration.Provider) + { + return MakeRegistryDiagnostic( + "contract.registry.provider_major_conflict", + "$.provider", + "one provider must own all versions of a capability major"); + } + } + + Entries.emplace(Key, FEntry{ std::move(Registration) }); + return std::nullopt; + } + + size_t UnregisterProvider(std::string_view Provider) + { + std::lock_guard Lock(Mutex); + size_t RemovedCount = 0; + for (auto Entry = Entries.begin(); Entry != Entries.end();) + { + if (Entry->second.Registration.Provider == Provider) + { + Entry = Entries.erase(Entry); + ++RemovedCount; + } + else + { + ++Entry; + } + } + return RemovedCount; + } + + void Clear() + { + std::lock_guard Lock(Mutex); + Entries.clear(); + } + + [[nodiscard]] std::vector ListDescriptors() const + { + std::lock_guard Lock(Mutex); + std::vector Descriptors; + Descriptors.reserve(Entries.size()); + for (const auto& [Key, Entry] : Entries) + { + static_cast(Key); + Descriptors.push_back(CloneDescriptor(Entry.Registration.Descriptor)); + } + return Descriptors; + } + + [[nodiscard]] std::optional ResolveDescriptor( + std::string_view Name, + FContractVersion SupportedVersion) const + { + std::lock_guard Lock(Mutex); + const FEntry* Entry = ResolveLocked(Name, SupportedVersion); + if (Entry == nullptr) + { + return std::nullopt; + } + return CloneDescriptor(Entry->Registration.Descriptor); + } + + [[nodiscard]] FInvocationResult Invoke( + std::string_view Name, + FContractVersion SupportedVersion, + const TRequest& Request) const + { + typename FRegistration::FHandler Handler; + typename FRegistration::FValidator Validator; + std::shared_ptr Lifetime; + { + std::lock_guard Lock(Mutex); + const FEntry* Entry = ResolveLocked(Name, SupportedVersion); + if (Entry == nullptr) + { + FInvocationResult Result; + Result.Diagnostics.push_back(MakeRegistryDiagnostic( + "contract.registry.capability_not_found", + "$.capability", + "no compatible capability version is registered")); + return Result; + } + if (!Entry->Registration.Descriptor.bAvailable) + { + FInvocationResult Result; + FStructuredDiagnostic Diagnostic = MakeRegistryDiagnostic( + "contract.registry.capability_unavailable", + "$.capability", + "capability is registered but currently unavailable"); + Diagnostic.Details = + Entry->Registration.Descriptor.AvailabilityReason; + Result.Diagnostics.push_back(std::move(Diagnostic)); + return Result; + } + Handler = Entry->Registration.Handler; + Validator = Entry->Registration.Validator; + Lifetime = Entry->Registration.Lifetime; + } + + FInvocationResult Result; + try + { + Result.Diagnostics = Validator(Request); + } + catch (const std::exception&) + { + Result.Diagnostics.push_back(MakeRegistryDiagnostic( + "contract.registry.validator_exception", + "$", + "capability validator failed with an exception")); + return Result; + } + catch (...) + { + Result.Diagnostics.push_back(MakeRegistryDiagnostic( + "contract.registry.validator_exception", + "$", + "capability validator failed with an unknown exception")); + return Result; + } + + const bool bHasError = std::any_of( + Result.Diagnostics.begin(), + Result.Diagnostics.end(), + [](const FStructuredDiagnostic& Diagnostic) + { + return Diagnostic.Severity == EDiagnosticSeverity::Error; + }); + if (bHasError) + { + return Result; + } + + try + { + Result.Value = Handler(Request); + } + catch (const std::exception&) + { + Result.Diagnostics.push_back(MakeRegistryDiagnostic( + "contract.registry.handler_exception", + "$", + "capability handler failed with an exception")); + } + catch (...) + { + Result.Diagnostics.push_back(MakeRegistryDiagnostic( + "contract.registry.handler_exception", + "$", + "capability handler failed with an unknown exception")); + } + static_cast(Lifetime); + return Result; + } + + private: + struct FEntry + { + FRegistration Registration; + }; + + static FStructuredDiagnostic MakeRegistryDiagnostic( + 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; + } + + static bool SchemasShareMajorIdentity( + const FValueSchema& Left, + const FValueSchema& Right) + { + return Left.Id.Name == Right.Id.Name + && Left.Id.Version.Major == Right.Id.Version.Major; + } + + const FEntry* ResolveLocked( + std::string_view Name, + FContractVersion SupportedVersion) const + { + const FEntry* BestEntry = nullptr; + for (const auto& [Key, Entry] : Entries) + { + if (Key.Name != Name + || Key.Version.Major != SupportedVersion.Major + || Key.Version.Minor > SupportedVersion.Minor) + { + continue; + } + if (BestEntry == nullptr + || BestEntry->Registration.Descriptor.Version < Key.Version) + { + BestEntry = &Entry; + } + } + return BestEntry; + } + + mutable std::mutex Mutex; + std::map Entries; + }; +} diff --git a/Engine/Source/Contract/ContractCore.cpp b/Engine/Source/Contract/ContractCore.cpp index 0c0b42e..cdfbadf 100644 --- a/Engine/Source/Contract/ContractCore.cpp +++ b/Engine/Source/Contract/ContractCore.cpp @@ -40,6 +40,35 @@ namespace WaveContract return Left != nullptr && Right != nullptr && *Left == *Right; } + FValueSchema CloneSchemaRecursive(const FValueSchema& Schema) + { + FValueSchema Clone = Schema; + Clone.Fields.clear(); + Clone.Fields.reserve(Schema.Fields.size()); + for (const FSchemaField& Field : Schema.Fields) + { + FSchemaField FieldClone; + FieldClone.Name = Field.Name; + FieldClone.bRequired = Field.bRequired; + if (Field.Schema != nullptr) + { + FieldClone.Schema = + std::make_shared(CloneSchemaRecursive(*Field.Schema)); + } + Clone.Fields.push_back(std::move(FieldClone)); + } + if (Schema.Items != nullptr) + { + Clone.Items = std::make_shared( + CloneSchemaRecursive(*Schema.Items)); + } + else + { + Clone.Items.reset(); + } + return Clone; + } + std::optional ValidateSchemaRecursive( const FValueSchema& Schema, std::string Path, @@ -1597,6 +1626,27 @@ namespace WaveContract return std::nullopt; } + FValueSchema CloneSchema(const FValueSchema& Schema) + { + if (ValidateSchema(Schema).has_value()) + { + return {}; + } + return CloneSchemaRecursive(Schema); + } + + FCapabilityDescriptor CloneDescriptor(const FCapabilityDescriptor& Descriptor) + { + if (ValidateDescriptor(Descriptor).has_value()) + { + return {}; + } + FCapabilityDescriptor Clone = Descriptor; + Clone.InputSchema = CloneSchemaRecursive(Descriptor.InputSchema); + Clone.OutputSchema = CloneSchemaRecursive(Descriptor.OutputSchema); + return Clone; + } + std::string SerializeSchemaCanonicalJson(const FValueSchema& Schema) { if (ValidateSchema(Schema).has_value()) diff --git a/Engine/Source/Contract/ContractCore.h b/Engine/Source/Contract/ContractCore.h index 933c32d..403c1fc 100644 --- a/Engine/Source/Contract/ContractCore.h +++ b/Engine/Source/Contract/ContractCore.h @@ -234,6 +234,9 @@ namespace WaveContract [[nodiscard]] std::optional ValidateSchema(const FValueSchema& Schema); [[nodiscard]] std::optional ValidateDescriptor( const FCapabilityDescriptor& Descriptor); + [[nodiscard]] FValueSchema CloneSchema(const FValueSchema& Schema); + [[nodiscard]] FCapabilityDescriptor CloneDescriptor( + const FCapabilityDescriptor& Descriptor); [[nodiscard]] std::string SerializeSchemaCanonicalJson(const FValueSchema& Schema); [[nodiscard]] bool DeserializeSchemaCanonicalJson( diff --git a/Engine/Source/Mcp/McpRegistry.cpp b/Engine/Source/Mcp/McpRegistry.cpp index aae581b..2cbdabe 100644 --- a/Engine/Source/Mcp/McpRegistry.cpp +++ b/Engine/Source/Mcp/McpRegistry.cpp @@ -2,6 +2,66 @@ #include "Misc/Log.h" +#include + +namespace +{ + bool ConvertNamedCapabilityInput( + const WaveContract::FCapabilityDescriptor& Descriptor, + const FJson& Input, + FJson& OutLegacyParams, + WaveContract::FStructuredDiagnostic& OutDiagnostic) + { + if (!Input.is_object()) + { + OutDiagnostic.Code = "contract.input.object_required"; + OutDiagnostic.Path = "$"; + OutDiagnostic.Message = "capability input must be a named JSON object"; + return false; + } + + for (auto Property = Input.begin(); Property != Input.end(); ++Property) + { + const auto Match = std::find_if( + Descriptor.InputSchema.Fields.begin(), + Descriptor.InputSchema.Fields.end(), + [&Property](const WaveContract::FSchemaField& Field) + { + return Field.Name == Property.key(); + }); + if (Match == Descriptor.InputSchema.Fields.end()) + { + OutDiagnostic.Code = "contract.input.field_unknown"; + OutDiagnostic.Path = "$." + Property.key(); + OutDiagnostic.Message = "capability input contains an unknown field"; + return false; + } + } + + OutLegacyParams = FJson::array(); + for (const WaveContract::FSchemaField& Field : Descriptor.InputSchema.Fields) + { + const auto Value = Input.find(Field.Name); + if (Value == Input.end()) + { + if (Field.bRequired) + { + OutDiagnostic.Code = "contract.input.field_required"; + OutDiagnostic.Path = "$." + Field.Name; + OutDiagnostic.Message = "required capability input field is missing"; + return false; + } + OutLegacyParams.push_back(nullptr); + } + else + { + OutLegacyParams.push_back(*Value); + } + } + return true; + } +} + FMcpRegistry& FMcpRegistry::Get() { static FMcpRegistry Instance; @@ -12,12 +72,72 @@ void FMcpRegistry::Insert(const std::string& Name, FMcpTool Tool) { std::lock_guard Lock(Mutex); check(Tools.find(Name) == Tools.end(), "Duplicate MCP tool name: ", Name); + check(Tool.Descriptor.Name == Name, "MCP capability name mismatch: ", Name); + check(Tool.Owner != nullptr, "MCP capability owner is required: ", Name); + + auto Provider = OwnerProviders.find(Tool.Owner); + if (Provider == OwnerProviders.end()) + { + Provider = OwnerProviders.emplace( + Tool.Owner, + "legacy.mcp.provider." + std::to_string(NextProviderId++)).first; + } + WaveContract::TCapabilityRegistration Registration; + Registration.Provider = Provider->second; + Registration.Descriptor = Tool.Descriptor; + Registration.Handler = [ + Descriptor = Tool.Descriptor, + Trampoline = Tool.Trampoline](const FJson& Input) + { + FJson LegacyParams; + WaveContract::FStructuredDiagnostic Diagnostic; + if (!ConvertNamedCapabilityInput( + Descriptor, + Input, + LegacyParams, + Diagnostic)) + { + throw std::invalid_argument(Diagnostic.Message); + } + return Trampoline(LegacyParams); + }; + Registration.Validator = [ + Descriptor = Tool.Descriptor, + LegacyValidator = Tool.Validator](const FJson& Input) + { + FJson LegacyParams; + WaveContract::FStructuredDiagnostic Diagnostic; + if (!ConvertNamedCapabilityInput( + Descriptor, + Input, + LegacyParams, + Diagnostic)) + { + return std::vector{ + std::move(Diagnostic) + }; + } + return LegacyValidator(LegacyParams); + }; + const std::optional Diagnostic = + Capabilities.Register(std::move(Registration)); + check(!Diagnostic.has_value(), + "MCP capability registration failed: ", + Diagnostic.has_value() ? Diagnostic->Code : std::string(), + " ", + Diagnostic.has_value() ? Diagnostic->Message : std::string()); Tools.emplace(Name, std::move(Tool)); } void FMcpRegistry::RemoveByOwner(void* Owner) { std::lock_guard Lock(Mutex); + const auto Provider = OwnerProviders.find(Owner); + if (Provider != OwnerProviders.end()) + { + Capabilities.UnregisterProvider(Provider->second); + OwnerProviders.erase(Provider); + } for (auto It = Tools.begin(); It != Tools.end();) { if (It->second.Owner == Owner) @@ -35,6 +155,9 @@ void FMcpRegistry::Clear() { std::lock_guard Lock(Mutex); Tools.clear(); + OwnerProviders.clear(); + Capabilities.Clear(); + NextProviderId = 1; } FJson FMcpRegistry::Invoke(const std::string& Name, const FJson& Params) const @@ -71,3 +194,8 @@ std::vector FMcpRegistry::ListNames() const } return Out; } + +std::vector FMcpRegistry::ListDescriptors() const +{ + return Capabilities.ListDescriptors(); +} diff --git a/Engine/Source/Mcp/McpRegistry.h b/Engine/Source/Mcp/McpRegistry.h index 6ad35f3..5966211 100644 --- a/Engine/Source/Mcp/McpRegistry.h +++ b/Engine/Source/Mcp/McpRegistry.h @@ -1,13 +1,19 @@ #pragma once +#include "Contract/CapabilityRegistry.h" #include "Core/Core.h" #include "JsonSerializer.h" #include +#include #include #include +#include #include +#include +#include #include +#include #include class FMcpMethodNotFound final : public std::runtime_error @@ -24,21 +30,61 @@ struct FMcpTool { std::string Name; std::function Trampoline; + std::function(const FJson&)> Validator; + WaveContract::FCapabilityDescriptor Descriptor; bool bReadOnly = false; // 元数据:声明该工具不修改引擎状态。仅用于自描述, // 当前 server 始终把 Invoke marshal 到主线程,避免与渲染线程数据竞争。 void* Owner = nullptr; }; +template +struct TMcpOptionalTraits +{ + static constexpr bool bIsOptional = false; +}; + +template +struct TMcpOptionalTraits> +{ + static constexpr bool bIsOptional = true; + using FValueType = T; +}; + +template +struct TMcpLegacyJsonTraits +{ + static constexpr bool bContainsLegacyJson = + std::is_same_v, FJson>; +}; + +template +struct TMcpLegacyJsonTraits> +{ + static constexpr bool bContainsLegacyJson = + TMcpLegacyJsonTraits::bContainsLegacyJson; +}; + class FMcpRegistry { public: static FMcpRegistry& Get(); template - void Add(const std::string& Name, TOwner* Self, TRet (TOwner::*Method)(TArgs...)); + void Add( + const std::string& Name, + std::string Summary, + std::initializer_list ParameterNames, + TOwner* Self, + TRet (TOwner::*Method)(TArgs...), + WaveContract::ECapabilityEffect Effect = WaveContract::ECapabilityEffect::Command); template - void AddReadOnly(const std::string& Name, TOwner* Self, TRet (TOwner::*Method)(TArgs...)); + void AddReadOnly( + const std::string& Name, + std::string Summary, + std::initializer_list ParameterNames, + TOwner* Self, + TRet (TOwner::*Method)(TArgs...)); void RemoveByOwner(void* Owner); void Clear(); @@ -49,6 +95,7 @@ class FMcpRegistry bool IsReadOnly(const std::string& Name) const; std::vector ListNames() const; + std::vector ListDescriptors() const; private: FMcpRegistry() = default; @@ -57,11 +104,27 @@ class FMcpRegistry auto MakeTrampoline(TOwner* Self, TRet (TOwner::*Method)(TArgs...), std::index_sequence); + template + auto MakeValidator(std::index_sequence); + + template + static WaveContract::FValueSchema MakeValueSchema(std::string Description); + + template + static WaveContract::FCapabilityDescriptor MakeDescriptor( + const std::string& Name, + std::string Summary, + std::initializer_list ParameterNames, + WaveContract::ECapabilityEffect Effect); + void Insert(const std::string& Name, FMcpTool Tool); mutable std::mutex Mutex; // std::map 让 ListNames 输出稳定排序,方便 diff / discover std::map Tools; + std::unordered_map OwnerProviders; + uint64_t NextProviderId = 1; + WaveContract::TCapabilityRegistry Capabilities; }; // ---- 模板实现 ---- @@ -104,18 +167,250 @@ auto FMcpRegistry::MakeTrampoline(TOwner* Self, TRet (TOwner::*Method)(TArgs...) }; } +template +auto FMcpRegistry::MakeValidator(std::index_sequence) +{ + using TArgsTuple = std::tuple...>; + return [](const FJson& Params) + -> std::vector + { + if (!Params.is_array() || Params.size() != sizeof...(TArgs)) + { + WaveContract::FStructuredDiagnostic Diagnostic; + Diagnostic.Code = "contract.input.positional_arity"; + Diagnostic.Path = "$.params"; + Diagnostic.Message = "legacy MCP argument count does not match the capability"; + return { std::move(Diagnostic) }; + } + try + { + static_cast(TArgsTuple{ + TJsonSerializer>::FromJson(Params[Is])... + }); + } + catch (const nlohmann::json::exception& Ex) + { + WaveContract::FStructuredDiagnostic Diagnostic; + Diagnostic.Code = "contract.input.type_mismatch"; + Diagnostic.Path = "$.params"; + Diagnostic.Message = "legacy MCP argument does not match the registered schema"; + Diagnostic.Details = Ex.what(); + return { std::move(Diagnostic) }; + } + return {}; + }; +} + +template +WaveContract::FValueSchema FMcpRegistry::MakeValueSchema(std::string Description) +{ + using namespace WaveContract; + using FValueType = std::decay_t; + + if constexpr (TMcpOptionalTraits::bIsOptional) + { + FValueSchema Schema = + MakeValueSchema::FValueType>( + std::move(Description)); + Schema.bNullable = true; + return Schema; + } + else + { + FValueSchema Schema; + Schema.Description = std::move(Description); + if constexpr (std::is_same_v) + { + Schema.Kind = EValueKind::Boolean; + } + else if constexpr (std::is_integral_v) + { + Schema.Kind = EValueKind::Integer; + if constexpr (std::is_unsigned_v) + { + Schema.Minimum = 0.0; + } + } + else if constexpr (std::is_floating_point_v) + { + Schema.Kind = EValueKind::Number; + } + else if constexpr (std::is_same_v) + { + Schema.Kind = EValueKind::String; + Schema.MaxLength = 1024 * 1024; + } + else if constexpr (std::is_same_v) + { + Schema.Kind = EValueKind::Array; + Schema.MinItems = 3; + Schema.MaxItems = 3; + Schema.CoordinateSystem = "LH_XForward_YRight_ZUp"; + Schema.Items = std::make_shared(); + Schema.Items->Kind = EValueKind::Number; + Schema.Items->Description = "finite vector component"; + } + else if constexpr (std::is_same_v) + { + Schema.Kind = EValueKind::Array; + Schema.MinItems = 4; + Schema.MaxItems = 4; + Schema.CoordinateSystem = "LH_XForward_YRight_ZUp"; + Schema.Items = std::make_shared(); + Schema.Items->Kind = EValueKind::Number; + Schema.Items->Description = "finite quaternion component"; + } + else if constexpr (std::is_same_v>) + { + Schema.Kind = EValueKind::Array; + Schema.MaxItems = 4096; + Schema.Items = std::make_shared(); + Schema.Items->Kind = EValueKind::String; + Schema.Items->Description = "string item"; + Schema.Items->MaxLength = 1024 * 1024; + } + else if constexpr (std::is_same_v) + { + Schema.Kind = EValueKind::Object; + Schema.bNullable = true; + Schema.Description += + " (legacy JSON compatibility value; concrete fields are not yet migrated)"; + } + else + { + static_assert(!std::is_same_v, + "Unsupported MCP capability schema type"); + } + return Schema; + } +} + +template +WaveContract::FCapabilityDescriptor FMcpRegistry::MakeDescriptor( + const std::string& Name, + std::string Summary, + std::initializer_list ParameterNames, + WaveContract::ECapabilityEffect Effect) +{ + using namespace WaveContract; + + check(ParameterNames.size() == sizeof...(TArgs), + "Capability parameter names do not match handler arity: ", Name); + + FCapabilityDescriptor Descriptor; + Descriptor.Name = Name; + Descriptor.Version = { 1, 0 }; + Descriptor.Stability = "legacy"; + Descriptor.MinimumReaderVersion = { 1, 0 }; + Descriptor.MinimumWriterVersion = { 1, 0 }; + Descriptor.Summary = std::move(Summary); + Descriptor.Effect = Effect; + if (Effect == ECapabilityEffect::Query) + { + Descriptor.Permission = "editor.read"; + Descriptor.Risk = "low"; + } + else if (Effect == ECapabilityEffect::ExternalAction) + { + Descriptor.Permission = "editor.external"; + Descriptor.Risk = "high"; + } + else + { + Descriptor.Permission = "editor.write"; + Descriptor.Risk = "medium"; + } + Descriptor.bIdempotent = Effect == ECapabilityEffect::Query; + Descriptor.bSupportsRetry = Effect == ECapabilityEffect::Query; + Descriptor.Budget = { 1024 * 1024, 1024 * 1024, 30000, 1 }; + Descriptor.ReferenceScope = EReferenceScope::Session; + constexpr bool bHasLegacyJson = + TMcpLegacyJsonTraits::bContainsLegacyJson + || (TMcpLegacyJsonTraits::bContainsLegacyJson || ...); + if constexpr (bHasLegacyJson) + { + Descriptor.bAvailable = false; + Descriptor.AvailabilityReason = + "legacy dynamic JSON shape is not yet a typed domain capability; " + "use the existing positional MCP compatibility method"; + } + + Descriptor.InputSchema.Id = { Name + ".input", { 1, 0 } }; + Descriptor.InputSchema.Kind = EValueKind::Object; + Descriptor.InputSchema.Description = + "Named input for the legacy MCP compatibility adapter"; + auto ParameterName = ParameterNames.begin(); + (Descriptor.InputSchema.Fields.push_back(FSchemaField{ + std::string(*ParameterName++), + !TMcpOptionalTraits>::bIsOptional, + std::make_shared( + MakeValueSchema>("capability input field")) + }), ...); + + Descriptor.OutputSchema.Id = { Name + ".output", { 1, 0 } }; + if constexpr (std::is_void_v) + { + Descriptor.OutputSchema.Kind = EValueKind::Object; + Descriptor.OutputSchema.bNullable = true; + Descriptor.OutputSchema.Description = "null result"; + } + else + { + FValueSchema OutputSchema = + MakeValueSchema>("capability output"); + OutputSchema.Id = Descriptor.OutputSchema.Id; + Descriptor.OutputSchema = std::move(OutputSchema); + } + return Descriptor; +} + template -void FMcpRegistry::Add(const std::string& Name, TOwner* Self, - TRet (TOwner::*Method)(TArgs...)) +void FMcpRegistry::Add( + const std::string& Name, + std::string Summary, + std::initializer_list ParameterNames, + TOwner* Self, + TRet (TOwner::*Method)(TArgs...), + WaveContract::ECapabilityEffect Effect) { auto Trampoline = MakeTrampoline(Self, Method, std::index_sequence_for{}); - Insert(Name, FMcpTool{ Name, std::move(Trampoline), false, Self }); + auto Validator = MakeValidator(std::index_sequence_for{}); + auto Descriptor = MakeDescriptor( + Name, + std::move(Summary), + ParameterNames, + Effect); + Insert(Name, FMcpTool{ + Name, + std::move(Trampoline), + std::move(Validator), + std::move(Descriptor), + false, + Self + }); } template -void FMcpRegistry::AddReadOnly(const std::string& Name, TOwner* Self, - TRet (TOwner::*Method)(TArgs...)) +void FMcpRegistry::AddReadOnly( + const std::string& Name, + std::string Summary, + std::initializer_list ParameterNames, + TOwner* Self, + TRet (TOwner::*Method)(TArgs...)) { auto Trampoline = MakeTrampoline(Self, Method, std::index_sequence_for{}); - Insert(Name, FMcpTool{ Name, std::move(Trampoline), true, Self }); + auto Validator = MakeValidator(std::index_sequence_for{}); + auto Descriptor = MakeDescriptor( + Name, + std::move(Summary), + ParameterNames, + WaveContract::ECapabilityEffect::Query); + Insert(Name, FMcpTool{ + Name, + std::move(Trampoline), + std::move(Validator), + std::move(Descriptor), + true, + Self + }); } diff --git a/Engine/Source/UI/EditorShell.cpp b/Engine/Source/UI/EditorShell.cpp index f8506ef..18b226c 100644 --- a/Engine/Source/UI/EditorShell.cpp +++ b/Engine/Source/UI/EditorShell.cpp @@ -138,14 +138,55 @@ void FEditorShell::Initialize(FMcpRegistry& Registry) try { Panels.RegisterMcpTools(Registry); - Registry.Add("editor.app.request_exit", this, &FEditorShell::RequestExit); - Registry.Add("editor.history.undo", this, &FEditorShell::Undo); - Registry.Add("editor.history.redo", this, &FEditorShell::Redo); - Registry.AddReadOnly("editor.history.can_undo", this, &FEditorShell::CanUndo); - Registry.AddReadOnly("editor.history.can_redo", this, &FEditorShell::CanRedo); - Registry.AddReadOnly("editor.window.list", this, &FEditorShell::ListWindows); - Registry.Add("editor.window.set_open", this, &FEditorShell::SetWindowOpen); - Registry.Add("editor.layout.reset", this, &FEditorShell::ResetLayout); + Registry.Add( + "editor.app.request_exit", + "Request a normal, lifecycle-safe editor shutdown", + {}, + this, + &FEditorShell::RequestExit, + WaveContract::ECapabilityEffect::ExternalAction); + Registry.Add( + "editor.history.undo", + "Undo the latest editor document command", + {}, + this, + &FEditorShell::Undo); + Registry.Add( + "editor.history.redo", + "Redo the latest undone editor document command", + {}, + this, + &FEditorShell::Redo); + Registry.AddReadOnly( + "editor.history.can_undo", + "Report whether an editor document command can be undone", + {}, + this, + &FEditorShell::CanUndo); + Registry.AddReadOnly( + "editor.history.can_redo", + "Report whether an editor document command can be redone", + {}, + this, + &FEditorShell::CanRedo); + Registry.AddReadOnly( + "editor.window.list", + "List editor windows and their current visibility", + {}, + this, + &FEditorShell::ListWindows); + Registry.Add( + "editor.window.set_open", + "Set one editor window visibility flag", + { "window_name", "open" }, + this, + &FEditorShell::SetWindowOpen); + Registry.Add( + "editor.layout.reset", + "Reset the editor docking layout", + {}, + this, + &FEditorShell::ResetLayout); bInitialized = true; } catch (...) diff --git a/Engine/Source/UI/Panels/AssetsPanel.cpp b/Engine/Source/UI/Panels/AssetsPanel.cpp index e0a738f..c1d7e55 100644 --- a/Engine/Source/UI/Panels/AssetsPanel.cpp +++ b/Engine/Source/UI/Panels/AssetsPanel.cpp @@ -749,8 +749,19 @@ const char* FAssetsPanel::GetAssetTypeName(EAssetType Type) void FAssetsPanel::RegisterMcpTools(FMcpRegistry& Registry) { - Registry.Add ("editor.assets.spawn", this, &FAssetsPanel::McpSpawn); - Registry.AddReadOnly("editor.assets.import_status", this, &FAssetsPanel::McpImportStatus); + Registry.Add( + "editor.assets.spawn", + "Load an asset path and add one scene instance", + { "path", "position", "rotation", "scale" }, + this, + &FAssetsPanel::McpSpawn, + WaveContract::ECapabilityEffect::ExternalAction); + Registry.AddReadOnly( + "editor.assets.import_status", + "Describe pending and completed asset imports", + {}, + this, + &FAssetsPanel::McpImportStatus); } uint32 FAssetsPanel::McpSpawn(std::string Path, diff --git a/Engine/Source/UI/Panels/EngineSettingsPanel.cpp b/Engine/Source/UI/Panels/EngineSettingsPanel.cpp index de6a9db..c4b85a9 100644 --- a/Engine/Source/UI/Panels/EngineSettingsPanel.cpp +++ b/Engine/Source/UI/Panels/EngineSettingsPanel.cpp @@ -376,11 +376,36 @@ void FEngineSettingsPanel::ResetToDefaults() void FEngineSettingsPanel::RegisterMcpTools(FMcpRegistry& Registry) { - Registry.AddReadOnly("editor.lighting.get_main", this, &FEngineSettingsPanel::McpLightingGetMain); - Registry.Add ("editor.lighting.set_main", this, &FEngineSettingsPanel::McpLightingSetMain); - Registry.AddReadOnly("editor.renderer.get_settings", this, &FEngineSettingsPanel::McpRendererGetSettings); - Registry.Add ("editor.renderer.set_setting", this, &FEngineSettingsPanel::McpRendererSetSetting); - Registry.AddReadOnly("editor.console.log_tail", this, &FEngineSettingsPanel::McpConsoleLogTail); + 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); } // ---- Lighting ---- diff --git a/Engine/Source/UI/Panels/HierarchyPanel.cpp b/Engine/Source/UI/Panels/HierarchyPanel.cpp index 65dd1e9..1be8423 100644 --- a/Engine/Source/UI/Panels/HierarchyPanel.cpp +++ b/Engine/Source/UI/Panels/HierarchyPanel.cpp @@ -369,10 +369,30 @@ void FHierarchyPanel::DuplicateInstance(const TRefCountPtr& Instance) void FHierarchyPanel::RegisterMcpTools(FMcpRegistry& Registry) { - Registry.AddReadOnly("editor.hierarchy.list", this, &FHierarchyPanel::McpList); - Registry.Add ("editor.hierarchy.select", this, &FHierarchyPanel::McpSelect); - Registry.Add ("editor.hierarchy.delete", this, &FHierarchyPanel::McpDelete); - Registry.Add ("editor.hierarchy.rename", this, &FHierarchyPanel::McpRename); + 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() diff --git a/Engine/Source/UI/Panels/InspectorPanel.cpp b/Engine/Source/UI/Panels/InspectorPanel.cpp index 172c5f2..8429346 100644 --- a/Engine/Source/UI/Panels/InspectorPanel.cpp +++ b/Engine/Source/UI/Panels/InspectorPanel.cpp @@ -467,9 +467,24 @@ void FInspectorPanel::CommitCameraEdit() void FInspectorPanel::RegisterMcpTools(FMcpRegistry& Registry) { - Registry.AddReadOnly("editor.inspector.get", this, &FInspectorPanel::McpGet); - Registry.Add("editor.inspector.set_field", this, &FInspectorPanel::McpSetField); - Registry.AddReadOnly("editor.inspector.list_fields", this, &FInspectorPanel::McpListFields); + 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) diff --git a/Engine/Source/UI/Panels/ProfilerPanel.cpp b/Engine/Source/UI/Panels/ProfilerPanel.cpp index 1aeace2..fe8bb9c 100644 --- a/Engine/Source/UI/Panels/ProfilerPanel.cpp +++ b/Engine/Source/UI/Panels/ProfilerPanel.cpp @@ -409,14 +409,54 @@ void FProfilerPanel::LaunchViewer() const void FProfilerPanel::RegisterMcpTools(FMcpRegistry& Registry) { - Registry.AddReadOnly("profiler_frame_stats", this, &FProfilerPanel::McpFrameStats); - Registry.AddReadOnly("profiler_pass_timings", this, &FProfilerPanel::McpPassTimings); - Registry.AddReadOnly("profiler_top_scopes", this, &FProfilerPanel::McpTopScopes); - Registry.AddReadOnly("editor.profile.frame_stats", this, &FProfilerPanel::McpFrameStats); - Registry.AddReadOnly("editor.profile.pass_timings", this, &FProfilerPanel::McpPassTimings); - Registry.Add("editor.profile.start_capture", this, &FProfilerPanel::McpStartCapture); - Registry.Add("editor.profile.stop_capture", this, &FProfilerPanel::McpStopCapture); - Registry.AddReadOnly("editor.profile.capture_status", this, &FProfilerPanel::McpCaptureStatus); + Registry.AddReadOnly( + "profiler_frame_stats", + "Read legacy per-frame profiler summary statistics", + {}, + this, + &FProfilerPanel::McpFrameStats); + Registry.AddReadOnly( + "profiler_pass_timings", + "Read legacy render pass timing statistics", + {}, + this, + &FProfilerPanel::McpPassTimings); + Registry.AddReadOnly( + "profiler_top_scopes", + "Read legacy top CPU profiler scopes", + {}, + this, + &FProfilerPanel::McpTopScopes); + Registry.AddReadOnly( + "editor.profile.frame_stats", + "Read per-frame profiler summary statistics", + {}, + this, + &FProfilerPanel::McpFrameStats); + Registry.AddReadOnly( + "editor.profile.pass_timings", + "Read render pass timing statistics", + {}, + this, + &FProfilerPanel::McpPassTimings); + Registry.Add( + "editor.profile.start_capture", + "Start a bounded profiler capture session", + { "enable_live", "port" }, + this, + &FProfilerPanel::McpStartCapture); + Registry.Add( + "editor.profile.stop_capture", + "Stop the active profiler capture session", + {}, + this, + &FProfilerPanel::McpStopCapture); + Registry.AddReadOnly( + "editor.profile.capture_status", + "Read the active profiler capture status", + {}, + this, + &FProfilerPanel::McpCaptureStatus); } FJson FProfilerPanel::McpStartCapture(bool bEnableLive, uint32 Port) diff --git a/Engine/Source/UI/Panels/ViewportPanel.cpp b/Engine/Source/UI/Panels/ViewportPanel.cpp index b4b8b14..8fefbea 100644 --- a/Engine/Source/UI/Panels/ViewportPanel.cpp +++ b/Engine/Source/UI/Panels/ViewportPanel.cpp @@ -1313,11 +1313,36 @@ void FViewportPanel::CancelGizmoEdit() void FViewportPanel::RegisterMcpTools(FMcpRegistry& Registry) { - Registry.AddReadOnly("editor.viewport.camera.get", this, &FViewportPanel::McpCameraGet); - Registry.Add ("editor.viewport.camera.set", this, &FViewportPanel::McpCameraSet); - Registry.Add ("editor.viewport.camera.move", this, &FViewportPanel::McpCameraMove); - Registry.Add ("editor.viewport.camera.look_at", this, &FViewportPanel::McpCameraLookAt); - Registry.Add ("editor.viewport.set_debug_view", this, &FViewportPanel::McpSetDebugView); + 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() diff --git a/Tests/ContractCoreContracts.cpp b/Tests/ContractCoreContracts.cpp index f8eb58f..fa08c41 100644 --- a/Tests/ContractCoreContracts.cpp +++ b/Tests/ContractCoreContracts.cpp @@ -1,5 +1,6 @@ #include "ContractCoreContracts.h" +#include "Contract/CapabilityRegistry.h" #include "Contract/ContractCore.h" #include @@ -110,6 +111,57 @@ namespace WaveContracts Descriptor.ReferenceScope = EReferenceScope::Document; return Descriptor; } + + using FTestCapabilityRegistry = TCapabilityRegistry; + using FTestCapabilityRegistration = + TCapabilityRegistration; + + FTestCapabilityRegistration MakeTestRegistration( + std::string Name, + FContractVersion Version, + ECapabilityEffect Effect, + std::string Provider, + std::shared_ptr HandlerCalls) + { + FCapabilityDescriptor Descriptor = MakeValidDescriptor(); + Descriptor.Name = std::move(Name); + Descriptor.Version = Version; + Descriptor.Effect = Effect; + Descriptor.InputSchema.Id.Name = Descriptor.Name + ".input"; + Descriptor.OutputSchema.Id.Name = Descriptor.Name + ".output"; + Descriptor.bIdempotent = Effect == ECapabilityEffect::Query; + Descriptor.bSupportsRetry = Effect == ECapabilityEffect::Query; + + FTestCapabilityRegistration Registration; + Registration.Provider = std::move(Provider); + Registration.Descriptor = std::move(Descriptor); + Registration.Handler = [ + HandlerCalls, + Version](const int& Request) + { + ++*HandlerCalls; + return std::to_string(Version.Major) + + "." + + std::to_string(Version.Minor) + + ":" + + std::to_string(Request); + }; + Registration.Validator = [](const int& Request) + { + std::vector Diagnostics; + if (Request < 0) + { + FStructuredDiagnostic Diagnostic; + Diagnostic.Code = "test.request.negative"; + Diagnostic.Path = "$.value"; + Diagnostic.Message = "test request must be non-negative"; + Diagnostics.push_back(std::move(Diagnostic)); + } + return Diagnostics; + }; + Registration.Lifetime = HandlerCalls; + return Registration; + } } int RunContractCoreContracts() @@ -223,6 +275,205 @@ namespace WaveContracts == EChangeCursorStatus::ResyncRequired, "expired cursor did not require resync"); + FTestCapabilityRegistry Registry; + const std::shared_ptr HandlerCalls = std::make_shared(0); + Require( + !Registry.Register(MakeTestRegistration( + "world.transform.describe", + { 1, 0 }, + ECapabilityEffect::Query, + "world", + HandlerCalls)).has_value(), + "valid capability registration rejected"); + Require( + !Registry.Register(MakeTestRegistration( + "world.transform.describe", + { 1, 2 }, + ECapabilityEffect::Query, + "world", + HandlerCalls)).has_value(), + "compatible capability minor rejected"); + Require( + !Registry.Register(MakeTestRegistration( + "asset.inspect", + { 1, 0 }, + ECapabilityEffect::Query, + "asset", + HandlerCalls)).has_value(), + "second capability registration rejected"); + + const std::vector Descriptors = Registry.ListDescriptors(); + Require(Descriptors.size() == 3, "capability descriptor listing size"); + Require( + Descriptors[0].Name == "asset.inspect" + && Descriptors[1].Name == "world.transform.describe" + && Descriptors[1].Version == FContractVersion{ 1, 0 } + && Descriptors[2].Version == FContractVersion{ 1, 2 }, + "capability descriptors are not deterministically sorted"); + + const std::optional Resolved10 = + Registry.ResolveDescriptor("world.transform.describe", { 1, 1 }); + const std::optional Resolved12 = + Registry.ResolveDescriptor("world.transform.describe", { 1, 2 }); + Require( + Resolved10.has_value() && Resolved10->Version == FContractVersion{ 1, 0 }, + "compatible capability version resolution"); + Require( + Resolved12.has_value() && Resolved12->Version == FContractVersion{ 1, 2 }, + "latest compatible capability version resolution"); + Require( + !Registry.ResolveDescriptor("world.transform.describe", { 2, 0 }).has_value(), + "capability resolution crossed a major version"); + + const auto Invocation = + Registry.Invoke("world.transform.describe", { 1, 2 }, 7); + Require(Invocation.Succeeded(), "registered capability invocation failed"); + Require( + Invocation.Value == std::optional("1.2:7"), + "capability invocation did not use the resolved handler"); + Require(*HandlerCalls == 1, "capability handler invocation count"); + + const auto RejectedInvocation = + Registry.Invoke("world.transform.describe", { 1, 2 }, -1); + Require(!RejectedInvocation.Succeeded(), "validator rejection invoked a capability"); + Require( + RejectedInvocation.Diagnostics.size() == 1 + && RejectedInvocation.Diagnostics.front().Code == "test.request.negative", + "validator diagnostic was not preserved"); + Require(*HandlerCalls == 1, "handler ran after validator rejection"); + + const std::optional DuplicateRegistration = + Registry.Register(MakeTestRegistration( + "world.transform.describe", + { 1, 2 }, + ECapabilityEffect::Query, + "world", + HandlerCalls)); + Require( + DuplicateRegistration.has_value() + && DuplicateRegistration->Code == "contract.registry.duplicate", + "duplicate capability registration was accepted"); + + const std::optional EffectDrift = + Registry.Register(MakeTestRegistration( + "world.transform.describe", + { 1, 3 }, + ECapabilityEffect::Command, + "world", + HandlerCalls)); + Require( + EffectDrift.has_value() + && EffectDrift->Code == "contract.registry.effect_major_mismatch", + "effect drift within a capability major was accepted"); + + FTestCapabilityRegistration SchemaDrift = MakeTestRegistration( + "world.transform.describe", + { 1, 3 }, + ECapabilityEffect::Query, + "world", + HandlerCalls); + SchemaDrift.Descriptor.InputSchema.Id.Name = "world.transform.replacement_input"; + const std::optional SchemaDriftDiagnostic = + Registry.Register(std::move(SchemaDrift)); + Require( + SchemaDriftDiagnostic.has_value() + && SchemaDriftDiagnostic->Code == "contract.registry.schema_major_mismatch", + "schema identity drift within a capability major was accepted"); + + const std::optional ProviderConflict = + Registry.Register(MakeTestRegistration( + "world.transform.describe", + { 1, 3 }, + ECapabilityEffect::Query, + "editor", + HandlerCalls)); + Require( + ProviderConflict.has_value() + && ProviderConflict->Code == "contract.registry.provider_major_conflict", + "multiple providers claimed one capability major"); + + Require( + !Registry.Register(MakeTestRegistration( + "world.transform.describe", + { 2, 0 }, + ECapabilityEffect::Command, + "world-v2", + HandlerCalls)).has_value(), + "new capability major could not change effect"); + + FTestCapabilityRegistration MissingValidator = MakeTestRegistration( + "world.invalid", + { 1, 0 }, + ECapabilityEffect::Query, + "world", + HandlerCalls); + MissingValidator.Validator = {}; + const std::optional MissingValidatorDiagnostic = + Registry.Register(std::move(MissingValidator)); + Require( + MissingValidatorDiagnostic.has_value() + && MissingValidatorDiagnostic->Code == "contract.registry.validator_required", + "capability without validator was accepted"); + + Require(Registry.UnregisterProvider("asset") == 1, "provider unregister count"); + Require( + !Registry.ResolveDescriptor("asset.inspect", { 1, 0 }).has_value(), + "provider unregister left a descriptor behind"); + + FTestCapabilityRegistry IsolationRegistry; + FTestCapabilityRegistration MutableRegistration = MakeTestRegistration( + "render.inspect", + { 1, 0 }, + ECapabilityEffect::Query, + "render", + HandlerCalls); + const std::shared_ptr ExternalSchema = + MutableRegistration.Descriptor.InputSchema.Fields.front().Schema; + Require( + !IsolationRegistry.Register(MutableRegistration).has_value(), + "descriptor isolation registration rejected"); + ExternalSchema->Description = "mutated after registration"; + std::optional IsolatedDescriptor = + IsolationRegistry.ResolveDescriptor("render.inspect", { 1, 0 }); + Require( + IsolatedDescriptor.has_value() + && IsolatedDescriptor->InputSchema.Fields.front().Schema->Description + != ExternalSchema->Description, + "registry retained caller-owned mutable schema storage"); + IsolatedDescriptor->InputSchema.Fields.front().Schema->Description = + "mutated returned descriptor"; + IsolatedDescriptor = + IsolationRegistry.ResolveDescriptor("render.inspect", { 1, 0 }); + Require( + IsolatedDescriptor.has_value() + && IsolatedDescriptor->InputSchema.Fields.front().Schema->Description + != "mutated returned descriptor", + "descriptor query exposed mutable registry storage"); + + FTestCapabilityRegistry AvailabilityRegistry; + FTestCapabilityRegistration UnavailableRegistration = MakeTestRegistration( + "build.inspect", + { 1, 0 }, + ECapabilityEffect::Query, + "build", + HandlerCalls); + UnavailableRegistration.Descriptor.bAvailable = false; + UnavailableRegistration.Descriptor.AvailabilityReason = + "provider prerequisites are missing"; + Require( + !AvailabilityRegistry.Register(std::move(UnavailableRegistration)).has_value(), + "unavailable capability descriptor was rejected"); + const auto UnavailableInvocation = + AvailabilityRegistry.Invoke("build.inspect", { 1, 0 }, 1); + Require( + !UnavailableInvocation.Succeeded() + && UnavailableInvocation.Diagnostics.size() == 1 + && UnavailableInvocation.Diagnostics.front().Code + == "contract.registry.capability_unavailable" + && UnavailableInvocation.Diagnostics.front().Details + == "provider prerequisites are missing", + "unavailable capability invocation did not return a stable diagnostic"); + return 0; } } diff --git a/Tests/mcp_asset_input_contracts.py b/Tests/mcp_asset_input_contracts.py index 6e6d4c5..fbf3dc5 100644 --- a/Tests/mcp_asset_input_contracts.py +++ b/Tests/mcp_asset_input_contracts.py @@ -13,6 +13,7 @@ import json import queue +import re import subprocess import sys import threading @@ -116,7 +117,7 @@ def expect_stderr(fragment: str, start_index: int) -> None: ) # Brackets and escaped quotes inside strings must not contribute to depth. - request( + discovery_response = request( { "jsonrpc": "2.0", "id": 3, @@ -125,6 +126,21 @@ def expect_stderr(fragment: str, start_index: int) -> None: }, None, ) + expected_names = sorted( + { + match.group(1) + for source_path in (repository / "Engine" / "Source" / "UI").rglob("*.cpp") + for match in re.finditer( + r'Registry\.(?:AddReadOnly|Add)\s*\(\s*"([^"]+)"', + source_path.read_text(encoding="utf-8"), + ) + } + ) + assert expected_names, "source capability registration catalog is empty" + assert discovery_response["result"] == expected_names, ( + discovery_response["result"], + expected_names, + ) # An explicit null id is still a request; a missing id is a notification. request({"jsonrpc": "2.0", "id": None, "method": "rpc.discover", "params": []}, None) diff --git a/Tests/mcp_capability_registration_contracts.py b/Tests/mcp_capability_registration_contracts.py new file mode 100644 index 0000000..d330978 --- /dev/null +++ b/Tests/mcp_capability_registration_contracts.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +def fail(message: str) -> None: + raise RuntimeError(message) + + +def split_top_level_arguments(call: str) -> list[str]: + arguments: list[str] = [] + start = 0 + depths = {"(": 0, "{": 0, "[": 0, "<": 0} + closing = {")": "(", "}": "{", "]": "[", ">": "<"} + in_string = False + escaped = False + + for index, character in enumerate(call): + if in_string: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + in_string = False + continue + if character == '"': + in_string = True + continue + if character in depths: + depths[character] += 1 + continue + if character in closing: + opener = closing[character] + depths[opener] -= 1 + continue + if character == "," and all(depth == 0 for depth in depths.values()): + arguments.append(call[start:index].strip()) + start = index + 1 + + arguments.append(call[start:].strip()) + return arguments + + +def extract_calls(source: str) -> list[tuple[str, list[str]]]: + calls: list[tuple[str, list[str]]] = [] + pattern = re.compile(r"Registry\.(AddReadOnly|Add)\s*\(") + for match in pattern.finditer(source): + position = match.end() + depth = 1 + in_string = False + escaped = False + while position < len(source) and depth: + character = source[position] + if in_string: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + in_string = False + elif character == '"': + in_string = True + elif character == "(": + depth += 1 + elif character == ")": + depth -= 1 + position += 1 + if depth: + fail("unterminated FMcpRegistry registration call") + body = source[match.end() : position - 1] + calls.append((match.group(1), split_top_level_arguments(body))) + return calls + + +def main() -> int: + if len(sys.argv) != 2: + print( + "usage: mcp_capability_registration_contracts.py ", + file=sys.stderr, + ) + return 2 + + repository_root = Path(sys.argv[1]).resolve() + ui_root = repository_root / "Engine" / "Source" / "UI" + source_files = sorted(ui_root.rglob("*.cpp")) + registrations: list[tuple[str, list[str], Path]] = [] + for source_path in source_files: + source = source_path.read_text(encoding="utf-8") + registrations.extend( + (kind, arguments, source_path) + for kind, arguments in extract_calls(source) + ) + + if not registrations: + fail("no legacy MCP capability registrations found") + + names: set[str] = set() + for kind, arguments, source_path in registrations: + expected_counts = {5} if kind == "AddReadOnly" else {5, 6} + if len(arguments) not in expected_counts: + fail( + f"{source_path.relative_to(repository_root)} uses legacy or malformed " + f"{kind} registration with {len(arguments)} arguments" + ) + if not re.fullmatch(r'"[^"]+"', arguments[0], re.DOTALL): + fail(f"{source_path.name} capability name must be a stable string literal") + if not re.fullmatch(r'"[^"]+"', arguments[1], re.DOTALL): + fail(f"{source_path.name} capability summary must be a non-empty literal") + if not arguments[2].startswith("{"): + fail(f"{source_path.name} capability must declare named input fields") + + name = arguments[0][1:-1] + if name in names: + fail(f"duplicate MCP capability name: {name}") + names.add(name) + + registry_header = ( + repository_root / "Engine" / "Source" / "Mcp" / "McpRegistry.h" + ).read_text(encoding="utf-8") + for fragment in ( + '#include "Contract/CapabilityRegistry.h"', + "FCapabilityDescriptor Descriptor", + "Validator", + "MakeDescriptor", + "ListDescriptors", + ): + if fragment not in registry_header: + fail(f"MCP registry is missing capability contract binding: {fragment}") + + registry_source = ( + repository_root / "Engine" / "Source" / "Mcp" / "McpRegistry.cpp" + ).read_text(encoding="utf-8") + for fragment in ( + "ConvertNamedCapabilityInput", + "Capabilities.Register", + "Registration.Descriptor", + "Registration.Handler", + "Registration.Validator", + ): + if fragment not in registry_source: + fail(f"MCP registry does not bind named capability input: {fragment}") + + server_source = ( + repository_root / "Engine" / "Source" / "Mcp" / "McpServer.cpp" + ).read_text(encoding="utf-8") + if 'Request.Method == "rpc.discover"' not in server_source or "ListNames()" not in server_source: + fail("legacy rpc.discover compatibility path changed during registry migration") + + print( + "MCP capability registration contracts passed: " + f"{len(registrations)} descriptor/handler/validator bindings, " + "named input metadata, legacy discover preserved" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError) as error: + print(f"MCP capability registration contracts failed: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/docs/specs/2026-07-25-a0-01-contract-core.md b/docs/specs/2026-07-25-a0-01-contract-core.md index 77e68d9..33c9123 100644 --- a/docs/specs/2026-07-25-a0-01-contract-core.md +++ b/docs/specs/2026-07-25-a0-01-contract-core.md @@ -13,7 +13,7 @@ - Supersedes: None - Superseded by: None -> 本 Spec 已获用户明确接受;M0-01 已 Verified,Slice 1 已通过独立 Debug/Release CPU 契约,当前准备实施 Slice 2。Slice 4 仍等待 G0-01。 +> 本 Spec 已获用户明确接受;M0-01 已 Verified,Slice 1~2 已通过独立 Debug/Release CPU 契约与现有 MCP 兼容验证,当前准备实施 Slice 3。Slice 4 仍等待 G0-01。 ## 1. Context @@ -30,7 +30,9 @@ - 当前产品仍只有单体 `WaveEngine` Editor executable;`WavePlayer`、Headless Developer host 和领域 capability federation 尚不存在。 - M0-01 已提供无 Engine PCH、窗口、RHI、Editor 或第三方依赖的 `WaveCoreContracts`,并由 CMake preset 与 Windows CPU CI 固定。 -- Contract Core Slice 1 已提供 schema、descriptor、effect/error/ref/revision/change cursor、artifact/provenance/evidence 公共词汇,以及有界规范 schema JSON 与基础校验;尚无领域 registry/handler。 +- Contract Core Slice 1 已提供 schema、descriptor、effect/error/ref/revision/change cursor、artifact/provenance/evidence 公共词汇,以及有界规范 schema JSON 与基础校验。 +- Slice 2 已提供 transport-neutral capability registry;同一注册点绑定 descriptor/handler/validator,按 name/version 稳定排序与兼容解析,并拒绝 duplicate、同 major 的 effect/schema/provider 漂移。 +- 现有 35 个 MCP 工具已作为 legacy compatibility provider 补齐版本、effect、具名输入、permission/risk/budget 和 availability;动态 `FJson` 形状在对应领域迁移前明确标为 unavailable,新 registry 不伪造类型保证,旧位置参数方法仍可用。 - 已有 stdio JSON-RPC 2.0 server、`FMcpRegistry`、主线程 Dispatcher 和输入上限。 - MCP 工具由 `FEditorShell` 和 Panel 注册,handler 直接调用 UI owner;没有 transport-neutral domain service。 - `rpc.discover` 只返回方法名。Registry 的 read-only 标记不控制并发或权限;所有调用仍同步切到主线程。 @@ -236,7 +238,7 @@ Start Job → Produce Staged Artifact → Validate → Promote with Command | Slice | Scope | Invariant | Verification | Status | |---|---|---|---|---| | 1 | `FValueSchema`、descriptor、effect/error/ref/revision/change-feed 基础类型 | 不依赖 MCP、UI 或模型 SDK | schema round-trip、version/cursor/resync、invalid descriptor contract | Verified | -| 2 | Registry 绑定 descriptor/handler;描述现有工具 | descriptor 与实际参数/效果不漂移 | registration、duplicate/version/effect tests | Pending | +| 2 | Registry 绑定 descriptor/handler;描述现有工具 | descriptor 与实际参数/效果不漂移 | registration、duplicate/version/effect tests | Verified | | 3 | 新 capability list/describe API 与结构化错误 adapter | 旧 `rpc.discover`、位置参数和 framing 不回退 | existing MCP contracts + new schema/error contracts | Pending | | 4 | target/header closure 与文档迁移 | Player/Runtime 无 Developer host/MCP closure | G0 boundary contract、Debug/Release | Pending | @@ -297,12 +299,14 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac - 已建立跨所有 Full SDD Spec 的 AI-Native Impact 门禁。 - M0-01 已 Verified;A0-01 进入 Implementing。 - Slice 1 已实现 `ContractCore` 公共类型、schema/descriptor 校验、有界规范 schema JSON、cursor resync 语义,并接入无 PCH 的 `WaveCoreContracts`。 -- Slice 1 在 Debug/Release CPU presets 下各通过 5/5 tests,静态闭包确认无 MCP/UI/RHI/第三方依赖。 +- Slice 2 已实现 transport-neutral `TCapabilityRegistry`,把 descriptor、handler、validator 与 provider lifetime 绑定在同一注册项,并对外返回深拷贝 descriptor,避免共享 schema 被调用者修改。 +- 现有 35 个 MCP 工具在原注册点生成具名 schema 与 metadata;运行期 catalog 与源码 catalog 一致,旧 `rpc.discover`、位置参数和错误码路径保持。 +- Debug/Release CPU presets 各通过 6/6 tests;独立 Contract Core 闭包无 MCP/UI/RHI/第三方依赖,MCP malformed-input/clean-exit contract 通过。 ### Next Step -- 实施 Slice 2:以领域 ownership 为边界绑定 descriptor/handler/validator,并增加 registration、duplicate/version/effect contracts。 -- Slice 3 在 Slice 2 验证后继续;target closure 的 Slice 4 等待 G0-01。 +- 实施 Slice 3:新增 capability list/describe API 与 descriptor JSON adapter,把结构化 contract diagnostic 映射到 JSON-RPC error data。 +- 保持旧 `rpc.discover`、位置参数方法和 framing;target closure 的 Slice 4 等待 G0-01。 ### Changes and Deviations @@ -313,6 +317,8 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac - 增加 schema/capability/格式独立演进、revisioned change feed 和多 session 冲突边界。 - 已把 Developer Host、Context/Evidence federation、独立 WaveDevHost 与 Agent Eval 的后继边界固化到 A0-02~A0-04。 - Slice 1 仅提供 transport-neutral vocabulary 与确定性校验;未引入 registry、MCP adapter、领域 handler 或任何模型 SDK。 +- Slice 2 不迁移 Panel 内的 legacy handler;它们只作为 compatibility provider 接入公共 registry,领域 ownership 仍由对应 G Spec 落地。 +- 无法由首版 `FValueSchema` 诚实表达的动态 `FJson` 输入/输出显式标为 unavailable 并给出迁移原因;旧 MCP 方法继续可用,不把 opaque payload 伪装成 typed capability。 ### Evidence @@ -326,8 +332,10 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac | 2026-07-25 | working tree | Downstream design review | A0-02 Session/Policy/Operation/Audit;A0-03 Context/Evidence/Artifact;A0-04 Headless/Conformance/Eval | Pass | 3 downstream Drafts 与依赖矩阵 | | 2026-07-26 | working tree | VS 2022 / Windows SDK 10.0.26100.0 / C++20 | `cmake --build Build --config Debug`;`cmake --build Build --config Release`;`ctest --preset test-cpu-debug`;`ctest --preset test-cpu-release` | Pass | 完整 Debug/Release build;CPU Debug 5/5、Release 5/5;schema canonical round-trip、invalid descriptor、version/cursor/resync contracts | | 2026-07-26 | working tree | Python 3 / static source closure | `py -3 Tests/spec_contracts.py .`;`py -3 Tests/core_contract_target_contracts.py .` | Pass | 8 Specs;16 explicit sources / 29 project files;无 forbidden dependency/PCH | +| 2026-07-26 | `a72fbe4` | GitHub Actions clean checkout | CPU Contracts run `30187005073` | Pass | Windows Debug/Release build 与 CPU tests;1m45s | +| 2026-07-26 | working tree | VS 2022 / Python 3 | `cmake --build Build --config Debug/Release`;`ctest --test-dir Build -C Debug --output-on-failure`;`ctest --preset test-cpu-release` | Pass | Debug 8/8、CPU Release 6/6;35 个 descriptor/handler/validator bindings;legacy discover/位置参数/clean exit;WaveEngine.log 无 Error/Fatal/D3D12 validation error | ### Remaining Work -- Slice 2~4 与 Acceptance Criteria;其中 Slice 4 等待 G0-01。 +- Slice 3~4 与 Acceptance Criteria;其中 Slice 4 等待 G0-01。 - A0-02~A0-04 的接受、产品依赖与实现。 From 121282282ddcb4adf694d701cd040dd3afcb2ca0 Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sun, 26 Jul 2026 12:35:52 +0800 Subject: [PATCH 07/16] publish capability contract APIs --- Engine/Source/Contract/ContractCore.cpp | 556 +++++++++++++++++- Engine/Source/Contract/ContractCore.h | 12 + Engine/Source/Mcp/JsonRpc.cpp | 12 +- Engine/Source/Mcp/JsonRpc.h | 9 +- Engine/Source/Mcp/McpCapabilityAdapter.cpp | 451 ++++++++++++++ Engine/Source/Mcp/McpCapabilityAdapter.h | 29 + Engine/Source/Mcp/McpRegistry.cpp | 7 + Engine/Source/Mcp/McpRegistry.h | 3 + Engine/Source/Mcp/McpServer.cpp | 90 ++- Tests/ContractCoreContracts.cpp | 63 ++ Tests/mcp_asset_input_contracts.py | 161 ++++- .../mcp_capability_registration_contracts.py | 29 +- docs/specs/2026-07-25-a0-01-contract-core.md | 26 +- 13 files changed, 1420 insertions(+), 28 deletions(-) create mode 100644 Engine/Source/Mcp/McpCapabilityAdapter.cpp create mode 100644 Engine/Source/Mcp/McpCapabilityAdapter.h diff --git a/Engine/Source/Contract/ContractCore.cpp b/Engine/Source/Contract/ContractCore.cpp index cdfbadf..2652c26 100644 --- a/Engine/Source/Contract/ContractCore.cpp +++ b/Engine/Source/Contract/ContractCore.cpp @@ -118,6 +118,28 @@ namespace WaveContract Path + ".id.name", "root schema id name is required"); } + if (Schema.Id.Name.size() > MaxContractNameBytes) + { + return MakeDiagnostic( + "contract.schema.id_size_exceeded", + Path + ".id.name", + "schema id name exceeds the supported byte limit"); + } + if (Schema.Description.size() > MaxContractTextBytes) + { + return MakeDiagnostic( + "contract.schema.description_size_exceeded", + Path + ".description", + "schema description exceeds the supported byte limit"); + } + if (Schema.Unit.size() > MaxContractNameBytes + || Schema.CoordinateSystem.size() > MaxContractNameBytes) + { + return MakeDiagnostic( + "contract.schema.metadata_size_exceeded", + Path, + "schema unit or coordinate metadata exceeds the supported byte limit"); + } if (!Schema.Id.Name.empty() && Schema.Id.Version.Major == 0) { return MakeDiagnostic( @@ -212,6 +234,13 @@ namespace WaveContract FieldPath + ".name", "schema field name is required"); } + if (Field.Name.size() > MaxContractNameBytes) + { + return MakeDiagnostic( + "contract.schema.field_name_size_exceeded", + FieldPath + ".name", + "schema field name exceeds the supported byte limit"); + } if (!FieldNames.insert(Field.Name).second) { return MakeDiagnostic( @@ -294,7 +323,9 @@ namespace WaveContract for (size_t ValueIndex = 0; ValueIndex < Schema.EnumValues.size(); ++ValueIndex) { const std::string& Value = Schema.EnumValues[ValueIndex]; - if (Value.empty() || !Values.insert(Value).second) + if (Value.empty() + || Value.size() > MaxContractTextBytes + || !Values.insert(Value).second) { return MakeDiagnostic( "contract.schema.enum_value_invalid", @@ -408,7 +439,10 @@ namespace WaveContract return AppendNumber(Output, *Value); } - bool AppendSchemaJson(std::string& Output, const FValueSchema& Schema) + bool AppendSchemaJson( + std::string& Output, + const FValueSchema& Schema, + size_t MaxOutputBytes = MaxSchemaJsonBytes) { Output += "{\"id\":{\"name\":"; AppendEscapedString(Output, Schema.Id.Name); @@ -473,7 +507,8 @@ namespace WaveContract Output += ",\"required\":"; Output += Field.bRequired ? "true" : "false"; Output += ",\"schema\":"; - if (Field.Schema == nullptr || !AppendSchemaJson(Output, *Field.Schema)) + if (Field.Schema == nullptr + || !AppendSchemaJson(Output, *Field.Schema, MaxOutputBytes)) { return false; } @@ -482,7 +517,7 @@ namespace WaveContract Output += "],\"items\":"; if (Schema.Items != nullptr) { - if (!AppendSchemaJson(Output, *Schema.Items)) + if (!AppendSchemaJson(Output, *Schema.Items, MaxOutputBytes)) { return false; } @@ -492,7 +527,99 @@ namespace WaveContract Output += "null"; } Output.push_back('}'); - return Output.size() <= MaxSchemaJsonBytes; + return Output.size() <= MaxOutputBytes; + } + + void AppendVersionJson( + std::string& Output, + const FContractVersion& Version) + { + Output += "{\"major\":"; + AppendUnsigned(Output, Version.Major); + Output += ",\"minor\":"; + AppendUnsigned(Output, Version.Minor); + Output.push_back('}'); + } + + bool AppendDescriptorJson( + std::string& Output, + const FCapabilityDescriptor& Descriptor) + { + Output += "{\"name\":"; + AppendEscapedString(Output, Descriptor.Name); + Output += ",\"version\":"; + AppendVersionJson(Output, Descriptor.Version); + Output += ",\"stability\":"; + AppendEscapedString(Output, Descriptor.Stability); + Output += ",\"deprecatedReplacement\":"; + AppendEscapedString(Output, Descriptor.DeprecatedReplacement); + Output += ",\"minimumReaderVersion\":"; + AppendVersionJson(Output, Descriptor.MinimumReaderVersion); + Output += ",\"minimumWriterVersion\":"; + AppendVersionJson(Output, Descriptor.MinimumWriterVersion); + Output += ",\"summary\":"; + AppendEscapedString(Output, Descriptor.Summary); + Output += ",\"effect\":"; + AppendEscapedString(Output, ToString(Descriptor.Effect)); + Output += ",\"inputSchema\":"; + if (!AppendSchemaJson( + Output, + Descriptor.InputSchema, + MaxDescriptorJsonBytes)) + { + return false; + } + Output += ",\"outputSchema\":"; + if (!AppendSchemaJson( + Output, + Descriptor.OutputSchema, + MaxDescriptorJsonBytes)) + { + return false; + } + Output += ",\"permission\":"; + AppendEscapedString(Output, Descriptor.Permission); + Output += ",\"risk\":"; + AppendEscapedString(Output, Descriptor.Risk); + Output += ",\"available\":"; + Output += Descriptor.bAvailable ? "true" : "false"; + Output += ",\"availabilityReason\":"; + AppendEscapedString(Output, Descriptor.AvailabilityReason); + Output += ",\"supportsPreview\":"; + Output += Descriptor.bSupportsPreview ? "true" : "false"; + Output += ",\"supportsUndo\":"; + Output += Descriptor.bSupportsUndo ? "true" : "false"; + Output += ",\"supportsCompensation\":"; + Output += Descriptor.bSupportsCompensation ? "true" : "false"; + Output += ",\"idempotent\":"; + Output += Descriptor.bIdempotent ? "true" : "false"; + Output += ",\"supportsCancel\":"; + Output += Descriptor.bSupportsCancel ? "true" : "false"; + Output += ",\"supportsRetry\":"; + Output += Descriptor.bSupportsRetry ? "true" : "false"; + Output += ",\"budget\":{\"maxInputBytes\":"; + AppendUnsigned(Output, Descriptor.Budget.MaxInputBytes); + Output += ",\"maxOutputBytes\":"; + AppendUnsigned(Output, Descriptor.Budget.MaxOutputBytes); + Output += ",\"timeoutMilliseconds\":"; + AppendUnsigned(Output, Descriptor.Budget.TimeoutMilliseconds); + Output += ",\"maxConcurrency\":"; + AppendUnsigned(Output, Descriptor.Budget.MaxConcurrency); + Output += "},\"coordinateSystem\":"; + AppendEscapedString(Output, Descriptor.CoordinateSystem); + Output += ",\"units\":"; + AppendEscapedString(Output, Descriptor.Units); + Output += ",\"referenceScope\":"; + if (Descriptor.ReferenceScope.has_value()) + { + AppendEscapedString(Output, ToString(*Descriptor.ReferenceScope)); + } + else + { + Output += "null"; + } + Output.push_back('}'); + return Output.size() <= MaxDescriptorJsonBytes; } enum class EJsonKind : uint8_t @@ -517,19 +644,27 @@ namespace WaveContract class FBoundedJsonParser { public: - explicit FBoundedJsonParser(std::string_view InJson) + explicit FBoundedJsonParser( + std::string_view InJson, + size_t InMaxBytes = MaxSchemaJsonBytes, + size_t InMaxNodes = MaxSchemaNodes * 8, + std::string InSizeDiagnosticCode = + "contract.schema_json.size_exceeded") : Json(InJson) + , MaxBytes(InMaxBytes) + , MaxNodes(InMaxNodes) + , SizeDiagnosticCode(std::move(InSizeDiagnosticCode)) { } bool Parse(FJsonValue& OutValue, FStructuredDiagnostic& OutDiagnostic) { - if (Json.size() > MaxSchemaJsonBytes) + if (Json.size() > MaxBytes) { OutDiagnostic = MakeDiagnostic( - "contract.schema_json.size_exceeded", + SizeDiagnosticCode, "$", - "schema JSON exceeds the supported byte limit"); + "contract JSON exceeds the supported byte limit"); return false; } if (!ParseValue(OutValue, 1)) @@ -582,7 +717,7 @@ namespace WaveContract "contract.schema_json.depth_exceeded", "schema JSON nesting exceeds the supported depth"); } - if (++NodeCount > MaxSchemaNodes * 8) + if (++NodeCount > MaxNodes) { return Fail( "contract.schema_json.nodes_exceeded", @@ -911,6 +1046,9 @@ namespace WaveContract } std::string_view Json; + size_t MaxBytes; + size_t MaxNodes; + std::string SizeDiagnosticCode; size_t Position = 0; size_t NodeCount = 0; FStructuredDiagnostic Diagnostic; @@ -1156,6 +1294,22 @@ namespace WaveContract return std::nullopt; } + std::optional ParseCapabilityEffect(std::string_view Value) + { + for (const ECapabilityEffect Effect : { + ECapabilityEffect::Query, + ECapabilityEffect::Command, + ECapabilityEffect::Job, + ECapabilityEffect::ExternalAction }) + { + if (ToString(Effect) == Value) + { + return Effect; + } + } + return std::nullopt; + } + bool ReadSchema( const FJsonValue& Value, FValueSchema& OutSchema, @@ -1415,6 +1569,271 @@ namespace WaveContract } return true; } + + bool ReadDescriptor( + const FJsonValue& Value, + FCapabilityDescriptor& OutDescriptor, + FStructuredDiagnostic& OutDiagnostic) + { + const std::string Path = "$"; + if (!RequireProperties( + Value, + { + "name", + "version", + "stability", + "deprecatedReplacement", + "minimumReaderVersion", + "minimumWriterVersion", + "summary", + "effect", + "inputSchema", + "outputSchema", + "permission", + "risk", + "available", + "availabilityReason", + "supportsPreview", + "supportsUndo", + "supportsCompensation", + "idempotent", + "supportsCancel", + "supportsRetry", + "budget", + "coordinateSystem", + "units", + "referenceScope" + }, + Path, + OutDiagnostic)) + { + return false; + } + + if (!ReadString( + *FindProperty(Value, "name"), + OutDescriptor.Name, + "$.name", + OutDiagnostic) + || !ReadVersion( + *FindProperty(Value, "version"), + OutDescriptor.Version, + "$.version", + OutDiagnostic) + || !ReadString( + *FindProperty(Value, "stability"), + OutDescriptor.Stability, + "$.stability", + OutDiagnostic) + || !ReadString( + *FindProperty(Value, "deprecatedReplacement"), + OutDescriptor.DeprecatedReplacement, + "$.deprecatedReplacement", + OutDiagnostic) + || !ReadVersion( + *FindProperty(Value, "minimumReaderVersion"), + OutDescriptor.MinimumReaderVersion, + "$.minimumReaderVersion", + OutDiagnostic) + || !ReadVersion( + *FindProperty(Value, "minimumWriterVersion"), + OutDescriptor.MinimumWriterVersion, + "$.minimumWriterVersion", + OutDiagnostic) + || !ReadString( + *FindProperty(Value, "summary"), + OutDescriptor.Summary, + "$.summary", + OutDiagnostic)) + { + return false; + } + + std::string EffectName; + if (!ReadString( + *FindProperty(Value, "effect"), + EffectName, + "$.effect", + OutDiagnostic)) + { + return false; + } + const std::optional Effect = + ParseCapabilityEffect(EffectName); + if (!Effect.has_value()) + { + OutDiagnostic = MakeDiagnostic( + "contract.descriptor_json.effect_invalid", + "$.effect", + "unknown capability effect"); + return false; + } + OutDescriptor.Effect = *Effect; + + size_t InputSchemaNodeCount = 0; + size_t OutputSchemaNodeCount = 0; + if (!ReadSchema( + *FindProperty(Value, "inputSchema"), + OutDescriptor.InputSchema, + "$.inputSchema", + 1, + InputSchemaNodeCount, + OutDiagnostic) + || !ReadSchema( + *FindProperty(Value, "outputSchema"), + OutDescriptor.OutputSchema, + "$.outputSchema", + 1, + OutputSchemaNodeCount, + OutDiagnostic) + || !ReadString( + *FindProperty(Value, "permission"), + OutDescriptor.Permission, + "$.permission", + OutDiagnostic) + || !ReadString( + *FindProperty(Value, "risk"), + OutDescriptor.Risk, + "$.risk", + OutDiagnostic) + || !ReadBoolean( + *FindProperty(Value, "available"), + OutDescriptor.bAvailable, + "$.available", + OutDiagnostic) + || !ReadString( + *FindProperty(Value, "availabilityReason"), + OutDescriptor.AvailabilityReason, + "$.availabilityReason", + OutDiagnostic) + || !ReadBoolean( + *FindProperty(Value, "supportsPreview"), + OutDescriptor.bSupportsPreview, + "$.supportsPreview", + OutDiagnostic) + || !ReadBoolean( + *FindProperty(Value, "supportsUndo"), + OutDescriptor.bSupportsUndo, + "$.supportsUndo", + OutDiagnostic) + || !ReadBoolean( + *FindProperty(Value, "supportsCompensation"), + OutDescriptor.bSupportsCompensation, + "$.supportsCompensation", + OutDiagnostic) + || !ReadBoolean( + *FindProperty(Value, "idempotent"), + OutDescriptor.bIdempotent, + "$.idempotent", + OutDiagnostic) + || !ReadBoolean( + *FindProperty(Value, "supportsCancel"), + OutDescriptor.bSupportsCancel, + "$.supportsCancel", + OutDiagnostic) + || !ReadBoolean( + *FindProperty(Value, "supportsRetry"), + OutDescriptor.bSupportsRetry, + "$.supportsRetry", + OutDiagnostic)) + { + return false; + } + + const FJsonValue& Budget = *FindProperty(Value, "budget"); + if (!RequireProperties( + Budget, + { + "maxInputBytes", + "maxOutputBytes", + "timeoutMilliseconds", + "maxConcurrency" + }, + "$.budget", + OutDiagnostic)) + { + return false; + } + uint64_t MaxConcurrency = 0; + if (!ReadUnsigned( + *FindProperty(Budget, "maxInputBytes"), + OutDescriptor.Budget.MaxInputBytes, + "$.budget.maxInputBytes", + OutDiagnostic) + || !ReadUnsigned( + *FindProperty(Budget, "maxOutputBytes"), + OutDescriptor.Budget.MaxOutputBytes, + "$.budget.maxOutputBytes", + OutDiagnostic) + || !ReadUnsigned( + *FindProperty(Budget, "timeoutMilliseconds"), + OutDescriptor.Budget.TimeoutMilliseconds, + "$.budget.timeoutMilliseconds", + OutDiagnostic) + || !ReadUnsigned( + *FindProperty(Budget, "maxConcurrency"), + MaxConcurrency, + "$.budget.maxConcurrency", + OutDiagnostic)) + { + return false; + } + if (MaxConcurrency > std::numeric_limits::max()) + { + OutDiagnostic = MakeDiagnostic( + "contract.descriptor_json.budget_invalid", + "$.budget.maxConcurrency", + "maxConcurrency exceeds uint32 range"); + return false; + } + OutDescriptor.Budget.MaxConcurrency = + static_cast(MaxConcurrency); + + if (!ReadString( + *FindProperty(Value, "coordinateSystem"), + OutDescriptor.CoordinateSystem, + "$.coordinateSystem", + OutDiagnostic) + || !ReadString( + *FindProperty(Value, "units"), + OutDescriptor.Units, + "$.units", + OutDiagnostic)) + { + return false; + } + + const FJsonValue& ReferenceScope = + *FindProperty(Value, "referenceScope"); + if (ReferenceScope.Kind == EJsonKind::Null) + { + OutDescriptor.ReferenceScope.reset(); + } + else + { + std::string ScopeName; + if (!ReadString( + ReferenceScope, + ScopeName, + "$.referenceScope", + OutDiagnostic)) + { + return false; + } + const std::optional Scope = + ParseReferenceScope(ScopeName); + if (!Scope.has_value()) + { + OutDiagnostic = MakeDiagnostic( + "contract.descriptor_json.reference_scope_invalid", + "$.referenceScope", + "unknown descriptor reference scope"); + return false; + } + OutDescriptor.ReferenceScope = *Scope; + } + return true; + } } bool FSchemaField::operator==(const FSchemaField& Other) const @@ -1497,6 +1916,20 @@ namespace WaveContract return "unknown"; } + std::string_view ToString(EDiagnosticSeverity Severity) + { + switch (Severity) + { + case EDiagnosticSeverity::Info: + return "info"; + case EDiagnosticSeverity::Warning: + return "warning"; + case EDiagnosticSeverity::Error: + return "error"; + } + return "unknown"; + } + EChangeCursorStatus EvaluateChangeCursor( const FChangeCursor& Cursor, std::string_view ExpectedProvider, @@ -1531,6 +1964,14 @@ namespace WaveContract "$.name", "capability name is required"); } + if (Descriptor.Name.size() > MaxContractNameBytes + || Descriptor.DeprecatedReplacement.size() > MaxContractNameBytes) + { + return MakeDiagnostic( + "contract.descriptor.name_size_exceeded", + "$.name", + "capability name or replacement exceeds the supported byte limit"); + } if (Descriptor.Version.Major == 0) { return MakeDiagnostic( @@ -1548,6 +1989,19 @@ namespace WaveContract "$", "stability, summary, permission, and risk are required"); } + if (Descriptor.Stability.size() > MaxContractNameBytes + || Descriptor.Summary.size() > MaxContractTextBytes + || Descriptor.Permission.size() > MaxContractNameBytes + || Descriptor.Risk.size() > MaxContractNameBytes + || Descriptor.AvailabilityReason.size() > MaxContractTextBytes + || Descriptor.CoordinateSystem.size() > MaxContractNameBytes + || Descriptor.Units.size() > MaxContractNameBytes) + { + return MakeDiagnostic( + "contract.descriptor.metadata_size_exceeded", + "$", + "capability descriptor metadata exceeds the supported byte limit"); + } if (Descriptor.MinimumReaderVersion.Major == 0 || Descriptor.MinimumWriterVersion.Major == 0) { @@ -1573,6 +2027,25 @@ namespace WaveContract Diagnostic->Path = "$.outputSchema" + Diagnostic->Path.substr(1); return Diagnostic; } + std::string SchemaJson; + SchemaJson.reserve(1024); + if (!AppendSchemaJson(SchemaJson, Descriptor.InputSchema) + || SchemaJson.size() > MaxSchemaJsonBytes) + { + return MakeDiagnostic( + "contract.descriptor.input_schema_size_exceeded", + "$.inputSchema", + "input schema JSON exceeds the supported byte limit"); + } + SchemaJson.clear(); + if (!AppendSchemaJson(SchemaJson, Descriptor.OutputSchema) + || SchemaJson.size() > MaxSchemaJsonBytes) + { + return MakeDiagnostic( + "contract.descriptor.output_schema_size_exceeded", + "$.outputSchema", + "output schema JSON exceeds the supported byte limit"); + } if (Descriptor.Budget.MaxInputBytes == 0 || Descriptor.Budget.MaxOutputBytes == 0 || Descriptor.Budget.TimeoutMilliseconds == 0 @@ -1689,4 +2162,67 @@ namespace WaveContract OutDiagnostic = {}; return true; } + + std::string SerializeDescriptorCanonicalJson( + const FCapabilityDescriptor& Descriptor) + { + if (ValidateDescriptor(Descriptor).has_value()) + { + return {}; + } + std::string Output; + Output.reserve(4096); + if (!AppendDescriptorJson(Output, Descriptor) + || Output.size() > MaxDescriptorJsonBytes) + { + return {}; + } + return Output; + } + + bool DeserializeDescriptorCanonicalJson( + std::string_view Json, + FCapabilityDescriptor& OutDescriptor, + FStructuredDiagnostic& OutDiagnostic) + { + const auto NormalizeDescriptorDiagnostic = + [](FStructuredDiagnostic& Diagnostic) + { + constexpr std::string_view SchemaJsonPrefix = + "contract.schema_json."; + if (Diagnostic.Code.starts_with(SchemaJsonPrefix)) + { + Diagnostic.Code = + "contract.descriptor_json." + + Diagnostic.Code.substr(SchemaJsonPrefix.size()); + } + }; + + FJsonValue Root; + FBoundedJsonParser Parser( + Json, + MaxDescriptorJsonBytes, + MaxSchemaNodes * 16, + "contract.descriptor_json.size_exceeded"); + if (!Parser.Parse(Root, OutDiagnostic)) + { + NormalizeDescriptorDiagnostic(OutDiagnostic); + return false; + } + + FCapabilityDescriptor ParsedDescriptor; + if (!ReadDescriptor(Root, ParsedDescriptor, OutDiagnostic)) + { + NormalizeDescriptorDiagnostic(OutDiagnostic); + return false; + } + if (auto Diagnostic = ValidateDescriptor(ParsedDescriptor)) + { + OutDiagnostic = std::move(*Diagnostic); + return false; + } + OutDescriptor = std::move(ParsedDescriptor); + OutDiagnostic = {}; + return true; + } } diff --git a/Engine/Source/Contract/ContractCore.h b/Engine/Source/Contract/ContractCore.h index 403c1fc..b36f6f0 100644 --- a/Engine/Source/Contract/ContractCore.h +++ b/Engine/Source/Contract/ContractCore.h @@ -12,8 +12,11 @@ namespace WaveContract { inline constexpr size_t MaxSchemaJsonBytes = 256 * 1024; + inline constexpr size_t MaxDescriptorJsonBytes = 1024 * 1024; inline constexpr size_t MaxSchemaDepth = 32; inline constexpr size_t MaxSchemaNodes = 4096; + inline constexpr size_t MaxContractNameBytes = 512; + inline constexpr size_t MaxContractTextBytes = 4096; struct FContractVersion { @@ -189,6 +192,8 @@ namespace WaveContract std::string CoordinateSystem; std::string Units; std::optional ReferenceScope; + + bool operator==(const FCapabilityDescriptor&) const = default; }; struct FArtifactRef @@ -224,6 +229,7 @@ namespace WaveContract [[nodiscard]] std::string_view ToString(EReferenceScope Scope); [[nodiscard]] std::string_view ToString(ECapabilityEffect Effect); [[nodiscard]] std::string_view ToString(EValueKind Kind); + [[nodiscard]] std::string_view ToString(EDiagnosticSeverity Severity); [[nodiscard]] EChangeCursorStatus EvaluateChangeCursor( const FChangeCursor& Cursor, @@ -243,4 +249,10 @@ namespace WaveContract std::string_view Json, FValueSchema& OutSchema, FStructuredDiagnostic& OutDiagnostic); + [[nodiscard]] std::string SerializeDescriptorCanonicalJson( + const FCapabilityDescriptor& Descriptor); + [[nodiscard]] bool DeserializeDescriptorCanonicalJson( + std::string_view Json, + FCapabilityDescriptor& OutDescriptor, + FStructuredDiagnostic& OutDiagnostic); } diff --git a/Engine/Source/Mcp/JsonRpc.cpp b/Engine/Source/Mcp/JsonRpc.cpp index cd70c65..40dea8a 100644 --- a/Engine/Source/Mcp/JsonRpc.cpp +++ b/Engine/Source/Mcp/JsonRpc.cpp @@ -1,6 +1,7 @@ #include "JsonRpc.h" #include +#include FJsonRpcRequest ParseJsonRpcRequest(const std::string& Line) { @@ -68,6 +69,10 @@ FJson FJsonRpcResponse::ToJson() const if (bIsError) { J["error"] = { {"code", ErrorCode}, {"message", ErrorMessage} }; + if (!ErrorData.is_null()) + { + J["error"]["data"] = ErrorData; + } } else { @@ -84,12 +89,17 @@ FJsonRpcResponse MakeJsonRpcResult(const FJson& Id, const FJson& Result) return R; } -FJsonRpcResponse MakeJsonRpcError(const FJson& Id, int Code, const std::string& Message) +FJsonRpcResponse MakeJsonRpcError( + const FJson& Id, + int Code, + const std::string& Message, + FJson Data) { FJsonRpcResponse R; R.Id = Id; R.bIsError = true; R.ErrorCode = Code; R.ErrorMessage = Message; + R.ErrorData = std::move(Data); return R; } diff --git a/Engine/Source/Mcp/JsonRpc.h b/Engine/Source/Mcp/JsonRpc.h index 2398c3b..be1cb8a 100644 --- a/Engine/Source/Mcp/JsonRpc.h +++ b/Engine/Source/Mcp/JsonRpc.h @@ -4,6 +4,7 @@ #include #include +#include // JSON-RPC 2.0 标准错误码 namespace EJsonRpcError @@ -14,6 +15,7 @@ namespace EJsonRpcError constexpr int InvalidParams = -32602; constexpr int InternalError = -32603; constexpr int ServerBusy = -32000; + constexpr int ContractError = -32001; } class FJsonRpcException final : public std::runtime_error @@ -43,6 +45,7 @@ struct FJsonRpcResponse FJson Result = nullptr; int ErrorCode = 0; std::string ErrorMessage; + FJson ErrorData = nullptr; FJson ToJson() const; }; @@ -54,4 +57,8 @@ FJsonRpcRequest ParseJsonRpcRequest(const std::string& Line); FJsonRpcResponse MakeJsonRpcResult(const FJson& Id, const FJson& Result); // 包装成错误响应 -FJsonRpcResponse MakeJsonRpcError(const FJson& Id, int Code, const std::string& Message); +FJsonRpcResponse MakeJsonRpcError( + const FJson& Id, + int Code, + const std::string& Message, + FJson Data = nullptr); diff --git a/Engine/Source/Mcp/McpCapabilityAdapter.cpp b/Engine/Source/Mcp/McpCapabilityAdapter.cpp new file mode 100644 index 0000000..a53161a --- /dev/null +++ b/Engine/Source/Mcp/McpCapabilityAdapter.cpp @@ -0,0 +1,451 @@ +#include "McpCapabilityAdapter.h" + +#include "JsonRpc.h" +#include "McpRegistry.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + constexpr WaveContract::FContractVersion CapabilityApiVersion{ 1, 0 }; + constexpr uint64_t DefaultListLimit = 50; + constexpr uint64_t MaxListLimit = 100; + + WaveContract::FStructuredDiagnostic MakeDiagnostic( + std::string Code, + std::string Path, + std::string Message) + { + WaveContract::FStructuredDiagnostic Diagnostic; + Diagnostic.Code = std::move(Code); + Diagnostic.Path = std::move(Path); + Diagnostic.Message = std::move(Message); + return Diagnostic; + } + + [[noreturn]] void ThrowInvalidInput( + std::string Code, + std::string Path, + std::string Message) + { + throw FMcpContractError( + EJsonRpcError::InvalidParams, + MakeDiagnostic(std::move(Code), std::move(Path), std::move(Message))); + } + + void RequireNamedObject( + const FJson& Value, + std::initializer_list AllowedProperties) + { + if (!Value.is_object()) + { + ThrowInvalidInput( + "contract.input.object_required", + "$", + "capability API params must be a named object"); + } + const std::unordered_set Allowed( + AllowedProperties.begin(), + AllowedProperties.end()); + for (auto Property = Value.begin(); Property != Value.end(); ++Property) + { + if (!Allowed.contains(Property.key())) + { + ThrowInvalidInput( + "contract.input.field_unknown", + "$." + Property.key(), + "capability API params contain an unknown field"); + } + } + } + + uint64_t ReadUnsigned( + const FJson& Value, + std::string Path) + { + if (Value.is_number_unsigned()) + { + try + { + return Value.get(); + } + catch (const FJson::exception&) + { + ThrowInvalidInput( + "contract.input.unsigned_range", + std::move(Path), + "unsigned integer is outside the supported range"); + } + } + if (!Value.is_number_integer()) + { + ThrowInvalidInput( + "contract.input.unsigned_required", + std::move(Path), + "expected an unsigned integer"); + } + const int64_t SignedValue = Value.get(); + if (SignedValue < 0) + { + ThrowInvalidInput( + "contract.input.unsigned_required", + std::move(Path), + "expected an unsigned integer"); + } + return static_cast(SignedValue); + } + + WaveContract::FContractVersion ReadVersion( + const FJson& Value, + std::string Path) + { + if (!Value.is_object() + || Value.size() != 2 + || !Value.contains("major") + || !Value.contains("minor")) + { + ThrowInvalidInput( + "contract.input.version_required", + std::move(Path), + "version must contain only major and minor"); + } + const uint64_t Major = ReadUnsigned(Value["major"], Path + ".major"); + const uint64_t Minor = ReadUnsigned(Value["minor"], Path + ".minor"); + if (Major > std::numeric_limits::max() + || Minor > std::numeric_limits::max()) + { + ThrowInvalidInput( + "contract.input.version_range", + std::move(Path), + "version component exceeds uint16 range"); + } + return { + static_cast(Major), + static_cast(Minor) + }; + } + + WaveContract::FContractVersion ReadApiVersion(const FJson& Params) + { + const auto Version = Params.find("api_version"); + const WaveContract::FContractVersion Supported = + Version == Params.end() + ? CapabilityApiVersion + : ReadVersion(*Version, "$.api_version"); + if (Supported.Major != CapabilityApiVersion.Major + || Supported.Minor < CapabilityApiVersion.Minor) + { + ThrowInvalidInput( + "contract.api_version.unsupported", + "$.api_version", + "capability API major version is unsupported"); + } + return Supported; + } + + FJson VersionToJson(WaveContract::FContractVersion Version) + { + return { + { "major", Version.Major }, + { "minor", Version.Minor } + }; + } + + FJson OptionalReferenceScopeToJson( + const std::optional& Scope) + { + if (!Scope.has_value()) + { + return nullptr; + } + return std::string(WaveContract::ToString(*Scope)); + } + + FJson SchemaToJson(const WaveContract::FValueSchema& Schema) + { + const std::string CanonicalJson = + WaveContract::SerializeSchemaCanonicalJson(Schema); + if (CanonicalJson.empty()) + { + WaveContract::FStructuredDiagnostic Diagnostic = MakeDiagnostic( + "contract.serialization.schema_failed", + "$", + "registered capability schema could not be serialized"); + throw FMcpContractError(EJsonRpcError::InternalError, std::move(Diagnostic)); + } + try + { + return FJson::parse(CanonicalJson); + } + catch (const FJson::exception&) + { + WaveContract::FStructuredDiagnostic Diagnostic = MakeDiagnostic( + "contract.serialization.schema_failed", + "$", + "registered capability schema produced invalid JSON"); + throw FMcpContractError(EJsonRpcError::InternalError, std::move(Diagnostic)); + } + } + + FJson DescriptorSummaryToJson( + const WaveContract::FCapabilityDescriptor& Descriptor) + { + return { + { "name", Descriptor.Name }, + { "version", VersionToJson(Descriptor.Version) }, + { "stability", Descriptor.Stability }, + { "deprecated_replacement", Descriptor.DeprecatedReplacement.empty() + ? FJson(nullptr) + : FJson(Descriptor.DeprecatedReplacement) }, + { "summary", Descriptor.Summary }, + { "effect", std::string(WaveContract::ToString(Descriptor.Effect)) }, + { "available", Descriptor.bAvailable }, + { "availability_reason", Descriptor.AvailabilityReason.empty() + ? FJson(nullptr) + : FJson(Descriptor.AvailabilityReason) } + }; + } + + FJson DescriptorToJson( + const WaveContract::FCapabilityDescriptor& Descriptor) + { + FJson Result = DescriptorSummaryToJson(Descriptor); + Result["minimum_reader_version"] = + VersionToJson(Descriptor.MinimumReaderVersion); + Result["minimum_writer_version"] = + VersionToJson(Descriptor.MinimumWriterVersion); + Result["input_schema"] = SchemaToJson(Descriptor.InputSchema); + Result["output_schema"] = SchemaToJson(Descriptor.OutputSchema); + Result["permission"] = Descriptor.Permission; + Result["risk"] = Descriptor.Risk; + Result["supports_preview"] = Descriptor.bSupportsPreview; + Result["supports_undo"] = Descriptor.bSupportsUndo; + Result["supports_compensation"] = Descriptor.bSupportsCompensation; + Result["idempotent"] = Descriptor.bIdempotent; + Result["supports_cancel"] = Descriptor.bSupportsCancel; + Result["supports_retry"] = Descriptor.bSupportsRetry; + Result["resource_budget"] = { + { "max_input_bytes", Descriptor.Budget.MaxInputBytes }, + { "max_output_bytes", Descriptor.Budget.MaxOutputBytes }, + { "timeout_milliseconds", Descriptor.Budget.TimeoutMilliseconds }, + { "max_concurrency", Descriptor.Budget.MaxConcurrency } + }; + Result["coordinate_system"] = Descriptor.CoordinateSystem.empty() + ? FJson(nullptr) + : FJson(Descriptor.CoordinateSystem); + Result["units"] = Descriptor.Units.empty() + ? FJson(nullptr) + : FJson(Descriptor.Units); + Result["reference_scope"] = + OptionalReferenceScopeToJson(Descriptor.ReferenceScope); + + const size_t SerializedBytes = Result.dump().size(); + if (SerializedBytes > WaveContract::MaxDescriptorJsonBytes) + { + WaveContract::FStructuredDiagnostic Diagnostic = MakeDiagnostic( + "contract.serialization.descriptor_size_exceeded", + "$", + "capability descriptor exceeds the supported output limit"); + throw FMcpContractError(EJsonRpcError::InternalError, std::move(Diagnostic)); + } + return Result; + } +} + +FJson ContractDiagnosticToJson( + const WaveContract::FStructuredDiagnostic& Diagnostic) +{ + return { + { "code", Diagnostic.Code }, + { "path", Diagnostic.Path }, + { "message", Diagnostic.Message }, + { "severity", std::string(WaveContract::ToString(Diagnostic.Severity)) }, + { "retryable", Diagnostic.bRetryable }, + { "details", Diagnostic.Details.empty() + ? FJson(nullptr) + : FJson(Diagnostic.Details) }, + { "correlation_id", Diagnostic.CorrelationId.empty() + ? FJson(nullptr) + : FJson(Diagnostic.CorrelationId) } + }; +} + +FJson CapabilityList(const FJson& Params) +{ + RequireNamedObject( + Params, + { + "api_version", + "cursor", + "limit", + "name_prefix", + "include_unavailable" + }); + const WaveContract::FContractVersion SupportedApiVersion = + ReadApiVersion(Params); + + uint64_t Cursor = 0; + if (const auto Value = Params.find("cursor"); Value != Params.end()) + { + Cursor = ReadUnsigned(*Value, "$.cursor"); + } + uint64_t Limit = DefaultListLimit; + if (const auto Value = Params.find("limit"); Value != Params.end()) + { + Limit = ReadUnsigned(*Value, "$.limit"); + } + if (Limit == 0 || Limit > MaxListLimit) + { + ThrowInvalidInput( + "contract.input.limit_range", + "$.limit", + "capability list limit must be between 1 and 100"); + } + + std::string NamePrefix; + if (const auto Value = Params.find("name_prefix"); Value != Params.end()) + { + if (!Value->is_string()) + { + ThrowInvalidInput( + "contract.input.string_required", + "$.name_prefix", + "name_prefix must be a string"); + } + NamePrefix = Value->get(); + if (NamePrefix.size() > WaveContract::MaxContractNameBytes) + { + ThrowInvalidInput( + "contract.input.string_size", + "$.name_prefix", + "name_prefix exceeds the supported byte limit"); + } + } + + bool bIncludeUnavailable = true; + if (const auto Value = Params.find("include_unavailable"); Value != Params.end()) + { + if (!Value->is_boolean()) + { + ThrowInvalidInput( + "contract.input.boolean_required", + "$.include_unavailable", + "include_unavailable must be a boolean"); + } + bIncludeUnavailable = Value->get(); + } + + std::vector Descriptors = + FMcpRegistry::Get().ListDescriptors(); + Descriptors.erase( + std::remove_if( + Descriptors.begin(), + Descriptors.end(), + [&](const WaveContract::FCapabilityDescriptor& Descriptor) + { + return (!NamePrefix.empty() + && !Descriptor.Name.starts_with(NamePrefix)) + || (!bIncludeUnavailable && !Descriptor.bAvailable) + || Descriptor.MinimumReaderVersion.Major + != SupportedApiVersion.Major + || Descriptor.MinimumReaderVersion.Minor + > SupportedApiVersion.Minor; + }), + Descriptors.end()); + + if (Cursor > Descriptors.size()) + { + ThrowInvalidInput( + "contract.input.cursor_range", + "$.cursor", + "capability list cursor is outside the filtered result"); + } + const size_t Begin = static_cast(Cursor); + const size_t Remaining = Descriptors.size() - Begin; + const size_t Count = std::min(static_cast(Limit), Remaining); + const size_t End = Begin + Count; + + FJson Items = FJson::array(); + for (size_t Index = Begin; Index < End; ++Index) + { + Items.push_back(DescriptorSummaryToJson(Descriptors[Index])); + } + + FJson Result = { + { "api_version", VersionToJson(CapabilityApiVersion) }, + { "items", std::move(Items) }, + { "next_cursor", End < Descriptors.size() + ? FJson(End) + : FJson(nullptr) }, + { "total", Descriptors.size() } + }; + if (Result.dump().size() > WaveContract::MaxDescriptorJsonBytes) + { + WaveContract::FStructuredDiagnostic Diagnostic = MakeDiagnostic( + "contract.serialization.list_size_exceeded", + "$", + "capability list response exceeds the supported output limit"); + throw FMcpContractError(EJsonRpcError::InternalError, std::move(Diagnostic)); + } + return Result; +} + +FJson CapabilityDescribe(const FJson& Params) +{ + RequireNamedObject(Params, { "api_version", "name", "version" }); + const WaveContract::FContractVersion SupportedApiVersion = + ReadApiVersion(Params); + + const auto NameValue = Params.find("name"); + if (NameValue == Params.end() || !NameValue->is_string()) + { + ThrowInvalidInput( + "contract.input.field_required", + "$.name", + "capability name is required"); + } + const std::string Name = NameValue->get(); + if (Name.empty() || Name.size() > WaveContract::MaxContractNameBytes) + { + ThrowInvalidInput( + "contract.input.string_size", + "$.name", + "capability name must contain 1 to 512 bytes"); + } + + const auto VersionValue = Params.find("version"); + if (VersionValue == Params.end()) + { + ThrowInvalidInput( + "contract.input.field_required", + "$.version", + "supported capability version is required"); + } + const WaveContract::FContractVersion SupportedCapabilityVersion = + ReadVersion(*VersionValue, "$.version"); + + const std::optional Descriptor = + FMcpRegistry::Get().ResolveDescriptor(Name, SupportedCapabilityVersion); + if (!Descriptor.has_value() + || Descriptor->MinimumReaderVersion.Major != SupportedApiVersion.Major + || Descriptor->MinimumReaderVersion.Minor > SupportedApiVersion.Minor) + { + WaveContract::FStructuredDiagnostic Diagnostic = MakeDiagnostic( + "contract.capability.not_found", + "$.name", + "no compatible capability descriptor is registered"); + throw FMcpContractError(EJsonRpcError::ContractError, std::move(Diagnostic)); + } + + return { + { "api_version", VersionToJson(CapabilityApiVersion) }, + { "descriptor", DescriptorToJson(*Descriptor) } + }; +} diff --git a/Engine/Source/Mcp/McpCapabilityAdapter.h b/Engine/Source/Mcp/McpCapabilityAdapter.h new file mode 100644 index 0000000..fa2f86c --- /dev/null +++ b/Engine/Source/Mcp/McpCapabilityAdapter.h @@ -0,0 +1,29 @@ +#pragma once + +#include "Contract/ContractCore.h" +#include "JsonSerializer.h" + +#include +#include + +inline constexpr const char* McpCapabilityListMethod = "capability.list"; +inline constexpr const char* McpCapabilityDescribeMethod = "capability.describe"; + +class FMcpContractError final : public std::runtime_error +{ +public: + FMcpContractError(int InJsonRpcCode, WaveContract::FStructuredDiagnostic InDiagnostic) + : std::runtime_error(InDiagnostic.Message) + , JsonRpcCode(InJsonRpcCode) + , Diagnostic(std::move(InDiagnostic)) + { + } + + int JsonRpcCode; + WaveContract::FStructuredDiagnostic Diagnostic; +}; + +[[nodiscard]] FJson ContractDiagnosticToJson( + const WaveContract::FStructuredDiagnostic& Diagnostic); +[[nodiscard]] FJson CapabilityList(const FJson& Params); +[[nodiscard]] FJson CapabilityDescribe(const FJson& Params); diff --git a/Engine/Source/Mcp/McpRegistry.cpp b/Engine/Source/Mcp/McpRegistry.cpp index 2cbdabe..c47bff3 100644 --- a/Engine/Source/Mcp/McpRegistry.cpp +++ b/Engine/Source/Mcp/McpRegistry.cpp @@ -199,3 +199,10 @@ std::vector FMcpRegistry::ListDescriptors() { return Capabilities.ListDescriptors(); } + +std::optional FMcpRegistry::ResolveDescriptor( + std::string_view Name, + WaveContract::FContractVersion SupportedVersion) const +{ + return Capabilities.ResolveDescriptor(Name, SupportedVersion); +} diff --git a/Engine/Source/Mcp/McpRegistry.h b/Engine/Source/Mcp/McpRegistry.h index 5966211..4d9f46e 100644 --- a/Engine/Source/Mcp/McpRegistry.h +++ b/Engine/Source/Mcp/McpRegistry.h @@ -96,6 +96,9 @@ class FMcpRegistry std::vector ListNames() const; std::vector ListDescriptors() const; + std::optional ResolveDescriptor( + std::string_view Name, + WaveContract::FContractVersion SupportedVersion) const; private: FMcpRegistry() = default; diff --git a/Engine/Source/Mcp/McpServer.cpp b/Engine/Source/Mcp/McpServer.cpp index 6323a76..6326ab4 100644 --- a/Engine/Source/Mcp/McpServer.cpp +++ b/Engine/Source/Mcp/McpServer.cpp @@ -2,6 +2,7 @@ #include "JsonRpc.h" #include "MainThreadDispatcher.h" +#include "McpCapabilityAdapter.h" #include "McpInput.h" #include "McpRegistry.h" #include "Misc/Log.h" @@ -12,6 +13,7 @@ #include #include #include +#include #if defined(_WIN32) #define WIN32_LEAN_AND_MEAN @@ -20,6 +22,21 @@ #include #endif +namespace +{ + FJson MakeServerDiagnostic( + std::string Code, + std::string Path, + std::string Message) + { + WaveContract::FStructuredDiagnostic Diagnostic; + Diagnostic.Code = std::move(Code); + Diagnostic.Path = std::move(Path); + Diagnostic.Message = std::move(Message); + return ContractDiagnosticToJson(Diagnostic); + } +} + FMcpServer& FMcpServer::Get() { static FMcpServer Instance; @@ -100,14 +117,22 @@ void FMcpServer::RunStdio() Response = MakeJsonRpcError( nullptr, EJsonRpcError::InvalidRequest, - "JSON-RPC request exceeds the 1 MiB line limit"); + "JSON-RPC request exceeds the 1 MiB line limit", + MakeServerDiagnostic( + "contract.transport.line_limit", + "$", + "JSON-RPC request exceeds the 1 MiB line limit")); } else if (McpInput::ExceedsJsonNestingDepth(Line)) { Response = MakeJsonRpcError( nullptr, EJsonRpcError::InvalidRequest, - "JSON-RPC request exceeds the maximum nesting depth of 128"); + "JSON-RPC request exceeds the maximum nesting depth of 128", + MakeServerDiagnostic( + "contract.transport.depth_limit", + "$", + "JSON-RPC request exceeds the maximum nesting depth of 128")); } else if (Line.empty()) { @@ -127,6 +152,14 @@ void FMcpServer::RunStdio() std::vector Names = FMcpRegistry::Get().ListNames(); Result = FJson(Names); } + else if (Request.Method == McpCapabilityListMethod) + { + Result = CapabilityList(Request.Params); + } + else if (Request.Method == McpCapabilityDescribeMethod) + { + Result = CapabilityDescribe(Request.Params); + } else { // 不区分 read-only:所有 Invoke 一律 marshal 到主线程, @@ -146,23 +179,66 @@ void FMcpServer::RunStdio() catch (const FJsonRpcException& Ex) { Id = Ex.Id; - Response = MakeJsonRpcError(Id, Ex.Code, Ex.what()); + Response = MakeJsonRpcError( + Id, + Ex.Code, + Ex.what(), + MakeServerDiagnostic( + "contract.json_rpc.invalid_request", + "$", + Ex.what())); + } + catch (const FMcpContractError& Ex) + { + Response = MakeJsonRpcError( + Id, + Ex.JsonRpcCode, + Ex.what(), + ContractDiagnosticToJson(Ex.Diagnostic)); } catch (const std::invalid_argument& Ex) { - Response = MakeJsonRpcError(Id, EJsonRpcError::InvalidParams, Ex.what()); + Response = MakeJsonRpcError( + Id, + EJsonRpcError::InvalidParams, + Ex.what(), + MakeServerDiagnostic( + "contract.input.invalid", + "$.params", + Ex.what())); } catch (const FMcpMethodNotFound& Ex) { - Response = MakeJsonRpcError(Id, EJsonRpcError::MethodNotFound, Ex.what()); + Response = MakeJsonRpcError( + Id, + EJsonRpcError::MethodNotFound, + Ex.what(), + MakeServerDiagnostic( + "contract.method.not_found", + "$.method", + Ex.what())); } catch (const std::runtime_error& Ex) { - Response = MakeJsonRpcError(Id, EJsonRpcError::InternalError, Ex.what()); + Response = MakeJsonRpcError( + Id, + EJsonRpcError::InternalError, + Ex.what(), + MakeServerDiagnostic( + "contract.handler.failed", + "$", + Ex.what())); } catch (const std::exception& Ex) { - Response = MakeJsonRpcError(Id, EJsonRpcError::InternalError, Ex.what()); + Response = MakeJsonRpcError( + Id, + EJsonRpcError::InternalError, + Ex.what(), + MakeServerDiagnostic( + "contract.handler.failed", + "$", + Ex.what())); } if (bIsNotification) diff --git a/Tests/ContractCoreContracts.cpp b/Tests/ContractCoreContracts.cpp index fa08c41..0926b22 100644 --- a/Tests/ContractCoreContracts.cpp +++ b/Tests/ContractCoreContracts.cpp @@ -175,6 +175,8 @@ namespace WaveContracts Require(ToString(EReferenceScope::Document) == "document", "reference scope spelling"); Require(ToString(ECapabilityEffect::ExternalAction) == "external_action", "effect spelling"); + Require(ToString(EDiagnosticSeverity::Warning) == "warning", + "diagnostic severity spelling"); const FValueSchema InputSchema = MakeInputSchema(); Require(!ValidateSchema(InputSchema).has_value(), "valid schema rejected"); @@ -223,6 +225,47 @@ namespace WaveContracts FCapabilityDescriptor Descriptor = MakeValidDescriptor(); Require(!ValidateDescriptor(Descriptor).has_value(), "valid descriptor rejected"); + const std::string DescriptorJson = + SerializeDescriptorCanonicalJson(Descriptor); + Require(!DescriptorJson.empty(), "valid descriptor serialization failed"); + Require( + DescriptorJson.size() <= MaxDescriptorJsonBytes, + "descriptor JSON byte bound"); + FCapabilityDescriptor RoundTrippedDescriptor; + Require( + DeserializeDescriptorCanonicalJson( + DescriptorJson, + RoundTrippedDescriptor, + Diagnostic), + "canonical descriptor JSON did not deserialize"); + Require( + RoundTrippedDescriptor == Descriptor, + "descriptor JSON round-trip changed the descriptor"); + Require( + SerializeDescriptorCanonicalJson(RoundTrippedDescriptor) + == DescriptorJson, + "descriptor JSON serialization is not canonical"); + Require( + !DeserializeDescriptorCanonicalJson( + "{\"name\":", + RoundTrippedDescriptor, + Diagnostic), + "malformed descriptor JSON was accepted"); + Require( + Diagnostic.Code.starts_with("contract.descriptor_json."), + "malformed descriptor diagnostic code is unstable"); + const std::string OversizedDescriptorJson( + MaxDescriptorJsonBytes + 1, + ' '); + Require( + !DeserializeDescriptorCanonicalJson( + OversizedDescriptorJson, + RoundTrippedDescriptor, + Diagnostic), + "oversized descriptor JSON was accepted"); + Require( + Diagnostic.Code == "contract.descriptor_json.size_exceeded", + "oversized descriptor JSON diagnostic code"); Descriptor.bSupportsUndo = true; const std::optional UndoDiagnostic = ValidateDescriptor(Descriptor); @@ -242,6 +285,26 @@ namespace WaveContracts AvailabilityDiagnostic->Code == "contract.descriptor.availability_reason_required", "availability descriptor diagnostic code"); + Descriptor = MakeValidDescriptor(); + Descriptor.Summary.assign(MaxContractTextBytes + 1, 'x'); + const std::optional DescriptorSizeDiagnostic = + ValidateDescriptor(Descriptor); + Require( + DescriptorSizeDiagnostic.has_value() + && DescriptorSizeDiagnostic->Code + == "contract.descriptor.metadata_size_exceeded", + "oversized descriptor metadata was accepted"); + + FValueSchema OversizedSchema = MakeInputSchema(); + OversizedSchema.Description.assign(MaxContractTextBytes + 1, 'x'); + const std::optional SchemaSizeDiagnostic = + ValidateSchema(OversizedSchema); + Require( + SchemaSizeDiagnostic.has_value() + && SchemaSizeDiagnostic->Code + == "contract.schema.description_size_exceeded", + "oversized schema metadata was accepted"); + FChangeCursor Cursor; Cursor.Provider = "world.change_feed"; Cursor.SchemaVersion = { 1, 3 }; diff --git a/Tests/mcp_asset_input_contracts.py b/Tests/mcp_asset_input_contracts.py index fbf3dc5..938f693 100644 --- a/Tests/mcp_asset_input_contracts.py +++ b/Tests/mcp_asset_input_contracts.py @@ -106,7 +106,8 @@ def expect_stderr(fragment: str, start_index: int) -> None: stderr_condition.wait(timeout=remaining) try: - request("x" * (1024 * 1024 + 1), -32600) + line_limit_response = request("x" * (1024 * 1024 + 1), -32600) + assert line_limit_response["error"]["data"]["code"] == "contract.transport.line_limit" nested = "0" for _ in range(129): @@ -147,7 +148,163 @@ def expect_stderr(fragment: str, start_index: int) -> None: process.stdin.write('{"jsonrpc":"2.0","method":"rpc.discover","params":[]}\n') process.stdin.flush() + capability_list = request( + { + "jsonrpc": "2.0", + "id": 11, + "method": "capability.list", + "params": {}, + }, + None, + )["result"] + assert capability_list["api_version"] == {"major": 1, "minor": 0} + assert capability_list["total"] == len(expected_names) + assert capability_list["next_cursor"] is None + assert [item["name"] for item in capability_list["items"]] == expected_names + assert all( + set(item) + == { + "name", + "version", + "stability", + "deprecated_replacement", + "summary", + "effect", + "available", + "availability_reason", + } + for item in capability_list["items"] + ) + + first_page = request( + { + "jsonrpc": "2.0", + "id": 12, + "method": "capability.list", + "params": {"limit": 2, "name_prefix": "editor."}, + }, + 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] + assert first_page["next_cursor"] == 2 + assert first_page["total"] == len(expected_editor_names) + + available_only = request( + { + "jsonrpc": "2.0", + "id": 13, + "method": "capability.list", + "params": {"include_unavailable": False}, + }, + 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) + + camera_set_descriptor = request( + { + "jsonrpc": "2.0", + "id": 14, + "method": "capability.describe", + "params": { + "name": "editor.viewport.camera.set", + "version": {"major": 1, "minor": 0}, + }, + }, + None, + )["result"]["descriptor"] + assert camera_set_descriptor["name"] == "editor.viewport.camera.set" + assert camera_set_descriptor["version"] == {"major": 1, "minor": 0} + assert camera_set_descriptor["effect"] == "command" + assert camera_set_descriptor["available"] is True + assert camera_set_descriptor["permission"] == "editor.write" + assert camera_set_descriptor["risk"] == "medium" + assert camera_set_descriptor["reference_scope"] == "session" + assert camera_set_descriptor["input_schema"]["kind"] == "object" + assert [ + field["name"] for field in camera_set_descriptor["input_schema"]["fields"] + ] == ["position", "rotation_deg"] + assert camera_set_descriptor["input_schema"]["fields"][0]["schema"][ + "coordinateSystem" + ] == "LH_XForward_YRight_ZUp" + + camera_get_descriptor = request( + { + "jsonrpc": "2.0", + "id": 15, + "method": "capability.describe", + "params": { + "name": "editor.viewport.camera.get", + "version": {"major": 1, "minor": 0}, + }, + }, + None, + )["result"]["descriptor"] + assert camera_get_descriptor["available"] is False + assert "legacy dynamic JSON shape" in camera_get_descriptor["availability_reason"] + + missing_capability = request( + { + "jsonrpc": "2.0", + "id": 16, + "method": "capability.describe", + "params": { + "name": "editor.viewport.camera.set", + "version": {"major": 2, "minor": 0}, + }, + }, + -32001, + ) + assert missing_capability["error"]["data"]["code"] == "contract.capability.not_found" + assert missing_capability["error"]["data"]["path"] == "$.name" + + invalid_capability_params = request( + { + "jsonrpc": "2.0", + "id": 17, + "method": "capability.list", + "params": [], + }, + -32602, + ) + assert ( + invalid_capability_params["error"]["data"]["code"] + == "contract.input.object_required" + ) + assert set(invalid_capability_params["error"]["data"]) == { + "code", + "path", + "message", + "severity", + "retryable", + "details", + "correlation_id", + } + + unsupported_api = request( + { + "jsonrpc": "2.0", + "id": 18, + "method": "capability.list", + "params": {"api_version": {"major": 2, "minor": 0}}, + }, + -32602, + ) + assert unsupported_api["error"]["data"]["code"] == "contract.api_version.unsupported" + request( + {"jsonrpc": "2.0", "id": None, "method": "capability.list", "params": {}}, + None, + ) + process.stdin.write( + '{"jsonrpc":"2.0","method":"capability.list","params":{}}\n' + ) + process.stdin.flush() + + invalid_lighting = request( { "jsonrpc": "2.0", "id": 4, @@ -156,6 +313,8 @@ def expect_stderr(fragment: str, start_index: int) -> None: }, -32602, ) + assert invalid_lighting["error"]["data"]["code"] == "contract.input.invalid" + assert invalid_lighting["error"]["data"]["path"] == "$.params" request( { "jsonrpc": "2.0", diff --git a/Tests/mcp_capability_registration_contracts.py b/Tests/mcp_capability_registration_contracts.py index d330978..e31fdd6 100644 --- a/Tests/mcp_capability_registration_contracts.py +++ b/Tests/mcp_capability_registration_contracts.py @@ -150,11 +150,38 @@ def main() -> int: ).read_text(encoding="utf-8") if 'Request.Method == "rpc.discover"' not in server_source or "ListNames()" not in server_source: fail("legacy rpc.discover compatibility path changed during registry migration") + for fragment in ( + "McpCapabilityListMethod", + "McpCapabilityDescribeMethod", + "ContractDiagnosticToJson", + ): + if fragment not in server_source: + fail(f"MCP server is missing capability API routing: {fragment}") + + adapter_source = ( + repository_root / "Engine" / "Source" / "Mcp" / "McpCapabilityAdapter.cpp" + ).read_text(encoding="utf-8") + for fragment in ( + "CapabilityList", + "CapabilityDescribe", + "SerializeSchemaCanonicalJson", + "MaxDescriptorJsonBytes", + "contract.api_version.unsupported", + "contract.capability.not_found", + ): + if fragment not in adapter_source: + fail(f"capability JSON adapter contract is missing: {fragment}") + + json_rpc_source = ( + repository_root / "Engine" / "Source" / "Mcp" / "JsonRpc.cpp" + ).read_text(encoding="utf-8") + if 'J["error"]["data"]' not in json_rpc_source: + fail("JSON-RPC structured error data is not serialized") print( "MCP capability registration contracts passed: " f"{len(registrations)} descriptor/handler/validator bindings, " - "named input metadata, legacy discover preserved" + "named input metadata, list/describe routing, legacy discover preserved" ) return 0 diff --git a/docs/specs/2026-07-25-a0-01-contract-core.md b/docs/specs/2026-07-25-a0-01-contract-core.md index 33c9123..7d15573 100644 --- a/docs/specs/2026-07-25-a0-01-contract-core.md +++ b/docs/specs/2026-07-25-a0-01-contract-core.md @@ -13,7 +13,7 @@ - Supersedes: None - Superseded by: None -> 本 Spec 已获用户明确接受;M0-01 已 Verified,Slice 1~2 已通过独立 Debug/Release CPU 契约与现有 MCP 兼容验证,当前准备实施 Slice 3。Slice 4 仍等待 G0-01。 +> 本 Spec 已获用户明确接受;M0-01 已 Verified,Slice 1~3 已通过 Debug/Release、规范 JSON、MCP compatibility 与结构化错误契约。Slice 4 仍等待 G0-01。 ## 1. Context @@ -33,6 +33,7 @@ - Contract Core Slice 1 已提供 schema、descriptor、effect/error/ref/revision/change cursor、artifact/provenance/evidence 公共词汇,以及有界规范 schema JSON 与基础校验。 - Slice 2 已提供 transport-neutral capability registry;同一注册点绑定 descriptor/handler/validator,按 name/version 稳定排序与兼容解析,并拒绝 duplicate、同 major 的 effect/schema/provider 漂移。 - 现有 35 个 MCP 工具已作为 legacy compatibility provider 补齐版本、effect、具名输入、permission/risk/budget 和 availability;动态 `FJson` 形状在对应领域迁移前明确标为 unavailable,新 registry 不伪造类型保证,旧位置参数方法仍可用。 +- Slice 3 已提供 `capability.list` / `capability.describe`,支持 API/capability 独立版本、稳定排序、prefix/availability filter、`1..100` 分页和完整 descriptor/schema;JSON-RPC error 在保留既有 code/message 时增加结构化 `error.data`。 - 已有 stdio JSON-RPC 2.0 server、`FMcpRegistry`、主线程 Dispatcher 和输入上限。 - MCP 工具由 `FEditorShell` 和 Panel 注册,handler 直接调用 UI owner;没有 transport-neutral domain service。 - `rpc.discover` 只返回方法名。Registry 的 read-only 标记不控制并发或权限;所有调用仍同步切到主线程。 @@ -156,6 +157,13 @@ resource_budget coordinate_system / units / reference_scope ``` +首版 MCP compatibility adapter 暴露两个具名参数 API: + +- `capability.list`:参数为 `{api_version?, cursor?, limit?, name_prefix?, include_unavailable?}`;`api_version` 当前为 `1.0`,`limit` 为 `1..100`,响应返回稳定排序的 descriptor summary、总数与下一 cursor。 +- `capability.describe`:参数为 `{api_version?, name, version}`;`version` 表示 client 支持的 capability major/minor,响应返回同 major 中不高于该 minor 的最新兼容完整 descriptor。 + +两者不加入领域 capability catalog,也不改变旧 `rpc.discover` 的字符串数组;它们是 adapter 自描述入口。完整 descriptor 中的 schema 使用 Contract Core 规范 JSON 表示。JSON-RPC error 保留既有 `code/message`,并在 `error.data` 增加 `{code,path,message,severity,retryable,details,correlation_id}`;旧 client 可忽略新增 data。 + 首版使用引擎自有、版本化的 `FValueSchema` 描述树,并提供规范 JSON 表示;它只覆盖实际 API 所需的 object、array、enum、number、string、reference 和约束。schema、validator、serializer 与 handler 在同一注册点绑定,避免手写文档漂移。新 API 使用具名 object 参数;旧位置数组只存在于 compatibility adapter。 schema、capability 和持久格式分别维护版本。兼容新增可提升 minor;删除、重命名、单位/默认值/effect 改变必须使用新 major 或显式迁移。Query 可以返回 snapshot、diff 或 change feed;增量 cursor 绑定 provider/schema/revision,过期或丢失返回稳定 `resync_required` 错误。 @@ -239,7 +247,7 @@ Start Job → Produce Staged Artifact → Validate → Promote with Command |---|---|---|---|---| | 1 | `FValueSchema`、descriptor、effect/error/ref/revision/change-feed 基础类型 | 不依赖 MCP、UI 或模型 SDK | schema round-trip、version/cursor/resync、invalid descriptor contract | Verified | | 2 | Registry 绑定 descriptor/handler;描述现有工具 | descriptor 与实际参数/效果不漂移 | registration、duplicate/version/effect tests | Verified | -| 3 | 新 capability list/describe API 与结构化错误 adapter | 旧 `rpc.discover`、位置参数和 framing 不回退 | existing MCP contracts + new schema/error contracts | Pending | +| 3 | 新 capability list/describe API 与结构化错误 adapter | 旧 `rpc.discover`、位置参数和 framing 不回退 | existing MCP contracts + new schema/error contracts | Verified | | 4 | target/header closure 与文档迁移 | Player/Runtime 无 Developer host/MCP closure | G0 boundary contract、Debug/Release | Pending | A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transaction、Headless 或 Agent workflow。那些能力只有在对应 G/A Spec Verified 后才能写入“已实现”文档。 @@ -301,12 +309,13 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac - Slice 1 已实现 `ContractCore` 公共类型、schema/descriptor 校验、有界规范 schema JSON、cursor resync 语义,并接入无 PCH 的 `WaveCoreContracts`。 - Slice 2 已实现 transport-neutral `TCapabilityRegistry`,把 descriptor、handler、validator 与 provider lifetime 绑定在同一注册项,并对外返回深拷贝 descriptor,避免共享 schema 被调用者修改。 - 现有 35 个 MCP 工具在原注册点生成具名 schema 与 metadata;运行期 catalog 与源码 catalog 一致,旧 `rpc.discover`、位置参数和错误码路径保持。 -- Debug/Release CPU presets 各通过 6/6 tests;独立 Contract Core 闭包无 MCP/UI/RHI/第三方依赖,MCP malformed-input/clean-exit contract 通过。 +- Slice 3 已实现 schema 与完整 descriptor 的有界规范 JSON round-trip、`capability.list` / `capability.describe` 及 JSON-RPC structured error data。 +- Debug 完整 8/8、Release CPU 6/6;独立 Contract Core 闭包无 MCP/UI/RHI/第三方依赖,MCP malformed-input/clean-exit 与新 API version/filter/pagination/error contract 通过。 ### Next Step -- 实施 Slice 3:新增 capability list/describe API 与 descriptor JSON adapter,把结构化 contract diagnostic 映射到 JSON-RPC error data。 -- 保持旧 `rpc.discover`、位置参数方法和 framing;target closure 的 Slice 4 等待 G0-01。 +- 按依赖顺序实施 G0-01 Runtime/Editor/Player targets;G0-01 Verified 后返回 A0-01 Slice 4,验证 target/header closure 与文档迁移。 +- A0-01 不在 G0-01 前宣称 Player/Runtime 已隔离 Developer host/MCP。 ### Changes and Deviations @@ -319,6 +328,8 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac - Slice 1 仅提供 transport-neutral vocabulary 与确定性校验;未引入 registry、MCP adapter、领域 handler 或任何模型 SDK。 - Slice 2 不迁移 Panel 内的 legacy handler;它们只作为 compatibility provider 接入公共 registry,领域 ownership 仍由对应 G Spec 落地。 - 无法由首版 `FValueSchema` 诚实表达的动态 `FJson` 输入/输出显式标为 unavailable 并给出迁移原因;旧 MCP 方法继续可用,不把 opaque payload 伪装成 typed capability。 +- `capability.list` / `capability.describe` 是 adapter 自描述入口,不注册为领域 capability,也不加入旧 `rpc.discover` 字符串数组。 +- 结构化错误以 JSON-RPC 标准允许的 `error.data` 增量交付;既有 error code/message、notification 与单行 framing 不变。 ### Evidence @@ -333,9 +344,10 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac | 2026-07-26 | working tree | VS 2022 / Windows SDK 10.0.26100.0 / C++20 | `cmake --build Build --config Debug`;`cmake --build Build --config Release`;`ctest --preset test-cpu-debug`;`ctest --preset test-cpu-release` | Pass | 完整 Debug/Release build;CPU Debug 5/5、Release 5/5;schema canonical round-trip、invalid descriptor、version/cursor/resync contracts | | 2026-07-26 | working tree | Python 3 / static source closure | `py -3 Tests/spec_contracts.py .`;`py -3 Tests/core_contract_target_contracts.py .` | Pass | 8 Specs;16 explicit sources / 29 project files;无 forbidden dependency/PCH | | 2026-07-26 | `a72fbe4` | GitHub Actions clean checkout | CPU Contracts run `30187005073` | Pass | Windows Debug/Release build 与 CPU tests;1m45s | -| 2026-07-26 | working tree | VS 2022 / Python 3 | `cmake --build Build --config Debug/Release`;`ctest --test-dir Build -C Debug --output-on-failure`;`ctest --preset test-cpu-release` | Pass | Debug 8/8、CPU Release 6/6;35 个 descriptor/handler/validator bindings;legacy discover/位置参数/clean exit;WaveEngine.log 无 Error/Fatal/D3D12 validation error | +| 2026-07-26 | `26438ff` | VS 2022 / Python 3 + GitHub Actions clean checkout | 本地 Debug/Release;CPU Contracts runs `30187622295` / `30187623006` | Pass | 本地 Debug 8/8、CPU Release 6/6;push/PR 两次 Windows Debug/Release CI 全绿;35 个 descriptor/handler/validator bindings | +| 2026-07-26 | working tree | VS 2022 / Python 3 | `cmake --build Build --config Debug/Release`;`ctest --test-dir Build -C Debug --output-on-failure`;`ctest --preset test-cpu-release` | Pass | schema/descriptor canonical round-trip;list/describe version/filter/pagination;structured error data;legacy discover/位置参数/notification/framing;Debug 8/8、CPU Release 6/6;日志无 Error/Fatal/D3D12 validation error | ### Remaining Work -- Slice 3~4 与 Acceptance Criteria;其中 Slice 4 等待 G0-01。 +- Slice 4 与尚未满足的 Acceptance Criteria;等待 G0-01。 - A0-02~A0-04 的接受、产品依赖与实现。 From 091bc67461aeca30544f98fc9f220ed38398e4ad Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sun, 26 Jul 2026 12:53:39 +0800 Subject: [PATCH 08/16] decouple runtime render view contract --- CMakeLists.txt | 14 +++ Engine/Source/Renderer/BasePass.cpp | 10 +- Engine/Source/Renderer/BasePass.h | 5 +- .../Source/Renderer/EditorCompositePass.cpp | 13 +- Engine/Source/Renderer/EditorCompositePass.h | 4 +- .../Source/Renderer/EditorSelectionPass.cpp | 13 +- Engine/Source/Renderer/EditorSelectionPass.h | 4 +- Engine/Source/Renderer/LightingPass.cpp | 11 +- Engine/Source/Renderer/LightingPass.h | 6 +- Engine/Source/Renderer/PathTracingPass.cpp | 11 +- Engine/Source/Renderer/PathTracingPass.h | 6 +- Engine/Source/Renderer/RenderFrame.h | 84 +++++++++++++ Engine/Source/Renderer/Renderer.cpp | 115 ++++++++++++++---- Engine/Source/Renderer/Renderer.h | 8 +- Engine/Source/Renderer/ShadowProjectPass.cpp | 12 +- Engine/Source/Renderer/ShadowProjectPass.h | 6 +- Engine/Source/Renderer/TonemapPass.cpp | 15 ++- Engine/Source/Renderer/TonemapPass.h | 8 +- Engine/Source/Scene/Camera.cpp | 37 +++--- Engine/Source/Scene/Camera.h | 2 + Engine/Source/Scene/Scene.cpp | 20 +-- Engine/Source/Scene/Scene.h | 8 +- Engine/Source/WaveEngine.cpp | 3 +- Tests/runtime_render_contracts.py | 113 +++++++++++++++++ ...26-07-25-game-engine-production-roadmap.md | 2 +- ...-25-g0-01-runtime-editor-player-targets.md | 28 +++-- docs/specs/README.md | 2 +- 27 files changed, 440 insertions(+), 120 deletions(-) create mode 100644 Engine/Source/Renderer/RenderFrame.h create mode 100644 Tests/runtime_render_contracts.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 23effd7..1881b37 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -320,6 +320,20 @@ if(BUILD_TESTING) LABELS "contract;mcp;cpu" ) + add_test( + NAME WaveEngine.RuntimeRenderContracts + COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/Tests/runtime_render_contracts.py" + "${PROJECT_SOURCE_DIR}" + ) + set_tests_properties( + WaveEngine.RuntimeRenderContracts + PROPERTIES + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + TIMEOUT 10 + LABELS "contract;render;cpu" + ) + add_test( NAME WaveEngine.McpAssetInputContracts COMMAND "${Python3_EXECUTABLE}" diff --git a/Engine/Source/Renderer/BasePass.cpp b/Engine/Source/Renderer/BasePass.cpp index 2bd1b4b..6b12921 100644 --- a/Engine/Source/Renderer/BasePass.cpp +++ b/Engine/Source/Renderer/BasePass.cpp @@ -9,14 +9,16 @@ IMPLEMENT_SHADER(FBasePassMS, "BasePass.hlsl", "BasePassMS", SF_Mesh); IMPLEMENT_SHADER(FBasePassPS, "BasePass.hlsl", "BasePassPS", SF_Pixel); -TRefCountPtr AddBasePass(FRenderGraph& RenderGraph, TRefCountPtr CullingResults) +TRefCountPtr AddBasePass( + FRenderGraph& RenderGraph, + TRefCountPtr CullingResults, + const FRenderExtent& Extent) { SCOPED_CPU_EVENT("SceneRender::BasePass"); TRefCountPtr Scene = FScene::GetInstance(); - const FUint2 SceneViewSize = GetSceneViewSize(); - const float Width = static_cast(SceneViewSize.x); - const float Height = static_cast(SceneViewSize.y); + const uint32 Width = Extent.Width; + const uint32 Height = Extent.Height; FMeshShaderRHIRef MeshShader = FShaderMap::GetMeshShader(); FPixelShaderRHIRef PixelShader = FShaderMap::GetPixelShader(); diff --git a/Engine/Source/Renderer/BasePass.h b/Engine/Source/Renderer/BasePass.h index 97bb639..d71b9a9 100644 --- a/Engine/Source/Renderer/BasePass.h +++ b/Engine/Source/Renderer/BasePass.h @@ -45,4 +45,7 @@ class FBasePassMS : public FShader } }; -TRefCountPtr AddBasePass(FRenderGraph& RenderGraph, TRefCountPtr); +TRefCountPtr AddBasePass( + FRenderGraph& RenderGraph, + TRefCountPtr CullingResults, + const FRenderExtent& Extent); diff --git a/Engine/Source/Renderer/EditorCompositePass.cpp b/Engine/Source/Renderer/EditorCompositePass.cpp index 42f7a64..f11b55f 100644 --- a/Engine/Source/Renderer/EditorCompositePass.cpp +++ b/Engine/Source/Renderer/EditorCompositePass.cpp @@ -4,7 +4,6 @@ #include "Resource.h" #include "RenderGraph/RenderGraph.h" #include "RHI/RHICommandList.h" -#include "UI/ImGuiLayer.h" IMPLEMENT_SHADER(FEditorCompositePS, "EditorComposite.hlsl", "EditorCompositePS", SF_Pixel); @@ -25,19 +24,19 @@ TRefCountPtr AddEditorCompositePass( FRGTextureHandle SceneColor, FRGTextureHandle SceneDepth, FRGTextureHandle SelectionDepth, - FRGTextureHandle SelectionId) + FRGTextureHandle SelectionId, + const FRenderExtent& Extent) { SCOPED_CPU_EVENT("SceneRender::EditorCompositePass"); - const FUint2 SceneViewSize = GetSceneViewSize(); - const uint32 Width = SceneViewSize.x; - const uint32 Height = SceneViewSize.y; + const uint32 Width = Extent.Width; + const uint32 Height = Extent.Height; FTextureCreateDesc OutputDesc = FTextureCreateDesc::Create2D( PF_R8G8B8A8, Width, Height, ERHITextureUsage::RTV | ERHITextureUsage::SRV); if (!GEditorCompositeColor || GEditorCompositeColorSize.x != Width || GEditorCompositeColorSize.y != Height) { GEditorCompositeColor = GDynamicRHI->RHICreateTexture(OutputDesc); - GEditorCompositeColorSize = SceneViewSize; + GEditorCompositeColorSize = FUint2(Width, Height); } FGraphicsPipelineStateInitializer PSOInit; @@ -56,6 +55,7 @@ TRefCountPtr AddEditorCompositePass( PassData->SelectionDepth = SelectionDepth; PassData->SelectionId = SelectionId; PassData->OutputColor = Builder.ImportTexture(GEditorCompositeColor); + PassData->OutputTexture = GEditorCompositeColor; Builder.ReadTexture(SceneColor); if (SceneDepth.IsValid()) { @@ -108,6 +108,5 @@ TRefCountPtr AddEditorCompositePass( FTextureRHIRef Output = Graph.GetTexture(PassData->OutputColor); CmdList->AddTransition(FRHITransition(Output.get(), ERHIAccess::RTV, ERHIAccess::SRV)); - SetSceneViewTexture(Output); }); } diff --git a/Engine/Source/Renderer/EditorCompositePass.h b/Engine/Source/Renderer/EditorCompositePass.h index 2f107bc..c40dba4 100644 --- a/Engine/Source/Renderer/EditorCompositePass.h +++ b/Engine/Source/Renderer/EditorCompositePass.h @@ -12,6 +12,7 @@ struct FEditorCompositePassData FRGTextureHandle SelectionDepth; FRGTextureHandle SelectionId; FRGTextureHandle OutputColor; + FTextureRHIRef OutputTexture; }; class FEditorCompositePS : public FShader @@ -36,5 +37,6 @@ TRefCountPtr AddEditorCompositePass( FRGTextureHandle SceneColor, FRGTextureHandle SceneDepth, FRGTextureHandle SelectionDepth, - FRGTextureHandle SelectionId); + FRGTextureHandle SelectionId, + const FRenderExtent& Extent); void ResetEditorCompositePassResources(); diff --git a/Engine/Source/Renderer/EditorSelectionPass.cpp b/Engine/Source/Renderer/EditorSelectionPass.cpp index 6752ff0..27235c1 100644 --- a/Engine/Source/Renderer/EditorSelectionPass.cpp +++ b/Engine/Source/Renderer/EditorSelectionPass.cpp @@ -5,22 +5,22 @@ #include "RenderGraph/RenderGraph.h" #include "RHI/RHICommandList.h" #include "Scene/Scene.h" -#include "UI/ImGuiLayer.h" IMPLEMENT_SHADER(FEditorSelectionPS, "BasePass.hlsl", "EditorSelectionPS", SF_Pixel); TRefCountPtr AddEditorSelectionPass( FRenderGraph& RenderGraph, - TRefCountPtr CullingResults) + TRefCountPtr CullingResults, + const FRenderExtent& Extent, + uint32 SelectedInstanceIndex) { SCOPED_CPU_EVENT("SceneRender::EditorSelectionPass"); TRefCountPtr Scene = FScene::GetInstance(); - const FUint2 SceneViewSize = GetSceneViewSize(); FTextureCreateDesc SelectionIdDesc = FTextureCreateDesc::Create2D( - PF_R32_UINT, SceneViewSize.x, SceneViewSize.y, ERHITextureUsage::RTV | ERHITextureUsage::SRV); + PF_R32_UINT, Extent.Width, Extent.Height, ERHITextureUsage::RTV | ERHITextureUsage::SRV); FTextureCreateDesc SelectionDepthDesc = FTextureCreateDesc::Create2D( - PF_DepthStencil, SceneViewSize.x, SceneViewSize.y, ERHITextureUsage::DSV | ERHITextureUsage::SRV); + PF_DepthStencil, Extent.Width, Extent.Height, ERHITextureUsage::DSV | ERHITextureUsage::SRV); FGraphicsPipelineStateInitializer PSOInit; PSOInit.MeshShader = FShaderMap::GetMeshShader(); @@ -46,7 +46,7 @@ TRefCountPtr AddEditorSelectionPass( { CmdList->SetGraphicsPipeline(GraphicsPSO); CmdList->SetViewport(FViewport(0, 0, - static_cast(SceneViewSize.x), static_cast(SceneViewSize.y))); + static_cast(Extent.Width), static_cast(Extent.Height))); FRGRenderPassInfo RenderPassInfo; RenderPassInfo.AddRenderTarget(PassData->SelectionId, ERenderTargetLoadAction::EClear); @@ -55,7 +55,6 @@ TRefCountPtr AddEditorSelectionPass( FBufferRHIRef IndirectArgs = Graph.IndirectArgsBuffer(PassData->CullingResults->ArgsBuffer); FBufferRHIRef VisibleMeshlets = Graph.ReadBuffer(PassData->CullingResults->VisibleMeshletsBuffer); - const uint32 SelectedInstanceIndex = GetSelectedInstanceIndex(); if (SelectedInstanceIndex != 0xFFFFFFFFu && IndirectArgs && VisibleMeshlets && Scene && Scene->SceneMeshletsCount > 0 && Scene->SceneUniformBuffer && Scene->MeshletsBuffer && Scene->InstanceDataBuffer) diff --git a/Engine/Source/Renderer/EditorSelectionPass.h b/Engine/Source/Renderer/EditorSelectionPass.h index 0700ae3..4d89753 100644 --- a/Engine/Source/Renderer/EditorSelectionPass.h +++ b/Engine/Source/Renderer/EditorSelectionPass.h @@ -28,4 +28,6 @@ class FEditorSelectionPS : public FShader TRefCountPtr AddEditorSelectionPass( FRenderGraph& RenderGraph, - TRefCountPtr CullingResults); + TRefCountPtr CullingResults, + const FRenderExtent& Extent, + uint32 SelectedInstanceIndex); diff --git a/Engine/Source/Renderer/LightingPass.cpp b/Engine/Source/Renderer/LightingPass.cpp index 831fb6a..cad20e5 100644 --- a/Engine/Source/Renderer/LightingPass.cpp +++ b/Engine/Source/Renderer/LightingPass.cpp @@ -9,14 +9,17 @@ IMPLEMENT_SHADER(FLightingPS, "Lighting.hlsl", "LightingPS", SF_Pixel); -TRefCountPtr AddLightingPass(FRenderGraph& RenderGraph, TRefCountPtr BasePassData, TRefCountPtr ShadowProjectPassData) +TRefCountPtr AddLightingPass( + FRenderGraph& RenderGraph, + TRefCountPtr BasePassData, + TRefCountPtr ShadowProjectPassData, + const FRenderExtent& Extent) { SCOPED_CPU_EVENT("SceneRender::LightingPass"); TRefCountPtr Scene = FScene::GetInstance(); - const FUint2 SceneViewSize = GetSceneViewSize(); - const uint32 Width = SceneViewSize.x; - const uint32 Height = SceneViewSize.y; + const uint32 Width = Extent.Width; + const uint32 Height = Extent.Height; FVertexShaderRHIRef VertexShader = FShaderMap::GetVertexShader(); FPixelShaderRHIRef PixelShader = FShaderMap::GetPixelShader(); diff --git a/Engine/Source/Renderer/LightingPass.h b/Engine/Source/Renderer/LightingPass.h index e1786f8..79ad61f 100644 --- a/Engine/Source/Renderer/LightingPass.h +++ b/Engine/Source/Renderer/LightingPass.h @@ -31,4 +31,8 @@ class FLightingPS : public FShader } }; -TRefCountPtr AddLightingPass(FRenderGraph& RenderGraph, TRefCountPtr BasePassData, TRefCountPtr ShadowProjectPassData); +TRefCountPtr AddLightingPass( + FRenderGraph& RenderGraph, + TRefCountPtr BasePassData, + TRefCountPtr ShadowProjectPassData, + const FRenderExtent& Extent); diff --git a/Engine/Source/Renderer/PathTracingPass.cpp b/Engine/Source/Renderer/PathTracingPass.cpp index 1c5aee0..e870cde 100644 --- a/Engine/Source/Renderer/PathTracingPass.cpp +++ b/Engine/Source/Renderer/PathTracingPass.cpp @@ -235,13 +235,16 @@ FRGAccelerationStructureHandle AddRayTracingAccelerationStructurePass(FRenderGra return TLASHandle; } -TRefCountPtr AddPathTracingPass(FRenderGraph& RenderGraph, FRGAccelerationStructureHandle TLASHandle, FRenderer& Renderer) +TRefCountPtr AddPathTracingPass( + FRenderGraph& RenderGraph, + FRGAccelerationStructureHandle TLASHandle, + FRenderer& Renderer, + const FRenderExtent& Extent) { SCOPED_CPU_EVENT("SceneRender::PathTracingPass"); - const FUint2 SceneViewSize = GetSceneViewSize(); - const uint32 Width = SceneViewSize.x; - const uint32 Height = SceneViewSize.y; + const uint32 Width = Extent.Width; + const uint32 Height = Extent.Height; FRayTracingPipelineStateRHIRef Pipeline = GetOrCreatePathTracingPipeline(); diff --git a/Engine/Source/Renderer/PathTracingPass.h b/Engine/Source/Renderer/PathTracingPass.h index 6cc0d2f..0ab4d0c 100644 --- a/Engine/Source/Renderer/PathTracingPass.h +++ b/Engine/Source/Renderer/PathTracingPass.h @@ -52,4 +52,8 @@ FRGAccelerationStructureHandle AddRayTracingAccelerationStructurePass(FRenderGra class FRenderer; bool IsPathTracingPipelineAvailable(); void ResetPathTracingPassResources(); -TRefCountPtr AddPathTracingPass(FRenderGraph& RenderGraph, FRGAccelerationStructureHandle TLAS, FRenderer& Renderer); +TRefCountPtr AddPathTracingPass( + FRenderGraph& RenderGraph, + FRGAccelerationStructureHandle TLAS, + FRenderer& Renderer, + const FRenderExtent& Extent); diff --git a/Engine/Source/Renderer/RenderFrame.h b/Engine/Source/Renderer/RenderFrame.h new file mode 100644 index 0000000..b799a63 --- /dev/null +++ b/Engine/Source/Renderer/RenderFrame.h @@ -0,0 +1,84 @@ +#pragma once + +#include "Core/Core.h" +#include "RHI/RHIFwd.h" + +#include + +enum class ERenderOutputMode : uint8 +{ + Offscreen, + Swapchain, +}; + +struct FRenderExtent +{ + uint32 Width = 0; + uint32 Height = 0; + + bool IsValid() const + { + return Width > 0 && Height > 0; + } +}; + +struct FRenderInteractionRegion +{ + float X = 0.0f; + float Y = 0.0f; + float Width = 0.0f; + float Height = 0.0f; + + bool IsValid() const + { + return std::isfinite(X) + && std::isfinite(Y) + && std::isfinite(Width) + && std::isfinite(Height) + && Width > 1.0f + && Height > 1.0f; + } + + bool Contains(float PointX, float PointY) const + { + return IsValid() + && PointX >= X + && PointX <= X + Width + && PointY >= Y + && PointY <= Y + Height; + } +}; + +struct FRenderViewInput +{ + static constexpr uint32 InvalidSelectedInstanceIndex = 0xFFFFFFFFu; + + FRenderExtent Extent; + FRenderInteractionRegion InteractionRegion; + ERenderOutputMode OutputMode = ERenderOutputMode::Offscreen; + uint32 SelectedInstanceIndex = InvalidSelectedInstanceIndex; + bool bEnableEditorSelection = false; + + bool IsValid() const + { + return Extent.IsValid(); + } + + bool HasEditorSelection() const + { + return bEnableEditorSelection + && SelectedInstanceIndex != InvalidSelectedInstanceIndex; + } +}; + +struct FRenderFrameOutput +{ + FTextureRHIRef ColorTexture; + FRenderExtent Extent; + ERenderOutputMode OutputMode = ERenderOutputMode::Offscreen; + + bool IsValid() const + { + return ColorTexture != nullptr && Extent.IsValid(); + } +}; diff --git a/Engine/Source/Renderer/Renderer.cpp b/Engine/Source/Renderer/Renderer.cpp index 0b15db8..83862ab 100644 --- a/Engine/Source/Renderer/Renderer.cpp +++ b/Engine/Source/Renderer/Renderer.cpp @@ -18,12 +18,16 @@ #include "Scene/Scene.h" #include "Core/Profiler.h" #include "Core/Engine.h" +#include "UI/ImGuiLayer.h" + +#include #include FRenderer::FRenderer() - : PersistentRenderGraph(std::make_unique()) + : ImGuiLayer(std::make_unique()) + , PersistentRenderGraph(std::make_unique()) { - ImGuiLayer.Init(); + ImGuiLayer->Init(); bImGuiInitialized = true; } @@ -73,25 +77,55 @@ bool FRenderer::ConsumePTResetFlag(const FMatrix4x4& CurrentVP, uint32 Width, ui return bReset; } -void FRenderer::Render() +FRenderViewInput FRenderer::GetViewInput() const { - SCOPED_CPU_EVENT("Renderer::Render"); + FRenderViewInput ViewInput; + ViewInput.OutputMode = ERenderOutputMode::Offscreen; TRefCountPtr Window = FWindow::GetInstance(); if (!Window || Window->IsMinimized()) { - return; + return ViewInput; } - GDynamicRHI->ResizeSwapchain(Window->Width(), Window->Height()); + const FSceneViewRect& SceneViewRect = GetSceneViewRect(); + if (SceneViewRect.IsValid()) + { + ViewInput.Extent.Width = std::max(static_cast(SceneViewRect.Width), 1u); + ViewInput.Extent.Height = std::max(static_cast(SceneViewRect.Height), 1u); + ViewInput.InteractionRegion = { + SceneViewRect.X, + SceneViewRect.Y, + SceneViewRect.Width, + SceneViewRect.Height, + }; + } + else + { + ViewInput.Extent = {std::max(Window->Width(), 1u), std::max(Window->Height(), 1u)}; + } + ViewInput.SelectedInstanceIndex = GetSelectedInstanceIndex(); + ViewInput.bEnableEditorSelection = + ViewInput.SelectedInstanceIndex != FRenderViewInput::InvalidSelectedInstanceIndex; + return ViewInput; +} + +FRenderFrameOutput FRenderer::RenderFrame(const FRenderViewInput& ViewInput) +{ + SCOPED_CPU_EVENT("Renderer::RenderFrame"); + + FRenderFrameOutput FrameOutput; + FrameOutput.Extent = ViewInput.Extent; + FrameOutput.OutputMode = ViewInput.OutputMode; + if (!ViewInput.IsValid()) { - SCOPED_CPU_EVENT("Renderer::ImGui"); - ImGuiLayer.BeginFrame(); - ImGuiLayer.DrawConfigWindow(); + return FrameOutput; } FRenderGraph& RenderGraph = *PersistentRenderGraph; + TRefCountPtr TonemapPassData; + TRefCountPtr EditorCompositePassData; { SCOPED_CPU_EVENT("Scene Render"); RG_EVENT_SCOPE(RenderGraph, "Scene Render"); @@ -120,7 +154,8 @@ void FRenderer::Render() FRGAccelerationStructureHandle TLASHandle = AddRayTracingAccelerationStructurePass(RenderGraph, Scene); if (TLASHandle.IsValid()) { - TRefCountPtr PTData = AddPathTracingPass(RenderGraph, TLASHandle, *this); + TRefCountPtr PTData = AddPathTracingPass( + RenderGraph, TLASHandle, *this, ViewInput.Extent); FRGTextureHandle ExposureTexture; if (Scene && Scene->bAutoExposureEnabled) @@ -128,7 +163,8 @@ void FRenderer::Render() TRefCountPtr AutoExposurePassData = AddAutoExposurePass(RenderGraph, PTData->LightingColor); ExposureTexture = AutoExposurePassData->ExposureTexture; } - AddTonemapPass(RenderGraph, PTData->LightingColor, ExposureTexture, false); + TonemapPassData = AddTonemapPass( + RenderGraph, PTData->LightingColor, ExposureTexture, ViewInput.Extent, false); bRenderedPathTracing = true; } } @@ -149,36 +185,71 @@ void FRenderer::Render() TRefCountPtr ShadowCullingPassData = AddShadowMeshletCullingPass(RenderGraph); TRefCountPtr ShadowDepthPassData = AddShadowDepthPass(RenderGraph, ShadowCullingPassData); TRefCountPtr CullingPassData = AddMeshletCullingPass(RenderGraph); - BasePassData = AddBasePass(RenderGraph, CullingPassData); - if (GetSelectedInstanceIndex() != 0xFFFFFFFFu) + BasePassData = AddBasePass(RenderGraph, CullingPassData, ViewInput.Extent); + if (ViewInput.HasEditorSelection()) { - EditorSelectionPassData = AddEditorSelectionPass(RenderGraph, CullingPassData); + EditorSelectionPassData = AddEditorSelectionPass( + RenderGraph, + CullingPassData, + ViewInput.Extent, + ViewInput.SelectedInstanceIndex); } - ShadowProjectPassData = AddShadowProjectPass(RenderGraph, BasePassData, ShadowDepthPassData); + ShadowProjectPassData = AddShadowProjectPass( + RenderGraph, BasePassData, ShadowDepthPassData, ViewInput.Extent); } - TRefCountPtr LightingPassData = AddLightingPass(RenderGraph, BasePassData, ShadowProjectPassData); + TRefCountPtr LightingPassData = AddLightingPass( + RenderGraph, BasePassData, ShadowProjectPassData, ViewInput.Extent); FRGTextureHandle ExposureTexture; if (Scene && Scene->bAutoExposureEnabled) { TRefCountPtr AutoExposurePassData = AddAutoExposurePass(RenderGraph, LightingPassData->LightingColor); ExposureTexture = AutoExposurePassData->ExposureTexture; } - TRefCountPtr TonemapPassData = AddTonemapPass( - RenderGraph, LightingPassData->LightingColor, ExposureTexture, true); + TonemapPassData = AddTonemapPass( + RenderGraph, LightingPassData->LightingColor, ExposureTexture, ViewInput.Extent, true); if (EditorSelectionPassData) { - AddEditorCompositePass( + EditorCompositePassData = AddEditorCompositePass( RenderGraph, TonemapPassData->OutputColor, BasePassData->GBuffer.DepthStencil, EditorSelectionPassData->SelectionDepth, - EditorSelectionPassData->SelectionId); + EditorSelectionPassData->SelectionId, + ViewInput.Extent); } } + } + RenderGraph.Execute(); FRenderGraphResourcePool::Get()->Flush(GetCurrentFrameIndex()); - ImGuiLayer.Render(); + FrameOutput.ColorTexture = EditorCompositePassData + ? EditorCompositePassData->OutputTexture + : (TonemapPassData ? TonemapPassData->OutputTexture : nullptr); + return FrameOutput; +} + +void FRenderer::Render() +{ + SCOPED_CPU_EVENT("Renderer::Render"); + + TRefCountPtr Window = FWindow::GetInstance(); + if (!Window || Window->IsMinimized()) + { + return; + } + + GDynamicRHI->ResizeSwapchain(Window->Width(), Window->Height()); + + { + SCOPED_CPU_EVENT("Renderer::ImGui"); + ImGuiLayer->BeginFrame(); + ImGuiLayer->DrawConfigWindow(); + } + + const FRenderFrameOutput FrameOutput = RenderFrame(GetViewInput()); + SetSceneViewTexture(FrameOutput.ColorTexture); + ImGuiLayer->Render(); { GDynamicRHI->Present(); @@ -189,7 +260,7 @@ void FRenderer::Shutdown() { if (bImGuiInitialized) { - ImGuiLayer.Shutdown(); + ImGuiLayer->Shutdown(); bImGuiInitialized = false; } diff --git a/Engine/Source/Renderer/Renderer.h b/Engine/Source/Renderer/Renderer.h index ada4622..63dd40b 100644 --- a/Engine/Source/Renderer/Renderer.h +++ b/Engine/Source/Renderer/Renderer.h @@ -1,10 +1,12 @@ #pragma once +#include "Core/Math.h" +#include "RenderFrame.h" #include "RenderGraph/RenderGraphResource.h" -#include "UI/ImGuiLayer.h" #include +class FImGuiLayer; class FRenderGraph; class FScene; @@ -24,6 +26,8 @@ class FRenderer public: FRenderer(); ~FRenderer(); + FRenderViewInput GetViewInput() const; + FRenderFrameOutput RenderFrame(const FRenderViewInput& ViewInput); void Render(); void Shutdown(); @@ -34,7 +38,7 @@ class FRenderer bool ConsumePTResetFlag(const FMatrix4x4& CurrentVP, uint32 Width, uint32 Height); private: - FImGuiLayer ImGuiLayer; + std::unique_ptr ImGuiLayer; std::unique_ptr PersistentRenderGraph; bool bImGuiInitialized = false; diff --git a/Engine/Source/Renderer/ShadowProjectPass.cpp b/Engine/Source/Renderer/ShadowProjectPass.cpp index 1a62d7d..98b4795 100644 --- a/Engine/Source/Renderer/ShadowProjectPass.cpp +++ b/Engine/Source/Renderer/ShadowProjectPass.cpp @@ -6,18 +6,20 @@ #include "RenderGraph/RenderGraph.h" #include "RHI/RHICommandList.h" #include "Scene/Scene.h" -#include "UI/ImGuiLayer.h" IMPLEMENT_SHADER(FShadowProjectPS, "ShadowProject.hlsl", "ShadowProjectPS", SF_Pixel); -TRefCountPtr AddShadowProjectPass(FRenderGraph& RenderGraph, TRefCountPtr BasePassData, TRefCountPtr ShadowDepthPassData) +TRefCountPtr AddShadowProjectPass( + FRenderGraph& RenderGraph, + TRefCountPtr BasePassData, + TRefCountPtr ShadowDepthPassData, + const FRenderExtent& Extent) { SCOPED_CPU_EVENT("SceneRender::ShadowProjectPass"); TRefCountPtr Scene = FScene::GetInstance(); - const FUint2 SceneViewSize = GetSceneViewSize(); - const uint32 Width = SceneViewSize.x; - const uint32 Height = SceneViewSize.y; + const uint32 Width = Extent.Width; + const uint32 Height = Extent.Height; FVertexShaderRHIRef VertexShader = FShaderMap::GetVertexShader(); FPixelShaderRHIRef PixelShader = FShaderMap::GetPixelShader(); diff --git a/Engine/Source/Renderer/ShadowProjectPass.h b/Engine/Source/Renderer/ShadowProjectPass.h index c9e92c1..dfaaf7d 100644 --- a/Engine/Source/Renderer/ShadowProjectPass.h +++ b/Engine/Source/Renderer/ShadowProjectPass.h @@ -31,4 +31,8 @@ class FShadowProjectPS : public FShader } }; -TRefCountPtr AddShadowProjectPass(FRenderGraph& RenderGraph, TRefCountPtr BasePassData, TRefCountPtr ShadowDepthPassData); +TRefCountPtr AddShadowProjectPass( + FRenderGraph& RenderGraph, + TRefCountPtr BasePassData, + TRefCountPtr ShadowDepthPassData, + const FRenderExtent& Extent); diff --git a/Engine/Source/Renderer/TonemapPass.cpp b/Engine/Source/Renderer/TonemapPass.cpp index bf3b244..1b66124 100644 --- a/Engine/Source/Renderer/TonemapPass.cpp +++ b/Engine/Source/Renderer/TonemapPass.cpp @@ -6,7 +6,6 @@ #include "RHI/RHICommandList.h" #include "Scene/Scene.h" #include "Core/Engine.h" -#include "UI/ImGuiLayer.h" IMPLEMENT_SHADER(FTonemapPS, "Tonemap.hlsl", "TonemapPS", SF_Pixel); @@ -22,14 +21,18 @@ void ResetTonemapPassResources() GSceneViewColorSize = FUint2(0u, 0u); } -TRefCountPtr AddTonemapPass(FRenderGraph& RenderGraph, FRGTextureHandle InputTexture, FRGTextureHandle ExposureTexture, bool bEnableDebugView) +TRefCountPtr AddTonemapPass( + FRenderGraph& RenderGraph, + FRGTextureHandle InputTexture, + FRGTextureHandle ExposureTexture, + const FRenderExtent& Extent, + bool bEnableDebugView) { SCOPED_CPU_EVENT("SceneRender::TonemapPass"); TRefCountPtr Scene = FScene::GetInstance(); - const FUint2 SceneViewSize = GetSceneViewSize(); - const uint32 Width = SceneViewSize.x; - const uint32 Height = SceneViewSize.y; + const uint32 Width = Extent.Width; + const uint32 Height = Extent.Height; FVertexShaderRHIRef VertexShader = FShaderMap::GetVertexShader(); FPixelShaderRHIRef PixelShader = FShaderMap::GetPixelShader(); @@ -61,6 +64,7 @@ TRefCountPtr AddTonemapPass(FRenderGraph& RenderGraph, FRGText InPassData->InputTexture = InputTexture; InPassData->OutputColor = GraphBuilder.ImportTexture(GSceneViewColor); InPassData->ExposureTexture = ExposureTexture; + InPassData->OutputTexture = GSceneViewColor; GraphBuilder.ReadTexture(InputTexture); GraphBuilder.WriteTexture(InPassData->OutputColor); @@ -102,7 +106,6 @@ TRefCountPtr AddTonemapPass(FRenderGraph& RenderGraph, FRGText if (Output.IsValid()) { CmdList->AddTransition(FRHITransition(Output.get(), ERHIAccess::RTV, ERHIAccess::SRV)); - SetSceneViewTexture(Output); } }); diff --git a/Engine/Source/Renderer/TonemapPass.h b/Engine/Source/Renderer/TonemapPass.h index 1e94a1a..4e43612 100644 --- a/Engine/Source/Renderer/TonemapPass.h +++ b/Engine/Source/Renderer/TonemapPass.h @@ -11,6 +11,7 @@ struct FTonemapPassData FRGTextureHandle InputTexture; FRGTextureHandle OutputColor; FRGTextureHandle ExposureTexture; + FTextureRHIRef OutputTexture; }; class FTonemapPS : public FShader @@ -30,5 +31,10 @@ class FTonemapPS : public FShader } }; -TRefCountPtr AddTonemapPass(FRenderGraph& RenderGraph, FRGTextureHandle InputTexture, FRGTextureHandle ExposureTexture, bool bEnableDebugView); +TRefCountPtr AddTonemapPass( + FRenderGraph& RenderGraph, + FRGTextureHandle InputTexture, + FRGTextureHandle ExposureTexture, + const FRenderExtent& Extent, + bool bEnableDebugView); void ResetTonemapPassResources(); diff --git a/Engine/Source/Scene/Camera.cpp b/Engine/Source/Scene/Camera.cpp index 8df5777..10ff029 100644 --- a/Engine/Source/Scene/Camera.cpp +++ b/Engine/Source/Scene/Camera.cpp @@ -1,7 +1,6 @@ #include "Camera.h" #include "Scene.h" #include "Input/InputSystem.h" -#include "UI/ImGuiLayer.h" FCamera* FCamera::Instance = nullptr; @@ -38,14 +37,9 @@ namespace OutUp = Normalize(BaseUp * CosRoll + BaseRight * SinRoll); } - bool IsMouseInsideSceneView(const FVector2& MousePosition) + bool IsMouseInsideRenderView(const FVector2& MousePosition, const FRenderViewInput& ViewInput) { - const FSceneViewRect& Rect = GetSceneViewRect(); - return Rect.IsValid() - && MousePosition.x >= Rect.X - && MousePosition.x <= Rect.X + Rect.Width - && MousePosition.y >= Rect.Y - && MousePosition.y <= Rect.Y + Rect.Height; + return ViewInput.InteractionRegion.Contains(MousePosition.x, MousePosition.y); } } @@ -78,6 +72,16 @@ void FCamera::SetRotation(const FRotator& InRotation) } void FCamera::Tick(float DeltaTime) +{ + FRenderViewInput ViewInput; + if (Window && !Window->IsMinimized()) + { + ViewInput.Extent = {Window->Width(), Window->Height()}; + } + Tick(DeltaTime, ViewInput); +} + +void FCamera::Tick(float DeltaTime, const FRenderViewInput& ViewInput) { FInputSystem& Input = FInputSystem::Get(); @@ -88,7 +92,7 @@ void FCamera::Tick(float DeltaTime) if (Input.WasMouseButtonPressed(GLFW_MOUSE_BUTTON_RIGHT)) { - if (IsMouseInsideSceneView(Input.GetMousePosition())) + if (IsMouseInsideRenderView(Input.GetMousePosition(), ViewInput)) { bRightMouseCaptured = true; Input.SetCursorEnabled(false); @@ -130,27 +134,20 @@ void FCamera::Tick(float DeltaTime) ProcessInput(DeltaTime); - if (!Window || Window->IsMinimized()) - { - return; - } - - const FSceneViewRect& Rect = GetSceneViewRect(); - const FUint2 ViewSize = Rect.IsValid() ? FUint2(static_cast(Rect.Width), static_cast(Rect.Height)) : Window->Size(); - if (ViewSize.x == 0 || ViewSize.y == 0) + if (!Window || Window->IsMinimized() || !ViewInput.Extent.IsValid()) { return; } - if (CachedViewSize.x != ViewSize.x || CachedViewSize.y != ViewSize.y) + if (CachedViewSize.x != ViewInput.Extent.Width || CachedViewSize.y != ViewInput.Extent.Height) { - CachedViewSize = ViewSize; + CachedViewSize = FUint2(ViewInput.Extent.Width, ViewInput.Extent.Height); SetDirty(); } if (IsDirty()) { - const float AspectRatio = ViewSize.x / static_cast(ViewSize.y); + const float AspectRatio = ViewInput.Extent.Width / static_cast(ViewInput.Extent.Height); FVector3 Forward; FVector3 Right; FVector3 Up; diff --git a/Engine/Source/Scene/Camera.h b/Engine/Source/Scene/Camera.h index f178f49..304d65f 100644 --- a/Engine/Source/Scene/Camera.h +++ b/Engine/Source/Scene/Camera.h @@ -1,5 +1,6 @@ #pragma once #include "Component.h" +#include "Renderer/RenderFrame.h" #include "Window.h" class FCamera : public FComponent @@ -32,6 +33,7 @@ class FCamera : public FComponent float GetFarPlane() const { return FarPlane; } void Tick(float DeltaTime) override; + void Tick(float DeltaTime, const FRenderViewInput& ViewInput); bool IsFreezeRendering() const { return bFreezeRendering; } bool IsNavigationCaptured() const { return bRightMouseCaptured; } diff --git a/Engine/Source/Scene/Scene.cpp b/Engine/Source/Scene/Scene.cpp index 2995645..d839f4e 100644 --- a/Engine/Source/Scene/Scene.cpp +++ b/Engine/Source/Scene/Scene.cpp @@ -6,9 +6,7 @@ #include "SceneSerializer.h" #include "SkyboxLoader.h" #include "WEMeshSerializer.h" -#include "Window.h" #include "ModelLoader.h" -#include "UI/ImGuiLayer.h" #include "Core/Profiler.h" @@ -137,11 +135,11 @@ FScene::~FScene() } } -void FScene::Tick(float DeltaTime) +void FScene::Tick(float DeltaTime, const FRenderViewInput& ViewInput) { SCOPED_CPU_EVENT("Scene::Tick"); - Camera.Tick(DeltaTime); + Camera.Tick(DeltaTime, ViewInput); { SCOPED_CPU_EVENT("Scene::Import Poll"); @@ -153,7 +151,7 @@ void FScene::Tick(float DeltaTime) } { SCOPED_CPU_EVENT("Scene::Update Uniforms"); - UpdateSceneUniformBuffer(); + UpdateSceneUniformBuffer(ViewInput.Extent); } FInputSystem& Input = FInputSystem::Get(); @@ -944,7 +942,7 @@ void FScene::UpdateGPUScene() PendingRemoveInstance.clear(); } -void FScene::UpdateSceneUniformBuffer() +void FScene::UpdateSceneUniformBuffer(const FRenderExtent& Extent) { HLSL::FSceneUniformData SceneInfo{}; SceneInfo.ProjectionMatrix = Camera.GetProjectionMatrix(); @@ -1075,14 +1073,8 @@ void FScene::UpdateSceneUniformBuffer() SceneInfo.LightViewProjectionMatrix = LightViewProjection; - TRefCountPtr Window = FWindow::GetInstance(); - const FSceneViewRect& Rect = GetSceneViewRect(); - const FUint2 WindowSize = (Window && !Window->IsMinimized()) ? Window->Size() : FUint2(1, 1); - const FUint2 ViewSize = Rect.IsValid() - ? FUint2(static_cast(Rect.Width), static_cast(Rect.Height)) - : WindowSize; - const float SafeWidth = std::max(1.0f, static_cast(ViewSize.x)); - const float SafeHeight = std::max(1.0f, static_cast(ViewSize.y)); + const float SafeWidth = std::max(1.0f, static_cast(Extent.Width)); + const float SafeHeight = std::max(1.0f, static_cast(Extent.Height)); SceneInfo.ScreenSize = FVector4(SafeWidth, SafeHeight, 1.0f / SafeWidth, 1.0f / SafeHeight); FBufferRHIRef NewSceneUniformBuffer = AcquireUniformBufferSnapshot( SceneUniformBufferPool, diff --git a/Engine/Source/Scene/Scene.h b/Engine/Source/Scene/Scene.h index 695491c..b17714d 100644 --- a/Engine/Source/Scene/Scene.h +++ b/Engine/Source/Scene/Scene.h @@ -3,7 +3,7 @@ #include "Light.h" #include "Instance.h" #include "Mesh.h" -#include "Renderer/Renderer.h" +#include "Renderer/RenderFrame.h" #include "Resource.h" #include "WEMeshSerializer.h" #include @@ -15,7 +15,7 @@ class FScene { - friend FRenderer; + friend class FRenderer; static TRefCountPtr Instance; public: @@ -50,7 +50,7 @@ class FScene FScene(); ~FScene(); - void Tick(float DeltaTime); + void Tick(float DeltaTime, const FRenderViewInput& ViewInput); std::vector> GameObjects; FBufferRHIRef SceneUniformBuffer; @@ -111,7 +111,7 @@ class FScene [[nodiscard]] bool SaveScene() const; void ImportDroppedModel(const std::string& Path); void UpdateGPUScene(); - void UpdateSceneUniformBuffer(); + void UpdateSceneUniformBuffer(const FRenderExtent& Extent); void RetainGPUResources(const FCommandListRef& CmdList) const; const FMatrix4x4& GetPrevViewProjectionMatrix() const { return PrevViewProjectionMatrixThisFrame; } bool HasPrevViewProjection() const { return bPrevViewProjectionValid; } diff --git a/Engine/Source/WaveEngine.cpp b/Engine/Source/WaveEngine.cpp index a3c12cd..41f1949 100644 --- a/Engine/Source/WaveEngine.cpp +++ b/Engine/Source/WaveEngine.cpp @@ -2,6 +2,7 @@ #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" @@ -90,7 +91,7 @@ int main() 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); + Scene->Tick(DeltaTimeSeconds, Renderer->GetViewInput()); } { SCOPED_CPU_EVENT("Renderer"); diff --git a/Tests/runtime_render_contracts.py b/Tests/runtime_render_contracts.py new file mode 100644 index 0000000..7a8205d --- /dev/null +++ b/Tests/runtime_render_contracts.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +RUNTIME_VIEW_CONSUMERS = ( + "Engine/Source/Renderer/BasePass.cpp", + "Engine/Source/Renderer/LightingPass.cpp", + "Engine/Source/Renderer/PathTracingPass.cpp", + "Engine/Source/Renderer/ShadowProjectPass.cpp", + "Engine/Source/Renderer/TonemapPass.cpp", + "Engine/Source/Scene/Camera.cpp", + "Engine/Source/Scene/Scene.cpp", +) +FORBIDDEN_RUNTIME_FRAGMENTS = ( + '#include "UI/', + "GetSceneViewRect(", + "GetSceneViewSize(", + "GetSceneViewTexture(", + "SetSceneViewTexture(", + "GetSelectedInstanceIndex(", +) + + +def fail(message: str) -> None: + raise RuntimeError(message) + + +def read(repository_root: Path, relative_path: str) -> str: + path = repository_root / relative_path + if not path.is_file(): + fail(f"missing required file: {relative_path}") + return path.read_text(encoding="utf-8") + + +def require_fragment(text: str, fragment: str, path: str) -> None: + if fragment not in text: + fail(f"{path} is missing contract fragment: {fragment}") + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: runtime_render_contracts.py ", file=sys.stderr) + return 2 + + repository_root = Path(sys.argv[1]).resolve() + render_contract_path = "Engine/Source/Renderer/RenderFrame.h" + render_contract = read(repository_root, render_contract_path) + for fragment in ( + "enum class ERenderOutputMode", + "struct FRenderExtent", + "struct FRenderInteractionRegion", + "struct FRenderViewInput", + "struct FRenderFrameOutput", + "FTextureRHIRef ColorTexture", + "bool HasEditorSelection() const", + ): + require_fragment(render_contract, fragment, render_contract_path) + + renderer_header_path = "Engine/Source/Renderer/Renderer.h" + renderer_header = read(repository_root, renderer_header_path) + for fragment in ( + "FRenderViewInput GetViewInput() const;", + "FRenderFrameOutput RenderFrame(const FRenderViewInput& ViewInput);", + ): + require_fragment(renderer_header, fragment, renderer_header_path) + if '#include "UI/' in renderer_header or '#include "imgui' in renderer_header.lower(): + fail("Renderer.h public closure must not include UI or ImGui") + + checked_files = 0 + for relative_path in RUNTIME_VIEW_CONSUMERS: + text = read(repository_root, relative_path) + for fragment in FORBIDDEN_RUNTIME_FRAGMENTS: + if fragment in text: + fail(f"{relative_path} retains frontend-global dependency: {fragment}") + checked_files += 1 + + tonemap_header_path = "Engine/Source/Renderer/TonemapPass.h" + tonemap_header = read(repository_root, tonemap_header_path) + require_fragment(tonemap_header, "FTextureRHIRef OutputTexture;", tonemap_header_path) + require_fragment(tonemap_header, "const FRenderExtent& Extent", tonemap_header_path) + + renderer_source_path = "Engine/Source/Renderer/Renderer.cpp" + renderer_source = read(repository_root, renderer_source_path) + if not re.search( + r"FRenderFrameOutput\s+FRenderer::RenderFrame\s*\(\s*" + r"const\s+FRenderViewInput&", + renderer_source, + ): + fail("Renderer.cpp must implement the explicit RenderFrame contract") + require_fragment( + renderer_source, + "FrameOutput.ColorTexture =", + renderer_source_path, + ) + + print( + "Runtime render contracts passed: explicit view/frame schema, stable " + f"Tonemap output, {checked_files} Runtime consumers free of UI globals" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError) as error: + print(f"Runtime render contracts failed: {error}", file=sys.stderr) + raise SystemExit(1) 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 ec28fcf..3b30992 100644 --- a/docs/plans/2026-07-25-game-engine-production-roadmap.md +++ b/docs/plans/2026-07-25-game-engine-production-roadmap.md @@ -287,7 +287,7 @@ A 系列只提供跨领域公共词汇、Developer host、Context/Evidence feder | ID | 内容 | 前置 | 状态 | |---|---|---|---| | [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-player-targets.md) | Runtime/Editor/Player Target 图 | 可与 M0-01 并行 | Accepted | +| [G0-01](../specs/2026-07-25-g0-01-runtime-editor-player-targets.md) | Runtime/Editor/Player Target 图 | M0-01 | Implementing | | [A0-01](../specs/2026-07-25-a0-01-contract-core.md) | AI-Native Contract Core 与领域能力注册 | Slice 1~3:M0-01;Slice 4:G0-01 | Implementing | | [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 | diff --git a/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md b/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md index 3672c5e..ce5c2e9 100644 --- a/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md +++ b/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md @@ -1,9 +1,9 @@ # G0-01:Runtime、Editor 与 Player Target 边界 - Spec ID: `G0-01` -- Status: Accepted +- Status: Implementing - Created: 2026-07-25 -- Updated: 2026-07-25 +- Updated: 2026-07-26 - Roadmap: [游戏产品化路线 G0](../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](./2026-07-25-m0-01-cpu-contract-baseline.md) 可并行;最终验证依赖其稳定构建/测试入口 @@ -168,7 +168,7 @@ Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。Player | Slice | Scope | Invariant | Verification | Status | |---|---|---|---|---| -| 1 | 显式 view input/frame output,移除 Runtime Pass 的 UI 依赖 | Editor 图像与行为不变 | Debug、CTest、strict RG、Editor smoke | Pending | +| 1 | 显式 view input/frame output,移除 Runtime Pass 的 UI 依赖 | Editor 图像与行为不变 | Debug、CTest、strict RG、Editor smoke | Verified | | 2 | ImGui、selection/composite 移入 Editor frontend | GPU UI teardown 仍在 idle 后 | header boundary、退出、failure injection | Pending | | 3 | 建立 Runtime、EditorSupport、Editor targets | Editor 功能与 MCP 不回退 | Debug/Release、CTest、discover | Pending | | 4 | Player presenter、Player target 和受控退出 | Player 不链接 Editor,直接 Present | boundary、raster、resize/minimize/exit | Pending | @@ -224,17 +224,19 @@ Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。Player ### Current Progress -- 已核对当前 target、Renderer/UI、主循环、MCP 与关闭边界。 -- Draft 已迁移到精简 Spec schema;尚未修改生产代码。 -- 已关闭 target 拆分中的方案分叉,并明确 Shipping Player 不包含 MCP/Developer host。 +- 用户已接受本 Spec,直接依赖 M0-01 已 Verified;G0-01 于 2026-07-26 进入 Implementing。 +- Slice 1 已完成并验证:`FRenderViewInput` 显式携带 extent、interaction region、output mode 与可选 selection;`FRenderFrameOutput` 返回持久 RHI Tonemap/Composite 输出。 +- Scene、Camera、Base/Lighting/ShadowProject/PathTracing/Tonemap 不再读取 UI viewport/selection/texture 全局状态;`Renderer.h` 公共闭包不再 include UI/ImGui。 +- 新增 CPU 静态边界契约,防止 Runtime view consumer 回退到 UI 全局状态。 ### Next Step -- 用户审查并明确接受 Draft;后继 G0-02 Application runner/config 与 G0-03 path/storage Draft 已建立。 +- 实施 Slice 2:把 ImGui 生命周期、selection/composite 编排与 viewport 输出适配移入 Editor frontend。 ### Changes and Deviations -- 没有生产实现;Draft 已明确 Shipping Player 的 Developer/MCP 闭包、共同 runner/presenter 边界和 AI-Native Impact。 +- 无设计偏差。为保证未来 Player 的相机投影与 Scene uniform 同样不依赖 UI,显式 view input 在 Slice 1 同步贯穿 Scene/Camera,而不只覆盖 Render Pass。 +- 后继 G0-02 Application runner/config 与 G0-03 path/storage 保持 Accepted,尚未进入生产实现。 ### Evidence @@ -242,11 +244,15 @@ Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。Player |---|---|---|---|---|---| | 2026-07-25 | working tree | Source review | CMake、Renderer、ImGuiLayer、WaveEngine、MCP 只读核对 | Pass | 本 Spec Current Facts | | 2026-07-25 | working tree | Python 3 | `py -3 Tests/spec_contracts.py .` | Pass | `2 concrete Spec(s)` | +| 2026-07-26 | working tree | Windows 11、VS 2022、Debug、DX12 validation、DXR Tier 1.1 | `cmake -S . -B Build`;`cmake --build Build --config Debug` | Pass | `WaveEngine.exe`、`WaveCoreContracts.exe`、`WaveTraceViewer.exe` | +| 2026-07-26 | working tree | Python 3 | `py -3 Tests/runtime_render_contracts.py .` | Pass | 7 个 Runtime view consumer 无 UI 全局依赖 | +| 2026-07-26 | working tree | CTest Debug | `ctest --test-dir Build -C Debug --output-on-failure` | Pass,9/9 | 含 CPU、MCP、WEMesh、strict RG | +| 2026-07-26 | working tree | Debug、`WAVE_RG_STRICT=1` | MCP 选择实例 → 等待 selection/composite 帧 → `editor.app.request_exit` | Pass | selection id 1;正常退出 | +| 2026-07-26 | working tree | `WaveEngine.log` | 搜索 `Error`、`Fatal`、D3D12 error/corruption 与未闭合 RG event | Pass | 无匹配 | 历史构建不是未来 G0-01 的验收证据;实现后必须重新执行第 8 节。 ### Remaining Work -- 用户审查并明确接受 Draft。 -- 所有 Delivery Slices 与 Acceptance Criteria。 -- G0-02/G0-03 的接受与后续实现。 +- G0-01 Delivery Slices 2~5 与 Acceptance Criteria。 +- G0-02/G0-03 的后续实现。 diff --git a/docs/specs/README.md b/docs/specs/README.md index 85dba9f..81fe274 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -21,7 +21,7 @@ Draft / Accepted / Implementing --被替代--> Superseded | [A0-02](./2026-07-25-a0-02-developer-host.md) | Accepted | Developer Host Session、Policy、Operation 与 Audit | [AI-native track](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) | 2026-07-25 | | [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-player-targets.md) | Accepted | Runtime、Editor 与 Player Target 边界 | [G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) | 2026-07-25 | +| [G0-01](./2026-07-25-g0-01-runtime-editor-player-targets.md) | Implementing | Runtime、Editor 与 Player 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 | From e27c9c4c01bc258ed12da975ca850e2ed9fc261c Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sun, 26 Jul 2026 12:58:53 +0800 Subject: [PATCH 09/16] move editor rendering behind frontend --- Engine/Source/Renderer/RenderFrame.h | 1 + Engine/Source/Renderer/RenderFrameExtension.h | 30 +++++ Engine/Source/Renderer/Renderer.cpp | 124 +++-------------- Engine/Source/Renderer/Renderer.h | 10 +- Engine/Source/UI/EditorFrontend.cpp | 125 ++++++++++++++++++ Engine/Source/UI/EditorFrontend.h | 25 ++++ Engine/Source/WaveEngine.cpp | 17 ++- Tests/mcp_asset_input_contracts.py | 30 ++++- Tests/runtime_render_contracts.py | 42 +++++- ...-25-g0-01-runtime-editor-player-targets.md | 13 +- 10 files changed, 293 insertions(+), 124 deletions(-) create mode 100644 Engine/Source/Renderer/RenderFrameExtension.h create mode 100644 Engine/Source/UI/EditorFrontend.cpp create mode 100644 Engine/Source/UI/EditorFrontend.h diff --git a/Engine/Source/Renderer/RenderFrame.h b/Engine/Source/Renderer/RenderFrame.h index b799a63..4d122c8 100644 --- a/Engine/Source/Renderer/RenderFrame.h +++ b/Engine/Source/Renderer/RenderFrame.h @@ -74,6 +74,7 @@ struct FRenderViewInput struct FRenderFrameOutput { FTextureRHIRef ColorTexture; + FTextureRHIRef TonemapTexture; FRenderExtent Extent; ERenderOutputMode OutputMode = ERenderOutputMode::Offscreen; diff --git a/Engine/Source/Renderer/RenderFrameExtension.h b/Engine/Source/Renderer/RenderFrameExtension.h new file mode 100644 index 0000000..d309094 --- /dev/null +++ b/Engine/Source/Renderer/RenderFrameExtension.h @@ -0,0 +1,30 @@ +#pragma once + +#include "RenderFrame.h" + +class FRenderGraph; +struct FBasePassData; +struct FMeshletCullingPassData; +struct FTonemapPassData; + +struct FRenderFrameExtensionContext +{ + FRenderGraph& RenderGraph; + const FRenderViewInput& ViewInput; + TRefCountPtr CullingResults; + TRefCountPtr BasePassData; + TRefCountPtr TonemapPassData; +}; + +struct FRenderFrameExtensionOutput +{ + FTextureRHIRef ColorTexture; +}; + +class IRenderFrameExtension +{ +public: + virtual ~IRenderFrameExtension() = default; + virtual FRenderFrameExtensionOutput AddPasses( + const FRenderFrameExtensionContext& Context) = 0; +}; diff --git a/Engine/Source/Renderer/Renderer.cpp b/Engine/Source/Renderer/Renderer.cpp index 83862ab..00b807b 100644 --- a/Engine/Source/Renderer/Renderer.cpp +++ b/Engine/Source/Renderer/Renderer.cpp @@ -2,8 +2,6 @@ #include "BasePass.h" #include "EnvironmentIBLPass.h" -#include "EditorCompositePass.h" -#include "EditorSelectionPass.h" #include "FullScreenPass.h" #include "LightingPass.h" #include "AutoExposurePass.h" @@ -18,17 +16,12 @@ #include "Scene/Scene.h" #include "Core/Profiler.h" #include "Core/Engine.h" -#include "UI/ImGuiLayer.h" -#include #include FRenderer::FRenderer() - : ImGuiLayer(std::make_unique()) - , PersistentRenderGraph(std::make_unique()) + : PersistentRenderGraph(std::make_unique()) { - ImGuiLayer->Init(); - bImGuiInitialized = true; } FRenderer::~FRenderer() @@ -77,41 +70,9 @@ bool FRenderer::ConsumePTResetFlag(const FMatrix4x4& CurrentVP, uint32 Width, ui return bReset; } -FRenderViewInput FRenderer::GetViewInput() const -{ - FRenderViewInput ViewInput; - ViewInput.OutputMode = ERenderOutputMode::Offscreen; - - TRefCountPtr Window = FWindow::GetInstance(); - if (!Window || Window->IsMinimized()) - { - return ViewInput; - } - - const FSceneViewRect& SceneViewRect = GetSceneViewRect(); - if (SceneViewRect.IsValid()) - { - ViewInput.Extent.Width = std::max(static_cast(SceneViewRect.Width), 1u); - ViewInput.Extent.Height = std::max(static_cast(SceneViewRect.Height), 1u); - ViewInput.InteractionRegion = { - SceneViewRect.X, - SceneViewRect.Y, - SceneViewRect.Width, - SceneViewRect.Height, - }; - } - else - { - ViewInput.Extent = {std::max(Window->Width(), 1u), std::max(Window->Height(), 1u)}; - } - - ViewInput.SelectedInstanceIndex = GetSelectedInstanceIndex(); - ViewInput.bEnableEditorSelection = - ViewInput.SelectedInstanceIndex != FRenderViewInput::InvalidSelectedInstanceIndex; - return ViewInput; -} - -FRenderFrameOutput FRenderer::RenderFrame(const FRenderViewInput& ViewInput) +FRenderFrameOutput FRenderer::RenderFrame( + const FRenderViewInput& ViewInput, + IRenderFrameExtension* Extension) { SCOPED_CPU_EVENT("Renderer::RenderFrame"); @@ -125,7 +86,9 @@ FRenderFrameOutput FRenderer::RenderFrame(const FRenderViewInput& ViewInput) FRenderGraph& RenderGraph = *PersistentRenderGraph; TRefCountPtr TonemapPassData; - TRefCountPtr EditorCompositePassData; + TRefCountPtr CullingPassData; + TRefCountPtr BasePassData; + FRenderFrameExtensionOutput ExtensionOutput; { SCOPED_CPU_EVENT("Scene Render"); RG_EVENT_SCOPE(RenderGraph, "Scene Render"); @@ -177,23 +140,13 @@ FRenderFrameOutput FRenderer::RenderFrame(const FRenderViewInput& ViewInput) && Scene->SceneMeshletsCount > 0 && Scene->MeshletsBuffer && Scene->InstanceDataBuffer; - TRefCountPtr BasePassData; TRefCountPtr ShadowProjectPassData; - TRefCountPtr EditorSelectionPassData; if (bHasSceneGeometry) { TRefCountPtr ShadowCullingPassData = AddShadowMeshletCullingPass(RenderGraph); TRefCountPtr ShadowDepthPassData = AddShadowDepthPass(RenderGraph, ShadowCullingPassData); - TRefCountPtr CullingPassData = AddMeshletCullingPass(RenderGraph); + CullingPassData = AddMeshletCullingPass(RenderGraph); BasePassData = AddBasePass(RenderGraph, CullingPassData, ViewInput.Extent); - if (ViewInput.HasEditorSelection()) - { - EditorSelectionPassData = AddEditorSelectionPass( - RenderGraph, - CullingPassData, - ViewInput.Extent, - ViewInput.SelectedInstanceIndex); - } ShadowProjectPassData = AddShadowProjectPass( RenderGraph, BasePassData, ShadowDepthPassData, ViewInput.Extent); } @@ -207,63 +160,31 @@ FRenderFrameOutput FRenderer::RenderFrame(const FRenderViewInput& ViewInput) } TonemapPassData = AddTonemapPass( RenderGraph, LightingPassData->LightingColor, ExposureTexture, ViewInput.Extent, true); - if (EditorSelectionPassData) - { - EditorCompositePassData = AddEditorCompositePass( - RenderGraph, - TonemapPassData->OutputColor, - BasePassData->GBuffer.DepthStencil, - EditorSelectionPassData->SelectionDepth, - EditorSelectionPassData->SelectionId, - ViewInput.Extent); - } } + if (Extension) + { + ExtensionOutput = Extension->AddPasses({ + RenderGraph, + ViewInput, + CullingPassData, + BasePassData, + TonemapPassData, + }); + } } RenderGraph.Execute(); FRenderGraphResourcePool::Get()->Flush(GetCurrentFrameIndex()); - FrameOutput.ColorTexture = EditorCompositePassData - ? EditorCompositePassData->OutputTexture - : (TonemapPassData ? TonemapPassData->OutputTexture : nullptr); + FrameOutput.TonemapTexture = TonemapPassData ? TonemapPassData->OutputTexture : nullptr; + FrameOutput.ColorTexture = ExtensionOutput.ColorTexture + ? ExtensionOutput.ColorTexture + : FrameOutput.TonemapTexture; return FrameOutput; } -void FRenderer::Render() -{ - SCOPED_CPU_EVENT("Renderer::Render"); - - TRefCountPtr Window = FWindow::GetInstance(); - if (!Window || Window->IsMinimized()) - { - return; - } - - GDynamicRHI->ResizeSwapchain(Window->Width(), Window->Height()); - - { - SCOPED_CPU_EVENT("Renderer::ImGui"); - ImGuiLayer->BeginFrame(); - ImGuiLayer->DrawConfigWindow(); - } - - const FRenderFrameOutput FrameOutput = RenderFrame(GetViewInput()); - SetSceneViewTexture(FrameOutput.ColorTexture); - ImGuiLayer->Render(); - - { - GDynamicRHI->Present(); - } -} - void FRenderer::Shutdown() { - if (bImGuiInitialized) - { - ImGuiLayer->Shutdown(); - bImGuiInitialized = false; - } - PTAccumulationTexture.reset(); PTAccumWidth = 0; PTAccumHeight = 0; @@ -272,6 +193,5 @@ void FRenderer::Shutdown() ResetPathTracingPassResources(); ResetAutoExposurePassResources(); ResetTonemapPassResources(); - ResetEditorCompositePassResources(); ResetFullscreenPassResources(); } diff --git a/Engine/Source/Renderer/Renderer.h b/Engine/Source/Renderer/Renderer.h index 63dd40b..ef640c1 100644 --- a/Engine/Source/Renderer/Renderer.h +++ b/Engine/Source/Renderer/Renderer.h @@ -2,11 +2,11 @@ #include "Core/Math.h" #include "RenderFrame.h" +#include "RenderFrameExtension.h" #include "RenderGraph/RenderGraphResource.h" #include -class FImGuiLayer; class FRenderGraph; class FScene; @@ -26,9 +26,9 @@ class FRenderer public: FRenderer(); ~FRenderer(); - FRenderViewInput GetViewInput() const; - FRenderFrameOutput RenderFrame(const FRenderViewInput& ViewInput); - void Render(); + FRenderFrameOutput RenderFrame( + const FRenderViewInput& ViewInput, + IRenderFrameExtension* Extension = nullptr); void Shutdown(); // Path-tracing accumulation buffer accessor (used by PathTracingPass). @@ -38,9 +38,7 @@ class FRenderer bool ConsumePTResetFlag(const FMatrix4x4& CurrentVP, uint32 Width, uint32 Height); private: - std::unique_ptr ImGuiLayer; std::unique_ptr PersistentRenderGraph; - bool bImGuiInitialized = false; // Path tracing accumulation state. FTextureRHIRef PTAccumulationTexture; diff --git a/Engine/Source/UI/EditorFrontend.cpp b/Engine/Source/UI/EditorFrontend.cpp new file mode 100644 index 0000000..d1a0811 --- /dev/null +++ b/Engine/Source/UI/EditorFrontend.cpp @@ -0,0 +1,125 @@ +#include "EditorFrontend.h" + +#include "Renderer/BasePass.h" +#include "Renderer/EditorCompositePass.h" +#include "Renderer/EditorSelectionPass.h" +#include "Renderer/Renderer.h" +#include "Renderer/TonemapPass.h" +#include "RHI/DynamicRHI.h" +#include "Window.h" + +#include + +FEditorFrontend::~FEditorFrontend() +{ + Shutdown(); +} + +void FEditorFrontend::Init() +{ + if (bInitialized) + { + return; + } + + ImGuiLayer.Init(); + bInitialized = true; +} + +void FEditorFrontend::Shutdown() +{ + if (!bInitialized) + { + return; + } + + ImGuiLayer.Shutdown(); + ResetEditorCompositePassResources(); + bInitialized = false; +} + +FRenderViewInput FEditorFrontend::GetViewInput() const +{ + FRenderViewInput ViewInput; + ViewInput.OutputMode = ERenderOutputMode::Offscreen; + + TRefCountPtr Window = FWindow::GetInstance(); + if (!Window || Window->IsMinimized()) + { + return ViewInput; + } + + const FSceneViewRect& SceneViewRect = GetSceneViewRect(); + if (SceneViewRect.IsValid()) + { + ViewInput.Extent.Width = std::max(static_cast(SceneViewRect.Width), 1u); + ViewInput.Extent.Height = std::max(static_cast(SceneViewRect.Height), 1u); + ViewInput.InteractionRegion = { + SceneViewRect.X, + SceneViewRect.Y, + SceneViewRect.Width, + SceneViewRect.Height, + }; + } + else + { + ViewInput.Extent = {std::max(Window->Width(), 1u), std::max(Window->Height(), 1u)}; + } + + ViewInput.SelectedInstanceIndex = GetSelectedInstanceIndex(); + ViewInput.bEnableEditorSelection = + ViewInput.SelectedInstanceIndex != FRenderViewInput::InvalidSelectedInstanceIndex; + return ViewInput; +} + +void FEditorFrontend::Render(FRenderer& Renderer) +{ + SCOPED_CPU_EVENT("EditorFrontend::Render"); + + TRefCountPtr Window = FWindow::GetInstance(); + if (!Window || Window->IsMinimized()) + { + return; + } + + GDynamicRHI->ResizeSwapchain(Window->Width(), Window->Height()); + + { + SCOPED_CPU_EVENT("EditorFrontend::ImGui"); + ImGuiLayer.BeginFrame(); + ImGuiLayer.DrawConfigWindow(); + } + + const FRenderFrameOutput FrameOutput = Renderer.RenderFrame(GetViewInput(), this); + SetSceneViewTexture(FrameOutput.ColorTexture); + ImGuiLayer.Render(); + GDynamicRHI->Present(); +} + +FRenderFrameExtensionOutput FEditorFrontend::AddPasses( + const FRenderFrameExtensionContext& Context) +{ + FRenderFrameExtensionOutput Output; + if (!Context.ViewInput.HasEditorSelection() + || !Context.CullingResults + || !Context.BasePassData + || !Context.TonemapPassData) + { + return Output; + } + + TRefCountPtr SelectionPassData = AddEditorSelectionPass( + Context.RenderGraph, + Context.CullingResults, + Context.ViewInput.Extent, + Context.ViewInput.SelectedInstanceIndex); + TRefCountPtr CompositePassData = AddEditorCompositePass( + Context.RenderGraph, + Context.TonemapPassData->OutputColor, + Context.BasePassData->GBuffer.DepthStencil, + SelectionPassData->SelectionDepth, + SelectionPassData->SelectionId, + Context.ViewInput.Extent); + Output.ColorTexture = CompositePassData->OutputTexture; + return Output; +} diff --git a/Engine/Source/UI/EditorFrontend.h b/Engine/Source/UI/EditorFrontend.h new file mode 100644 index 0000000..7166366 --- /dev/null +++ b/Engine/Source/UI/EditorFrontend.h @@ -0,0 +1,25 @@ +#pragma once + +#include "ImGuiLayer.h" +#include "Renderer/RenderFrameExtension.h" + +class FRenderer; + +class FEditorFrontend final : public IRenderFrameExtension +{ +public: + FEditorFrontend() = default; + ~FEditorFrontend(); + + void Init(); + void Shutdown(); + FRenderViewInput GetViewInput() const; + void Render(FRenderer& Renderer); + + FRenderFrameExtensionOutput AddPasses( + const FRenderFrameExtensionContext& Context) override; + +private: + FImGuiLayer ImGuiLayer; + bool bInitialized = false; +}; diff --git a/Engine/Source/WaveEngine.cpp b/Engine/Source/WaveEngine.cpp index 41f1949..5a79498 100644 --- a/Engine/Source/WaveEngine.cpp +++ b/Engine/Source/WaveEngine.cpp @@ -13,6 +13,7 @@ #include "RenderGraph/RenderGraphResourcePool.h" #include "Trace/TraceRecorder.h" #include "Developer/RenderDocIntegration.h" +#include "UI/EditorFrontend.h" #include #include @@ -49,6 +50,7 @@ int main() TRefCountPtr Window; TRefCountPtr Scene; std::unique_ptr Renderer; + std::unique_ptr EditorFrontend; try { @@ -63,10 +65,12 @@ int main() FShaderMap::Init(); Scene = FScene::Init(); Renderer = std::make_unique(); + EditorFrontend = std::make_unique(); + EditorFrontend->Init(); FFPSCounter FPSCounter; SET_THREAD_NAME("Main Thread"); - // MCP server 必须在 Renderer 构造完(ImGuiLayer::Init 跑完、所有 Panel 工具注册完) + // MCP server 必须在 Editor frontend 初始化完(所有 Panel 工具注册完) // 之后启动,否则 server 线程可能在 Tools map 还在被填的中间读到坏 bucket。 FMcpServer::Get().StartStdio(); @@ -91,7 +95,7 @@ int main() 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, Renderer->GetViewInput()); + Scene->Tick(DeltaTimeSeconds, EditorFrontend->GetViewInput()); } { SCOPED_CPU_EVENT("Renderer"); @@ -101,7 +105,7 @@ int main() && RenderDoc.BeginFrameCapture(NativeWindow); try { - Renderer->Render(); + EditorFrontend->Render(*Renderer); if (bRenderDocCaptureStarted) { FRHIThread::WaitForLastPresent(); @@ -134,7 +138,7 @@ int main() ExitCode = 1; } - // Renderer 必须跨过 catch 保持存活:延迟命令可能仍引用 ImGui backend。 + // Renderer 与 Editor frontend 必须跨过 catch 保持存活:延迟命令可能仍引用 ImGui backend。 // 先拒绝/取消外部请求,再排空 RHI/GPU,最后释放 UI 与场景 GPU 资源。 FMainThreadDispatcher::Get().Shutdown(); FMcpServer::Get().Shutdown(); @@ -173,6 +177,11 @@ int main() std::_Exit(ExitCode != 0 ? ExitCode : 1); } + if (EditorFrontend) + { + EditorFrontend->Shutdown(); + EditorFrontend.reset(); + } if (Renderer) { Renderer->Shutdown(); diff --git a/Tests/mcp_asset_input_contracts.py b/Tests/mcp_asset_input_contracts.py index 938f693..80f5abc 100644 --- a/Tests/mcp_asset_input_contracts.py +++ b/Tests/mcp_asset_input_contracts.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 -"""Focused contracts for MCP framing and malformed asset rejection. +"""Focused contracts for MCP framing, Editor selection, and malformed assets. Usage: py -3 Tests/mcp_asset_input_contracts.py Build/Debug/WaveEngine.exe -The test only invokes operations expected to fail and therefore does not alter -the saved scene. It asks the child process to exit cleanly after the final -health check. +The test only invokes operations expected to fail plus transient Editor +selection, so it does not alter the saved scene. It asks the child process to +exit cleanly after the final health check. """ from __future__ import annotations @@ -295,6 +295,28 @@ def expect_stderr(fragment: str, start_index: int) -> None: ) assert unsupported_api["error"]["data"]["code"] == "contract.api_version.unsupported" + hierarchy = request( + { + "jsonrpc": "2.0", + "id": 19, + "method": "editor.hierarchy.list", + "params": [], + }, + None, + )["result"] + assert hierarchy, "default scene hierarchy must not be empty" + selected_id = hierarchy[0]["id"] + request( + { + "jsonrpc": "2.0", + "id": 20, + "method": "editor.hierarchy.select", + "params": [selected_id], + }, + None, + ) + time.sleep(0.1) + request( {"jsonrpc": "2.0", "id": None, "method": "capability.list", "params": {}}, None, diff --git a/Tests/runtime_render_contracts.py b/Tests/runtime_render_contracts.py index 7a8205d..a02b485 100644 --- a/Tests/runtime_render_contracts.py +++ b/Tests/runtime_render_contracts.py @@ -11,6 +11,7 @@ "Engine/Source/Renderer/BasePass.cpp", "Engine/Source/Renderer/LightingPass.cpp", "Engine/Source/Renderer/PathTracingPass.cpp", + "Engine/Source/Renderer/Renderer.cpp", "Engine/Source/Renderer/ShadowProjectPass.cpp", "Engine/Source/Renderer/TonemapPass.cpp", "Engine/Source/Scene/Camera.cpp", @@ -57,6 +58,7 @@ def main() -> int: "struct FRenderViewInput", "struct FRenderFrameOutput", "FTextureRHIRef ColorTexture", + "FTextureRHIRef TonemapTexture", "bool HasEditorSelection() const", ): require_fragment(render_contract, fragment, render_contract_path) @@ -64,8 +66,9 @@ def main() -> int: renderer_header_path = "Engine/Source/Renderer/Renderer.h" renderer_header = read(repository_root, renderer_header_path) for fragment in ( - "FRenderViewInput GetViewInput() const;", - "FRenderFrameOutput RenderFrame(const FRenderViewInput& ViewInput);", + "FRenderFrameOutput RenderFrame(", + "const FRenderViewInput& ViewInput", + "IRenderFrameExtension* Extension = nullptr", ): require_fragment(renderer_header, fragment, renderer_header_path) if '#include "UI/' in renderer_header or '#include "imgui' in renderer_header.lower(): @@ -88,8 +91,9 @@ def main() -> int: renderer_source = read(repository_root, renderer_source_path) if not re.search( r"FRenderFrameOutput\s+FRenderer::RenderFrame\s*\(\s*" - r"const\s+FRenderViewInput&", + r"const\s+FRenderViewInput&.*?IRenderFrameExtension\*", renderer_source, + re.DOTALL, ): fail("Renderer.cpp must implement the explicit RenderFrame contract") require_fragment( @@ -97,10 +101,38 @@ def main() -> int: "FrameOutput.ColorTexture =", renderer_source_path, ) + require_fragment( + renderer_source, + "FrameOutput.TonemapTexture =", + renderer_source_path, + ) + + extension_path = "Engine/Source/Renderer/RenderFrameExtension.h" + extension = read(repository_root, extension_path) + for fragment in ( + "class IRenderFrameExtension", + "struct FRenderFrameExtensionContext", + "virtual FRenderFrameExtensionOutput AddPasses(", + ): + require_fragment(extension, fragment, extension_path) + + editor_frontend_path = "Engine/Source/UI/EditorFrontend.cpp" + editor_frontend = read(repository_root, editor_frontend_path) + for fragment in ( + "FEditorFrontend::GetViewInput() const", + "FEditorFrontend::Render(FRenderer& Renderer)", + "FEditorFrontend::AddPasses(", + "AddEditorSelectionPass(", + "AddEditorCompositePass(", + "ImGuiLayer.Render();", + "GDynamicRHI->Present();", + ): + require_fragment(editor_frontend, fragment, editor_frontend_path) print( - "Runtime render contracts passed: explicit view/frame schema, stable " - f"Tonemap output, {checked_files} Runtime consumers free of UI globals" + "Runtime render contracts passed: explicit view/frame extension schema, " + "stable Tonemap output, Editor-owned selection/composite, " + f"{checked_files} Runtime consumers free of UI globals" ) return 0 diff --git a/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md b/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md index ce5c2e9..d41ce54 100644 --- a/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md +++ b/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md @@ -169,7 +169,7 @@ Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。Player | Slice | Scope | Invariant | Verification | Status | |---|---|---|---|---| | 1 | 显式 view input/frame output,移除 Runtime Pass 的 UI 依赖 | Editor 图像与行为不变 | Debug、CTest、strict RG、Editor smoke | Verified | -| 2 | ImGui、selection/composite 移入 Editor frontend | GPU UI teardown 仍在 idle 后 | header boundary、退出、failure injection | Pending | +| 2 | ImGui、selection/composite 移入 Editor frontend | GPU UI teardown 仍在 idle 后 | header boundary、退出、failure injection | Verified | | 3 | 建立 Runtime、EditorSupport、Editor targets | Editor 功能与 MCP 不回退 | Debug/Release、CTest、discover | Pending | | 4 | Player presenter、Player target 和受控退出 | Player 不链接 Editor,直接 Present | boundary、raster、resize/minimize/exit | Pending | | 5 | 迁移脚本、文档并移除临时入口 | 无无主旧 target 引用 | `rg`、CTest、兼容门核对 | Pending | @@ -228,14 +228,18 @@ Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。Player - Slice 1 已完成并验证:`FRenderViewInput` 显式携带 extent、interaction region、output mode 与可选 selection;`FRenderFrameOutput` 返回持久 RHI Tonemap/Composite 输出。 - Scene、Camera、Base/Lighting/ShadowProject/PathTracing/Tonemap 不再读取 UI viewport/selection/texture 全局状态;`Renderer.h` 公共闭包不再 include UI/ImGui。 - 新增 CPU 静态边界契约,防止 Runtime view consumer 回退到 UI 全局状态。 +- Slice 2 已完成并验证:`FEditorFrontend` 独立拥有 ImGui 生命周期、selection/composite 扩展、viewport texture 适配与 Editor Present。 +- Runtime Renderer 通过 `IRenderFrameExtension` 在同一 RenderGraph 构建期提供受限扩展上下文;Editor pass 不再由 Runtime source include/编排,transient handle 未跨帧逃逸。 +- Editor frontend 与 Renderer 均跨顶层异常存活到 GPU idle;failure injection 在 idle 不可证明时保持非零 quarantine。 ### Next Step -- 实施 Slice 2:把 ImGui 生命周期、selection/composite 编排与 viewport 输出适配移入 Editor frontend。 +- 实施 Slice 3:建立显式 `WaveRuntime`、`WaveEditorSupport` 与 `WaveEditor` source/link target 图并迁移 Editor CTest。 ### Changes and Deviations - 无设计偏差。为保证未来 Player 的相机投影与 Scene uniform 同样不依赖 UI,显式 view input 在 Slice 1 同步贯穿 Scene/Camera,而不只覆盖 Render Pass。 +- Slice 2 采用 Runtime-neutral `IRenderFrameExtension`,使 Editor selection/composite 继续在同一 RenderGraph 内声明依赖,避免引入 Spec 明确排除的 RenderGraph extraction。 - 后继 G0-02 Application runner/config 与 G0-03 path/storage 保持 Accepted,尚未进入生产实现。 ### Evidence @@ -249,10 +253,13 @@ Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。Player | 2026-07-26 | working tree | CTest Debug | `ctest --test-dir Build -C Debug --output-on-failure` | Pass,9/9 | 含 CPU、MCP、WEMesh、strict RG | | 2026-07-26 | working tree | Debug、`WAVE_RG_STRICT=1` | MCP 选择实例 → 等待 selection/composite 帧 → `editor.app.request_exit` | Pass | selection id 1;正常退出 | | 2026-07-26 | working tree | `WaveEngine.log` | 搜索 `Error`、`Fatal`、D3D12 error/corruption 与未闭合 RG event | Pass | 无匹配 | +| 2026-07-26 | working tree | Windows 11、VS 2022、Debug | `cmake --build Build --config Debug`;`ctest --test-dir Build -C Debug --output-on-failure` | Pass,9/9 | Editor frontend、MCP selection/composite、strict RG | +| 2026-07-26 | working tree | Debug failure injection | `WAVE_RHI_INJECT_FAILURE=1` 启动 Editor | Pass | exit 1;worker failure → idle wait failure → quarantine | +| 2026-07-26 | working tree | clean Debug run | failure injection 后重跑完整 CTest,并扫描日志 | Pass | 无 `Error`/`Fatal`/D3D12 error/未闭合 RG event | 历史构建不是未来 G0-01 的验收证据;实现后必须重新执行第 8 节。 ### Remaining Work -- G0-01 Delivery Slices 2~5 与 Acceptance Criteria。 +- G0-01 Delivery Slices 3~5 与 Acceptance Criteria。 - G0-02/G0-03 的后续实现。 From 6135c54f64e60ff655b24b37b37e09895b3d7af3 Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sun, 26 Jul 2026 13:06:19 +0800 Subject: [PATCH 10/16] split runtime and editor targets --- CMakeLists.txt | 183 +++++++++++++---- Engine/Shaders/BasePass.hlsl | 28 +-- Engine/Shaders/BasePassCommon.hlsli | 13 ++ Engine/Shaders/EditorSelection.hlsl | 15 ++ .../Source/Renderer/EditorSelectionPass.cpp | 2 +- Tests/core_contract_target_contracts.py | 4 +- Tests/runtime_target_contracts.py | 186 ++++++++++++++++++ ...-25-g0-01-runtime-editor-player-targets.md | 14 +- 8 files changed, 375 insertions(+), 70 deletions(-) create mode 100644 Engine/Shaders/BasePassCommon.hlsli create mode 100644 Engine/Shaders/EditorSelection.hlsl create mode 100644 Tests/runtime_target_contracts.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 1881b37..ccc8c4e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,19 +18,69 @@ set(SHADERS_FOLDER "${ENGINE_FOLDER}/Shaders") set(ENGINE_TEMP_FOLDER "${ENGINE_FOLDER}/Save") set(ASSETS_FOLDER "${PROJECT_SOURCE_DIR}/Assets") -file(GLOB_RECURSE ENGINE_HEADER CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/Engine/Source/*.h") -file(GLOB_RECURSE ENGINE_SOURCE CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/Engine/Source/*.cpp") -file(GLOB_RECURSE THIRD_PARTY_FILES CONFIGURE_DEPENDS +file(GLOB_RECURSE WAVE_RUNTIME_HEADER CONFIGURE_DEPENDS + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/*.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Input/*.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Misc/*.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Renderer/*.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/RenderGraph/*.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/RHI/*.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Scene/*.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Shader/*.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Trace/*.h") +list(APPEND WAVE_RUNTIME_HEADER + "${PROJECT_SOURCE_DIR}/Engine/Source/Window.h") +file(GLOB_RECURSE WAVE_RUNTIME_SOURCE CONFIGURE_DEPENDS + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/*.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Input/*.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Misc/*.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Renderer/*.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/RenderGraph/*.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/RHI/*.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Scene/*.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Shader/*.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Trace/*.cpp") +list(APPEND WAVE_RUNTIME_SOURCE + "${PROJECT_SOURCE_DIR}/Engine/Source/Window.cpp") +list(REMOVE_ITEM WAVE_RUNTIME_HEADER + "${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/Renderer/EditorCompositePass.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Renderer/EditorSelectionPass.cpp") + +file(GLOB_RECURSE WAVE_EDITOR_SUPPORT_HEADER CONFIGURE_DEPENDS + "${PROJECT_SOURCE_DIR}/Engine/Source/Contract/*.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Developer/*.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Mcp/*.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/UI/*.h") +file(GLOB_RECURSE WAVE_EDITOR_SUPPORT_SOURCE CONFIGURE_DEPENDS + "${PROJECT_SOURCE_DIR}/Engine/Source/Contract/*.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Developer/*.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Mcp/*.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/UI/*.cpp") +list(APPEND WAVE_EDITOR_SUPPORT_HEADER + "${PROJECT_SOURCE_DIR}/Engine/Source/Renderer/EditorCompositePass.h" + "${PROJECT_SOURCE_DIR}/Engine/Source/Renderer/EditorSelectionPass.h") +list(APPEND WAVE_EDITOR_SUPPORT_SOURCE + "${PROJECT_SOURCE_DIR}/Engine/Source/Renderer/EditorCompositePass.cpp" + "${PROJECT_SOURCE_DIR}/Engine/Source/Renderer/EditorSelectionPass.cpp") +set(WAVE_EDITOR_ENTRY + "${PROJECT_SOURCE_DIR}/Engine/Source/WaveEngine.cpp") + +file(GLOB_RECURSE WAVE_RUNTIME_THIRD_PARTY_FILES CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/Engine/ThirdParty/*.h" "${PROJECT_SOURCE_DIR}/Engine/ThirdParty/*.cpp") -list(FILTER THIRD_PARTY_FILES EXCLUDE REGEX ".*/Engine/ThirdParty/imgui/examples/.*") -list(FILTER THIRD_PARTY_FILES EXCLUDE REGEX ".*/Engine/ThirdParty/imgui/docs/.*") -list(FILTER THIRD_PARTY_FILES EXCLUDE REGEX ".*/Engine/ThirdParty/imgui/misc/.*") -list(FILTER THIRD_PARTY_FILES EXCLUDE REGEX ".*/Engine/ThirdParty/imgui/backends/.*") -list(APPEND THIRD_PARTY_FILES +list(FILTER WAVE_RUNTIME_THIRD_PARTY_FILES EXCLUDE REGEX ".*/Engine/ThirdParty/imgui/.*") +list(APPEND WAVE_RUNTIME_THIRD_PARTY_FILES + "${PROJECT_SOURCE_DIR}/Engine/ThirdParty/ufbx/ufbx.c") + +file(GLOB WAVE_EDITOR_THIRD_PARTY_FILES CONFIGURE_DEPENDS + "${PROJECT_SOURCE_DIR}/Engine/ThirdParty/imgui/*.h" + "${PROJECT_SOURCE_DIR}/Engine/ThirdParty/imgui/*.cpp") +list(APPEND WAVE_EDITOR_THIRD_PARTY_FILES "${PROJECT_SOURCE_DIR}/Engine/ThirdParty/imgui/backends/imgui_impl_dx12.cpp" "${PROJECT_SOURCE_DIR}/Engine/ThirdParty/imgui/backends/imgui_impl_glfw.cpp") -list(APPEND THIRD_PARTY_FILES "${PROJECT_SOURCE_DIR}/Engine/ThirdParty/ufbx/ufbx.c") if (WIN32) set(GLFW_DIR "${PROJECT_SOURCE_DIR}/Engine/ThirdParty/glfw") set(GLFW_SRC_DIR "${GLFW_DIR}/src") @@ -57,7 +107,7 @@ if (WIN32) "${GLFW_SRC_DIR}/win32_window.c" "${GLFW_SRC_DIR}/wgl_context.c" ) - list(APPEND THIRD_PARTY_FILES + list(APPEND WAVE_RUNTIME_THIRD_PARTY_FILES ${GLFW_SOURCES} "${GLFW_DIR}/include/GLFW/glfw3.h" "${GLFW_DIR}/include/GLFW/glfw3native.h" @@ -72,18 +122,43 @@ if (WIN32) "${GLFW_SRC_DIR}/win32_thread.h" ) endif() -file(GLOB_RECURSE ENGINE_SHADERS CONFIGURE_DEPENDS "${SHADERS_FOLDER}/*.hlsl" "${SHADERS_FOLDER}/*.hlsli" "${SHADERS_FOLDER}/*.h") +file(GLOB_RECURSE WAVE_RUNTIME_SHADERS CONFIGURE_DEPENDS + "${SHADERS_FOLDER}/*.hlsl" + "${SHADERS_FOLDER}/*.hlsli" + "${SHADERS_FOLDER}/*.h") +set(WAVE_EDITOR_SHADERS + "${SHADERS_FOLDER}/EditorComposite.hlsl" + "${SHADERS_FOLDER}/EditorSelection.hlsl") +list(REMOVE_ITEM WAVE_RUNTIME_SHADERS ${WAVE_EDITOR_SHADERS}) + +set(ENGINE_HEADER ${WAVE_RUNTIME_HEADER} ${WAVE_EDITOR_SUPPORT_HEADER}) +set(ENGINE_SOURCE ${WAVE_RUNTIME_SOURCE} ${WAVE_EDITOR_SUPPORT_SOURCE} ${WAVE_EDITOR_ENTRY}) +set(THIRD_PARTY_FILES ${WAVE_RUNTIME_THIRD_PARTY_FILES} ${WAVE_EDITOR_THIRD_PARTY_FILES}) +set(ENGINE_SHADERS ${WAVE_RUNTIME_SHADERS} ${WAVE_EDITOR_SHADERS}) set(CORE_RUNTIME_CONTRACT_SUITE "${PROJECT_SOURCE_DIR}/Tests/CoreRuntimeContracts.h" "${PROJECT_SOURCE_DIR}/Tests/CoreRuntimeContracts.cpp") -add_executable(WaveEngine - ${ENGINE_HEADER} - ${ENGINE_SOURCE} - ${THIRD_PARTY_FILES} - ${ENGINE_SHADERS}) -set_property(TARGET WaveEngine PROPERTY COMPILE_WARNING_AS_ERROR ON) +add_library(WaveRuntime STATIC + ${WAVE_RUNTIME_HEADER} + ${WAVE_RUNTIME_SOURCE} + ${WAVE_RUNTIME_THIRD_PARTY_FILES} + ${WAVE_RUNTIME_SHADERS}) +add_library(WaveEditorSupport STATIC + ${WAVE_EDITOR_SUPPORT_HEADER} + ${WAVE_EDITOR_SUPPORT_SOURCE} + ${WAVE_EDITOR_THIRD_PARTY_FILES} + ${WAVE_EDITOR_SHADERS}) +add_executable(WaveEditor ${WAVE_EDITOR_ENTRY}) + +# Temporary build-target compatibility during G0-01 migration. This does not +# create a second executable or a legacy WaveEngine.exe artifact. +add_custom_target(WaveEngine DEPENDS WaveEditor) + +set_property(TARGET WaveRuntime PROPERTY COMPILE_WARNING_AS_ERROR ON) +set_property(TARGET WaveEditorSupport PROPERTY COMPILE_WARNING_AS_ERROR ON) +set_property(TARGET WaveEditor PROPERTY COMPILE_WARNING_AS_ERROR ON) # ---------------- WaveCoreContracts ---------------- # Explicit CPU-only source closure. This target intentionally does not use @@ -150,33 +225,43 @@ endif() # Third party libraries # find_package(Vulkan REQUIRED) -target_link_libraries(WaveEngine PUBLIC +target_link_libraries(WaveRuntime PUBLIC # Vulkan::Vulkan "${PROJECT_SOURCE_DIR}/Engine/ThirdParty/Lib/dxcompiler.lib" #... ) +target_link_libraries(WaveEditorSupport PUBLIC WaveRuntime) +target_link_libraries(WaveEditor PRIVATE WaveEditorSupport) if (WIN32) - target_link_libraries(WaveEngine PUBLIC ws2_32) + target_link_libraries(WaveRuntime PUBLIC ws2_32) endif () -target_include_directories(WaveEngine PUBLIC +target_include_directories(WaveRuntime PUBLIC "${PROJECT_SOURCE_DIR}/Engine/Source" "${PROJECT_SOURCE_DIR}/Engine/Shaders/Shared" "${PROJECT_SOURCE_DIR}/Engine/ThirdParty" "${PROJECT_SOURCE_DIR}/Engine/ThirdParty/Include" "${PROJECT_SOURCE_DIR}/Engine/ThirdParty/glfw/include" +) +target_include_directories(WaveEditorSupport PUBLIC + "${PROJECT_SOURCE_DIR}/Engine/Source" "${PROJECT_SOURCE_DIR}/Engine/ThirdParty/imgui" "${PROJECT_SOURCE_DIR}/Engine/ThirdParty/imgui/backends" - #... ) if (WIN32) - target_include_directories(WaveEngine PRIVATE + target_include_directories(WaveRuntime PRIVATE "${PROJECT_SOURCE_DIR}/Engine/ThirdParty/glfw/src" ) endif() -target_precompile_headers(WaveEngine PRIVATE +target_precompile_headers(WaveRuntime PRIVATE + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/EnginePCH.h" +) +target_precompile_headers(WaveEditorSupport PRIVATE + "${PROJECT_SOURCE_DIR}/Engine/Source/Core/EnginePCH.h" +) +target_precompile_headers(WaveEditor PRIVATE "${PROJECT_SOURCE_DIR}/Engine/Source/Core/EnginePCH.h" ) @@ -212,25 +297,35 @@ add_definitions(-DSHADERS_FOLDER="${SHADERS_FOLDER}/") add_definitions(-DASSETS_FOLDER="${ASSETS_FOLDER}/") add_definitions(-DENGINE_TEMP_FOLDER="${ENGINE_TEMP_FOLDER}/") -# include("${CMAKE_CURRENT_SOURCE_DIR}/Cmake/group.cmake") -# groupCmakeFiles(WaveEngine) - -source_group(TREE "${PROJECT_SOURCE_DIR}/Engine/Source" PREFIX "Source" FILES ${ENGINE_HEADER} ${ENGINE_SOURCE}) -source_group(TREE "${PROJECT_SOURCE_DIR}/Engine/ThirdParty" PREFIX "ThirdParty" FILES ${THIRD_PARTY_FILES}) -source_group(TREE "${PROJECT_SOURCE_DIR}/Engine/Shaders" PREFIX "Shaders" FILES ${ENGINE_SHADERS}) +source_group(TREE "${PROJECT_SOURCE_DIR}/Engine/Source" PREFIX "Runtime Source" + FILES ${WAVE_RUNTIME_HEADER} ${WAVE_RUNTIME_SOURCE}) +source_group(TREE "${PROJECT_SOURCE_DIR}/Engine/Source" PREFIX "Editor Source" + FILES ${WAVE_EDITOR_SUPPORT_HEADER} ${WAVE_EDITOR_SUPPORT_SOURCE} ${WAVE_EDITOR_ENTRY}) +source_group(TREE "${PROJECT_SOURCE_DIR}/Engine/ThirdParty" PREFIX "Runtime ThirdParty" + FILES ${WAVE_RUNTIME_THIRD_PARTY_FILES}) +source_group(TREE "${PROJECT_SOURCE_DIR}/Engine/ThirdParty" PREFIX "Editor ThirdParty" + FILES ${WAVE_EDITOR_THIRD_PARTY_FILES}) +source_group(TREE "${PROJECT_SOURCE_DIR}/Engine/Shaders" PREFIX "Runtime Shaders" + FILES ${WAVE_RUNTIME_SHADERS}) +source_group(TREE "${PROJECT_SOURCE_DIR}/Engine/Shaders" PREFIX "Editor Shaders" + FILES ${WAVE_EDITOR_SHADERS}) if(MSVC) # Edit-and-Continue and incremental linking are Debug-only. Enabling them in # optimized configurations silently weakens Release link optimization. - target_compile_options(WaveEngine PRIVATE "$<$:/ZI>") - target_link_options(WaveEngine PRIVATE "$<$:/INCREMENTAL>") - target_compile_options(WaveEngine PRIVATE /bigobj /Zc:preprocessor) + foreach(WAVE_TARGET WaveRuntime WaveEditorSupport WaveEditor) + target_compile_options(${WAVE_TARGET} PRIVATE "$<$:/ZI>") + target_compile_options(${WAVE_TARGET} PRIVATE /bigobj /Zc:preprocessor) + endforeach() + target_link_options(WaveEditor PRIVATE "$<$:/INCREMENTAL>") ## Only include hlsl as text file. set_source_files_properties(${ENGINE_SHADERS} PROPERTIES HEADER_FILE_ONLY TRUE) else() - target_compile_options(WaveEngine PRIVATE -Wa,-mbig-obj) + target_compile_options(WaveRuntime PRIVATE -Wa,-mbig-obj) + target_compile_options(WaveEditorSupport PRIVATE -Wa,-mbig-obj) + target_compile_options(WaveEditor PRIVATE -Wa,-mbig-obj) endif() if(WIN32) @@ -238,10 +333,10 @@ if(WIN32) foreach(BINARY_FILE ${BINARY_FILES}) message(STATUS "Copy ${BINARY_FILE}") add_custom_command( - TARGET ${PROJECT_NAME} POST_BUILD + TARGET WaveEditor POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different "${BINARY_FILE}" - "$" + "$" VERBATIM ) endforeach() @@ -334,11 +429,25 @@ if(BUILD_TESTING) LABELS "contract;render;cpu" ) + add_test( + NAME WaveEngine.RuntimeTargetContracts + COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/Tests/runtime_target_contracts.py" + "${PROJECT_SOURCE_DIR}" + ) + set_tests_properties( + WaveEngine.RuntimeTargetContracts + PROPERTIES + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + TIMEOUT 10 + LABELS "build;contract;runtime;cpu" + ) + add_test( NAME WaveEngine.McpAssetInputContracts COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/Tests/mcp_asset_input_contracts.py" - "$" + "$" ) set_tests_properties( WaveEngine.McpAssetInputContracts @@ -354,7 +463,7 @@ if(BUILD_TESTING) NAME WaveEngine.WEMeshCacheContracts COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/Tests/wemesh_cache_contracts.py" - "$" + "$" ) set_tests_properties( WaveEngine.WEMeshCacheContracts diff --git a/Engine/Shaders/BasePass.hlsl b/Engine/Shaders/BasePass.hlsl index 5a795e8..bcdc9e2 100644 --- a/Engine/Shaders/BasePass.hlsl +++ b/Engine/Shaders/BasePass.hlsl @@ -1,4 +1,5 @@ #include "Common.hlsli" +#include "BasePassCommon.hlsli" #ifndef FRUSTUM_CULLING #define FRUSTUM_CULLING 1 @@ -12,20 +13,6 @@ #define HZB_CULLING 1 #endif -ConstantBuffer RenderData : register(b0); - -struct FBasePassMSOut -{ - float4 Position : SV_Position; - float4 Normal : TEXCOORD0; - float2 UV : TEXCOORD1; - float3 WorldPos : TEXCOORD2; - nointerpolation uint MaterialBufferIndex : TEXCOORD3; - nointerpolation uint MaterialIndex : TEXCOORD4; - nointerpolation float4 Color : TEXCOORD5; - nointerpolation uint InstanceIndex : TEXCOORD6; -}; - #define DEBUG_VIEW_NONE 0 #define DEBUG_VIEW_MESHLET_INDEX 1 #define DEBUG_VIEW_GROUP_ID 2 @@ -285,16 +272,3 @@ void BasePassPS( Velocity = float4(Motion, 0.0f, 0.0f); } - -[RootSignature(BindlessRootSignature)] -void EditorSelectionPS( - FBasePassMSOut Input, - out uint SelectionId : SV_Target0) -{ - if (Input.InstanceIndex != RenderData.SelectedInstanceIndex) - { - discard; - } - - SelectionId = Input.InstanceIndex + 1u; -} diff --git a/Engine/Shaders/BasePassCommon.hlsli b/Engine/Shaders/BasePassCommon.hlsli new file mode 100644 index 0000000..2e60dfa --- /dev/null +++ b/Engine/Shaders/BasePassCommon.hlsli @@ -0,0 +1,13 @@ +ConstantBuffer RenderData : register(b0); + +struct FBasePassMSOut +{ + float4 Position : SV_Position; + float4 Normal : TEXCOORD0; + float2 UV : TEXCOORD1; + float3 WorldPos : TEXCOORD2; + nointerpolation uint MaterialBufferIndex : TEXCOORD3; + nointerpolation uint MaterialIndex : TEXCOORD4; + nointerpolation float4 Color : TEXCOORD5; + nointerpolation uint InstanceIndex : TEXCOORD6; +}; diff --git a/Engine/Shaders/EditorSelection.hlsl b/Engine/Shaders/EditorSelection.hlsl new file mode 100644 index 0000000..d689524 --- /dev/null +++ b/Engine/Shaders/EditorSelection.hlsl @@ -0,0 +1,15 @@ +#include "Common.hlsli" +#include "BasePassCommon.hlsli" + +[RootSignature(BindlessRootSignature)] +void EditorSelectionPS( + FBasePassMSOut Input, + out uint SelectionId : SV_Target0) +{ + if (Input.InstanceIndex != RenderData.SelectedInstanceIndex) + { + discard; + } + + SelectionId = Input.InstanceIndex + 1u; +} diff --git a/Engine/Source/Renderer/EditorSelectionPass.cpp b/Engine/Source/Renderer/EditorSelectionPass.cpp index 27235c1..9042aa4 100644 --- a/Engine/Source/Renderer/EditorSelectionPass.cpp +++ b/Engine/Source/Renderer/EditorSelectionPass.cpp @@ -6,7 +6,7 @@ #include "RHI/RHICommandList.h" #include "Scene/Scene.h" -IMPLEMENT_SHADER(FEditorSelectionPS, "BasePass.hlsl", "EditorSelectionPS", SF_Pixel); +IMPLEMENT_SHADER(FEditorSelectionPS, "EditorSelection.hlsl", "EditorSelectionPS", SF_Pixel); TRefCountPtr AddEditorSelectionPass( FRenderGraph& RenderGraph, diff --git a/Tests/core_contract_target_contracts.py b/Tests/core_contract_target_contracts.py index 85b559d..05855a0 100644 --- a/Tests/core_contract_target_contracts.py +++ b/Tests/core_contract_target_contracts.py @@ -147,9 +147,9 @@ def main() -> int: if target_body.strip() != "${WAVE_CORE_CONTRACT_SOURCES}": fail("WaveCoreContracts executable must use only WAVE_CORE_CONTRACT_SOURCES") - editor_target_body = find_command_body(cmake, "add_executable", "WaveEngine") + editor_target_body = find_command_body(cmake, "add_executable", "WaveEditor") if "CORE_RUNTIME_CONTRACT_SUITE" in editor_target_body: - fail("WaveEngine must not compile the standalone contract runner") + fail("WaveEditor must not compile the standalone contract runner") if re.search(r"target_sources\s*\(\s*WaveCoreContracts\b", cmake, re.IGNORECASE): fail("WaveCoreContracts sources must remain in WAVE_CORE_CONTRACT_SOURCES") diff --git a/Tests/runtime_target_contracts.py b/Tests/runtime_target_contracts.py new file mode 100644 index 0000000..7898fe6 --- /dev/null +++ b/Tests/runtime_target_contracts.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +RUNTIME_SOURCE_DIRECTORIES = ( + "Core", + "Input", + "Misc", + "Renderer", + "RenderGraph", + "RHI", + "Scene", + "Shader", + "Trace", +) +FORBIDDEN_RUNTIME_PREFIXES = ( + "engine/source/contract/", + "engine/source/developer/", + "engine/source/mcp/", + "engine/source/ui/", +) +FORBIDDEN_RUNTIME_FILES = { + "engine/source/renderer/editorcompositepass.cpp", + "engine/source/renderer/editorcompositepass.h", + "engine/source/renderer/editorselectionpass.cpp", + "engine/source/renderer/editorselectionpass.h", +} + + +def fail(message: str) -> None: + raise RuntimeError(message) + + +def require_pattern(text: str, pattern: str, message: str) -> None: + if not re.search(pattern, text, re.IGNORECASE | re.DOTALL): + fail(message) + + +def project_relative(repository_root: Path, path: Path) -> str: + try: + return path.resolve().relative_to(repository_root).as_posix() + except ValueError: + fail(f"dependency escapes repository: {path.resolve()}") + + +def validate_runtime_include_closure(repository_root: Path) -> int: + source_root = repository_root / "Engine" / "Source" + pending: list[Path] = [] + for directory in RUNTIME_SOURCE_DIRECTORIES: + pending.extend((source_root / directory).rglob("*.h")) + pending.extend((source_root / directory).rglob("*.cpp")) + pending.extend((source_root / name) for name in ("Window.h", "Window.cpp")) + + include_pattern = re.compile( + r'^\s*#\s*include\s*"([^"]+)"', + re.MULTILINE, + ) + visited: set[Path] = set() + while pending: + dependency = pending.pop().resolve() + if dependency in visited: + continue + relative_path = project_relative(repository_root, dependency) + normalized = relative_path.lower() + if normalized in FORBIDDEN_RUNTIME_FILES: + continue + if normalized.startswith(FORBIDDEN_RUNTIME_PREFIXES): + fail(f"WaveRuntime include closure reaches forbidden source: {relative_path}") + if not dependency.is_file(): + fail(f"missing WaveRuntime source/include: {relative_path}") + visited.add(dependency) + + text = dependency.read_text(encoding="utf-8", errors="ignore") + for include_name in include_pattern.findall(text): + candidates = ( + dependency.parent / include_name, + source_root / include_name, + ) + included_path = next((path for path in candidates if path.is_file()), None) + if included_path is not None: + included_relative = project_relative(repository_root, included_path) + included_normalized = included_relative.lower() + if included_normalized.startswith(FORBIDDEN_RUNTIME_PREFIXES): + fail( + "WaveRuntime include closure reaches forbidden source: " + f"{relative_path} -> {included_relative}" + ) + if included_normalized in FORBIDDEN_RUNTIME_FILES: + fail( + "WaveRuntime include closure reaches Editor render source: " + f"{relative_path} -> {included_relative}" + ) + pending.append(included_path) + + return len(visited) + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: runtime_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") + + require_pattern( + cmake, + r"add_library\s*\(\s*WaveRuntime\s+STATIC\b", + "missing static WaveRuntime target", + ) + require_pattern( + cmake, + r"add_library\s*\(\s*WaveEditorSupport\s+STATIC\b", + "missing static WaveEditorSupport target", + ) + require_pattern( + cmake, + r"add_executable\s*\(\s*WaveEditor\b", + "missing WaveEditor executable", + ) + require_pattern( + cmake, + r"target_link_libraries\s*\(\s*WaveEditorSupport\s+PUBLIC\s+WaveRuntime\s*\)", + "WaveEditorSupport must link publicly and one-way to WaveRuntime", + ) + require_pattern( + cmake, + r"target_link_libraries\s*\(\s*WaveEditor\s+PRIVATE\s+WaveEditorSupport\s*\)", + "WaveEditor must link only its Editor support boundary", + ) + if re.search( + r"target_link_libraries\s*\(\s*WaveRuntime\b[^)]*" + r"(?:WaveEditorSupport|WaveEditor)", + cmake, + re.IGNORECASE | re.DOTALL, + ): + fail("WaveRuntime must not link Editor targets") + + for fragment in ( + 'list(FILTER WAVE_RUNTIME_THIRD_PARTY_FILES EXCLUDE REGEX ' + '".*/Engine/ThirdParty/imgui/.*")', + "list(REMOVE_ITEM WAVE_RUNTIME_HEADER", + "Renderer/EditorCompositePass.h", + "Renderer/EditorSelectionPass.h", + "list(REMOVE_ITEM WAVE_RUNTIME_SOURCE", + "Renderer/EditorCompositePass.cpp", + "Renderer/EditorSelectionPass.cpp", + "list(REMOVE_ITEM WAVE_RUNTIME_SHADERS ${WAVE_EDITOR_SHADERS})", + '"$"', + ): + if fragment not in cmake: + fail(f"CMake target boundary is missing: {fragment}") + if '"$"' in cmake: + fail("CTest must not execute the legacy WaveEngine target") + + editor_selection = ( + repository_root / "Engine" / "Source" / "Renderer" / "EditorSelectionPass.cpp" + ).read_text(encoding="utf-8") + if '"EditorSelection.hlsl"' not in editor_selection: + fail("Editor selection shader entry must live in EditorSelection.hlsl") + base_pass_shader = ( + repository_root / "Engine" / "Shaders" / "BasePass.hlsl" + ).read_text(encoding="utf-8") + if "EditorSelectionPS" in base_pass_shader: + fail("Runtime BasePass.hlsl retains the Editor selection entry") + + include_count = validate_runtime_include_closure(repository_root) + print( + "Runtime target contracts passed: one-way WaveRuntime -> " + "WaveEditorSupport -> WaveEditor graph, Editor/ImGui sources excluded, " + f"{include_count} Runtime project files in the include closure" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError) as error: + print(f"Runtime target contracts failed: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md b/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md index d41ce54..38929af 100644 --- a/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md +++ b/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md @@ -170,7 +170,7 @@ Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。Player |---|---|---|---|---| | 1 | 显式 view input/frame output,移除 Runtime Pass 的 UI 依赖 | Editor 图像与行为不变 | Debug、CTest、strict RG、Editor smoke | Verified | | 2 | ImGui、selection/composite 移入 Editor frontend | GPU UI teardown 仍在 idle 后 | header boundary、退出、failure injection | Verified | -| 3 | 建立 Runtime、EditorSupport、Editor targets | Editor 功能与 MCP 不回退 | Debug/Release、CTest、discover | Pending | +| 3 | 建立 Runtime、EditorSupport、Editor targets | Editor 功能与 MCP 不回退 | Debug/Release、CTest、discover | Verified | | 4 | Player presenter、Player target 和受控退出 | Player 不链接 Editor,直接 Present | boundary、raster、resize/minimize/exit | Pending | | 5 | 迁移脚本、文档并移除临时入口 | 无无主旧 target 引用 | `rg`、CTest、兼容门核对 | Pending | @@ -231,15 +231,19 @@ Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。Player - Slice 2 已完成并验证:`FEditorFrontend` 独立拥有 ImGui 生命周期、selection/composite 扩展、viewport texture 适配与 Editor Present。 - Runtime Renderer 通过 `IRenderFrameExtension` 在同一 RenderGraph 构建期提供受限扩展上下文;Editor pass 不再由 Runtime source include/编排,transient handle 未跨帧逃逸。 - Editor frontend 与 Renderer 均跨顶层异常存活到 GPU idle;failure injection 在 idle 不可证明时保持非零 quarantine。 +- Slice 3 已完成并验证:生成 `WaveRuntime.lib`、`WaveEditorSupport.lib` 与规范产物 `WaveEditor.exe`,链接方向为 `WaveEditor → WaveEditorSupport → WaveRuntime`。 +- Runtime source/include closure 自动递归检查 144 个工程文件,排除 UI、MCP、Developer、Contract、ImGui 与 Editor selection/composite。 +- Editor selection shader 已从 Runtime `BasePass.hlsl` 分离;Debug/Release 的 Editor、TraceViewer、CoreContracts 与完整 CTest 均通过。 ### Next Step -- 实施 Slice 3:建立显式 `WaveRuntime`、`WaveEditorSupport` 与 `WaveEditor` source/link target 图并迁移 Editor CTest。 +- 实施 Slice 4:增加 Runtime-owned Player presenter、`WavePlayer` target 与有限帧正常退出 smoke。 ### Changes and Deviations - 无设计偏差。为保证未来 Player 的相机投影与 Scene uniform 同样不依赖 UI,显式 view input 在 Slice 1 同步贯穿 Scene/Camera,而不只覆盖 Render Pass。 - Slice 2 采用 Runtime-neutral `IRenderFrameExtension`,使 Editor selection/composite 继续在同一 RenderGraph 内声明依赖,避免引入 Spec 明确排除的 RenderGraph extraction。 +- Slice 3 暂时保留仅依赖 `WaveEditor` 的 CMake compatibility build target `WaveEngine`;它不生成第二个 executable,按 Slice 5 removal gate 删除。 - 后继 G0-02 Application runner/config 与 G0-03 path/storage 保持 Accepted,尚未进入生产实现。 ### Evidence @@ -256,10 +260,14 @@ Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。Player | 2026-07-26 | working tree | Windows 11、VS 2022、Debug | `cmake --build Build --config Debug`;`ctest --test-dir Build -C Debug --output-on-failure` | Pass,9/9 | Editor frontend、MCP selection/composite、strict RG | | 2026-07-26 | working tree | Debug failure injection | `WAVE_RHI_INJECT_FAILURE=1` 启动 Editor | Pass | exit 1;worker failure → idle wait failure → quarantine | | 2026-07-26 | working tree | clean Debug run | failure injection 后重跑完整 CTest,并扫描日志 | Pass | 无 `Error`/`Fatal`/D3D12 error/未闭合 RG event | +| 2026-07-26 | working tree | CMake 4.0.1、VS 2022 x64 | `py -3 Tests/runtime_target_contracts.py .`;`cmake -S . -B Build` | Pass | 144 个 Runtime closure 文件;单向 target 图 | +| 2026-07-26 | working tree | Debug | 构建 `WaveEditor`、`WaveTraceViewer`、`WaveCoreContracts`;完整 CTest | Pass,10/10 | `WaveRuntime.lib`、`WaveEditorSupport.lib`、`WaveEditor.exe` | +| 2026-07-26 | working tree | Release | 构建 `WaveEditor`、`WaveTraceViewer`、`WaveCoreContracts`;完整 CTest | Pass,10/10 | Release target 图、MCP discover 与 GPU contracts | +| 2026-07-26 | working tree | clean Release run | 扫描 `WaveEngine.log` | Pass | 无 `Error`/`Fatal`/D3D12 error/未闭合 RG event | 历史构建不是未来 G0-01 的验收证据;实现后必须重新执行第 8 节。 ### Remaining Work -- G0-01 Delivery Slices 3~5 与 Acceptance Criteria。 +- G0-01 Delivery Slices 4~5 与 Acceptance Criteria。 - G0-02/G0-03 的后续实现。 From fa3eae66fbd9baab1ff3ddabe48af1839f2ca364 Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sun, 26 Jul 2026 13:29:44 +0800 Subject: [PATCH 11/16] finish runtime editor boundary migration --- AGENTS.md | 8 +- CMakeLists.txt | 22 +- Engine/Source/Misc/Log.h | 2 +- Engine/Source/UI/Panels/ProfilerPanel.cpp | 2 +- .../Source/{WaveEngine.cpp => WaveEditor.cpp} | 0 README.md | 8 +- Tests/core_contract_target_contracts.py | 4 +- Tests/editor_window_lifecycle_contracts.py | 234 ++++++++++++++++++ Tests/mcp_asset_input_contracts.py | 4 +- Tests/runtime_target_contracts.py | 6 + Tests/wemesh_cache_contracts.py | 4 +- Tools/Mcp/_client.py | 12 +- Tools/Mcp/bench_fps.py | 2 +- Tools/Mcp/demo_full.py | 2 +- Tools/Mcp/smoke.py | 2 +- ...26-07-25-game-engine-production-roadmap.md | 16 +- docs/specs/2026-07-25-a0-01-contract-core.md | 22 +- ...-07-25-a0-04-headless-agent-conformance.md | 2 +- ...026-07-25-g0-01-runtime-editor-targets.md} | 140 +++++------ ...25-g0-02-engine-loop-application-config.md | 2 +- docs/specs/README.md | 2 +- 21 files changed, 372 insertions(+), 124 deletions(-) rename Engine/Source/{WaveEngine.cpp => WaveEditor.cpp} (100%) create mode 100644 Tests/editor_window_lifecycle_contracts.py rename docs/specs/{2026-07-25-g0-01-runtime-editor-player-targets.md => 2026-07-25-g0-01-runtime-editor-targets.md} (58%) diff --git a/AGENTS.md b/AGENTS.md index ef36a3c..5e024ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ cmake -S . -B Build # 构建与运行 cmake --build Build --config Debug -& .\Build\Debug\WaveEngine.exe +& .\Build\Debug\WaveEditor.exe ``` `Rebuild.bat` 复用 `Build/`,不是 clean rebuild。Visual Studio 产物在 `Build//`;构建后复制 `Engine/ThirdParty/bin/` 的运行库。 @@ -96,8 +96,8 @@ ctest --test-dir Build -C Debug --output-on-failure | Release 语义、assert | `ctest --test-dir Build -C Release -R CoreRuntimeContracts --output-on-failure` | | RenderGraph、Pass | Debug 下设置 `WAVE_RG_STRICT=1`,验证真实光栅路径和日志 | | Path Tracing | 仅在 DXR Tier 1.1 硬件验证;硬件不足明确记为未运行 | -| MCP framing、数值或资产输入 | `py -3 Tests/mcp_asset_input_contracts.py Build/Debug/WaveEngine.exe` | -| WEMesh、模型依赖、缓存 | `py -3 Tests/wemesh_cache_contracts.py Build/Debug/WaveEngine.exe` | +| MCP framing、数值或资产输入 | `py -3 Tests/mcp_asset_input_contracts.py Build/Debug/WaveEditor.exe` | +| WEMesh、模型依赖、缓存 | `py -3 Tests/wemesh_cache_contracts.py Build/Debug/WaveEditor.exe` | | 坐标、Rotator、相机、导入或序列化 | 同时运行 Core Runtime 与 WEMesh 契约 | | RHI 异常/关闭 | Debug 设置 `WAVE_RHI_INJECT_FAILURE=1`,应经 quarantine 非零退出 | | Profiler | Start/Stop、`.wetrace`、live、Viewer、多帧分析、dropped/overflow | @@ -110,7 +110,7 @@ ctest --test-dir Build -C Debug --output-on-failure ### 生命周期、RHI 与线程 -- 入口为 `Engine/Source/WaveEngine.cpp`。启动顺序:Log → Window → Input → RHI/worker → ShaderMap → Scene → Renderer/UI → MCP。 +- 入口为 `Engine/Source/WaveEditor.cpp`。启动顺序:Log → Window → Input → RHI/worker → ShaderMap → Scene → Renderer/UI → MCP。 - 主循环顺序:Input → MCP main-thread drain → Scene Tick → Renderer。MCP drain 不依赖窗口可渲染状态。 - MCP 只能在 Renderer/UI 完成工具注册后启动。关闭顺序:拒绝新任务 → 停 MCP → 等 RHI/GPU idle → 销毁 GPU-backed UI/Renderer/cache → Scene/Shader/Input → `FDynamicRHI::Destroy()` → Window。 - Renderer 必须跨过顶层异常保持存活。无法证明 GPU idle 时停止 worker 并经 `_Exit` quarantine,不能执行不安全 GPU teardown。 diff --git a/CMakeLists.txt b/CMakeLists.txt index ccc8c4e..8e9590f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -66,7 +66,7 @@ list(APPEND WAVE_EDITOR_SUPPORT_SOURCE "${PROJECT_SOURCE_DIR}/Engine/Source/Renderer/EditorCompositePass.cpp" "${PROJECT_SOURCE_DIR}/Engine/Source/Renderer/EditorSelectionPass.cpp") set(WAVE_EDITOR_ENTRY - "${PROJECT_SOURCE_DIR}/Engine/Source/WaveEngine.cpp") + "${PROJECT_SOURCE_DIR}/Engine/Source/WaveEditor.cpp") file(GLOB_RECURSE WAVE_RUNTIME_THIRD_PARTY_FILES CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/Engine/ThirdParty/*.h" @@ -152,10 +152,6 @@ add_library(WaveEditorSupport STATIC ${WAVE_EDITOR_SHADERS}) add_executable(WaveEditor ${WAVE_EDITOR_ENTRY}) -# Temporary build-target compatibility during G0-01 migration. This does not -# create a second executable or a legacy WaveEngine.exe artifact. -add_custom_target(WaveEngine DEPENDS WaveEditor) - set_property(TARGET WaveRuntime PROPERTY COMPILE_WARNING_AS_ERROR ON) set_property(TARGET WaveEditorSupport PROPERTY COMPILE_WARNING_AS_ERROR ON) set_property(TARGET WaveEditor PROPERTY COMPILE_WARNING_AS_ERROR ON) @@ -443,6 +439,22 @@ if(BUILD_TESTING) LABELS "build;contract;runtime;cpu" ) + add_test( + NAME WaveEngine.EditorWindowLifecycleContracts + COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/Tests/editor_window_lifecycle_contracts.py" + "$" + ) + set_tests_properties( + WaveEngine.EditorWindowLifecycleContracts + PROPERTIES + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + TIMEOUT 120 + LABELS "contract;editor;gpu;lifecycle" + ENVIRONMENT "WAVE_RG_STRICT=1" + RUN_SERIAL TRUE + ) + add_test( NAME WaveEngine.McpAssetInputContracts COMMAND "${Python3_EXECUTABLE}" diff --git a/Engine/Source/Misc/Log.h b/Engine/Source/Misc/Log.h index c423be9..d049af2 100644 --- a/Engine/Source/Misc/Log.h +++ b/Engine/Source/Misc/Log.h @@ -139,7 +139,7 @@ class Log // 选择控制台输出流: // - 当 stdout 被重定向到 pipe 时(e.g. MCP stdio 模式),走 cerr 避免污染协议流。 - // - 否则走 cout(兼容现有 `WaveEngine.exe > log.txt` 的用户)。 + // - 否则走 cout(兼容现有 `WaveEditor.exe > log.txt` 的用户)。 // 在静态初始化阶段一次性决定。 static std::ostream& ConsoleStream(); diff --git a/Engine/Source/UI/Panels/ProfilerPanel.cpp b/Engine/Source/UI/Panels/ProfilerPanel.cpp index fe8bb9c..d99220d 100644 --- a/Engine/Source/UI/Panels/ProfilerPanel.cpp +++ b/Engine/Source/UI/Panels/ProfilerPanel.cpp @@ -287,7 +287,7 @@ void FProfilerPanel::DrawQuickActions() ImGui::EndDisabled(); if (!bViewerAvailable && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { - ImGui::SetTooltip("WaveTraceViewer.exe was not found beside WaveEngine.exe."); + ImGui::SetTooltip("WaveTraceViewer.exe was not found beside WaveEditor.exe."); } } diff --git a/Engine/Source/WaveEngine.cpp b/Engine/Source/WaveEditor.cpp similarity index 100% rename from Engine/Source/WaveEngine.cpp rename to Engine/Source/WaveEditor.cpp diff --git a/README.md b/README.md index ad78d6a..77c32d2 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ WaveEngine 是面向 Windows x64 的 C++20 实时渲染引擎与 ImGui 场景编辑器。当前唯一渲染后端是 DirectX 12,项目重点是现代 GPU 管线、静态场景编辑、自动化和性能分析,并以 AI-native 作为未来整个引擎的设计约束。 > [!IMPORTANT] -> 当前版本可用于渲染研究、静态场景搭建和编辑器工具开发,但还不能独立制作与发布完整游戏。Runtime/Editor/Player、Gameplay、物理、音频和打包能力仍在[产品化路线图](docs/plans/2026-07-25-game-engine-production-roadmap.md)中。 +> 当前版本可用于渲染研究、静态场景搭建和编辑器工具开发,但还不能独立制作与发布完整游戏。当前 AI-Native 基础改造只迁移已有能力及其架构边界,不新增 Player、Gameplay、物理、音频或打包功能;长期产品方向见[产品化路线图](docs/plans/2026-07-25-game-engine-production-roadmap.md)。 ## 已实现能力 @@ -51,14 +51,14 @@ cd WaveEngine cmake -S . -B Build -G "Visual Studio 17 2022" -A x64 cmake --build Build --config Debug -& .\Build\Debug\WaveEngine.exe +& .\Build\Debug\WaveEditor.exe ``` Release: ```powershell cmake --build Build --config Release -& .\Build\Release\WaveEngine.exe +& .\Build\Release\WaveEditor.exe ``` `Rebuild.bat` 会复用已有 `Build/` 重新生成并构建 Debug,不是 clean rebuild。构建后 CMake 会复制 Agility SDK、DXC 等运行时 DLL。 @@ -154,7 +154,7 @@ Draft → Accepted → Implementing → Verified ## 当前边界 -- 只有单一 `WaveEngine.exe`,没有独立 Runtime、Editor、Player 或 Game target。 +- 已拆分 `WaveRuntime`、`WaveEditorSupport` 与 `WaveEditor.exe`;没有新增 Player、Game 或发布功能。 - 没有完整 World/Entity/Component、Gameplay、PIE、Action Input、物理、音频和 Runtime UI。 - 没有稳定 Asset ID、Cook、Stage 或 Package。 - glTF alpha、双面、UV1、每纹理 sampler 和正确 sRGB 过滤尚未完整实现。 diff --git a/Tests/core_contract_target_contracts.py b/Tests/core_contract_target_contracts.py index 05855a0..cf71728 100644 --- a/Tests/core_contract_target_contracts.py +++ b/Tests/core_contract_target_contracts.py @@ -179,11 +179,11 @@ def main() -> int: ) editor_main = ( - repository_root / "Engine" / "Source" / "WaveEngine.cpp" + repository_root / "Engine" / "Source" / "WaveEditor.cpp" ).read_text(encoding="utf-8") for legacy_fragment in ("CoreRuntimeContracts", "RunCoreRuntimeContracts"): if legacy_fragment in editor_main: - fail(f"WaveEngine.cpp retains legacy contract runner usage: {legacy_fragment}") + fail(f"WaveEditor.cpp retains legacy contract runner usage: {legacy_fragment}") print( "WaveCoreContracts boundary passed: " diff --git a/Tests/editor_window_lifecycle_contracts.py b/Tests/editor_window_lifecycle_contracts.py new file mode 100644 index 0000000..deffc76 --- /dev/null +++ b/Tests/editor_window_lifecycle_contracts.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Exercise existing WaveEditor resize/minimize/MCP/normal-exit behavior.""" + +from __future__ import annotations + +import ctypes +from ctypes import wintypes +import json +import os +import queue +import subprocess +import sys +import threading +import time +from pathlib import Path + + +RESPONSE_TIMEOUT_SECONDS = 30.0 +WINDOW_TIMEOUT_SECONDS = 30.0 +SW_MINIMIZE = 6 +SW_RESTORE = 9 +SWP_NOMOVE = 0x0002 +SWP_NOZORDER = 0x0004 +SWP_NOACTIVATE = 0x0010 + + +def fail(message: str) -> None: + raise RuntimeError(message) + + +def wait_until(predicate, timeout: float, message: str) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.05) + fail(message) + + +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): + assert self.process.stdin is not None + self.next_id += 1 + request = {"jsonrpc": "2.0", "id": self.next_id, "method": method} + if params is not None: + request["params"] = params + os.write( + self.process.stdin.fileno(), + (json.dumps(request, separators=(",", ":")) + "\n").encode("utf-8"), + ) + try: + line = self.responses.get(timeout=RESPONSE_TIMEOUT_SECONDS) + except queue.Empty: + fail(f"timed out waiting for {method}") + if line is None: + fail(f"WaveEditor closed stdout while waiting for {method}") + response = json.loads(line) + if response.get("id") != self.next_id: + fail(f"unexpected response id for {method}: {response!r}") + if "error" in response: + fail(f"{method} returned {response['error']!r}") + return response.get("result") + + +def find_process_window(user32, process_id: int) -> int | None: + result: list[int] = [] + callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + + @callback_type + def visit_window(window, _parameter): + owner_process_id = wintypes.DWORD() + user32.GetWindowThreadProcessId(window, ctypes.byref(owner_process_id)) + if owner_process_id.value == process_id and user32.IsWindowVisible(window): + result.append(int(window)) + return False + return True + + user32.EnumWindows(visit_window, 0) + return result[0] if result else None + + +def main() -> int: + if len(sys.argv) != 2: + raise SystemExit("expected path to WaveEditor.exe") + if sys.platform != "win32": + raise SystemExit("WaveEditor window lifecycle contracts require Windows") + + repository = Path(__file__).resolve().parents[1] + executable = Path(sys.argv[1]).resolve() + if not executable.is_file(): + raise SystemExit(f"executable not found: {executable}") + + process = subprocess.Popen( + [str(executable)], + cwd=repository, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + session = RpcSession(process) + user32 = ctypes.WinDLL("user32", use_last_error=True) + user32.IsIconic.argtypes = [wintypes.HWND] + user32.IsIconic.restype = wintypes.BOOL + user32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int] + user32.ShowWindow.restype = wintypes.BOOL + user32.SetWindowPos.argtypes = [ + wintypes.HWND, + wintypes.HWND, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + wintypes.UINT, + ] + user32.SetWindowPos.restype = wintypes.BOOL + + normal_exit_requested = False + try: + methods = session.send("rpc.discover") + if "editor.app.request_exit" not in methods: + fail("rpc.discover omitted editor.app.request_exit") + + window_holder: list[int | None] = [None] + + def capture_window() -> bool: + window_holder[0] = find_process_window(user32, process.pid) + return window_holder[0] is not None + + wait_until( + capture_window, + WINDOW_TIMEOUT_SECONDS, + "timed out waiting for the WaveEditor window", + ) + window = wintypes.HWND(window_holder[0]) + + if not user32.SetWindowPos( + window, + None, + 0, + 0, + 1200, + 720, + SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE, + ): + fail(f"SetWindowPos failed with Win32 error {ctypes.get_last_error()}") + time.sleep(0.25) + frame_stats = session.send("editor.profile.frame_stats") + if not isinstance(frame_stats, dict): + fail("editor.profile.frame_stats did not return an object after resize") + + user32.ShowWindow(window, SW_MINIMIZE) + wait_until( + lambda: bool(user32.IsIconic(window)), + WINDOW_TIMEOUT_SECONDS, + "WaveEditor did not enter minimized state", + ) + hierarchy = session.send("editor.hierarchy.list") + if not isinstance(hierarchy, list): + fail("editor.hierarchy.list did not return an array while minimized") + if hierarchy: + session.send("editor.hierarchy.select", [hierarchy[0]["id"]]) + + user32.ShowWindow(window, SW_RESTORE) + wait_until( + lambda: not bool(user32.IsIconic(window)), + WINDOW_TIMEOUT_SECONDS, + "WaveEditor did not restore from minimized state", + ) + session.send("editor.profile.frame_stats") + + user32.ShowWindow(window, SW_MINIMIZE) + wait_until( + lambda: bool(user32.IsIconic(window)), + WINDOW_TIMEOUT_SECONDS, + "WaveEditor did not enter the second minimized state", + ) + session.send("editor.app.request_exit") + normal_exit_requested = True + process.wait(timeout=RESPONSE_TIMEOUT_SECONDS) + if process.returncode != 0: + fail(f"WaveEditor returned {process.returncode} after normal request_exit") + finally: + if process.poll() is None: + if not normal_exit_requested: + try: + session.send("editor.app.request_exit") + except (BrokenPipeError, OSError, RuntimeError): + pass + try: + process.wait(timeout=5.0) + except subprocess.TimeoutExpired: + process.terminate() + try: + process.wait(timeout=5.0) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5.0) + + print( + "WaveEditor window lifecycle contracts passed: resize, minimize, " + "main-thread MCP drain, restore, and minimized request_exit" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError, subprocess.SubprocessError) as error: + print(f"WaveEditor window lifecycle contracts failed: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/Tests/mcp_asset_input_contracts.py b/Tests/mcp_asset_input_contracts.py index 80f5abc..ccb3939 100644 --- a/Tests/mcp_asset_input_contracts.py +++ b/Tests/mcp_asset_input_contracts.py @@ -2,7 +2,7 @@ """Focused contracts for MCP framing, Editor selection, and malformed assets. Usage: - py -3 Tests/mcp_asset_input_contracts.py Build/Debug/WaveEngine.exe + py -3 Tests/mcp_asset_input_contracts.py Build/Debug/WaveEditor.exe The test only invokes operations expected to fail plus transient Editor selection, so it does not alter the saved scene. It asks the child process to @@ -34,7 +34,7 @@ def _reader(stream, output: queue.Queue[str | None]) -> None: def main() -> int: if len(sys.argv) != 2: - raise SystemExit("expected path to WaveEngine.exe") + raise SystemExit("expected path to WaveEditor.exe") repository = Path(__file__).resolve().parents[1] executable = Path(sys.argv[1]).resolve() diff --git a/Tests/runtime_target_contracts.py b/Tests/runtime_target_contracts.py index 7898fe6..2642706 100644 --- a/Tests/runtime_target_contracts.py +++ b/Tests/runtime_target_contracts.py @@ -157,6 +157,12 @@ def main() -> int: fail(f"CMake target boundary is missing: {fragment}") if '"$"' in cmake: fail("CTest must not execute the legacy WaveEngine target") + if re.search( + r"add_(?:custom_target|executable)\s*\(\s*WaveEngine\b", + cmake, + re.IGNORECASE, + ): + fail("legacy WaveEngine target must be removed after migration") editor_selection = ( repository_root / "Engine" / "Source" / "Renderer" / "EditorSelectionPass.cpp" diff --git a/Tests/wemesh_cache_contracts.py b/Tests/wemesh_cache_contracts.py index b31c970..8be84e5 100644 --- a/Tests/wemesh_cache_contracts.py +++ b/Tests/wemesh_cache_contracts.py @@ -2,7 +2,7 @@ """WEMesh cache provenance and backward-compatibility contracts. Usage: - py -3 Tests/wemesh_cache_contracts.py Build/Debug/WaveEngine.exe + py -3 Tests/wemesh_cache_contracts.py Build/Debug/WaveEditor.exe The test creates assets in a temporary directory. MCP spawn calls save editor state, so the original scene and UI config are restored byte-for-byte (including @@ -172,7 +172,7 @@ def make_legacy_v2(source: Path, destination: Path) -> None: def main() -> int: if len(sys.argv) != 2: - raise SystemExit("expected path to WaveEngine.exe") + raise SystemExit("expected path to WaveEditor.exe") repository = Path(__file__).resolve().parents[1] executable = Path(sys.argv[1]).resolve() diff --git a/Tools/Mcp/_client.py b/Tools/Mcp/_client.py index cd7ad53..7f9b623 100644 --- a/Tools/Mcp/_client.py +++ b/Tools/Mcp/_client.py @@ -19,7 +19,7 @@ from pathlib import Path DEFAULT_REPO_ROOT = Path(__file__).resolve().parents[2] -DEFAULT_EXE = DEFAULT_REPO_ROOT / "Build" / "Debug" / "WaveEngine.exe" +DEFAULT_EXE = DEFAULT_REPO_ROOT / "Build" / "Debug" / "WaveEditor.exe" class WaveMcpClient: @@ -72,9 +72,10 @@ def start(self) -> None: def close(self) -> None: if self._proc is None: return - # 关 stdin 让 server 端 getline 退出。 + # stdin 使用 bufsize=0 的原始 FileIO;通过对象关闭一次,避免先 + # os.close(fd) 后在 Python 析构时再次关闭同一 descriptor。 try: - os.close(self._stdin_fd) + self._proc.stdin.close() except OSError: pass # 引擎主窗口不会因 stdin EOF 关闭——给 5s 兜底,超时强杀。 @@ -86,11 +87,6 @@ def close(self) -> None: self._proc.wait(timeout=5) except subprocess.TimeoutExpired: self._proc.kill() - # 阻止 Python 解释器析构 BufferedWriter 时再 close 一次 fd。 - try: - self._proc.stdin.detach() - except Exception: - pass self._proc = None self._stdin_fd = None diff --git a/Tools/Mcp/bench_fps.py b/Tools/Mcp/bench_fps.py index 12a12f4..dd506a9 100644 --- a/Tools/Mcp/bench_fps.py +++ b/Tools/Mcp/bench_fps.py @@ -1,7 +1,7 @@ """Measure WaveEngine FPS via MCP (sample 5s after 6s warm-up). Usage: - py -3 Tools/Mcp/bench_fps.py [path/to/WaveEngine.exe] + py -3 Tools/Mcp/bench_fps.py [path/to/WaveEditor.exe] """ import statistics import sys diff --git a/Tools/Mcp/demo_full.py b/Tools/Mcp/demo_full.py index 86c5034..a8beb1e 100644 --- a/Tools/Mcp/demo_full.py +++ b/Tools/Mcp/demo_full.py @@ -1,7 +1,7 @@ """End-to-end demo: spawn a model, sweep camera positions, sample profile. Usage: - py -3 Tools/Mcp/demo_full.py [path/to/WaveEngine.exe] + py -3 Tools/Mcp/demo_full.py [path/to/WaveEditor.exe] """ import json import sys diff --git a/Tools/Mcp/smoke.py b/Tools/Mcp/smoke.py index f4107f9..d674836 100644 --- a/Tools/Mcp/smoke.py +++ b/Tools/Mcp/smoke.py @@ -1,7 +1,7 @@ """WaveEngine MCP smoke test. Runs each tool once and prints results. Usage: - py -3 Tools/Mcp/smoke.py [path/to/WaveEngine.exe] + py -3 Tools/Mcp/smoke.py [path/to/WaveEditor.exe] """ import json import sys 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 3b30992..85a6353 100644 --- a/docs/plans/2026-07-25-game-engine-production-roadmap.md +++ b/docs/plans/2026-07-25-game-engine-production-roadmap.md @@ -88,7 +88,7 @@ G6 Cook / Package / SampleGame / Release | 阶段 | 状态 | 核心产物 | 阶段结束时可运行结果 | |---|---|---|---| | M0 | 已完成 | CMake Presets、CPU CI、无窗口测试入口 | 干净 checkout 可复现基础构建和测试 | -| G0 | 待实施 | Runtime/Editor/Player targets、Engine Loop、路径层 | 独立 Player 显示现有场景 | +| G0 | 实施中 | Runtime/Editor targets、Engine Loop、路径层 | 现有 Editor 行为保持且 Runtime 无开发期闭包 | | G1 | 待实施 | `.weproject`、Asset ID、Registry、共享 Handle | 移动资产后 Scene 仍可加载 | | G2 | 待实施 | World、Entity、Component、层级、Scene v4 | World 场景可编辑、保存和渲染 | | G3 | 待实施 | Game Module、Action Input、PIE、Time | PIE 中玩法可运行且 Stop 可恢复 | @@ -140,12 +140,12 @@ A 系列只提供跨领域公共词汇、Developer host、Context/Evidence feder ## 6. G0:产品边界与 Engine Loop -**目标:** 同一 Runtime 可由 Editor 和独立 Player 驱动,现有编辑器能力不回退。 +**目标:** 把现有 Editor 与 Runtime 形成单向依赖边界,现有编辑器能力不回退;本轮 AI-Native 基础改造不新增 Player 或产品功能。 **范围:** -- 拆出最少必要的 Runtime/Editor library,生成 `WaveEditor`、`WavePlayer` 和现有 `WaveTraceViewer`。 -- Player 的源码和链接闭包不包含 UI、MCP、RenderDoc 或其他 Editor-only 模块。 +- 拆出最少必要的 Runtime/Editor library,生成 `WaveEditor` 并保留现有 `WaveTraceViewer`。 +- Runtime 的源码和链接闭包不包含 UI、MCP、RenderDoc 或其他 Editor-only 模块。 - 从 `main()` 提取公共 Engine Loop 和结构化 Application Config。 - 保留启动、异常传播、MCP/Dispatcher 顺序、GPU idle 与 quarantine 不变量。 - 区分 executable、project、source、derived/cooked 和用户 Saved 路径。 @@ -153,12 +153,12 @@ A 系列只提供跨领域公共词汇、Developer host、Context/Evidence feder **完成门禁:** -- Debug/Release 同时构建 Editor、Player 和 Viewer。 +- Debug/Release 同时构建 Runtime、Editor 和 Viewer。 - Editor 七个面板、MCP、保存和退出行为不回退。 -- Player 不初始化 Editor-only 服务,并可从非仓库目录通过显式 project 参数启动现有场景。 +- Runtime target/header closure 不包含 Editor-only 服务。 - 干净 checkout 的 CPU CI 可复现配置、构建和测试。 -当前设计入口:[G0-01 Runtime、Editor 与 Player Target 边界](../specs/2026-07-25-g0-01-runtime-editor-player-targets.md)。 +当前设计入口:[G0-01 Runtime 与 Editor Target 边界](../specs/2026-07-25-g0-01-runtime-editor-targets.md)。 本节由 G0-01、G0-02 和 G0-03 共同完成;单个 Spec 不代表整个 G0 已交付。 @@ -287,7 +287,7 @@ A 系列只提供跨领域公共词汇、Developer host、Context/Evidence feder | ID | 内容 | 前置 | 状态 | |---|---|---|---| | [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-player-targets.md) | Runtime/Editor/Player Target 图 | M0-01 | Implementing | +| [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 | Implementing | | [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 | diff --git a/docs/specs/2026-07-25-a0-01-contract-core.md b/docs/specs/2026-07-25-a0-01-contract-core.md index 7d15573..9975e19 100644 --- a/docs/specs/2026-07-25-a0-01-contract-core.md +++ b/docs/specs/2026-07-25-a0-01-contract-core.md @@ -6,7 +6,7 @@ - Updated: 2026-07-26 - Roadmap: [AI-native 横向设计](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) - Architecture: [AI-Native Engine Architecture](../architecture/ai-native-engine.md) -- Depends on: 设计可独立审查;实施 Slice 1~3 依赖 [M0-01](./2026-07-25-m0-01-cpu-contract-baseline.md),Slice 4 依赖 [G0-01](./2026-07-25-g0-01-runtime-editor-player-targets.md) +- Depends on: 设计可独立审查;实施 Slice 1~3 依赖 [M0-01](./2026-07-25-m0-01-cpu-contract-baseline.md),Slice 4 依赖 [G0-01](./2026-07-25-g0-01-runtime-editor-targets.md) - Owners: WaveEngine maintainers - Accepted by: User - Accepted on: 2026-07-25 @@ -28,7 +28,7 @@ **Current facts** -- 当前产品仍只有单体 `WaveEngine` Editor executable;`WavePlayer`、Headless Developer host 和领域 capability federation 尚不存在。 +- 当前已拆分 `WaveRuntime`、`WaveEditorSupport` 与 `WaveEditor`;Headless Developer host 和领域 capability federation 尚不存在。 - M0-01 已提供无 Engine PCH、窗口、RHI、Editor 或第三方依赖的 `WaveCoreContracts`,并由 CMake preset 与 Windows CPU CI 固定。 - Contract Core Slice 1 已提供 schema、descriptor、effect/error/ref/revision/change cursor、artifact/provenance/evidence 公共词汇,以及有界规范 schema JSON 与基础校验。 - Slice 2 已提供 transport-neutral capability registry;同一注册点绑定 descriptor/handler/validator,按 name/version 稳定排序与兼容解析,并拒绝 duplicate、同 major 的 effect/schema/provider 漂移。 @@ -54,7 +54,7 @@ 2. 建立领域拥有的 capability descriptor/handler/validator 同注册机制,而不是中央业务服务。 3. 将操作明确分为 Query、Command、Job 和 External Action,分别定义一致性与失败语义。 4. 保留现有 MCP client,提供从位置参数/Panel handler 到新契约的渐进迁移入口。 -5. 保证首个 SampleGame 的 Shipping Player 不链接 Developer host、MCP、代码/文档索引或模型 SDK。 +5. 保证 `WaveRuntime` 不链接 Developer host、MCP、代码/文档索引或模型 SDK。 6. 为每个 G 系列领域 Spec 提供共同 vocabulary,同时为 A0-02~A0-04 的宿主/联邦设施提供边界。 **Non-Goals** @@ -76,7 +76,7 @@ | Inspect | client 查询一个 capability | 得到 effect、schema、permission、availability、preview/undo 语义和版本 | | Invalid input | 参数类型、范围或引用错误 | invoke 前返回带 code/path/retryable 的结构化错误 | | Unsupported effect | client 请求对 Job 做全局 atomic batch | 明确拒绝,不伪造 rollback 保证 | -| Player boundary | 构建首个 SampleGame 的 Shipping Player | link/source closure 中没有 Developer host、MCP、开发期索引或模型 SDK | +| Runtime boundary | 构建 `WaveRuntime` | link/source closure 中没有 Developer host、MCP、开发期索引或模型 SDK | | Model replacement | 更换外部 Agent/模型 | 不修改领域状态、schema 或 capability handler | ## 4. Constraints and Invariants @@ -85,7 +85,7 @@ - **Domain ownership**:同一能力只有一套领域业务实现;领域同时拥有 schema、query/effect、validator 和 evidence provider。 - **Common core**:Contract Core 只提供公共类型、注册约束和序列化,不调用 Asset/World/Render 等领域业务。 - **Adapters**:UI、MCP、CLI 和测试只是调用者;Developer host 只拥有 session/policy/operation/audit。 -- **Deployment**:Developer host、MCP 和开发期索引仅存在于 Editor/Developer/Test target;Shipping Player 保持 Runtime-only 闭包。 +- **Deployment**:Developer host、MCP 和开发期索引仅存在于 Editor/Developer/Test target;`WaveRuntime` 保持纯运行时闭包。 - **Untrusted input**:AI 输出与磁盘输入同等不可信;schema、finite、数量、路径、permission 和 budget 在副作用前检查。 - **Explicit effect**:每个 capability 必须声明 Query、Command、Job 或 External Action;未声明不得注册。 - **Honest guarantees**:dry-run、Undo、idempotency、atomicity、cancel 和 retry 都是逐 capability 能力,不是全局默认。 @@ -128,10 +128,10 @@ Human UI / Agent / Script / Test │ Deterministic Runtime -Shipping WavePlayer ───────────> Runtime domains only +WaveRuntime ──────────────────> Runtime domains only ``` -Contract Core 不转发领域业务;federation 只聚合 descriptor/provider。Asset、World、Render、Build 等 G 系列 Spec 分别迁移自己的 handler、validator 和 evidence。G0-01 先证明 Player/Editor 链接边界;A0-02 再从现有 EditorSupport 提取无 UI 的 Developer Host Services,A0-04 增加 headless adapter。 +Contract Core 不转发领域业务;federation 只聚合 descriptor/provider。Asset、World、Render、Build 等领域 Spec 分别迁移自己的 handler、validator 和 evidence。G0-01 先证明 Runtime/Editor 链接边界;A0-02 再从现有 EditorSupport 提取无 UI 的 Developer Host Services,A0-04 增加 headless adapter。 运行时模型推理若未来确有产品需求,使用另一条依赖: @@ -248,7 +248,7 @@ Start Job → Produce Staged Artifact → Validate → Promote with Command | 1 | `FValueSchema`、descriptor、effect/error/ref/revision/change-feed 基础类型 | 不依赖 MCP、UI 或模型 SDK | schema round-trip、version/cursor/resync、invalid descriptor contract | Verified | | 2 | Registry 绑定 descriptor/handler;描述现有工具 | descriptor 与实际参数/效果不漂移 | registration、duplicate/version/effect tests | Verified | | 3 | 新 capability list/describe API 与结构化错误 adapter | 旧 `rpc.discover`、位置参数和 framing 不回退 | existing MCP contracts + new schema/error contracts | Verified | -| 4 | target/header closure 与文档迁移 | Player/Runtime 无 Developer host/MCP closure | G0 boundary contract、Debug/Release | Pending | +| 4 | target/header closure 与文档迁移 | Runtime 无 Developer host/MCP closure | G0 boundary contract、Debug/Release | Pending | A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transaction、Headless 或 Agent workflow。那些能力只有在对应 G/A Spec Verified 后才能写入“已实现”文档。 @@ -260,7 +260,7 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac | Registry | 注册全部现有 capability | name/version/effect/schema 与 handler 一致 | Yes | | Error | 类型、范围、引用、availability 失败注入 | code/path/retryable 稳定 | Yes | | Compatibility | 当前 MCP contract 与 `Tools/Mcp/smoke.py` | 旧 discover、调用和 framing 不回退 | Yes | -| Boundary | target/header/source closure | Runtime/Player 无 Developer host/MCP | Yes | +| Boundary | target/header/source closure | Runtime 无 Developer host/MCP | Yes | | Security | 超限输入、输出和 descriptor | invoke 前拒绝,无无限分配 | Yes | | Build | Debug/Release + Debug CTest + Release Core | warning-as-error,全量必需测试通过 | Yes | @@ -314,8 +314,8 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac ### Next Step -- 按依赖顺序实施 G0-01 Runtime/Editor/Player targets;G0-01 Verified 后返回 A0-01 Slice 4,验证 target/header closure 与文档迁移。 -- A0-01 不在 G0-01 前宣称 Player/Runtime 已隔离 Developer host/MCP。 +- 收口 G0-01 Runtime/Editor target 与文档迁移;随后完成 A0-01 Slice 4 的 Runtime target/header closure 验证。 +- A0-01 不在 G0-01 前宣称 Runtime 已隔离 Developer host/MCP。 ### Changes and Deviations diff --git a/docs/specs/2026-07-25-a0-04-headless-agent-conformance.md b/docs/specs/2026-07-25-a0-04-headless-agent-conformance.md index bd80f3b..8ee5a52 100644 --- a/docs/specs/2026-07-25-a0-04-headless-agent-conformance.md +++ b/docs/specs/2026-07-25-a0-04-headless-agent-conformance.md @@ -6,7 +6,7 @@ - Updated: 2026-07-25 - Roadmap: [AI-native 横向设计](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) - Architecture: [AI-Native Engine Architecture](../architecture/ai-native-engine.md) -- Depends on: [A0-02](./2026-07-25-a0-02-developer-host.md)、[A0-03](./2026-07-25-a0-03-context-evidence-federation.md)、[G0-01](./2026-07-25-g0-01-runtime-editor-player-targets.md)、[G0-02](./2026-07-25-g0-02-engine-loop-application-config.md)、[G0-03](./2026-07-25-g0-03-project-package-user-paths.md) +- Depends on: [A0-02](./2026-07-25-a0-02-developer-host.md)、[A0-03](./2026-07-25-a0-03-context-evidence-federation.md)、[G0-01](./2026-07-25-g0-01-runtime-editor-targets.md)、[G0-02](./2026-07-25-g0-02-engine-loop-application-config.md)、[G0-03](./2026-07-25-g0-03-project-package-user-paths.md) - Owners: WaveEngine maintainers - Accepted by: User - Accepted on: 2026-07-25 diff --git a/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md b/docs/specs/2026-07-25-g0-01-runtime-editor-targets.md similarity index 58% rename from docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md rename to docs/specs/2026-07-25-g0-01-runtime-editor-targets.md index 38929af..cb3fd67 100644 --- a/docs/specs/2026-07-25-g0-01-runtime-editor-player-targets.md +++ b/docs/specs/2026-07-25-g0-01-runtime-editor-targets.md @@ -1,7 +1,7 @@ -# G0-01:Runtime、Editor 与 Player Target 边界 +# G0-01:Runtime 与 Editor Target 边界 - Spec ID: `G0-01` -- Status: Implementing +- Status: Verified - Created: 2026-07-25 - Updated: 2026-07-26 - Roadmap: [游戏产品化路线 G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) @@ -13,23 +13,23 @@ - Supersedes: None - Superseded by: None -> 本 Spec 已获用户明确接受;实施按依赖顺序等待 M0-01,并从 Delivery Slice 1 开始。 +> 本 Spec 已获用户明确接受。2026-07-26 用户进一步明确“只对原有基础功能做 AI-Native 改造,不新增功能”,因此移除尚未提交的 Player presenter/target 交付,只保留 Runtime/Editor 隔离与既有行为迁移。 ## 1. Context -把单体 `WaveEngine` executable 拆成单向依赖的 Runtime、Editor 与 Player target。Editor 保持现有行为;Player 不包含 EditorShell、MCP 或 RenderDoc,但能渲染默认场景并安全退出。 +把单体 `WaveEngine` executable 拆成单向依赖的 Runtime 与 Editor target。Editor 保持现有行为;Runtime 不包含 EditorShell、MCP、RenderDoc 或 ImGui,为后续把现有基础能力迁回领域层建立硬边界。 -**Current facts** +**Acceptance baseline (before implementation)** - `CMakeLists.txt` 把全部 `Engine/Source` 编入单一 `WaveEngine`;`WaveTraceViewer` 是唯一独立 executable。 - `FRenderer` 直接拥有 `FImGuiLayer`,Runtime Pass 通过 UI 全局状态获取尺寸、selection 和输出。 -- Tonemap/EditorComposite 把结果交给 ImGui Viewport,Player 没有直接 swapchain 呈现路径。 +- Tonemap/EditorComposite 把结果交给 ImGui Viewport。 - `WaveEngine.cpp` 同时负责 Runtime 生命周期、Editor、RenderDoc、MCP 和关闭顺序。 - MCP 必须在 Panel 注册完成后启动;GPU idle 不可证明时通过 quarantine/`_Exit` 避免不安全 teardown。 - MCP/WEMesh CTest 当前以 `WaveEngine` target file 为被测进程。 - 编译期绝对资源路径仍存在,由 G0-03 解决。 -**Problem** +**Problem at acceptance** ```text WaveEngine main @@ -38,23 +38,22 @@ WaveEngine main └─ Renderer ──> ImGui / Editor / swapchain ``` -Runtime 与 Editor 形成编译期闭环,无法构建真正独立的 Player。简单关闭 ImGui 不会解决链接边界、最终呈现和 GPU teardown 所有权。 +Runtime 与 Editor 形成编译期闭环,现有基础能力只能通过 Panel/MCP owner 间接操作。简单关闭 ImGui 不会解决链接边界、业务 ownership 和 GPU teardown 所有权。 ## 2. Goals and Non-Goals **Goals** -1. 生成 `WaveRuntime`、`WaveEditorSupport`、`WaveEditor` 和 `WavePlayer`,保留 `WaveTraceViewer`。 -2. Player/Runtime 的 source、link closure 和公共头不包含 UI、MCP、Developer、RenderDoc 或 ImGui。 +1. 生成 `WaveRuntime`、`WaveEditorSupport` 和 `WaveEditor`,保留 `WaveTraceViewer`。 +2. Runtime 的 source、link closure 和公共头不包含 UI、MCP、Developer、RenderDoc 或 ImGui。 3. Renderer 从显式 Runtime-only view input 获取尺寸、输出模式和可选 Editor feature。 4. Editor 保持 Viewport、selection、七个 Panel、Undo/Redo、MCP 和保存行为。 -5. Player 不创建 ImGui context,直接呈现共同 Tonemap 结果。 -6. Editor/Player 复用公共启动、异常和安全关闭骨架。 -7. 迁移测试、脚本和文档,不降低覆盖。 +5. 保持既有启动、异常和安全关闭骨架,不在本 Spec 新增应用模式。 +6. 迁移测试、脚本和文档,不降低覆盖。 **Non-Goals** -- 不引入 `.weproject`、可移植路径、World/Entity、Gameplay、PIE、Action Input 或 Runtime UI。 +- 不新增 Player、`.weproject`、可移植路径、World/Entity、Gameplay、PIE、Action Input 或 Runtime UI。 - 不修改 Scene、WEMesh、ui config、Trace、MCP 或 Shader cache 格式。 - 不改变渲染算法、材质、坐标、Path Tracing 结果或 RenderGraph 资源模型。 - 不实现 RenderGraph extraction、DLL ABI、热重载、Vulkan 或第二 RHI。 @@ -65,27 +64,25 @@ Runtime 与 Editor 形成编译期闭环,无法构建真正独立的 Player。 | Scenario | Given / When | Expected | |---|---|---| | Editor | 启动 Debug/Release `WaveEditor` | 默认场景、七个 Panel、selection、Undo/Redo、MCP 与退出保持 | -| Player | 启动 `WavePlayer` | 无 ImGui/MCP/RenderDoc,使用窗口尺寸渲染并直接 Present | -| Minimize | Editor/Player 最小化或恢复 | 不创建 0 尺寸资源;Editor MCP 与双方退出仍响应 | +| Minimize | Editor 最小化或恢复 | 不创建 0 尺寸资源;MCP 与退出仍响应 | | GPU failure | worker/submit/signal/wait 失败 | 停止新工作;仅在 idle 已证明后 teardown,否则 quarantine | -| Boundary | 自动化检查 Player/Runtime closure | 发现 UI/MCP/Developer 依赖即失败 | +| Boundary | 自动化检查 Runtime closure | 发现 UI/MCP/Developer 依赖即失败 | ## 4. Constraints and Invariants ```text WaveRuntime - ├─> WavePlayer └─> WaveEditorSupport ──> WaveEditor -WaveRuntime / WavePlayer ─X─> UI / MCP / Developer +WaveRuntime ─X─> UI / MCP / Developer ``` - **Target**:`WaveRuntime` 为 static library;EditorSupport 可为 static/object library。Runtime third-party 不得在同一 executable 重复编译。 -- **Functional**:Editor 行为不变;Editor/Player 共享 Scene/Tonemap 和 raster/Path Tracing 选择;selection/composite 仅属于 Editor。 +- **Functional**:Editor 行为不变;Scene/Tonemap 和 raster/Path Tracing 选择仍由 Runtime 拥有;selection/composite 仅属于 Editor。 - **Threading/Lifetime**:Renderer 与可选 frontend 跨过顶层 `catch` 存活到 GPU idle;MCP 在 Panel 注册后启动并在 GPU 排空前停止;Present 仍由 RHI thread 处理。 -- **GPU/RenderGraph**:Runtime Pass 不包含 `ImGuiLayer.h`;view input 显式传入。跨帧输出只能是 imported/persistent RHI resource,不能让 transient handle 逃逸。Player 声明 RTV/Present 状态;严格 RG 下两端均无未声明访问。 +- **GPU/RenderGraph**:Runtime Pass 不包含 `ImGuiLayer.h`;view input 显式传入。跨帧输出只能是 imported/persistent RHI resource,不能让 transient handle 逃逸。Editor frontend 负责 backbuffer RTV/Present;严格 RG 下无未声明访问。 - **Data/Compatibility**:现有 Scene/UI 配置继续加载;target 变更同步 CTest、脚本和文档。旧 `WaveEngine` 名如短期保留,必须指向唯一 Editor 实现并有移除门。 -- **Security/Protocol**:Editor MCP 的 framing、1 MiB/128 深度、`id:null` 和主线程调度不变;Player 不启动 JSON-RPC。 +- **Security/Protocol**:Editor MCP 的 framing、1 MiB/128 深度、`id:null` 和主线程调度不变;Runtime 不拥有或启动 JSON-RPC。 ### AI-Native Impact @@ -93,13 +90,13 @@ WaveRuntime / WavePlayer ─X─> UI / MCP / Developer |---|---|---| | Identity | Deferred | 本 Spec 不创建领域对象 ID;Asset/Entity 身份分别由 G1-01/G2-01 提供 | | Schema | Required | Runtime view/frame output 与 frontend mode 使用显式类型,不读取 UI 全局状态 | -| Query | N/A | 本 Spec 只拆 target/runner,不新增领域查询面 | +| Query | N/A | 本 Spec 只拆 target/frontend,不新增领域查询面 | | Effect | N/A | 本 Spec 不新增可调用写能力;现有 MCP effect 保持兼容 | -| Determinism | Required | Editor/Player 共享 Scene/Tonemap;有限帧 smoke 具有确定退出条件 | -| Validation/Evidence | Required | target/header closure、raster、strict RG、failure injection 和日志留证 | +| Determinism | Required | Renderer 的 Scene/Tonemap 语义在 target 拆分前后保持 | +| Validation/Evidence | Required | target/header closure、Editor raster、strict RG、failure injection 和日志留证 | | Provenance/Migration | Required | `WaveEngine` alias、CTest、脚本和文档迁移具有明确移除 Slice | -| Security/Budget | Required | Player closure 排除 UI/MCP/Developer host;resize/帧预算受控 | -| Headless | Deferred | G0 只建立 runner 前置;Developer headless adapter 由 A0-04 提供 | +| Security/Budget | Required | Runtime closure 排除 UI/MCP/Developer host;Editor 帧与 MCP 输入预算保持 | +| Headless | Deferred | G0 只建立 target/frontend 前置;Developer headless adapter 由 A0-04 提供 | ## 5. Design @@ -110,22 +107,20 @@ WaveRuntime / WavePlayer ─X─> UI / MCP / Developer | `WaveRuntime` | Core、Window/Input、RHI、RenderGraph、Runtime Renderer、Scene、Shader、Trace 与 Runtime third-party | | `WaveEditorSupport` | UI、当前 MCP adapter、Developer、Editor selection/composite 与 ImGui backend | | `WaveEditor` | Editor entry point,链接 EditorSupport → Runtime | -| `WavePlayer` | Player entry point,只链接 Runtime | | `WaveTraceViewer` | 保持当前 D3D11/ImGui 独立边界 | Source set 按职责分组,不能继续把全部 `Engine/Source` 一次加入同一 target。 -这是 G0 的过渡 ownership。A0-02 后续从 EditorSupport 提取无 UI 的 Developer Host Services,A0-04 增加 headless adapter;Asset、World、Render 等业务仍由各领域拥有,不能集中进 host,也不能进入 Player 的开发期闭包。 +这是 G0 的过渡 ownership。A0-02 后续从 EditorSupport 提取无 UI 的 Developer Host Services,A0-04 增加 headless adapter;Asset、World、Render 等已有业务仍由各领域拥有,不能集中进 host 或 Runtime 的开发期闭包。 ### Application boundary -提取最小公共 runner,接收 Editor/Player mode 和可选 frontend services: +本 Spec 只把 Editor frontend 与 Runtime Renderer 解耦,不新增第二种应用模式: -- Runtime runner 拥有公共 Init/Tick/Shutdown,不包含 Editor 类型。 -- 使用仓库内部的最小 `IApplicationFrontend`/service 虚接口;Editor 与 Player 静态链接各自实现,不形成公开 ABI。 -- Editor services 负责 RenderDoc、当前 MCP adapter 和 Editor frontend;Player frontend 只负责 Runtime 输出呈现。 +- Runtime Renderer 只接受显式 view input 与可选 render extension,不包含 Editor 类型。 +- Editor frontend 负责 RenderDoc、当前 MCP adapter、ImGui、selection/composite 与最终 Present。 - “停止产生新工作”发生在 GPU idle 前;GPU-backed frontend 销毁只发生在 idle 证明后。 -- 完整 Application Config 和路径层属于 G0-02/G0-03。 +- 公共 Application runner、完整 config 和路径层属于 G0-02/G0-03。 ### Render input and output @@ -139,11 +134,10 @@ Renderer 返回有稳定 RHI 所有权的 Tonemap output: ```text Scene passes → common Tonemap output - ├─ Editor: optional selection composite → ImGui viewport/backbuffer - └─ Player: fullscreen presenter → swapchain + └─ Editor: optional selection composite → ImGui viewport/backbuffer ``` -Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。Player presenter 使用 window/swapchain extent,不初始化 Editor 服务。实际类型名可调整,但依赖方向和资源生命周期不可改变。 +Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。实际类型名可调整,但依赖方向和资源生命周期不可改变。 本期只改变 selection/composite 的 target ownership 和 include/link 边界,不要求物理移动整个源码目录;目录整理在边界契约稳定后另行处理。 @@ -151,18 +145,17 @@ Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。Player - MCP/恶意资产与 WEMesh 契约迁移到 `WaveEditor`。 - Core contract 只进入 M0-01 的无窗口 target,不建立临时宿主;G0-01 的最终验证等待 M0-01。 -- 新增 target/header boundary contract,以及测试专用 `--smoke-frames N` Player raster smoke;完成 N 次成功 Present 后走公共正常关闭路径。 -- 日志记录 application mode、build config、退出原因和 smoke 帧数。 -- Profiler 区分 Runtime frame、Editor frontend 与 Player present;不修改 WaveTrace 格式。 +- 新增 target/header boundary contract;既有 Editor smoke 继续通过正常 `editor.app.request_exit` 关闭。 +- Profiler 区分 Runtime frame 与 Editor frontend;不修改 WaveTrace 格式。 ## 6. Alternatives | Alternative | Benefit | Cost / Rejection reason | |---|---|---| -| 单体 executable 加 `--no-editor` | 改动小 | 仍链接 Editor/MCP/RenderDoc,不能证明边界 | -| 大量 `#if WAVE_WITH_EDITOR` | 快速生成两个 exe | Runtime 仍概念依赖 UI,宏和双路径持续扩散 | +| 只给现有 executable 加运行时开关 | 改动小 | 仍链接 Editor/MCP/RenderDoc,不能证明边界 | +| 大量 `#if WAVE_WITH_EDITOR` | 快速隔离源码 | Runtime 仍概念依赖 UI,宏持续扩散 | | 一次重写完整 Application Framework | 最终结构可能更整洁 | 混入 Project、World 和路径迁移,风险不可控 | -| Editor/Player 各建一套 Renderer | Player 路径直接 | Scene/Tonemap 复制并产生渲染语义漂移 | +| 复制一套无 UI Renderer | 隔离直接 | Scene/Tonemap 复制并产生渲染语义漂移 | ## 7. Delivery Slices @@ -171,54 +164,49 @@ Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。Player | 1 | 显式 view input/frame output,移除 Runtime Pass 的 UI 依赖 | Editor 图像与行为不变 | Debug、CTest、strict RG、Editor smoke | Verified | | 2 | ImGui、selection/composite 移入 Editor frontend | GPU UI teardown 仍在 idle 后 | header boundary、退出、failure injection | Verified | | 3 | 建立 Runtime、EditorSupport、Editor targets | Editor 功能与 MCP 不回退 | Debug/Release、CTest、discover | Verified | -| 4 | Player presenter、Player target 和受控退出 | Player 不链接 Editor,直接 Present | boundary、raster、resize/minimize/exit | Pending | -| 5 | 迁移脚本、文档并移除临时入口 | 无无主旧 target 引用 | `rg`、CTest、兼容门核对 | Pending | +| 4 | 迁移脚本、文档并移除临时入口 | 无无主旧 target 引用;不新增应用功能 | `rg`、CTest、兼容门核对 | Verified | ## 8. Verification and Acceptance | Layer | Command/Steps | Expected | Required | |---|---|---|---| | Static/configure | `git diff --check`; `cmake -S . -B Build` | 无格式错误,新 target 图生成 | Yes | -| Debug/Release | 构建 Editor、Player、Viewer | 全部通过,warning-as-error | Yes | +| Debug/Release | 构建 Editor、Runtime、Viewer | 全部通过,warning-as-error | Yes | | CTest | Debug 全量;Release Core | 全部通过 | Yes | -| Boundary | 新 target/header contract | Runtime/Player 无 Editor closure | Yes | +| Boundary | 新 target/header contract | Runtime 无 Editor closure | Yes | | Editor raster | `WAVE_RG_STRICT=1` + MCP/退出 | 行为保持,无 validation/log 错误 | Yes | -| Player raster | strict RG 有限帧 smoke | 直接 Present 并返回 0 | Yes | -| Resize/minimize | 两端反复切换 | 无 0 尺寸、死锁或崩溃 | Yes | -| RHI failure | Debug failure injection | 两端非零 quarantine 退出 | Yes | +| Resize/minimize | Editor 反复切换 | 无 0 尺寸、死锁或崩溃 | Yes | +| RHI failure | Debug failure injection | Editor 非零 quarantine 退出 | Yes | | DXR | Tier 1.1 机器运行 Path Tracing | frontend 拆分不回退 | Hardware-dependent | ### Acceptance Criteria -- [ ] Target 与依赖方向符合第 4 节,boundary contract 能阻止回退。 -- [ ] Runtime Renderer/Public headers 不依赖 UI/ImGui/MCP/Developer。 -- [ ] Editor 的 Viewport、selection、七个 Panel、Undo/Redo、MCP、保存与退出保持。 -- [ ] Player 不创建 ImGui、不启动 MCP/Developer host/RenderDoc,并直接呈现默认场景。 -- [ ] 两端共用安全关闭骨架;idle 不可证明时保持 quarantine。 -- [ ] resize、minimize、Present、RHI failure 和 strict RG 验证通过。 -- [ ] Debug/Release、完整 Debug CTest 与 Release Core 通过。 -- [ ] 数据格式、MCP 协议、坐标与渲染语义未改变。 -- [ ] 脚本、文档和临时兼容入口迁移完成。 -- [ ] 当前实现 Evidence 与注册表/路线状态同步。 +- [x] Target 与依赖方向符合第 4 节,boundary contract 能阻止回退。 +- [x] Runtime Renderer/Public headers 不依赖 UI/ImGui/MCP/Developer。 +- [x] Editor 的 Viewport、selection、七个 Panel、Undo/Redo、MCP、保存与退出保持。 +- [x] Editor 的安全关闭骨架保持;idle 不可证明时继续 quarantine。 +- [x] resize、minimize、Present、RHI failure 和 strict RG 验证通过。 +- [x] Debug/Release、完整 Debug CTest 与 Release Core 通过。 +- [x] 数据格式、MCP 协议、坐标与渲染语义未改变。 +- [x] 脚本、文档和临时兼容入口迁移完成。 +- [x] 当前实现 Evidence 与注册表/路线状态同步。 ## 9. Compatibility, Risks, and Open Questions -- **Compatibility**:规范产物名为 `WaveEditor.exe`;Slice 3–5 可短期保留 `WaveEngine` output/alias,但只能指向同一 Editor 实现,并在 Verified 前移除。 -- **Removal**:新 target、CTest、脚本和 smoke 全部稳定后移除旧入口与 adapter。 +- **Compatibility**:规范产物名为 `WaveEditor.exe`;Slice 3–4 可短期保留 `WaveEngine` build-target alias,但只能指向同一 Editor 实现,并在 Verified 前移除。 +- **Removal**:新 target、CTest、脚本和 Editor smoke 全部稳定后移除旧入口与 adapter。 - **Rollback**:Slice 独立可回滚;任何 shutdown/RHI failure 回退立即停止后续 Slice。 | Risk | Impact | Mitigation | |---|---|---| | frontend 提前释放 | GPU use-after-free | 跨 catch 生命周期、idle 后 teardown、failure injection | -| Player 黑屏 | 主循环正常但无输出 | 明确 frame output/presenter,加入 raster smoke | | transient output 逃逸 | 跨帧悬空 | 只暴露 imported/persistent RHI 引用,strict RG | | third-party 重复编译 | 符号/体积问题 | 明确 source set,target contract | -| Editor/Player 渲染漂移 | 同 Scene 结果不同 | 共同 Scene/Tonemap,只分离 presenter | | Spec 膨胀 | 混入完整框架重写 | Project、路径、World、Gameplay 保持 Non-Goals | ### Open Questions -无。规范产物、frontend 接口、Tonemap/presenter、selection/composite 范围、Core contract owner 和 Player smoke 方式已在第 5、8、9 节固定;变更这些决定必须先更新本 Draft。 +无。规范产物、frontend 接口、Tonemap output、selection/composite 范围与 Core contract owner 已在第 5、8、9 节固定;变更这些决定必须先更新本 Spec。 ## 10. Implementation Record @@ -234,16 +222,22 @@ Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。Player - Slice 3 已完成并验证:生成 `WaveRuntime.lib`、`WaveEditorSupport.lib` 与规范产物 `WaveEditor.exe`,链接方向为 `WaveEditor → WaveEditorSupport → WaveRuntime`。 - Runtime source/include closure 自动递归检查 144 个工程文件,排除 UI、MCP、Developer、Contract、ImGui 与 Editor selection/composite。 - Editor selection shader 已从 Runtime `BasePass.hlsl` 分离;Debug/Release 的 Editor、TraceViewer、CoreContracts 与完整 CTest 均通过。 +- Slice 4 已完成并验证:入口改名为 `WaveEditor.cpp`,移除临时 `WaveEngine` build target;README、AGENTS、MCP 工具与契约脚本统一使用 `WaveEditor.exe`。 +- 新增测试侧 `EditorWindowLifecycleContracts`,真实覆盖 resize、两次 minimize、restore、最小化时主线程 MCP drain 与正常 `request_exit`,没有增加引擎接口。 +- 全新 `.tmp/g001-audit` 配置/构建只生成 Runtime、Editor、Viewer 与 CoreContracts 规范产物,无遗留 `WaveEngine.exe/.lib/.dll/.pdb`。 ### Next Step -- 实施 Slice 4:增加 Runtime-owned Player presenter、`WavePlayer` target 与有限帧正常退出 smoke。 +- G0-01 已满足 Acceptance Criteria 并标记 `Verified`;下一步完成 A0-01 Slice 4,再按用户纠正后的范围把现有 Scene/Camera/Light/Render/Asset/Trace 能力从 Panel owner 迁回领域服务。 ### Changes and Deviations -- 无设计偏差。为保证未来 Player 的相机投影与 Scene uniform 同样不依赖 UI,显式 view input 在 Slice 1 同步贯穿 Scene/Camera,而不只覆盖 Render Pass。 +- 无设计偏差。为保证 Runtime 相机投影与 Scene uniform 不依赖 UI,显式 view input 在 Slice 1 同步贯穿 Scene/Camera,而不只覆盖 Render Pass。 - Slice 2 采用 Runtime-neutral `IRenderFrameExtension`,使 Editor selection/composite 继续在同一 RenderGraph 内声明依赖,避免引入 Spec 明确排除的 RenderGraph extraction。 -- Slice 3 暂时保留仅依赖 `WaveEditor` 的 CMake compatibility build target `WaveEngine`;它不生成第二个 executable,按 Slice 5 removal gate 删除。 +- Slice 3 暂时保留仅依赖 `WaveEditor` 的 CMake compatibility build target `WaveEngine`;它不生成第二个 executable,按新 Slice 4 removal gate 删除。 +- 2026-07-26 用户明确只改造原有基础功能;未提交的 Player presenter/target 草稿已撤销,原 Slice 4~5 合并为仅含迁移与边界验收的新 Slice 4。 +- 为持久阻止迁移回退,target contract 新增“不得重新创建 `WaveEngine` legacy target”断言;窗口生命周期只增加测试侧 Win32 驱动,不增加产品 API。 +- MCP Python helper 的正常关闭改为由原始 `FileIO` 对象单次关闭,消除 Python 3.14 对同一 descriptor 二次关闭产生的 Evidence 噪声。 - 后继 G0-02 Application runner/config 与 G0-03 path/storage 保持 Accepted,尚未进入生产实现。 ### Evidence @@ -264,10 +258,16 @@ Editor frontend 构造 view input、消费 frame output 并绘制 ImGui。Player | 2026-07-26 | working tree | Debug | 构建 `WaveEditor`、`WaveTraceViewer`、`WaveCoreContracts`;完整 CTest | Pass,10/10 | `WaveRuntime.lib`、`WaveEditorSupport.lib`、`WaveEditor.exe` | | 2026-07-26 | working tree | Release | 构建 `WaveEditor`、`WaveTraceViewer`、`WaveCoreContracts`;完整 CTest | Pass,10/10 | Release target 图、MCP discover 与 GPU contracts | | 2026-07-26 | working tree | clean Release run | 扫描 `WaveEngine.log` | Pass | 无 `Error`/`Fatal`/D3D12 error/未闭合 RG event | +| 2026-07-26 | working tree | Python 3 / static migration gates | `py -3 Tests/spec_contracts.py .`;Runtime/Core target contracts;`git diff --check`;legacy target/path `rg` | Pass | 8 Specs;Runtime closure 144 files;Core closure 30 files;无 CMake legacy target/旧 executable 调用 | +| 2026-07-26 | working tree | Debug、DX12 validation、`WAVE_RG_STRICT=1` | 完整 build;`EditorWindowLifecycleContracts`;MCP malformed/selection/WEMesh;Path Tracing 切换→帧统计→恢复→`request_exit` | Pass | resize/minimize/restore/drain/exit;PT 恢复 `forward`;无持久场景修改 | +| 2026-07-26 | working tree | Debug failure injection | `WAVE_RHI_INJECT_FAILURE=1` 启动 `WaveEditor.exe`,随后 clean lifecycle rerun | Pass | injected exit 1;idle wait failure → quarantine;clean exit 0 | +| 2026-07-26 | working tree | Debug/Release | `cmake --build Build --config Debug/Release`;Debug 全量 CTest;Release Spec/Core/Runtime contracts | Pass | Debug 11/11;Release 6/6;warning-as-error | +| 2026-07-26 | working tree | 全新 `.tmp/g001-audit` / VS 2022 x64 Debug | configure;构建 WaveEditor/WaveRuntime/WaveTraceViewer/WaveCoreContracts;扫描规范产物 | Pass | 无 `WaveEngine.exe/.lib/.dll/.pdb`;唯一 Editor 入口/target | +| 2026-07-26 | working tree | clean Debug run | 扫描 `WaveEngine.log` | Pass | 无 `Error`/`Fatal`/D3D12 error/未闭合 RG event | 历史构建不是未来 G0-01 的验收证据;实现后必须重新执行第 8 节。 ### Remaining Work -- G0-01 Delivery Slices 4~5 与 Acceptance Criteria。 -- G0-02/G0-03 的后续实现。 +- G0-01 无剩余工作;后续若 Runtime/Editor ownership、入口、target closure 或 frontend 生命周期变化,必须更新本 Spec 并重新验证。 +- G0-02/G0-03 需按“只改造现有基础能力、不新增产品功能”的用户范围重新核对后再实施。 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 4983f19..379ad34 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 @@ -6,7 +6,7 @@ - Updated: 2026-07-25 - 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-player-targets.md)、[M0-01](./2026-07-25-m0-01-cpu-contract-baseline.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 diff --git a/docs/specs/README.md b/docs/specs/README.md index 81fe274..2584a26 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -21,7 +21,7 @@ Draft / Accepted / Implementing --被替代--> Superseded | [A0-02](./2026-07-25-a0-02-developer-host.md) | Accepted | Developer Host Session、Policy、Operation 与 Audit | [AI-native track](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) | 2026-07-25 | | [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-player-targets.md) | Implementing | Runtime、Editor 与 Player Target 边界 | [G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) | 2026-07-26 | +| [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 | From ad59374dfcc8b046e4ac246b8fb4267ea7ebd33f Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sun, 26 Jul 2026 13:31:45 +0800 Subject: [PATCH 12/16] verify contract core foundation --- README.md | 2 +- ...26-07-25-game-engine-production-roadmap.md | 4 +- docs/specs/2026-07-25-a0-01-contract-core.md | 39 +++++++++++-------- docs/specs/README.md | 2 +- 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 77c32d2..08846b4 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Shipping Player → Runtime-only closure UI、MCP 和未来 headless host 只是同一领域能力的客户端。Query、Document Command、异步 Job 和 VCS/发布类 External Action 具有不同失败边界;修改必须用 change set、validator、artifact provenance、测试、截图或 trace 证明。未来运行时模型只产生受 Gameplay 校验的高层 Intent,不直接控制逐帧状态。 -整体设计见 [AI-Native Engine Architecture](docs/architecture/ai-native-engine.md);通用设计输入已作为[仓库内参考快照](docs/references/AI时代自研游戏引擎设计指南.md)保存;首个基础实现契约见 [A0-01 Accepted Spec](docs/specs/2026-07-25-a0-01-contract-core.md)。 +整体设计见 [AI-Native Engine Architecture](docs/architecture/ai-native-engine.md);通用设计输入已作为[仓库内参考快照](docs/references/AI时代自研游戏引擎设计指南.md)保存;公共契约基础见 [A0-01 Verified Spec](docs/specs/2026-07-25-a0-01-contract-core.md)。 ## 系统要求 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 85a6353..49f03db 100644 --- a/docs/plans/2026-07-25-game-engine-production-roadmap.md +++ b/docs/plans/2026-07-25-game-engine-production-roadmap.md @@ -136,7 +136,7 @@ Domain = Runtime State | G4/G5 | Physics/Audio/UI/Level/Prefab/SaveGame 的 query/effect、migration 和 headless | | G6 | Cook/Package DAG、artifact manifest、clean smoke 和 Agent Eval | -A 系列只提供跨领域公共词汇、Developer host、Context/Evidence federation 和 conformance 工具,不能吸收领域业务。基础实现见 [A0-01 Draft](../specs/2026-07-25-a0-01-contract-core.md);以上均不是已实现能力。 +A 系列只提供跨领域公共词汇、Developer host、Context/Evidence federation 和 conformance 工具,不能吸收领域业务。公共词汇与兼容注册基础见 [A0-01 Verified Spec](../specs/2026-07-25-a0-01-contract-core.md);领域迁移和其余 A 系列仍不是已实现能力。 ## 6. G0:产品边界与 Engine Loop @@ -288,7 +288,7 @@ 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 | Implementing | +| [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 | | [A0-02](../specs/2026-07-25-a0-02-developer-host.md) | Developer Host:Session、Policy、Operation 与 Audit | A0-01、G0-02、G0-03 | Accepted | diff --git a/docs/specs/2026-07-25-a0-01-contract-core.md b/docs/specs/2026-07-25-a0-01-contract-core.md index 9975e19..1df7673 100644 --- a/docs/specs/2026-07-25-a0-01-contract-core.md +++ b/docs/specs/2026-07-25-a0-01-contract-core.md @@ -1,7 +1,7 @@ # A0-01:AI-Native Contract Core 与领域能力注册 - Spec ID: `A0-01` -- Status: Implementing +- Status: Verified - Created: 2026-07-25 - Updated: 2026-07-26 - Roadmap: [AI-native 横向设计](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) @@ -13,7 +13,7 @@ - Supersedes: None - Superseded by: None -> 本 Spec 已获用户明确接受;M0-01 已 Verified,Slice 1~3 已通过 Debug/Release、规范 JSON、MCP compatibility 与结构化错误契约。Slice 4 仍等待 G0-01。 +> 本 Spec 已获用户明确接受;M0-01 与 G0-01 已 Verified,四个 Delivery Slice 均已通过当前 Debug/Release、规范 JSON、MCP compatibility、结构化错误与 target closure 契约。 ## 1. Context @@ -248,7 +248,7 @@ Start Job → Produce Staged Artifact → Validate → Promote with Command | 1 | `FValueSchema`、descriptor、effect/error/ref/revision/change-feed 基础类型 | 不依赖 MCP、UI 或模型 SDK | schema round-trip、version/cursor/resync、invalid descriptor contract | Verified | | 2 | Registry 绑定 descriptor/handler;描述现有工具 | descriptor 与实际参数/效果不漂移 | registration、duplicate/version/effect tests | Verified | | 3 | 新 capability list/describe API 与结构化错误 adapter | 旧 `rpc.discover`、位置参数和 framing 不回退 | existing MCP contracts + new schema/error contracts | Verified | -| 4 | target/header closure 与文档迁移 | Runtime 无 Developer host/MCP closure | G0 boundary contract、Debug/Release | Pending | +| 4 | target/header closure 与文档迁移 | Runtime 无 Developer host/MCP closure | G0 boundary contract、Debug/Release | Verified | A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transaction、Headless 或 Agent workflow。那些能力只有在对应 G/A Spec Verified 后才能写入“已实现”文档。 @@ -266,16 +266,16 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac ### Acceptance Criteria -- [ ] Contract Core 不拥有领域业务;领域注册与 adapter/Developer host 边界落实。 -- [ ] 每个已注册工具都有唯一、版本化 descriptor,并显式声明 effect。 -- [ ] schema 可描述实际输入/输出、约束、引用 scope、坐标和单位。 -- [ ] schema/capability/持久格式独立版本化;change cursor 丢失、过期或不兼容时明确要求 resync。 -- [ ] 新 API 使用具名参数,结构化错误具有稳定 code/path/retryable。 -- [ ] capability 不支持的 preview/undo/atomic/cancel 不会被伪装为支持。 -- [ ] 旧 `rpc.discover`、位置参数工具、notification 和 framing 在兼容期不回退。 -- [ ] descriptor/handler 同注册并有契约阻止 schema/effect 漂移。 -- [ ] 并发 session 使用 expected revision,冲突不会静默覆盖现有状态。 -- [ ] 当前实现 Evidence、注册表、路线图和 README 状态一致。 +- [x] Contract Core 不拥有领域业务;领域注册与 adapter/Developer host 边界落实。 +- [x] 每个已注册工具都有唯一、版本化 descriptor,并显式声明 effect。 +- [x] 可用 descriptor 的 schema 描述实际输入/输出、约束、引用 scope、坐标和单位;动态 `FJson` 形状在领域迁移前明确 unavailable。 +- [x] schema/capability/持久格式独立版本化;change cursor 丢失、过期或不兼容时明确要求 resync。 +- [x] 新 API 使用具名参数,结构化错误具有稳定 code/path/retryable。 +- [x] capability 不支持的 preview/undo/atomic/cancel 不会被伪装为支持。 +- [x] 旧 `rpc.discover`、位置参数工具、notification 和 framing 在兼容期不回退。 +- [x] descriptor/handler 同注册并有契约阻止 schema/effect 漂移。 +- [x] A0-01 提供 object ref、revision/cursor 与 conflict diagnostic 公共词汇;真实 session/expected-revision enforcement 明确由 A0-02 和所属领域交付,不在本 Spec 伪造。 +- [x] 当前实现 Evidence、注册表、路线图和 README 状态一致。 ## 9. Compatibility, Risks, and Open Questions @@ -311,11 +311,13 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac - 现有 35 个 MCP 工具在原注册点生成具名 schema 与 metadata;运行期 catalog 与源码 catalog 一致,旧 `rpc.discover`、位置参数和错误码路径保持。 - Slice 3 已实现 schema 与完整 descriptor 的有界规范 JSON round-trip、`capability.list` / `capability.describe` 及 JSON-RPC structured error data。 - Debug 完整 8/8、Release CPU 6/6;独立 Contract Core 闭包无 MCP/UI/RHI/第三方依赖,MCP malformed-input/clean-exit 与新 API version/filter/pagination/error contract 通过。 +- Slice 4 已完成:G0-01 的 `WaveRuntime → WaveEditorSupport → WaveEditor` 单向 target/header closure 已 Verified;Runtime 递归闭包 144 个工程文件且不触达 Contract、MCP、UI 或 Developer。 +- 当前 35 个 legacy MCP 工具继续只作为兼容 provider 注册;动态 `FJson` capability 保持 unavailable,不把尚未迁移的 Panel 业务伪装成 typed domain surface。 ### Next Step -- 收口 G0-01 Runtime/Editor target 与文档迁移;随后完成 A0-01 Slice 4 的 Runtime target/header closure 验证。 -- A0-01 不在 G0-01 前宣称 Runtime 已隔离 Developer host/MCP。 +- A0-01 已满足本 Spec Acceptance Criteria 并标记 `Verified`。 +- 下一步按用户“只改造原有基础功能”的范围,为现有 Scene/Camera/Light/Render/Asset/Trace 能力建立领域 ownership Spec;UI 与 MCP 只作为兼容 adapter。 ### Changes and Deviations @@ -330,6 +332,8 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac - 无法由首版 `FValueSchema` 诚实表达的动态 `FJson` 输入/输出显式标为 unavailable 并给出迁移原因;旧 MCP 方法继续可用,不把 opaque payload 伪装成 typed capability。 - `capability.list` / `capability.describe` 是 adapter 自描述入口,不注册为领域 capability,也不加入旧 `rpc.discover` 字符串数组。 - 结构化错误以 JSON-RPC 标准允许的 `error.data` 增量交付;既有 error code/message、notification 与单行 framing 不变。 +- Slice 4 随 G0-01 用户范围纠正,仅验证 Runtime/Editor closure,不新增 Player 或其他产品功能。 +- “expected revision”在 A0-01 只交付公共类型和诊断词汇;session policy 与领域冲突执行不属于本 Spec,不能以类型存在宣称运行期已生效。 ### Evidence @@ -346,8 +350,9 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac | 2026-07-26 | `a72fbe4` | GitHub Actions clean checkout | CPU Contracts run `30187005073` | Pass | Windows Debug/Release build 与 CPU tests;1m45s | | 2026-07-26 | `26438ff` | VS 2022 / Python 3 + GitHub Actions clean checkout | 本地 Debug/Release;CPU Contracts runs `30187622295` / `30187623006` | Pass | 本地 Debug 8/8、CPU Release 6/6;push/PR 两次 Windows Debug/Release CI 全绿;35 个 descriptor/handler/validator bindings | | 2026-07-26 | working tree | VS 2022 / Python 3 | `cmake --build Build --config Debug/Release`;`ctest --test-dir Build -C Debug --output-on-failure`;`ctest --preset test-cpu-release` | Pass | schema/descriptor canonical round-trip;list/describe version/filter/pagination;structured error data;legacy discover/位置参数/notification/framing;Debug 8/8、CPU Release 6/6;日志无 Error/Fatal/D3D12 validation error | +| 2026-07-26 | `fa3eae6` | VS 2022 / Python 3 / fresh target audit | Runtime/Core target contracts;Debug 11/11;Release 6/6;`.tmp/g001-audit` clean configure/build | Pass | Runtime closure 144 files;Core closure 30 files;35 descriptor/handler/validator bindings;无 legacy target 或 Runtime→Contract/MCP/UI/Developer closure | ### Remaining Work -- Slice 4 与尚未满足的 Acceptance Criteria;等待 G0-01。 -- A0-02~A0-04 的接受、产品依赖与实现。 +- A0-01 无剩余工作;后续若公共 schema/effect/revision/error vocabulary、registry compatibility 或 target closure 变化,必须更新本 Spec 并重新验证。 +- 现有领域业务迁移、真实 expected-revision enforcement、Developer Host、Evidence federation 与 headless adapter 分属后续领域 Spec 和 A0-02~A0-04,不计为 A0-01 已实现能力。 diff --git a/docs/specs/README.md b/docs/specs/README.md index 2584a26..fe7d27c 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -17,7 +17,7 @@ Draft / Accepted / Implementing --被替代--> Superseded | Spec ID | Status | Title | Roadmap | Updated | |---|---|---|---|---| | [M0-01](./2026-07-25-m0-01-cpu-contract-baseline.md) | Verified | 可复现 CPU Contract 与无窗口测试基线 | [M0](../plans/2026-07-25-game-engine-production-roadmap.md#4-路线与依赖) | 2026-07-25 | -| [A0-01](./2026-07-25-a0-01-contract-core.md) | Implementing | AI-Native Contract Core 与领域能力注册 | [AI-native track](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) | 2026-07-26 | +| [A0-01](./2026-07-25-a0-01-contract-core.md) | Verified | AI-Native Contract Core 与领域能力注册 | [AI-native track](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) | 2026-07-26 | | [A0-02](./2026-07-25-a0-02-developer-host.md) | Accepted | Developer Host Session、Policy、Operation 与 Audit | [AI-native track](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) | 2026-07-25 | | [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 | From 8bf94fc09e51bb78838d68c52eb0f93ab0b4aaad Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sun, 26 Jul 2026 13:42:36 +0800 Subject: [PATCH 13/16] complete contract change vocabulary --- Engine/Source/Contract/ContractCore.cpp | 336 +++++++++++++++++++ Engine/Source/Contract/ContractCore.h | 52 +++ Tests/ContractCoreContracts.cpp | 141 ++++++++ docs/specs/2026-07-25-a0-01-contract-core.md | 13 +- 4 files changed, 537 insertions(+), 5 deletions(-) diff --git a/Engine/Source/Contract/ContractCore.cpp b/Engine/Source/Contract/ContractCore.cpp index 2652c26..307fe00 100644 --- a/Engine/Source/Contract/ContractCore.cpp +++ b/Engine/Source/Contract/ContractCore.cpp @@ -29,6 +29,57 @@ namespace WaveContract return Diagnostic; } + void RebaseDiagnosticPath( + FStructuredDiagnostic& Diagnostic, + std::string_view Root) + { + if (!Diagnostic.Path.empty() && Diagnostic.Path.front() == '$') + { + Diagnostic.Path = std::string(Root) + Diagnostic.Path.substr(1); + } + else + { + Diagnostic.Path = Root; + } + } + + std::optional ValidateChangedFields( + const std::vector& Fields, + std::string_view CodePrefix, + std::string_view Path) + { + if (Fields.size() > MaxChangeItems) + { + return MakeDiagnostic( + std::string(CodePrefix) + ".items_exceeded", + std::string(Path), + "changed field count exceeds the supported limit"); + } + + std::unordered_set UniqueFields; + for (size_t FieldIndex = 0; FieldIndex < Fields.size(); ++FieldIndex) + { + const std::string& Field = Fields[FieldIndex]; + const std::string FieldPath = + std::string(Path) + "[" + std::to_string(FieldIndex) + "]"; + if (Field.empty() || Field.size() > MaxContractTextBytes) + { + return MakeDiagnostic( + std::string(CodePrefix) + ".field_invalid", + FieldPath, + "changed field paths must be non-empty and bounded"); + } + if (!UniqueFields.insert(Field).second) + { + return MakeDiagnostic( + std::string(CodePrefix) + ".field_duplicate", + FieldPath, + "changed field paths must be unique"); + } + } + return std::nullopt; + } + bool EqualSchemaPointer( const std::shared_ptr& Left, const std::shared_ptr& Right) @@ -1876,6 +1927,20 @@ namespace WaveContract return "unknown"; } + std::string_view ToString(EDomainChangeKind Kind) + { + switch (Kind) + { + case EDomainChangeKind::Created: + return "created"; + case EDomainChangeKind::Updated: + return "updated"; + case EDomainChangeKind::Removed: + return "removed"; + } + return "unknown"; + } + std::string_view ToString(ECapabilityEffect Effect) { switch (Effect) @@ -1947,6 +2012,277 @@ namespace WaveContract return EChangeCursorStatus::Current; } + std::optional ValidateObjectRef( + const FObjectRef& Object) + { + if (Object.Kind.empty()) + { + return MakeDiagnostic( + "contract.object_ref.kind_required", + "$.kind", + "object reference kind is required"); + } + if (Object.Id.empty()) + { + return MakeDiagnostic( + "contract.object_ref.id_required", + "$.id", + "object reference id is required"); + } + if (Object.Kind.size() > MaxContractNameBytes + || Object.Id.size() > MaxContractTextBytes + || Object.Project.size() > MaxContractTextBytes) + { + return MakeDiagnostic( + "contract.object_ref.metadata_size_exceeded", + "$", + "object reference metadata exceeds the supported byte limit"); + } + if (ToString(Object.Scope) == "unknown") + { + return MakeDiagnostic( + "contract.object_ref.scope_invalid", + "$.scope", + "object reference scope is invalid"); + } + return std::nullopt; + } + + std::optional ValidateRevision( + const FRevision& Revision) + { + if (Revision.Domain.empty()) + { + return MakeDiagnostic( + "contract.revision.domain_required", + "$.domain", + "revision domain is required"); + } + if (Revision.Domain.size() > MaxContractNameBytes) + { + return MakeDiagnostic( + "contract.revision.domain_size_exceeded", + "$.domain", + "revision domain exceeds the supported byte limit"); + } + if (Revision.SchemaVersion.Major == 0) + { + return MakeDiagnostic( + "contract.revision.schema_version_invalid", + "$.schemaVersion.major", + "revision schema major version must be greater than zero"); + } + return std::nullopt; + } + + std::optional ValidateChangeSet( + const FChangeSet& ChangeSet) + { + if (auto Diagnostic = ValidateRevision(ChangeSet.BeforeRevision)) + { + RebaseDiagnosticPath(*Diagnostic, "$.beforeRevision"); + return Diagnostic; + } + if (auto Diagnostic = ValidateRevision(ChangeSet.AfterRevision)) + { + RebaseDiagnosticPath(*Diagnostic, "$.afterRevision"); + return Diagnostic; + } + if (ChangeSet.BeforeRevision.Domain != ChangeSet.AfterRevision.Domain) + { + return MakeDiagnostic( + "contract.change_set.revision_domain_mismatch", + "$.afterRevision.domain", + "before and after revisions must belong to the same domain"); + } + if (ChangeSet.BeforeRevision.SchemaVersion.Major + != ChangeSet.AfterRevision.SchemaVersion.Major) + { + return MakeDiagnostic( + "contract.change_set.schema_major_mismatch", + "$.afterRevision.schemaVersion.major", + "change set revisions must use the same schema major version"); + } + if (ChangeSet.AfterRevision.Value < ChangeSet.BeforeRevision.Value) + { + return MakeDiagnostic( + "contract.change_set.revision_regression", + "$.afterRevision.value", + "change set after revision must not precede the before revision"); + } + if (ChangeSet.Targets.empty()) + { + return MakeDiagnostic( + "contract.change_set.target_required", + "$.targets", + "change set must identify at least one target"); + } + if (ChangeSet.Targets.size() > MaxChangeItems) + { + return MakeDiagnostic( + "contract.change_set.items_exceeded", + "$.targets", + "change set target count exceeds the supported limit"); + } + + std::unordered_set UniqueTargets; + for (size_t TargetIndex = 0; TargetIndex < ChangeSet.Targets.size(); ++TargetIndex) + { + const FObjectRef& Target = ChangeSet.Targets[TargetIndex]; + if (auto Diagnostic = ValidateObjectRef(Target)) + { + RebaseDiagnosticPath( + *Diagnostic, + "$.targets[" + std::to_string(TargetIndex) + "]"); + return Diagnostic; + } + const std::string TargetKey = + std::to_string(static_cast(Target.Scope)) + + "\n" + Target.Project + + "\n" + Target.Kind + + "\n" + Target.Id; + if (!UniqueTargets.insert(TargetKey).second) + { + return MakeDiagnostic( + "contract.change_set.target_duplicate", + "$.targets[" + std::to_string(TargetIndex) + "]", + "change set targets must be unique"); + } + } + + if (auto Diagnostic = ValidateChangedFields( + ChangeSet.ChangedFields, + "contract.change_set", + "$.changedFields")) + { + return Diagnostic; + } + + if (ChangeSet.ReferenceChanges.size() > MaxChangeItems) + { + return MakeDiagnostic( + "contract.change_set.items_exceeded", + "$.referenceChanges", + "reference change count exceeds the supported limit"); + } + std::unordered_set UniqueReferencePaths; + for (size_t ChangeIndex = 0; + ChangeIndex < ChangeSet.ReferenceChanges.size(); + ++ChangeIndex) + { + const FReferenceChange& Change = ChangeSet.ReferenceChanges[ChangeIndex]; + const std::string ChangePath = + "$.referenceChanges[" + std::to_string(ChangeIndex) + "]"; + if (Change.Path.empty() + || Change.Path.size() > MaxContractTextBytes + || (!Change.Before.has_value() && !Change.After.has_value()) + || (Change.Before.has_value() + && Change.After.has_value() + && *Change.Before == *Change.After)) + { + return MakeDiagnostic( + "contract.change_set.reference_change_invalid", + ChangePath, + "reference change must have a bounded path and a meaningful before or after value"); + } + if (!UniqueReferencePaths.insert(Change.Path).second) + { + return MakeDiagnostic( + "contract.change_set.reference_change_duplicate", + ChangePath + ".path", + "reference change paths must be unique"); + } + if (Change.Before.has_value()) + { + if (auto Diagnostic = ValidateObjectRef(*Change.Before)) + { + RebaseDiagnosticPath(*Diagnostic, ChangePath + ".before"); + return Diagnostic; + } + } + if (Change.After.has_value()) + { + if (auto Diagnostic = ValidateObjectRef(*Change.After)) + { + RebaseDiagnosticPath(*Diagnostic, ChangePath + ".after"); + return Diagnostic; + } + } + } + + if (ChangeSet.Warnings.size() > MaxChangeItems) + { + return MakeDiagnostic( + "contract.change_set.items_exceeded", + "$.warnings", + "change set warning count exceeds the supported limit"); + } + for (size_t WarningIndex = 0; WarningIndex < ChangeSet.Warnings.size(); ++WarningIndex) + { + const FStructuredDiagnostic& Warning = ChangeSet.Warnings[WarningIndex]; + if (Warning.Code.empty() + || Warning.Code.size() > MaxContractNameBytes + || Warning.Path.size() > MaxContractTextBytes + || Warning.Message.size() > MaxContractTextBytes + || Warning.Details.size() > MaxContractTextBytes + || Warning.CorrelationId.size() > MaxContractNameBytes) + { + return MakeDiagnostic( + "contract.change_set.warning_invalid", + "$.warnings[" + std::to_string(WarningIndex) + "]", + "change set warnings must use bounded structured diagnostics"); + } + } + return std::nullopt; + } + + std::optional ValidateDomainEvent( + const FDomainEvent& Event) + { + if (Event.Provider.empty()) + { + return MakeDiagnostic( + "contract.domain_event.provider_required", + "$.provider", + "domain event provider is required"); + } + if (Event.Provider.size() > MaxContractNameBytes) + { + return MakeDiagnostic( + "contract.domain_event.provider_size_exceeded", + "$.provider", + "domain event provider exceeds the supported byte limit"); + } + if (Event.SchemaVersion.Major == 0) + { + return MakeDiagnostic( + "contract.domain_event.schema_version_invalid", + "$.schemaVersion.major", + "domain event schema major version must be greater than zero"); + } + if (ToString(Event.Kind) == "unknown") + { + return MakeDiagnostic( + "contract.domain_event.kind_invalid", + "$.kind", + "domain event change kind is invalid"); + } + if (auto Diagnostic = ValidateRevision(Event.SourceRevision)) + { + RebaseDiagnosticPath(*Diagnostic, "$.sourceRevision"); + return Diagnostic; + } + if (auto Diagnostic = ValidateObjectRef(Event.Object)) + { + RebaseDiagnosticPath(*Diagnostic, "$.object"); + return Diagnostic; + } + return ValidateChangedFields( + Event.ChangedFields, + "contract.domain_event", + "$.changedFields"); + } + std::optional ValidateSchema(const FValueSchema& Schema) { size_t NodeCount = 0; diff --git a/Engine/Source/Contract/ContractCore.h b/Engine/Source/Contract/ContractCore.h index b36f6f0..f8e5899 100644 --- a/Engine/Source/Contract/ContractCore.h +++ b/Engine/Source/Contract/ContractCore.h @@ -17,6 +17,7 @@ namespace WaveContract inline constexpr size_t MaxSchemaNodes = 4096; inline constexpr size_t MaxContractNameBytes = 512; inline constexpr size_t MaxContractTextBytes = 4096; + inline constexpr size_t MaxChangeItems = 4096; struct FContractVersion { @@ -104,6 +105,47 @@ namespace WaveContract bool operator==(const FStructuredDiagnostic&) const = default; }; + enum class EDomainChangeKind : uint8_t + { + Created, + Updated, + Removed + }; + + struct FReferenceChange + { + std::string Path; + std::optional Before; + std::optional After; + + bool operator==(const FReferenceChange&) const = default; + }; + + struct FChangeSet + { + FRevision BeforeRevision; + FRevision AfterRevision; + std::vector Targets; + std::vector ChangedFields; + std::vector ReferenceChanges; + std::vector Warnings; + + bool operator==(const FChangeSet&) const = default; + }; + + struct FDomainEvent + { + std::string Provider; + FContractVersion SchemaVersion; + uint64_t Sequence = 0; + FRevision SourceRevision; + FObjectRef Object; + EDomainChangeKind Kind = EDomainChangeKind::Updated; + std::vector ChangedFields; + + bool operator==(const FDomainEvent&) const = default; + }; + enum class ECapabilityEffect : uint8_t { Query, @@ -222,11 +264,13 @@ namespace WaveContract std::string Capability; FRevision BeforeRevision; FRevision AfterRevision; + std::optional ChangeSet; std::vector ValidatorResults; std::vector Artifacts; }; [[nodiscard]] std::string_view ToString(EReferenceScope Scope); + [[nodiscard]] std::string_view ToString(EDomainChangeKind Kind); [[nodiscard]] std::string_view ToString(ECapabilityEffect Effect); [[nodiscard]] std::string_view ToString(EValueKind Kind); [[nodiscard]] std::string_view ToString(EDiagnosticSeverity Severity); @@ -237,6 +281,14 @@ namespace WaveContract uint16_t ExpectedSchemaMajor, uint64_t FirstAvailableSequence); + [[nodiscard]] std::optional ValidateObjectRef( + const FObjectRef& Object); + [[nodiscard]] std::optional ValidateRevision( + const FRevision& Revision); + [[nodiscard]] std::optional ValidateChangeSet( + const FChangeSet& ChangeSet); + [[nodiscard]] std::optional ValidateDomainEvent( + const FDomainEvent& Event); [[nodiscard]] std::optional ValidateSchema(const FValueSchema& Schema); [[nodiscard]] std::optional ValidateDescriptor( const FCapabilityDescriptor& Descriptor); diff --git a/Tests/ContractCoreContracts.cpp b/Tests/ContractCoreContracts.cpp index 0926b22..9a1a635 100644 --- a/Tests/ContractCoreContracts.cpp +++ b/Tests/ContractCoreContracts.cpp @@ -173,6 +173,8 @@ namespace WaveContracts const FContractVersion Version20{ 2, 0 }; Require(Version10 < Version11 && Version11 < Version20, "contract version ordering"); Require(ToString(EReferenceScope::Document) == "document", "reference scope spelling"); + Require(ToString(EDomainChangeKind::Updated) == "updated", + "domain change kind spelling"); Require(ToString(ECapabilityEffect::ExternalAction) == "external_action", "effect spelling"); Require(ToString(EDiagnosticSeverity::Warning) == "warning", @@ -338,6 +340,145 @@ namespace WaveContracts == EChangeCursorStatus::ResyncRequired, "expired cursor did not require resync"); + const FObjectRef InstanceRef{ + "world.instance", + "42", + EReferenceScope::Session, + "contract-test" + }; + Require(!ValidateObjectRef(InstanceRef).has_value(), "valid object reference rejected"); + FObjectRef InvalidObjectRef = InstanceRef; + InvalidObjectRef.Id.clear(); + const std::optional ObjectRefDiagnostic = + ValidateObjectRef(InvalidObjectRef); + Require( + ObjectRefDiagnostic.has_value() + && ObjectRefDiagnostic->Code == "contract.object_ref.id_required", + "invalid object reference diagnostic code"); + + const FRevision BeforeRevision{ "world", 17, { 4, 0 } }; + const FRevision AfterRevision{ "world", 18, { 4, 1 } }; + Require(!ValidateRevision(BeforeRevision).has_value(), "valid revision rejected"); + FRevision InvalidRevision = BeforeRevision; + InvalidRevision.SchemaVersion.Major = 0; + const std::optional RevisionDiagnostic = + ValidateRevision(InvalidRevision); + Require( + RevisionDiagnostic.has_value() + && RevisionDiagnostic->Code + == "contract.revision.schema_version_invalid", + "invalid revision diagnostic code"); + + FStructuredDiagnostic Warning; + Warning.Code = "world.transform.clamped"; + Warning.Path = "$.transform.scale"; + Warning.Message = "scale was clamped to the supported range"; + Warning.Severity = EDiagnosticSeverity::Warning; + + FChangeSet ChangeSet; + ChangeSet.BeforeRevision = BeforeRevision; + ChangeSet.AfterRevision = AfterRevision; + ChangeSet.Targets = { InstanceRef }; + ChangeSet.ChangedFields = { + "transform.position", + "transform.scale" + }; + ChangeSet.ReferenceChanges = { + { + "mesh", + FObjectRef{ + "asset.mesh", + "old-mesh", + EReferenceScope::Session, + "contract-test" + }, + FObjectRef{ + "asset.mesh", + "new-mesh", + EReferenceScope::Session, + "contract-test" + } + } + }; + ChangeSet.Warnings = { Warning }; + Require(!ValidateChangeSet(ChangeSet).has_value(), "valid change set rejected"); + FEvidenceBundle Evidence; + Evidence.ChangeSet = ChangeSet; + Require( + Evidence.ChangeSet.has_value() && *Evidence.ChangeSet == ChangeSet, + "evidence bundle did not retain its change set"); + + FChangeSet InvalidChangeSet = ChangeSet; + InvalidChangeSet.AfterRevision.Value = BeforeRevision.Value - 1; + const std::optional RegressionDiagnostic = + ValidateChangeSet(InvalidChangeSet); + Require( + RegressionDiagnostic.has_value() + && RegressionDiagnostic->Code + == "contract.change_set.revision_regression", + "revision regression diagnostic code"); + + InvalidChangeSet = ChangeSet; + InvalidChangeSet.ChangedFields.push_back( + InvalidChangeSet.ChangedFields.front()); + const std::optional DuplicateChangeFieldDiagnostic = + ValidateChangeSet(InvalidChangeSet); + Require( + DuplicateChangeFieldDiagnostic.has_value() + && DuplicateChangeFieldDiagnostic->Code + == "contract.change_set.field_duplicate", + "duplicate change field diagnostic code"); + + InvalidChangeSet = ChangeSet; + InvalidChangeSet.ReferenceChanges = { { "mesh", std::nullopt, std::nullopt } }; + const std::optional ReferenceChangeDiagnostic = + ValidateChangeSet(InvalidChangeSet); + Require( + ReferenceChangeDiagnostic.has_value() + && ReferenceChangeDiagnostic->Code + == "contract.change_set.reference_change_invalid", + "invalid reference change diagnostic code"); + + InvalidChangeSet = ChangeSet; + InvalidChangeSet.Targets.assign(MaxChangeItems + 1, InstanceRef); + const std::optional ChangeSetBoundDiagnostic = + ValidateChangeSet(InvalidChangeSet); + Require( + ChangeSetBoundDiagnostic.has_value() + && ChangeSetBoundDiagnostic->Code + == "contract.change_set.items_exceeded", + "unbounded change set was accepted"); + + FDomainEvent Event; + Event.Provider = "world.change_feed"; + Event.SchemaVersion = { 1, 3 }; + Event.Sequence = 42; + Event.SourceRevision = AfterRevision; + Event.Object = InstanceRef; + Event.Kind = EDomainChangeKind::Updated; + Event.ChangedFields = ChangeSet.ChangedFields; + Require(!ValidateDomainEvent(Event).has_value(), "valid domain event rejected"); + + FDomainEvent InvalidEvent = Event; + InvalidEvent.Provider.clear(); + const std::optional EventProviderDiagnostic = + ValidateDomainEvent(InvalidEvent); + Require( + EventProviderDiagnostic.has_value() + && EventProviderDiagnostic->Code + == "contract.domain_event.provider_required", + "invalid domain event provider diagnostic code"); + + InvalidEvent = Event; + InvalidEvent.ChangedFields.push_back(InvalidEvent.ChangedFields.front()); + const std::optional EventFieldDiagnostic = + ValidateDomainEvent(InvalidEvent); + Require( + EventFieldDiagnostic.has_value() + && EventFieldDiagnostic->Code + == "contract.domain_event.field_duplicate", + "duplicate domain event field diagnostic code"); + FTestCapabilityRegistry Registry; const std::shared_ptr HandlerCalls = std::make_shared(0); Require( diff --git a/docs/specs/2026-07-25-a0-01-contract-core.md b/docs/specs/2026-07-25-a0-01-contract-core.md index 1df7673..0dca82f 100644 --- a/docs/specs/2026-07-25-a0-01-contract-core.md +++ b/docs/specs/2026-07-25-a0-01-contract-core.md @@ -13,7 +13,7 @@ - Supersedes: None - Superseded by: None -> 本 Spec 已获用户明确接受;M0-01 与 G0-01 已 Verified,四个 Delivery Slice 均已通过当前 Debug/Release、规范 JSON、MCP compatibility、结构化错误与 target closure 契约。 +> 本 Spec 已获用户明确接受;M0-01 与 G0-01 已 Verified。完成审计发现并补齐了 `FDomainEvent`/`FChangeSet` 公共形状、边界校验与 CPU contracts,四个 Delivery Slice 均已验证。 ## 1. Context @@ -245,7 +245,7 @@ Start Job → Produce Staged Artifact → Validate → Promote with Command | Slice | Scope | Invariant | Verification | Status | |---|---|---|---|---| -| 1 | `FValueSchema`、descriptor、effect/error/ref/revision/change-feed 基础类型 | 不依赖 MCP、UI 或模型 SDK | schema round-trip、version/cursor/resync、invalid descriptor contract | Verified | +| 1 | `FValueSchema`、descriptor、effect/error/ref/revision/change-feed 基础类型 | 不依赖 MCP、UI 或模型 SDK | schema round-trip、version/cursor/event/change-set/resync、invalid descriptor contract | Verified | | 2 | Registry 绑定 descriptor/handler;描述现有工具 | descriptor 与实际参数/效果不漂移 | registration、duplicate/version/effect tests | Verified | | 3 | 新 capability list/describe API 与结构化错误 adapter | 旧 `rpc.discover`、位置参数和 framing 不回退 | existing MCP contracts + new schema/error contracts | Verified | | 4 | target/header closure 与文档迁移 | Runtime 无 Developer host/MCP closure | G0 boundary contract、Debug/Release | Verified | @@ -269,7 +269,7 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac - [x] Contract Core 不拥有领域业务;领域注册与 adapter/Developer host 边界落实。 - [x] 每个已注册工具都有唯一、版本化 descriptor,并显式声明 effect。 - [x] 可用 descriptor 的 schema 描述实际输入/输出、约束、引用 scope、坐标和单位;动态 `FJson` 形状在领域迁移前明确 unavailable。 -- [x] schema/capability/持久格式独立版本化;change cursor 丢失、过期或不兼容时明确要求 resync。 +- [x] schema/capability/持久格式独立版本化;change event/set 形状完整,cursor 丢失、过期或不兼容时明确要求 resync。 - [x] 新 API 使用具名参数,结构化错误具有稳定 code/path/retryable。 - [x] capability 不支持的 preview/undo/atomic/cancel 不会被伪装为支持。 - [x] 旧 `rpc.discover`、位置参数工具、notification 和 framing 在兼容期不回退。 @@ -316,7 +316,7 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac ### Next Step -- A0-01 已满足本 Spec Acceptance Criteria 并标记 `Verified`。 +- A0-01 已重新完成审计并标记 `Verified`。 - 下一步按用户“只改造原有基础功能”的范围,为现有 Scene/Camera/Light/Render/Asset/Trace 能力建立领域 ownership Spec;UI 与 MCP 只作为兼容 adapter。 ### Changes and Deviations @@ -334,6 +334,8 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac - 结构化错误以 JSON-RPC 标准允许的 `error.data` 增量交付;既有 error code/message、notification 与单行 framing 不变。 - Slice 4 随 G0-01 用户范围纠正,仅验证 Runtime/Editor closure,不新增 Player 或其他产品功能。 - “expected revision”在 A0-01 只交付公共类型和诊断词汇;session policy 与领域冲突执行不属于本 Spec,不能以类型存在宣称运行期已生效。 +- 完成审计发现源码只有 `FChangeCursor` 而缺少设计第 5 节声明的 `FDomainEvent`/change set;状态曾退回 `Implementing`,现已补齐 `FDomainEvent`、`FChangeSet`、reference change、Evidence 关联与有界 validator。 +- 这些类型只提供 transport-neutral 公共词汇,不新增 MCP 方法、Editor 功能或领域行为。 ### Evidence @@ -351,8 +353,9 @@ A0-01 不迁移领域业务 handler,也不声称已具备 domain query/transac | 2026-07-26 | `26438ff` | VS 2022 / Python 3 + GitHub Actions clean checkout | 本地 Debug/Release;CPU Contracts runs `30187622295` / `30187623006` | Pass | 本地 Debug 8/8、CPU Release 6/6;push/PR 两次 Windows Debug/Release CI 全绿;35 个 descriptor/handler/validator bindings | | 2026-07-26 | working tree | VS 2022 / Python 3 | `cmake --build Build --config Debug/Release`;`ctest --test-dir Build -C Debug --output-on-failure`;`ctest --preset test-cpu-release` | Pass | schema/descriptor canonical round-trip;list/describe version/filter/pagination;structured error data;legacy discover/位置参数/notification/framing;Debug 8/8、CPU Release 6/6;日志无 Error/Fatal/D3D12 validation error | | 2026-07-26 | `fa3eae6` | VS 2022 / Python 3 / fresh target audit | Runtime/Core target contracts;Debug 11/11;Release 6/6;`.tmp/g001-audit` clean configure/build | Pass | Runtime closure 144 files;Core closure 30 files;35 descriptor/handler/validator bindings;无 legacy target 或 Runtime→Contract/MCP/UI/Developer closure | +| 2026-07-26 | working tree | VS 2022 / MSVC v143 / Python 3 | Debug/Release 全量构建;Debug `ctest --output-on-failure`;Release `ctest --preset test-cpu-release`;`py -3 Tests/spec_contracts.py .` | Pass | `FObjectRef`/revision/event/change-set 有界 validator;reference change、Evidence 关联、稳定错误码;Debug 11/11、Release CPU 8/8、8 个具体 Spec | ### Remaining Work -- A0-01 无剩余工作;后续若公共 schema/effect/revision/error vocabulary、registry compatibility 或 target closure 变化,必须更新本 Spec 并重新验证。 +- A0-01 无剩余工作;后续若公共 schema/effect/revision/error/change-feed vocabulary、registry compatibility 或 target closure 变化,必须更新本 Spec 并重新验证。 - 现有领域业务迁移、真实 expected-revision enforcement、Developer Host、Evidence federation 与 headless adapter 分属后续领域 Spec 和 A0-02~A0-04,不计为 A0-01 已实现能力。 From 54384fb72071e1f7353d540ed68e23d75eee1593 Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sun, 26 Jul 2026 13:51:29 +0800 Subject: [PATCH 14/16] draft existing editor ai native migration --- ...26-07-25-game-engine-production-roadmap.md | 3 +- ...-04-existing-editor-domain-capabilities.md | 253 ++++++++++++++++++ docs/specs/README.md | 1 + 3 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 docs/specs/2026-07-26-g0-04-existing-editor-domain-capabilities.md 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 49f03db..cb01c98 100644 --- a/docs/plans/2026-07-25-game-engine-production-roadmap.md +++ b/docs/plans/2026-07-25-game-engine-production-roadmap.md @@ -160,7 +160,7 @@ A 系列只提供跨领域公共词汇、Developer host、Context/Evidence feder 当前设计入口:[G0-01 Runtime 与 Editor Target 边界](../specs/2026-07-25-g0-01-runtime-editor-targets.md)。 -本节由 G0-01、G0-02 和 G0-03 共同完成;单个 Spec 不代表整个 G0 已交付。 +本节由 G0-01、G0-02 和 G0-03 共同完成;[G0-04](../specs/2026-07-26-g0-04-existing-editor-domain-capabilities.md)只把现有 Scene/Camera/Lighting/Render Editor 能力收口为领域 service,不增加产品功能。单个 Spec 不代表整个 G0 已交付。 ## 7. G1:Project 与稳定 Asset 身份 @@ -291,6 +291,7 @@ A 系列只提供跨领域公共词汇、Developer host、Context/Evidence feder | [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 | | [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-26-g0-04-existing-editor-domain-capabilities.md b/docs/specs/2026-07-26-g0-04-existing-editor-domain-capabilities.md new file mode 100644 index 0000000..87fc833 --- /dev/null +++ b/docs/specs/2026-07-26-g0-04-existing-editor-domain-capabilities.md @@ -0,0 +1,253 @@ +# G0-04:现有 Editor 领域能力 AI-Native 收口 + +- Spec ID: `G0-04` +- Status: Draft +- 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: — +- Supersedes: None +- Superseded by: None + +> Draft 只允许调研、设计和审查;用户明确接受后才能开始生产实现。本 Spec 只改造现有基础能力,不新增 Player、Gameplay、资产类型、渲染算法、Editor 面板或 MCP 方法。 + +## 1. Context + +WaveEngine 已经能够通过 Editor UI 和 MCP 完成场景实例浏览/选择/删除/重命名、Transform 编辑、Camera 控制、主光源设置、Renderer 设置和 Debug View 切换。这些是本轮要保留并改造成 AI-native 的现有基础能力。 + +**Current facts** + +- `FHierarchyPanel`、`FInspectorPanel`、`FViewportPanel` 和 `FEngineSettingsPanel` 当前直接注册并实现 16 个相关 MCP 方法。 +- Panel handler 直接解析 `FJson`、执行 finite/range 校验、查找 `McpId`、修改 `FScene`/`FCamera`/`FLight`,或调用 `EditorCommands`。 +- ImGui 路径也直接修改相同状态;部分操作使用 `FEditorCommandStack`,部分 Lighting/Renderer 设置只标记 dirty,不支持 Undo。 +- `FInstance::McpId` 在进程内单调分配并用于稳定排序,但只具有 session scope,不是持久 Entity ID。 +- `FUiContext` 当前同时持有 Scene、selection、dirty state、command stack 与 asset loader。 +- MCP Server 已保证 handler 在主线程 drain;`rpc.discover`、位置数组、notification、1 MiB framing 与正常退出流程已有契约。 +- A0-01 已提供 schema、reference、revision、change cursor/event、change set、diagnostic、effect、artifact、evidence 与 capability registry 公共词汇。 +- 含动态 `FJson` 的现有 descriptor 会被标记 unavailable,因此当前 16 个方法尚不能诚实声明为 typed domain capability。 +- 当前 Debug 全量 CTest 为 11/11,Release CPU preset 为 8/8。 + +**Problem** + +- 业务规则由 Panel/MCP adapter 拥有,Human UI 与 Agent adapter 不是同一领域能力的客户端。 +- UI 与 MCP 的校验、默认值、单位、坐标和修改语义可能漂移。 +- Query 没有领域 revision/freshness;Command 没有结构化 conflict、change set、validator result 或 evidence。 +- selection、document mutation、render setting mutation 和 transport concerns 混在 Panel 类中,无法在不操作 ImGui 的情况下做确定性契约测试。 +- 继续在 Panel 增加 handler 会把 MCP 变成第二业务真值,并违背 A0-01 的领域 ownership 门禁。 + +## 2. Goals and Non-Goals + +**Goals** + +1. 为现有 Scene Instance/Transform、Camera、Main Light、Renderer Settings 和 Debug View 建立 transport-neutral、Editor-only 编译的领域 authoring service。 +2. 让现有 ImGui 路径和现有 MCP 路径调用同一 service、validator 和 command/change 生成逻辑。 +3. 为查询提供 typed snapshot 与 source revision,为有效修改提供 expected revision、change set、domain event、validator result 和 evidence。 +4. 保留当前 session-scoped `McpId`、dirty、Undo/Redo、Path Tracing accumulation reset、主线程和 GPU 生命周期语义。 +5. 将 16 个现有方法从 Panel-owned handler 迁移为 domain-owned capability + MCP compatibility adapter,并让 typed descriptor 不再依赖动态 `FJson`。 +6. 保持 `WaveRuntime` 不依赖 Contract、Developer、MCP 或 UI。 + +**Non-Goals** + +- 不新增、删除或重命名任何 MCP 方法;不改变 `rpc.discover` 方法集合。 +- 不新增 Editor 面板、菜单、设置项、场景对象类型、组件、层级、Prefab、Gameplay、Player 或 SampleGame 功能。 +- 不新增渲染 Pass、Shader、材质能力、Path Tracing 算法、RHI API 或 GPU 队列行为。 +- 不引入持久 Asset/World/Entity ID,不修改 WEScene/WEMesh/ui_config 格式。 +- 不迁移 Asset import/spawn、Scene save、Profiler、Console、Window/Layout/History 或 app lifecycle;它们由后继 Spec 单独处理。 +- 不实现 A0-02 Developer Host、A0-03 federation、A0-04 独立 headless host 或新的网络/JSON-RPC 入口。 +- 不声称所有现有 setting 都支持 Undo、preview、atomic batch、cancel 或 compensation。 +- 不把 Contract Core、Editor document ledger 或 MCP registry 变成拥有领域业务的中央 God Service。 + +## 3. Scenarios + +| Scenario | Given / When | Expected | +|---|---|---| +| Scene query | UI 或 MCP 请求当前 hierarchy/instance fields | 同一 Scene service 生成按 session ref 稳定排序的 snapshot;typed result 带 source revision/freshness,legacy JSON 保持当前形状 | +| Scene command | UI 或 MCP 删除、重命名或修改 Transform | 同一 validator 和 command 路径执行;有效修改恰好递增一次 Scene revision,生成 change set/event/evidence 并保持现有 dirty/Undo | +| Camera command | UI drag、Inspector、frame-selected 或现有 camera MCP 方法修改 Camera | 使用同一 Camera service;Pitch/Yaw/Roll 与 `LH_XForward_YRight_ZUp` 不变,Roll 不丢失,Undo 与 accumulation 行为不回退 | +| Lighting/Render command | UI 或现有 MCP 修改主光源、Renderer setting 或 Debug View | 使用同一 Lighting/Render Settings service;范围、归一化、dirty 和需要时的 PT accumulation reset 与当前行为一致 | +| Selection | `editor.hierarchy.select` 或 UI selection 改变选择 | 只改变 Editor session state,不伪装成 Scene document 修改,不写 dirty,不声称 Undo | +| Invalid input | ref 不存在、field/path/enum 未知、数值非 finite/越界、expected revision 冲突 | 副作用前返回稳定结构化 diagnostic;状态、revision、dirty、Undo 栈和 PT reset flag 不变 | +| No-op | 新值与当前值等价 | 不增加 revision、不新增 Undo、不产生伪 change event;返回可机器读取的 no-op result | +| Undo/Redo | 对现有可 Undo 的 Instance/Camera command 执行 Undo/Redo | 使用单调递增的新 revision 记录逆向/重做 change,而不是把 revision 倒退 | +| Feed gap | 客户端 cursor 早于有界事件窗口或 schema/provider 不兼容 | 返回 `ResyncRequired` 并要求重新取 snapshot,不以 event 代替当前状态 | +| Shutdown | Editor 拒绝新任务并开始关闭 | service 不持有 GPU resource、不启动线程;现有 dispatcher drain、MCP stop、GPU idle 与 teardown 顺序不变 | + +## 4. Constraints and Invariants + +- **Functional**:用户可见功能、UI 布局、MCP 方法集合、位置参数数量、legacy JSON 成功结果和错误类别保持兼容。领域 service 不包含 ImGui、MCP、JSON-RPC 或日志文本解析。 +- **Threading/Lifetime**:所有 service query/command 仅在主线程访问 `FScene`;MCP 继续经 `FMainThreadDispatcher`。service 和 document ledger 不拥有后台线程,关闭时不延长 Scene、Renderer 或 GPU-backed 对象生命周期。 +- **GPU/RenderGraph**:本 Spec 不修改 RenderGraph/RHI/Shader。Renderer setting 只修改现有 CPU state;需要时设置现有 `bPTResetAccumulation`,不直接提交 GPU 工作。 +- **Data/Compatibility**:Scene/Settings revision 是 process-local、单调 `uint64`,schema major 为 1;不持久化到 WEScene/ui_config。现有 `McpId` 映射为 session `FObjectRef`,不得写成 persistent/document identity。 +- **Security/Input**:ref、字符串、field path、集合和输出有界;所有向量、四元数、角度、颜色、强度、曝光、LOD、PT 参数先做 finite/range/normalization 校验。失败不产生部分修改。 +- **Target closure**:领域 authoring service 和 Contract 只进入 `WaveEditorSupport`/测试;`WaveRuntime` closure 不增加 Contract、Developer、MCP 或 UI。 +- **Compatibility adapter**:legacy MCP 可在主线程调用时捕获当前 revision 作为本次 command base,但不得因此宣称具备跨请求 optimistic concurrency;真正的 client-supplied expected revision 由 A0-02 host 接入。 + +### AI-Native Impact + +| Dimension | Status | Decision / Verification | +|---|---|---| +| Identity | Required | Instance 使用 `{kind=scene.instance,id=McpId,scope=session}`;Camera/MainLight/RenderSettings 使用 session singleton ref;CPU contract 拒绝伪 persistent scope | +| Schema | Required | 每个 typed request/snapshot/result 使用领域 schema v1;现有 16 个方法保留 legacy v1 adapter,并注册可用的 typed capability v2 descriptor | +| Query | Required | Scene 与 Render Settings 各有 revisioned snapshot;有界 1024-event feed 使用 provider/schema/source revision/cursor,gap/过期要求 resync | +| Effect | Required | Query/Command 分类明确;selection 是 session Command;Instance/Camera Document Command 保留当前 Undo,Lighting/Render Command 不伪造 Undo;无 Job/External Action | +| Determinism | Required | Instance 按 session ref 排序;有效 command、no-op、Undo/Redo、事件 sequence 和 revision 递增具有 CPU contract | +| Validation/Evidence | Required | service 在副作用前执行 typed validator;每次有效 command 生成 `FChangeSet`、validator result 和 `FEvidenceBundle`,失败生成稳定 diagnostic | +| Provenance/Migration | N/A | 不新增 artifact、磁盘格式或持久状态;descriptor 从 legacy unavailable v1 迁移为并存的 typed v2,旧 MCP wire 不迁移 | +| Security/Budget | Required | 复用 A0-01 上限并为 hierarchy/feed/evidence 设置 4096/1024/256 项硬上限;名称保持 1~256 bytes,数值范围与现有 UI/MCP 一致 | +| Headless | Deferred | A0-02/A0-04 复用这些 service 接入 named invocation 和独立 headless host;本 Spec 不新增 executable 或 transport | + +## 5. Design + +```text +ImGui Panels ───────────────┐ + ├─→ Scene/Camera/Lighting/Render authoring services +Legacy MCP compatibility ──┘ │ + ├─→ existing FScene/FCamera/FLight state + └─→ document ledger + ├─ revision/event cursor + ├─ dirty + existing command stack + └─ change set/diagnostic/evidence +``` + +### Domain ownership + +- `SceneAuthoring` 拥有 instance ref resolution、stable list、display name、Transform schema/validator 和 add/remove/rename/transform command 语义。 +- `CameraAuthoring` 拥有 Camera snapshot、set/move/look-at、坐标/角度校验和 Camera command 语义。 +- `LightingAuthoring` 拥有 main directional light snapshot、color/intensity/direction 规则。 +- `RenderSettingsAuthoring` 拥有当前已暴露 setting/debug-view path、enum/range 和 PT reset 决策。 +- 共享 document ledger 只提供 revision、expected-revision 检查、bounded event/evidence retention、dirty/Undo 桥接,不知道具体字段规则,也不直接修改 `FScene`。 +- Selection 继续属于 Editor session state;它可通过 adapter 调用 selection service,但不进入 Scene change feed。 + +Editor-only 领域源码放在 `Engine/Source/Developer/Scene/` 与 `Engine/Source/Developer/Render/`,公共 ledger 放在 `Engine/Source/Developer/Document/`。这些目录由 `WaveEditorSupport` 编译,不加入 `WaveRuntime`。领域头不得 include `UI/`、`Mcp/`、ImGui 或 nlohmann JSON。 + +### Interfaces + +- Query 返回领域 typed snapshot,其中包含 `FSnapshotMeta` 和稳定排序的 typed data。 +- Command request 包含 target、typed payload、expected revision 与 command label;结果包含 before/after revision、no-op/conflict、`FChangeSet`、diagnostics 和 evidence。 +- 每个 service 提供自己的 descriptor/schema factory、validator、query/command handler;MCP adapter 只负责位置数组/JSON 与 typed value 的转换。 +- `FEditorDomainMcpAdapter` 集中注册当前 16 个方法,但不拥有规则。四个 Panel 不再包含这些方法的 MCP handler 或 `McpValidation`/`McpIdentity` 依赖。 +- `capability.list/describe` 可同时看到 legacy unavailable v1 与 typed available v2 descriptor;`rpc.discover` 仍只返回原方法名且不出现重复名称。 +- A0-02 未来可直接注册 typed v2 handler;本 Spec 不新增 `capability.invoke` 或其他 JSON-RPC 方法。 + +### Revision, change, and evidence + +- `editor.scene` revision 覆盖 Instance/Transform/Camera document state;`editor.render_settings` revision 覆盖 Main Light/Renderer/Debug View。 +- 每个 domain 的 event sequence 独立单调递增;ring 最多保留 1024 个事件。cursor 不匹配、过期或有 gap 时明确 resync。 +- no-op、selection 和失败不增加 document revision。Undo/Redo 是新的有效 command,因此 revision 继续递增。 +- `FChangeSet.Targets` 使用 session refs;`ChangedFields` 使用稳定领域 path;reference change 只在现有实例 mesh/reference 真的变化时生成,本 Spec 不新增此类编辑功能。 +- Evidence ring 最多保留 256 条,记录 capability、before/after revision、change set 与 validator results;不包含 prompt、密钥、屏幕像素或无界日志。 + +### Existing capability mapping + +| Owner | Existing methods | Effect / guarantee | +|---|---|---| +| SceneAuthoring | `editor.hierarchy.list/delete/rename`、`editor.inspector.get/set_field/list_fields` | Query 或 Scene Document Command;delete/rename/transform 保留 Undo | +| Editor session | `editor.hierarchy.select` | Session Command;不 dirty、不 Undo | +| CameraAuthoring | `editor.viewport.camera.get/set/move/look_at` | Query 或 Scene Document Command;保留 Undo 和坐标契约 | +| RenderSettingsAuthoring | `editor.viewport.set_debug_view`、`editor.renderer.get_settings/set_setting` | Query 或 Settings Command;只声明当前真实恢复语义 | +| LightingAuthoring | `editor.lighting.get_main/set_main` | Query 或 Settings Command;direction 归一化,范围不变 | + +### UI migration + +- Hierarchy、Inspector、Viewport 和 Settings Panel 的现有控件改为调用同一 service。 +- drag/gizmo 等连续 UI 编辑在开始时捕获 base revision,在结束时提交一个 command;取消恢复原值且不生成 change。 +- UI-only 的 Duplicate、Frame Selected、Reset Settings 是现有 service command 的组合,不新增 capability。 +- dirty 与 command stack 的现有用户体验保持;迁移过程中不允许 UI 路径继续保留第二套字段范围或归一化逻辑。 + +## 6. Alternatives + +| Alternative | Benefit | Cost / Rejection reason | +|---|---|---| +| 只给 Panel handler 增加 schema | 修改小 | Panel 仍拥有业务,UI/MCP 继续两套路径,无法满足领域 ownership | +| 建立一个中央 `WaveAutomationService` | 注册简单 | 形成跨领域 God Service,违反 A0-01 与架构宪法 | +| 直接把 `FScene` 所有字段 reflection 给 Agent | 表面覆盖广 | 暴露 GPU/内部状态、缺少 effect/validator/Undo 边界,也会新增未审核能力 | +| 新增一组 `v2` MCP 方法 | legacy 最清晰 | 用户明确要求不新增功能/方法;本 Spec 保留方法集合,通过 descriptor version 和后继 host 处理 typed invocation | +| 把 Contract/authoring service 链入 Runtime | service 可被更多 target 复用 | 污染 Runtime closure,并把开发期能力带入运行时 | + +## 7. Delivery Slices + +| Slice | Scope | Invariant | Verification | Status | +|---|---|---|---|---| +| 1 | document ledger、领域 typed schema/request/result、revision/change/evidence CPU contracts | 不接 UI/MCP,不改变运行行为;Runtime closure 不变 | 新 Editor-domain CPU contracts、A0-01 validators、target boundary | 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;迁移 UI 与 5 个现有 MCP 方法 | setting 集合/范围、Debug View、PT reset、无伪 Undo | CPU schema/range、MCP raster/PT/restore、`WAVE_RG_STRICT=1` | +| 5 | 移除四个 Panel 的业务/MCP ownership,完成 typed v2 descriptor、feed/evidence 与兼容审计 | `rpc.discover` 仍为原 35 个名称;Runtime/Editor target closure 不回退 | full Debug/Release、registration/wire contracts、日志与 RHI failure | + +每个 Slice 必须可独立验证并保持 Editor 可运行。Asset/Save/Trace 不在本 Spec 中顺带迁移。 + +## 8. Verification and Acceptance + +| Layer | Command/Steps | Expected | Required | +|---|---|---|---| +| Static | `git diff --check`;ownership/include/registration contract | Panel 不拥有领域 handler;领域 service 无 UI/MCP/JSON include;16 个方法名与总 35 个 discover 名称不变 | Yes | +| Spec | `py -3 Tests/spec_contracts.py .`;`ctest --test-dir Build -C Debug -R SpecContracts --output-on-failure` | Spec/注册表/状态一致 | Yes | +| Build | `cmake --build Build --config Debug`;`cmake --build Build --config Release` | Runtime、Editor、Viewer、contracts 均通过 | Yes | +| CPU | Editor-domain contracts;`ctest --preset test-cpu-release` | schema/ref/revision/conflict/no-op/event/evidence/Undo 决定性通过 | Yes | +| MCP | `ctest --test-dir Build -C Debug --output-on-failure`;现有 16 方法兼容 fixture | 名称、参数、成功 JSON、错误、notification、minimized drain 不回退 | Yes | +| Runtime/GPU | Debug `WAVE_RG_STRICT=1` 启动 raster,切 PT/restore,编辑 Camera/Light/Render 后正常退出 | 实际画面路径工作,日志无新增 Error/Fatal/D3D12 validation error | Yes | +| Failure | revision conflict、invalid ref/path/non-finite/range;`WAVE_RHI_INJECT_FAILURE=1` | 无部分修改;RHI 仍经 quarantine 非零退出 | Yes | +| Boundary | Runtime target/header closure contracts | `WaveRuntime` 不触达 Contract/Developer/MCP/UI | Yes | + +### Acceptance Criteria + +- [ ] 没有新增、删除或重命名 MCP 方法,现有 UI 功能、参数、legacy JSON 和视觉结果不回退。 +- [ ] Scene/Camera/Lighting/Render Settings 的 UI 与 MCP 都调用同一领域 service 和 validator。 +- [ ] 领域 service 不依赖 UI/MCP/JSON;Panel 不再拥有这 16 个 MCP handler 或字段业务规则。 +- [ ] 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 注册表与路线图状态已同步。 + +## 9. Compatibility, Risks, and Open Questions + +- **Compatibility/Migration**:不迁移磁盘数据。legacy v1 MCP 方法和 `rpc.discover` 原样保留;typed descriptor 使用 v2,未来 A0-02 host 直接复用,不要求再次复制业务。 +- **Removal/Rollback**:每个 Slice 保持可运行;在对应 UI/MCP 调用点全部迁移并通过契约后,才删除旧 Panel handler。回退单个 Slice 不改变 Scene 格式或资产。 + +| Risk | Impact | Mitigation | +|---|---|---| +| 为了“共用”把 UI state 塞进领域 service | service 仍不可 headless 复用 | selection 与 layout 留在 Editor session;领域 request/result 不引用 ImGui/FUiContext | +| service 变成跨领域中央业务层 | ownership 再次模糊 | Scene/Camera/Lighting/Render 分开拥有规则;ledger 只做通用 revision/evidence | +| UI drag 每帧增加 revision/Undo | feed 爆炸、体验回退 | begin 捕获、live preview、end 单次 commit;cancel 不提交 | +| legacy MCP 没有 client expected revision | Agent 误以为并发安全 | descriptor 明确 legacy 限制;只在 A0-02 typed host 声明 client-supplied conflict 保证 | +| descriptor 与 legacy wire 漂移 | capability.describe 误导 | schema/serializer/handler 同注册,保存固定 JSON fixture | +| setting 修改遗漏 PT reset | Path Tracing 画面错误 | service 集中 reset 决策,真实 PT switch/restore 验证 | +| Contract 依赖进入 Runtime | Shipping closure 膨胀 | CMake target/header closure 测试作为每 Slice 门禁 | + +### Open Questions + +- None;范围固定为当前 16 个方法及其已有 UI 对应操作,不新增对外能力。typed invocation 与跨请求 expected revision 由 A0-02/A0-04 处理。 + +## 10. Implementation Record + +### Current Progress + +- 已完成源码调研与现有能力清单。 +- 已确认 16 个目标方法分别由 4 个 Panel 注册,业务/校验同时存在于 Panel/MCP 和 ImGui 路径。 +- 尚未修改生产代码。 + +### Next Step + +- 用户明确接受本 Draft 后,将状态改为 `Accepted`/`Implementing`,从 Slice 1 开始。 + +### Changes and Deviations + +- 用户纠正范围后,本 Spec 明确禁止新增 Player、Gameplay、面板、MCP 方法或业务功能。 +- 现有 Asset/Save/Trace 暂不并入,避免跨领域大爆炸;它们使用后继 Spec 按同一模式迁移。 + +### Evidence + +| Date | Commit | Environment | Command/Steps | Result | Artifacts | +|---|---|---|---|---|---| +| 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 | + +### Remaining Work + +- 用户接受 Draft。 +- Delivery Slice 1~5 的生产实现与验证。 +- 为现有 Asset/Save/Trace 建立独立、同样禁止新增业务功能的后继 Spec。 diff --git a/docs/specs/README.md b/docs/specs/README.md index fe7d27c..72a62ae 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -24,6 +24,7 @@ Draft / Accepted / Implementing --被替代--> Superseded | [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 | ## Session 入口 From 89b1fe4ddb59712cfd8a07a20519d18fb853f42d Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sun, 26 Jul 2026 13:58:06 +0800 Subject: [PATCH 15/16] tighten existing capability migration contract --- ...-04-existing-editor-domain-capabilities.md | 89 +++++++++++++++++-- 1 file changed, 82 insertions(+), 7 deletions(-) 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 87fc833..e8f1e6f 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 @@ -24,9 +24,15 @@ WaveEngine 已经能够通过 Editor UI 和 MCP 完成场景实例浏览/选择/ - `FHierarchyPanel`、`FInspectorPanel`、`FViewportPanel` 和 `FEngineSettingsPanel` 当前直接注册并实现 16 个相关 MCP 方法。 - Panel handler 直接解析 `FJson`、执行 finite/range 校验、查找 `McpId`、修改 `FScene`/`FCamera`/`FLight`,或调用 `EditorCommands`。 - ImGui 路径也直接修改相同状态;部分操作使用 `FEditorCommandStack`,部分 Lighting/Renderer 设置只标记 dirty,不支持 Undo。 +- `FEditorShell` toolbar 还会直接修改 Render Mode 与 Auto Exposure;`FImGuiLayer::ApplyConfigToScene()` 直接写入完整 Lighting/Renderer settings。 +- `FImGuiLayer::Init()` 当前先创建 `FEditorShell` 并注册 MCP,配置直到第一次 `DrawConfigWindow()` 才加载;理论上首个 MCP 修改可能被首帧配置覆盖。 +- `FCamera::Tick()` 在 Scene Tick 中直接处理键鼠导航,`FImGuiLayer::TrackExternalSceneChanges()` 事后只检测 Camera 差异并标记 dirty;该路径当前不支持 Undo。 +- `FEditorAssetLoader`/`FAssetsPanel` 通过 `EditorCommands::AddInstance` 把既有 Asset spawn/import 结果加入 Scene;Asset job 本身不属于本 Spec,但最终 Scene add effect 与本 Spec 相交。 - `FInstance::McpId` 在进程内单调分配并用于稳定排序,但只具有 session scope,不是持久 Entity ID。 - `FUiContext` 当前同时持有 Scene、selection、dirty state、command stack 与 asset loader。 - MCP Server 已保证 handler 在主线程 drain;`rpc.discover`、位置数组、notification、1 MiB framing 与正常退出流程已有契约。 +- `FMcpRegistry::Tools` 当前每个方法名只允许一个 legacy trampoline;底层 `TCapabilityRegistry` 已支持同名不同 major/minor。要并存 typed v2 descriptor,必须只向 capability registry 增加版本,不能向 `rpc.discover` 的 Tool map 重复插入。 +- 当前 capability registry 已有 named-object→legacy positional 转换和 handler/validator 调用,但 JSON-RPC 只公开 list/describe 与 legacy method invoke;本 Spec 不新增 generic invoke route。 - A0-01 已提供 schema、reference、revision、change cursor/event、change set、diagnostic、effect、artifact、evidence 与 capability registry 公共词汇。 - 含动态 `FJson` 的现有 descriptor 会被标记 unavailable,因此当前 16 个方法尚不能诚实声明为 typed domain capability。 - 当前 Debug 全量 CTest 为 11/11,Release CPU preset 为 8/8。 @@ -73,18 +79,21 @@ WaveEngine 已经能够通过 Editor UI 和 MCP 完成场景实例浏览/选择/ | Invalid input | ref 不存在、field/path/enum 未知、数值非 finite/越界、expected revision 冲突 | 副作用前返回稳定结构化 diagnostic;状态、revision、dirty、Undo 栈和 PT reset flag 不变 | | No-op | 新值与当前值等价 | 不增加 revision、不新增 Undo、不产生伪 change event;返回可机器读取的 no-op result | | Undo/Redo | 对现有可 Undo 的 Instance/Camera command 执行 Undo/Redo | 使用单调递增的新 revision 记录逆向/重做 change,而不是把 revision 倒退 | +| Camera navigation | 现有右键键鼠导航在 `FCamera::Tick()` 修改 Camera | 保留实时导航与“dirty 但不可 Undo”语义;Editor observer 将有效帧差异登记为 revision/change/evidence,不重复计算 service 已知修改 | +| Config hydration | Editor 启动并读取现有 `ui_config.cfg` | 在注册 MCP/建立 revision baseline 前完成校验和 hydration;不标记 dirty、不创建 Undo/event/evidence | +| Asset promotion | 现有 Asset loader 完成 CPU/GPU load 并准备加入实例 | Asset job 行为不变;最终 AddInstance 经 SceneAuthoring effect,保持 selection/dirty/Undo | | Feed gap | 客户端 cursor 早于有界事件窗口或 schema/provider 不兼容 | 返回 `ResyncRequired` 并要求重新取 snapshot,不以 event 代替当前状态 | | Shutdown | Editor 拒绝新任务并开始关闭 | service 不持有 GPU resource、不启动线程;现有 dispatcher drain、MCP stop、GPU idle 与 teardown 顺序不变 | ## 4. Constraints and Invariants -- **Functional**:用户可见功能、UI 布局、MCP 方法集合、位置参数数量、legacy JSON 成功结果和错误类别保持兼容。领域 service 不包含 ImGui、MCP、JSON-RPC 或日志文本解析。 +- **Functional**:用户可见功能、UI 布局、MCP 方法集合、位置参数数量、legacy JSON 成功结果和错误类别保持兼容。领域 service 不包含 ImGui、MCP、JSON-RPC 或日志文本解析。历史 UI/MCP 的不同输入窄化规则可留在 compatibility adapter,但不得绕过共同领域不变量。 - **Threading/Lifetime**:所有 service query/command 仅在主线程访问 `FScene`;MCP 继续经 `FMainThreadDispatcher`。service 和 document ledger 不拥有后台线程,关闭时不延长 Scene、Renderer 或 GPU-backed 对象生命周期。 - **GPU/RenderGraph**:本 Spec 不修改 RenderGraph/RHI/Shader。Renderer setting 只修改现有 CPU state;需要时设置现有 `bPTResetAccumulation`,不直接提交 GPU 工作。 - **Data/Compatibility**:Scene/Settings revision 是 process-local、单调 `uint64`,schema major 为 1;不持久化到 WEScene/ui_config。现有 `McpId` 映射为 session `FObjectRef`,不得写成 persistent/document identity。 - **Security/Input**:ref、字符串、field path、集合和输出有界;所有向量、四元数、角度、颜色、强度、曝光、LOD、PT 参数先做 finite/range/normalization 校验。失败不产生部分修改。 - **Target closure**:领域 authoring service 和 Contract 只进入 `WaveEditorSupport`/测试;`WaveRuntime` closure 不增加 Contract、Developer、MCP 或 UI。 -- **Compatibility adapter**:legacy MCP 可在主线程调用时捕获当前 revision 作为本次 command base,但不得因此宣称具备跨请求 optimistic concurrency;真正的 client-supplied expected revision 由 A0-02 host 接入。 +- **Compatibility adapter**:legacy MCP 可在主线程调用时捕获当前 revision 作为本次 command base,但不得因此宣称具备跨请求 optimistic concurrency;真正的 client-supplied expected revision 由 A0-02 host 接入。adapter 可以保留比领域 schema 更窄的历史限制,例如 MCP Scale 最大 100,但不能接受领域 validator 拒绝的输入。 ### AI-Native Impact @@ -130,9 +139,43 @@ Editor-only 领域源码放在 `Engine/Source/Developer/Scene/` 与 `Engine/Sour - Command request 包含 target、typed payload、expected revision 与 command label;结果包含 before/after revision、no-op/conflict、`FChangeSet`、diagnostics 和 evidence。 - 每个 service 提供自己的 descriptor/schema factory、validator、query/command handler;MCP adapter 只负责位置数组/JSON 与 typed value 的转换。 - `FEditorDomainMcpAdapter` 集中注册当前 16 个方法,但不拥有规则。四个 Panel 不再包含这些方法的 MCP handler 或 `McpValidation`/`McpIdentity` 依赖。 -- `capability.list/describe` 可同时看到 legacy unavailable v1 与 typed available v2 descriptor;`rpc.discover` 仍只返回原方法名且不出现重复名称。 +- `capability.list/describe` 可同时看到 legacy v1 与 typed available v2 descriptor;v1 中动态 `FJson` 项继续按现状 unavailable,其余 availability 不回退;`rpc.discover` 仍只返回原方法名且不出现重复名称。 - A0-02 未来可直接注册 typed v2 handler;本 Spec 不新增 `capability.invoke` 或其他 JSON-RPC 方法。 +Slice 1 的 transport-neutral contract surface 固定为以下职责,不固定无关命名细节: + +```text +FEditorCommandContext + expected revision + request/correlation id + invocation mode = Typed | LegacyCompatibility | ExternalObservation + +FEditorTypedSnapshot + FSnapshotMeta meta + domain typed value + +FEditorCommandResult + status = Applied | NoOp | Conflict + before/after revision + optional change set + diagnostics + evidence + +FEditorDocumentLedger + GetRevision(domain) + CheckExpected(domain, context) + CommitValidated(domain, change set, diagnostics) + ObserveExternal(domain, before, after) + ReadEvents(domain, cursor, limit) + EstablishHydratedBaseline(scene revision, settings revision) +``` + +- Typed invocation 必须提供匹配的 expected revision;缺失或不匹配在副作用前失败。 +- Legacy compatibility 由可信 adapter 在同一主线程 turn 捕获当前 revision,并在 evidence/diagnostic 标明未提供 client base;该模式不对 A0-02 host 暴露。 +- External observation 只登记已经由现有 Runtime input 产生且通过领域 validator 的差异,不进入 Undo。 +- ledger 的 `CommitValidated` 只接受已通过 `ValidateChangeSet` 的对象;它不解析 field payload,不访问 `FScene`,也不生成领域 path。 +- v2 registration 使用 service 提供的 descriptor/handler/validator,并与 legacy Tool 共用 owner lifecycle;移除 owner 时两代 capability 一起注销,但 Tool map 始终只有一个方法名。 + ### Revision, change, and evidence - `editor.scene` revision 覆盖 Instance/Transform/Camera document state;`editor.render_settings` revision 覆盖 Main Light/Renderer/Debug View。 @@ -141,6 +184,33 @@ Editor-only 领域源码放在 `Engine/Source/Developer/Scene/` 与 `Engine/Sour - `FChangeSet.Targets` 使用 session refs;`ChangedFields` 使用稳定领域 path;reference change 只在现有实例 mesh/reference 真的变化时生成,本 Spec 不新增此类编辑功能。 - Evidence ring 最多保留 256 条,记录 capability、before/after revision、change set 与 validator results;不包含 prompt、密钥、屏幕像素或无界日志。 +### Hydration and external mutation + +- Scene 与现有 `ui_config.cfg` 先按当前迁移/default/clamp 规则完成加载,再创建 document ledger baseline,最后注册领域 MCP adapter。启动 hydration 不属于用户 Command。 +- 如果 config 不存在或无效,沿用当前 defaults/fallback;不因 AI-native 改造新增磁盘字段或改变原子写入方式。 +- `FCamera::Tick()` 继续位于 Runtime Scene Tick,避免 Runtime 反向依赖 Developer。Editor-side Camera observer 在 Scene Tick 后比较 service 已确认 snapshot 与实际 Camera;仅对真实外部导航差异登记一次非 Undo Command。 +- service 自身修改 Camera 时同步 observer baseline,防止同一变化在 `TrackExternalSceneChanges` 再次递增 revision。 +- Asset import/spawn 的后台阶段保持原实现;它准备好的 `FInstance` 只在主线程通过 SceneAuthoring AddInstance 提交。 + +### Audited behavior matrix + +| State | Common domain invariant | Legacy/UI compatibility retained | +|---|---|---| +| Instance name | UTF-8 byte string,1~256 bytes | Hierarchy/Inspector 257-byte buffer与 MCP 1~256 检查保持 | +| Instance position | 三分量 finite | legacy MCP 继续显式报 non-finite;UI numeric edit/gizmo 不新增位置范围 | +| Instance rotation | finite、非零 quaternion 后归一化;Euler 按 Pitch/Yaw/Roll | legacy MCP Euler 继续 `remainder(360)`;UI Euler→quaternion 与 gizmo local/world 规则不变 | +| Instance scale | 三分量 finite 且每分量至少 0.001 | MCP 与 gizmo 保留最大 100;Inspector 只保留下限,不把其历史可输入范围静默缩到 100 | +| Camera rotation | finite;`FCamera::SetRotation` 保留 Pitch `[-89,89]` 与 Yaw/Roll normalize | set/move/look-at、Inspector、Frame Selected 和键鼠导航的现有 clamp/Undo 差异保持 | +| Light color/intensity | color `[0,1]`,intensity `[0,20]`,全部 finite | ColorEdit/Slider 与 MCP 范围一致 | +| Light direction | finite、非零,写入时归一化 | UI 对长度不超过 `1e-4` 的编辑不提交;legacy MCP 对不超过 `1e-6` 返回错误 | +| Debug view | `none/lit=0`、`meshlet_index=1`、`group_id=2` | UI label `Lit/Meshlets/Groups` 与 MCP enum spelling 均不变 | +| Render mode | `forward/raster=0`、`path_tracing=1` | UI 继续按 DXR availability 禁用;legacy MCP 的当前接受行为不变,并在 evidence 中记录 unsupported warning | +| Exposure | speed `[0.1,10]`、min `[0.01,2]`、max `[1,20]`、middle grey `[0.05,0.5]`、manual `[0.1,10]`,min/max 成对有效 | MCP 当前只暴露 auto-enabled/manual;其余 UI-only settings 不新增 MCP path | +| Path tracing | bounces `[1,16]`、samples `[1,8]`、RR start `[0,8]`、accumulation bool | MCP 当前只暴露 bounces/samples/accumulation;UI-only RR/reset 不新增 MCP path | +| LOD error | finite `[0.0001,1]` | UI/MCP/config clamp 或拒绝语义分别保持 | + +共同 validator 只定义所有客户端都必须满足的领域不变量;legacy MCP、ImGui widget、硬件 policy 和 config migration 可以在 adapter 层继续执行历史上更窄或不同的处理,但 adapter 规则必须由测试固定,不能再散落为未记录的第二业务定义。 + ### Existing capability mapping | Owner | Existing methods | Effect / guarantee | @@ -154,8 +224,10 @@ Editor-only 领域源码放在 `Engine/Source/Developer/Scene/` 与 `Engine/Sour ### UI migration - Hierarchy、Inspector、Viewport 和 Settings Panel 的现有控件改为调用同一 service。 +- EditorShell toolbar、首次 config hydration 和 Camera external-change tracking 同步迁移,不留下 Panel 之外的隐蔽 writer。 - drag/gizmo 等连续 UI 编辑在开始时捕获 base revision,在结束时提交一个 command;取消恢复原值且不生成 change。 - UI-only 的 Duplicate、Frame Selected、Reset Settings 是现有 service command 的组合,不新增 capability。 +- Asset loader/spawn 仍由后继 Asset Spec 拥有,但最终 Scene add 使用 SceneAuthoring;本 Spec 不改变 import job、路径或返回。 - dirty 与 command stack 的现有用户体验保持;迁移过程中不允许 UI 路径继续保留第二套字段范围或归一化逻辑。 ## 6. Alternatives @@ -172,11 +244,11 @@ Editor-only 领域源码放在 `Engine/Source/Developer/Scene/` 与 `Engine/Sour | Slice | Scope | Invariant | Verification | Status | |---|---|---|---|---| -| 1 | document ledger、领域 typed schema/request/result、revision/change/evidence CPU contracts | 不接 UI/MCP,不改变运行行为;Runtime closure 不变 | 新 Editor-domain CPU contracts、A0-01 validators、target boundary | Pending | +| 1 | document ledger、领域 typed schema/request/result、revision/change/evidence CPU contracts;定义 hydration baseline 与 adapter policy | 不接 UI/MCP,不改变运行行为;Runtime closure 不变 | 新 Editor-domain CPU contracts、A0-01 validators、target boundary、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;迁移 UI 与 5 个现有 MCP 方法 | setting 集合/范围、Debug View、PT reset、无伪 Undo | CPU schema/range、MCP raster/PT/restore、`WAVE_RG_STRICT=1` | -| 5 | 移除四个 Panel 的业务/MCP ownership,完成 typed v2 descriptor、feed/evidence 与兼容审计 | `rpc.discover` 仍为原 35 个名称;Runtime/Editor target closure 不回退 | full Debug/Release、registration/wire contracts、日志与 RHI failure | +| 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 | 每个 Slice 必须可独立验证并保持 Editor 可运行。Asset/Save/Trace 不在本 Spec 中顺带迁移。 @@ -197,7 +269,8 @@ Editor-only 领域源码放在 `Engine/Source/Developer/Scene/` 与 `Engine/Sour - [ ] 没有新增、删除或重命名 MCP 方法,现有 UI 功能、参数、legacy JSON 和视觉结果不回退。 - [ ] Scene/Camera/Lighting/Render Settings 的 UI 与 MCP 都调用同一领域 service 和 validator。 -- [ ] 领域 service 不依赖 UI/MCP/JSON;Panel 不再拥有这 16 个 MCP handler 或字段业务规则。 +- [ ] 领域 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。 @@ -229,6 +302,8 @@ Editor-only 领域源码放在 `Engine/Source/Developer/Scene/` 与 `Engine/Sour - 已完成源码调研与现有能力清单。 - 已确认 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 时发生静默行为变化。 - 尚未修改生产代码。 ### Next Step From 2c8d1ae529fcad334c7534d15a7d3116e9e0e6ad Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sun, 26 Jul 2026 14:00:49 +0800 Subject: [PATCH 16/16] complete editor capability draft audit --- ...26-07-26-g0-04-existing-editor-domain-capabilities.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 e8f1e6f..3f5259e 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 @@ -133,6 +133,8 @@ Legacy MCP compatibility ──┘ │ Editor-only 领域源码放在 `Engine/Source/Developer/Scene/` 与 `Engine/Source/Developer/Render/`,公共 ledger 放在 `Engine/Source/Developer/Document/`。这些目录由 `WaveEditorSupport` 编译,不加入 `WaveRuntime`。领域头不得 include `UI/`、`Mcp/`、ImGui 或 nlohmann JSON。 +Slice 1 新增显式源码闭包的 `WaveEditorDomainContracts` CPU test target;它只编译 Contract Core、document ledger、领域 schema/validator/pure change 计算和对应测试,不链接 `WaveRuntime`/`WaveEditorSupport`,不包含 Scene/Renderer/RHI/UI/MCP/Developer service adapter 或 ThirdParty。现有 `WaveCoreContracts` 的禁止 Developer 边界保持不变。实际 `FScene` 应用路径由 Editor/MCP integration 验证,不能为了单测复制一个第二业务模型。 + ### Interfaces - Query 返回领域 typed snapshot,其中包含 `FSnapshotMeta` 和稳定排序的 typed data。 @@ -244,7 +246,7 @@ FEditorDocumentLedger | Slice | Scope | Invariant | Verification | Status | |---|---|---|---|---| -| 1 | document ledger、领域 typed schema/request/result、revision/change/evidence CPU contracts;定义 hydration baseline 与 adapter policy | 不接 UI/MCP,不改变运行行为;Runtime closure 不变 | 新 Editor-domain CPU contracts、A0-01 validators、target boundary、audited behavior matrix fixtures | Pending | +| 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` | @@ -259,7 +261,7 @@ FEditorDocumentLedger | Static | `git diff --check`;ownership/include/registration contract | Panel 不拥有领域 handler;领域 service 无 UI/MCP/JSON include;16 个方法名与总 35 个 discover 名称不变 | Yes | | Spec | `py -3 Tests/spec_contracts.py .`;`ctest --test-dir Build -C Debug -R SpecContracts --output-on-failure` | Spec/注册表/状态一致 | Yes | | Build | `cmake --build Build --config Debug`;`cmake --build Build --config Release` | Runtime、Editor、Viewer、contracts 均通过 | Yes | -| CPU | Editor-domain contracts;`ctest --preset test-cpu-release` | schema/ref/revision/conflict/no-op/event/evidence/Undo 决定性通过 | Yes | +| CPU | `WaveEditorDomainContracts` Debug/Release;其显式 closure boundary;`ctest --preset test-cpu-release` | schema/ref/revision/conflict/no-op/event/evidence 决定性通过;不污染 WaveCore/Runtime | Yes | | MCP | `ctest --test-dir Build -C Debug --output-on-failure`;现有 16 方法兼容 fixture | 名称、参数、成功 JSON、错误、notification、minimized drain 不回退 | Yes | | Runtime/GPU | Debug `WAVE_RG_STRICT=1` 启动 raster,切 PT/restore,编辑 Camera/Light/Render 后正常退出 | 实际画面路径工作,日志无新增 Error/Fatal/D3D12 validation error | Yes | | Failure | revision conflict、invalid ref/path/non-finite/range;`WAVE_RHI_INJECT_FAILURE=1` | 无部分修改;RHI 仍经 quarantine 非零退出 | Yes | @@ -314,12 +316,15 @@ FEditorDocumentLedger - 用户纠正范围后,本 Spec 明确禁止新增 Player、Gameplay、面板、MCP 方法或业务功能。 - 现有 Asset/Save/Trace 暂不并入,避免跨领域大爆炸;它们使用后继 Spec 按同一模式迁移。 +- 源码审计确认 UI/MCP 并非所有边界一致;改为“共同领域不变量 + 有测试的 legacy/UI adapter 窄化”,避免为追求表面统一而改变现有输入行为。 +- `WaveCoreContracts` 明确不允许 Developer closure;Slice 1 使用独立显式 CPU target,不稀释 M0-01/A0-01 边界。 ### Evidence | Date | Commit | Environment | Command/Steps | Result | Artifacts | |---|---|---|---|---|---| | 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 通过 | ### Remaining Work